feat(web): settle folder references on pick and move descent to a drill verb

A directory row in the @ menu had one verb doing two jobs: picking it
inserted literal @dir/ text and kept the menu open, so a user wanting
the folder itself never got a settled entity — the token kept its
trigger character and stayed editable, nothing like a file's atomic
chip.

Split the intents on the same row, mapped to shell-completion instincts:
row click / Enter settles the directory as an atomic folder chip (the
file chip's exact language; canonical @dir/ mention as its serialized
form — the { insert } arm the folder path never took), while Tab or the
row's trailing chevron drills: literal editable text, menu open on the
children. One new dimension carries it: candidate.drill advertises the
verb, InputTriggerPick.action reports it, ArbitrateKey gains 'tab', and
the keymap intercepts Tab only while a drill row is highlighted.

Covered by controller arbitration, MenuView chevron routing, the
ui-reference verb split, a keymap Tab-passthrough spec, and a real-
browser e2e driving all three gestures; menu golden refreshed for the
chevron and the fixture directory.
This commit is contained in:
Yichen Jiang
2026-08-24 16:45:34 +08:00
parent 6f17d10102
commit dad39c8c18
27 changed files with 308 additions and 38 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md
2026-08-24-folder-reference-pick-vs-drill.md: de5d4930eb86a2fc961044cab8aecb7bcaf87a9c
2026-08-24-folder-reference-pick-vs-drill.zh.md: 773e5b2b7cf54b02bc93638ad20bbdc08f34c3d1
@@ -0,0 +1,31 @@
# Agent Note: Folder references settle on pick; descent moves to an explicit drill verb
Status: implemented
English | [中文](2026-08-24-folder-reference-pick-vs-drill.zh.md)
## Problem
A directory row in the `@` menu had one verb doing two jobs. Picking it inserted literal `@dir/` text and kept the menu open — the descent path for reaching a file — so a user who wanted the folder *itself* as context never got a settled entity: the token kept its trigger character, stayed editable (typing `123` kept filtering children), and looked nothing like the atomic chip a file pick produces. Field feedback with a competitor screenshot made the expectation concrete: a chosen folder should be as settled as a chosen file.
## Decision
Split the two intents into two verbs on the same row, keyboard-mapped to shell-completion instincts:
- **Settle** (row click / Enter): the directory resolves as an atomic folder chip — the file chip's exact language: folder glyph, `dir/` label, no trigger character, one deletable unit — whose serialized and clipboard form is the canonical `@dir/` mention. Implementation is the `{ insert }` arm the folder path had simply never taken; `appearance: 'folder'` was already supported end to end.
- **Drill** (Tab / the row's trailing chevron): the previous behavior verbatim — literal editable `@dir/` text, menu open on the children.
The plumbing is one new dimension, not a parallel path: `InputTriggerCandidate.drill?: boolean` advertises the second verb (only `ui-reference` directories set it), `InputTriggerPick.action: 'pick' | 'drill'` reports which one ran, `ArbitrateKey` gains `'tab'`, and the composer keymap registers `KEY_TAB_COMMAND` through the same arbitration helper as the arrows — `'consumed'` prevents default, anything else leaves native focus traversal alone. MenuView renders the chevron only on drill rows (`role="button"` span inside the option, mousedown like the row so composer focus survives, `stopPropagation` so the row's settling pick stays out).
## Alternatives considered
- **Settle on menu close** (auto-fold a literal `@dir/` into a chip when the menu dismisses): rejected — the moment an editable token becomes an entity would be invisible and surprising; hand-typed mentions stay honest text.
- **Drill on click, settle via a dedicated row button** (the inverse mapping): rejected — settling is the common intent and deserves the primary gesture; descent is the power-user refinement, which matches Tab.
- **CSS-overpainting the trigger character** on the literal text instead of introducing an entity: rejected earlier for the same reason it failed the folder-glyph fix — a Lexical text node cannot split its trigger character out, and the literal text is not a settled entity anyway.
## Consequences
- A picked folder and a picked file are the same species: atomic, glyph-labeled, no `@`, whole-unit deletion; hand-typed `@dir/` remains a plain-text reference with the glyph prefix.
- `onPick` implementations that ignore `action` behave exactly as before (`'pick'` is what every pre-existing path reports); the only behavioral change sits in `ui-reference`'s directory arm.
- Tab is intercepted only while the menu highlights a drill row; everywhere else the browser keeps it, pinned by the keymap-routing spec.
- Coverage: controller arbitration (drill / plain / pick-action), MenuView chevron routing, `ui-reference` verb split, and a real-browser e2e driving all three gestures (Enter settle, Tab drill, chevron drill) against a real workspace directory.
@@ -0,0 +1,31 @@
# Agent Note: 文件夹引用 pick 即选定;下钻移交给显式 drill 动词
Status: implemented
[English](2026-08-24-folder-reference-pick-vs-drill.md) | 中文
## 问题
`@` 菜单里的目录行用一个动词干两件事。pick 它会插入字面 `@dir/` 文本并保持菜单打开——那是抵达文件的下钻路径——于是想要文件夹*本身*作为上下文的用户永远得不到一个已选定的实体:token 保留触发字符、保持可编辑(继续输入 `123` 会继续筛选子项),与文件 pick 产出的原子 chip 完全不是一个物种。现场反馈附上竞品截图把期望说得很具体:选中的文件夹应当与选中的文件一样"定下来"。
## 决策
把两个意图拆成同一行上的两个动词,键位对齐 shell 补全直觉:
- **选定**(点击行主体 / Enter):目录解析为原子 folder chip——与文件 chip 完全同语言:文件夹图标、`dir/` 标签、无触发字符、整体删除一个单位——序列化与剪贴板形式为规范 `@dir/` mention。实现上就是文件夹路径从未走过的 `{ insert }` 分支;`appearance: 'folder'` 端到端早已支持。
- **钻取**Tab / 行尾 chevron):原行为原样保留——字面可编辑的 `@dir/` 文本,菜单对子项保持打开。
管线上是一个新维度而非平行通路:`InputTriggerCandidate.drill?: boolean` 声明第二动词(只有 `ui-reference` 的目录行设置),`InputTriggerPick.action: 'pick' | 'drill'` 报告实际执行的是哪一个,`ArbitrateKey` 增加 `'tab'`composer keymap 经与方向键相同的仲裁 helper 注册 `KEY_TAB_COMMAND`——`'consumed'` 才 preventDefault,其余情况原生焦点遍历不受影响。MenuView 只在 drill 行渲染 chevronoption 内的 `role="button"` span,与行同用 mousedown 保住 composer 焦点,`stopPropagation` 把行主体的选定 pick 挡在外面)。
## 曾考虑的替代方案
- **菜单关闭时选定**(菜单消失时把字面 `@dir/` 自动固化为 chip):否决——可编辑 token 变实体的时机不可见且令人意外;手敲的 mention 保持诚实文本。
- **点击钻取、行内专用按钮选定**(反向映射):否决——选定是常见意图,应占据主手势;下钻是进阶细化,与 Tab 匹配。
- **对字面文本 CSS 覆盖触发字符**而不引入实体:早先在文件夹图标修复中已因同一原因否决——Lexical 文本节点无法拆出触发字符,而且字面文本本来就不是已选定的实体。
## 后果
- pick 出的文件夹与文件是同一物种:原子、带图标标签、无 `@`、整体删除;手敲的 `@dir/` 仍是带图标前缀的纯文本引用。
- 忽略 `action``onPick` 实现行为与从前完全一致(既有路径全部报告 `'pick'`);唯一的行为变化在 `ui-reference` 的目录分支。
- 只有菜单高亮在 drill 行时才拦截 Tab;其余场合浏览器保有该键,由 keymap-routing spec 钉住。
- 覆盖:controller 仲裁(drill / 普通行 / pick action)、MenuView chevron 路由、`ui-reference` 动词分流,以及在真实工作区目录上驱动全部三个手势(Enter 选定、Tab 钻取、chevron 钻取)的真浏览器 e2e。
+39 -1
View File
@@ -1,7 +1,7 @@
// Web e2e scenario: the shipped composition discovers local files and cold
// sessions through the real Host, groups both domains in the shared @ menu,
// and projects each pick as a complete inline range without issuing a model call.
import { writeFile } from 'node:fs/promises'
import { mkdir, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -127,6 +127,8 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n')
await mkdir(join(scaffold.workspaceCwd, 'workspace', 'folderx'), { recursive: true })
await writeFile(join(scaffold.workspaceCwd, 'workspace', 'folderx', 'child.txt'), 'child fixture\n')
}, 120_000)
afterAll(async () => {
@@ -235,6 +237,42 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
expect(tripwire.warnings).toEqual([])
})
it('settles a folder as an atomic chip; Tab and the chevron drill instead', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-folder'))
const input = page.locator('[data-composer-input]').first()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
// Settle: Enter on the highlighted folder row resolves the folder itself
// as an atomic chip — folder glyph, no trigger character, one unit.
await input.fill('@folderx')
await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor()
await page.keyboard.press('Enter')
const chip = input.locator('[data-composer-chip]').last()
await expect.poll(() => chip.textContent()).toBe('folderx/')
await expect.poll(() => chip.locator('svg').count()).toBe(1)
await expect.poll(() => input.textContent()).toBe('folderx/ ')
// Tab drills: the literal descent text stays editable and the open menu
// lists the folder's children.
await input.fill('@folderx')
await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor()
await page.keyboard.press('Tab')
await expect.poll(() => input.textContent()).toBe('@folderx/')
await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor()
// The row chevron drills the same way by pointer.
await input.fill('@folderx')
const row = menu.getByRole('option', { name: /Folder · folderx\// })
await row.waitFor()
await row.getByRole('button', { name: 'Browse folder' }).click()
await expect.poll(() => input.textContent()).toBe('@folderx/')
await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor()
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('renders the durable direct-message then recall order', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
@@ -1,6 +1,9 @@
- listbox "Trigger suggestions":
- text: Files & folders
- option "File · reference.txt reference.txt" [selected]
- option "Folder · folderx/ folderx Browse folder" [selected]:
- text: Folder · folderx/ folderx
- button "Browse folder":
- option "File · reference.txt reference.txt"
- text: Session conversations
- option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}"
- option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}"
@@ -153,6 +153,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi
session,
position: 'leading',
via: 'menu',
action: 'pick',
span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
}
return source.onPick(pick)
@@ -66,7 +66,7 @@ export type PickOutcome =
| undefined
/** Keyboard keys intercepted by an open trigger menu. */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape'
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
/** Trigger-menu keyboard routing result. */
export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
@@ -15,7 +15,7 @@
import type { LexicalEditor } from 'lexical'
import {
COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, PASTE_COMMAND,
KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/input.ts'
@@ -84,6 +84,9 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
}),
editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
// Tab drills into a drillable highlighted row; otherwise it passes so the
// browser keeps its native focus traversal.
editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL),
editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
// Escape layering: an open overlay closes; claimed without an overlay
// does NOT release (backspacing the token is the only exit gesture).
@@ -33,4 +33,31 @@ describe('keymap keydown routing', () => {
fireEvent.keyDown(root, { key: 'Enter', metaKey: true })
expect(submit).toHaveBeenCalledWith(true)
})
it('routes Tab through arbitration and passes when unconsumed', () => {
const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } })
const root = document.createElement('div')
root.contentEditable = 'true'
document.body.appendChild(root)
editor.setRootElement(root)
registerPlainText(editor)
const arbitrate = vi.fn<(key: string, composing: boolean) => 'consumed' | 'pick-highlighted' | 'pass'>()
.mockReturnValueOnce('consumed')
.mockReturnValue('pass')
registerComposerKeymap(editor, {
arbitrate,
space: () => false,
dismissPopup: () => {},
canSubmit: () => true,
submit: () => {},
intakeFiles: () => {},
pasteText: () => {},
})
const consumed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
expect(arbitrate).toHaveBeenCalledWith('tab', false)
expect(consumed).toBe(false) // consumed: preventDefault fired
const passed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
expect(passed).toBe(true) // pass: the browser keeps native focus traversal
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
README.md: 248fd14e5c441ebb3ebf7806919d30a5f71a78e4
README.zh.md: a04ce3efba5d3fa32e895429fb119d9b29983fb0
README.md: e4bcf975251d19c91938ced335024fcfcf83034f
README.zh.md: b81c29a12a0b2accb6d9d9731ea5ed790aa3a833
+1 -1
View File
@@ -6,7 +6,7 @@ Input trigger pipeline plugin: `/` and `@` detection under the caret (word-bound
Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `ReferenceInsert.appearance` optionally identifies a `session`, `file`, or `folder` display without changing its serialized `ref`; the consuming composer owns the glyph and color. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name). `showGroupTitle: false` suppresses that row through pending and ready states, while a ready group whose candidates declare sections uses those section rows in place of the source title. The list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name). `showGroupTitle: false` suppresses that row through pending and ready states, while a ready group whose candidates declare sections uses those section rows in place of the source title. The list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the composer surface, rows pick on mousedown, the highlight rides `aria-activedescendant`. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives.
The `/client` exports are the plugin body (`apply`/`inject`), `InputTriggerService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
@@ -6,7 +6,7 @@
分层:`src/core/` 是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`ReferenceInsert.appearance` 可以把显示类型标为 `session``file``folder`,且不会改变其序列化 `ref`;图标与颜色由消费它的输入框负责。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在编辑器表面,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。
`/client` 导出接口是插件主体(`apply``inject`)、`InputTriggerService``MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
@@ -95,6 +95,26 @@
color: var(--dsw-alias-label-tertiary);
}
/* Trailing descent affordance on drillable rows (directories): a quiet
chevron that brightens on its own hover, separate from the row pick. */
.drill {
flex: none;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
margin-left: auto;
border-radius: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 1;
}
.drill:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* Heading row above a source group: non-interactive small grey text,
* padding aligned with items (mirrors ui-primitives Menu .label). */
.groupTitle {
@@ -110,6 +110,22 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) {
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
<span className={css.itemName}>{item.name}</span>
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
{item.drill === true && (
<span
role="button"
aria-label={t('drill.aria')}
className={css.drill}
// mousedown so the composer keeps focus, same as the row;
// stopPropagation keeps the row's settling pick out of it.
onMouseDown={(ev) => {
ev.preventDefault()
ev.stopPropagation()
onPick(group.source, index, 'drill')
}}
>
</span>
)}
</button>
</Fragment>
)
@@ -17,7 +17,7 @@ import { detectTrigger } from '../core/detect.ts'
import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts'
import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts'
import type {
ClientSessionContext, InputTriggerSource, SubmitEnvelope, TriggerChar, TriggerGuard,
ClientSessionContext, InputTriggerSource, PickAction, SubmitEnvelope, TriggerChar, TriggerGuard,
} from '../types.ts'
/** Roster access the controller borrows from the root service (registration order preserved). */
@@ -154,8 +154,9 @@ export class InputTriggerController {
* and execute claim/insert outcomes via the scoped input events.
* @param source - source (group) name.
* @param index - candidate index within the group.
* @param action - settling pick (default) or the candidate's drill action.
*/
pick(source: string, index: number): void {
pick(source: string, index: number, action: PickAction = 'pick'): void {
const state = this.menu.getSnapshot()
const hit = this.hit
if (this.disposed || !state.open || hit === null) return
@@ -169,6 +170,7 @@ export class InputTriggerController {
session: this.project(),
position: hit.position,
via: 'menu',
action,
span: hit.span,
})
this.stopFetch()
@@ -205,6 +207,18 @@ export class InputTriggerController {
this.pick(state.highlight.source, state.highlight.index)
return 'pick-highlighted'
}
case 'tab': {
// Tab drills into the highlighted candidate when it offers descent;
// otherwise the key passes so native focus behavior is untouched.
if (state.highlight === null) return 'pass'
const group = state.groups.find(g => g.source === state.highlight?.source)
const item = group !== undefined && group.status === 'ready'
? group.items[state.highlight.index]
: undefined
if (item?.drill !== true) return 'pass'
this.pick(state.highlight.source, state.highlight.index, 'drill')
return 'consumed'
}
}
}
@@ -74,7 +74,7 @@ export function apply(ctx: ClientContext): void {
const controller = inputTriggers.sessionOf(actx)
return {
menu: controller.menu,
onPick: (source, index) => { controller.pick(source, index) },
onPick: (source, index, action) => { controller.pick(source, index, action) },
onDismiss: () => { controller.dismiss() },
}
},
@@ -10,6 +10,7 @@ export const zh = {
'skill': '技能',
'subagent': '子智能体',
'loading': '正在加载…',
'drill.aria': '进入目录',
'suggestions.aria': '触发候选建议',
} satisfies Record<string, string>
@@ -22,5 +23,6 @@ export const en = {
'skill': 'Skills',
'subagent': 'Subagents',
'loading': 'Loading…',
'drill.aria': 'Browse folder',
'suggestions.aria': 'Trigger suggestions',
} satisfies Record<MenuKey, string>
@@ -1,5 +1,6 @@
/** Slash-menu props for the Conversation-owned input overlay. */
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PickAction } from '../types.ts'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { MenuState } from '../core/contract.ts'
@@ -11,8 +12,9 @@ export interface MenuViewInjected {
* Pointer pick routed back through the service pipeline.
* @param source - source (group) name.
* @param index - candidate index within the group.
* @param action - settling pick (default) or the candidate's drill action.
*/
onPick: (source: string, index: number) => void
onPick: (source: string, index: number, action?: PickAction) => void
/** Dismiss the menu (external pointer outside the composer area). */
onDismiss: () => void
}
@@ -37,6 +37,9 @@ export type TriggerPosition = 'leading' | 'inline'
/** Which of the three pick paths produced a pick. */
export type PickVia = 'menu' | 'space' | 'enter'
/** What a pick asks for: resolve the candidate, or drill into it in place. */
export type PickAction = 'pick' | 'drill'
/** One menu candidate. Pure display data — zero behavior declaration. */
export interface InputTriggerCandidate {
readonly name: string
@@ -47,6 +50,12 @@ export interface InputTriggerCandidate {
readonly section?: string
/** Opaque source-owned pick payload. */
readonly value?: string
/**
* The row offers a drill action beside the settling pick: Tab or the row's
* chevron refines the query in place (directory descent) instead of
* resolving the candidate.
*/
readonly drill?: boolean
}
/**
@@ -74,6 +83,8 @@ export interface InputTriggerPick {
readonly session: ClientSessionContext
readonly position: TriggerPosition
readonly via: PickVia
/** Settling pick, or the candidate's drill action (Tab / row chevron). */
readonly action: PickAction
readonly span: TokenSpan
}
@@ -135,6 +135,27 @@ describe('MenuView', () => {
expect(onPick).toHaveBeenCalledWith('reference', 2)
})
it('renders the drill chevron only on drillable rows and routes its own action', () => {
const { onPick } = mount(openState({
groups: [{
source: 'reference',
status: 'ready',
items: [
{ name: 'Folder · src/', drill: true },
{ name: 'File · README.md' },
],
}],
highlight: { source: 'reference', index: 0 },
}))
const chevrons = screen.getAllByRole('button', { name: '进入目录' })
expect(chevrons).toHaveLength(1)
// The chevron drills; the row body still settles the pick untouched.
fireEvent.mouseDown(chevrons[0]!)
expect(onPick).toHaveBeenCalledWith('reference', 0, 'drill')
fireEvent.mouseDown(screen.getAllByRole('option')[0]!)
expect(onPick).toHaveBeenCalledWith('reference', 0)
})
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
mount(openState({ highlight: { source: 'command', index: 1 } }))
const listbox = screen.getByRole('listbox')
@@ -707,6 +707,28 @@ describe('arbitrate', () => {
expect(controller.menu.getSnapshot().open).toBe(false)
})
it('tab drills into a drillable highlight and passes on plain rows', async () => {
const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
const { controller } = controllerBench([drillable.source])
controller.track('/s', 2, { tier: 'plain' }, 1)
await tick()
expect(controller.arbitrate('tab', false)).toBe('consumed')
expect(drillable.picks[0]!.action).toBe('drill')
expect(drillable.picks[0]!.candidate.name).toBe('src')
// Plain row (no drill flag): the key passes so native focus stays intact.
controller.track('/s', 2, { tier: 'plain' }, 2)
await tick()
controller.arbitrate('down', false)
expect(controller.arbitrate('tab', false)).toBe('pass')
expect(drillable.picks).toHaveLength(1)
})
it('a settling pick reports the pick action', async () => {
const { controller, cmd } = await menuBench()
controller.arbitrate('enter', false)
expect(cmd.picks[0]!.action).toBe('pick')
})
it('IME composition passes every key untouched', async () => {
const { controller } = await menuBench()
for (const key of ['up', 'down', 'enter', 'escape'] as const) {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md
README.md: 65387985b13a31c94d440c59f407811903645eac
README.zh.md: 0d57694122313fe883886c7d0354d13874e7b046
README.md: 9da6e756bfbfd43dc690cb242fcf72da6f26bd68
README.zh.md: dda55309dd9047c7d85854678942f3aa0d4d3d08
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, and renders the rows under non-selectable file and session section headings without a redundant raw `reference` source title. Either failed candidate domain degrades independently. An open `@"…` token searches files only.
File picks preserve the natural text defined by the shared `@path` grammar as their hidden serialized and clipboard form. A file closes completion as an atomic inline reference displayed with a file glyph, business-color filename, and no capsule. A directory remains plain editable path text with a folder glyph and keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted.
File picks preserve the natural text defined by the shared `@path` grammar as their hidden serialized and clipboard form. A file closes completion as an atomic inline reference displayed with a file glyph, business-color filename, and no capsule. A directory row carries two verbs: the settling pick (row click or Enter) resolves the folder itself as the same kind of atomic reference — folder glyph, trailing-slash label, canonical `@dir/` mention as its serialized form — while the drill action (Tab or the row's chevron) keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted.
Session picks insert an atomic inline reference whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. Its visible form is a chat-bubble glyph plus the business-color session title, without a capsule; serialization never reconstructs identity from that title. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`.
+1 -1
View File
@@ -4,7 +4,7 @@
统一的 Web `@file``@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list``sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。
选择文件会把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。文件会关闭补全,并显示为文件图标加业务色文件名、无胶囊容器的原子行内引用。目录仍是带文件夹图标的可编辑路径纯文本,并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
选择文件会把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。文件会关闭补全,并显示为文件图标加业务色文件名、无胶囊容器的原子行内引用。目录行携带两个动词:选定 pick(点击行主体或 Enter)把文件夹本身解析为同类原子引用——文件夹图标、带尾斜杠的标签、以规范 `@dir/` mention 为序列化形式;钻取动作(Tab 或行尾 chevron)则保持可编辑路径纯文本,并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
选择会话会插入一个原子的行内引用,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见形式为聊天气泡图标加业务色会话标题,不使用胶囊容器;序列化永远不会根据该标题重建身份。普通发送会通过 `session.prompt` 携带规范 mentionsession-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
@@ -52,20 +52,24 @@ export function apply(ctx: ClientContext): void {
...sessionItems.map(candidate => sessionCandidate(candidate, t)),
]
},
onPick({ candidate }) {
onPick({ candidate, action }) {
const value = parseCandidate(candidate.value)
if (value?.kind === 'file') {
return value.fileKind === 'directory'
? { text: value.mention, continue: true }
: {
insert: {
source: 'reference',
ref: value.mention,
label: value.label,
appearance: 'file',
clipboardText: value.mention,
},
}
// A directory row carries two verbs: the settling pick resolves the
// folder itself as an atomic reference, while the drill action (Tab /
// row chevron) keeps the literal descent text and the open menu.
if (value.fileKind === 'directory' && action === 'drill') {
return { text: value.mention, continue: true }
}
return {
insert: {
source: 'reference',
ref: value.mention,
label: value.fileKind === 'directory' ? `${value.label}/` : value.label,
appearance: value.fileKind === 'directory' ? 'folder' : 'file',
clipboardText: value.mention,
},
}
}
if (value?.kind === 'session') {
return {
@@ -111,6 +115,7 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean
description: candidate.path,
section: t('section.files'),
value: JSON.stringify(value),
...(directory ? { drill: true } : {}),
}]
}
@@ -209,6 +209,7 @@ describe('candidates', () => {
session,
position: 'inline',
via: 'menu',
action: 'pick',
span: { start: 0, end: 6, draftRev: 1 },
})).toEqual({
insert: {
@@ -276,18 +277,33 @@ describe('candidates', () => {
})
describe('pick and codec', () => {
const pick = (source: InputTriggerSource, candidate: InputTriggerCandidate) => source.onPick({
candidate,
session,
position: 'inline',
via: 'menu',
span: { start: 0, end: 1, draftRev: 1 },
})
const pickAs = (action: 'pick' | 'drill') =>
(source: InputTriggerSource, candidate: InputTriggerCandidate) => source.onPick({
candidate,
session,
position: 'inline',
via: 'menu',
action,
span: { start: 0, end: 1, draftRev: 1 },
})
const pick = pickAs('pick')
const drill = pickAs('drill')
it('inserts files as atomic icon labels while keeping directory completion open', async () => {
it('settles files and directories as atomic icon labels; drill keeps directory completion open', async () => {
const { source } = await bench()
const [directory, file] = await source.candidates(session, request(''))
expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true })
expect(directory?.drill).toBe(true)
expect(file?.drill).toBeUndefined()
expect(pick(source, directory!)).toEqual({
insert: {
source: 'reference',
ref: '@src/',
label: 'src/',
appearance: 'folder',
clipboardText: '@src/',
},
})
expect(drill(source, directory!)).toEqual({ text: '@src/', continue: true })
expect(pick(source, file!)).toEqual({
insert: {
source: 'reference',
@@ -298,7 +314,7 @@ describe('pick and codec', () => {
},
})
const [quotedDirectory] = await source.candidates(session, request('', { quoted: true }))
expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true })
expect(drill(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true })
})
it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => {
@@ -355,6 +355,7 @@ describe('pick lands plain text', () => {
session: proj('s1'),
position: 'leading',
via: 'menu',
action: 'pick',
span: { start: 0, end: 4, draftRev: 7 },
})
expect(outcome).toEqual({ text: '/commit-helper ' })