({ kind: 'success' }))
+ const restored = new SessionInputShell({
+ actx: {} as ClientContext,
+ defaultSink: sink,
+ commandImages,
+ })
+ restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
+ restored.submit()
+ await vi.waitFor(() => {
+ expect(sink).toHaveBeenCalledWith(spacedMention, [], 'queue', expect.any(AbortSignal))
+ })
+ })
+
it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
const serializeReference = vi.fn(() => Promise.resolve(mention))
const sink = vi.fn<(
@@ -55,8 +91,8 @@ describe('reference submission', () => {
})
chip(shell)
expect(shell.snapshot).toMatchObject({
- draft: `${PLACEHOLDER} `,
- occurrences: [{ source: 'reference', ref: mention, label: '@Research' }],
+ draft: '@Research ',
+ occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
})
shell.submit('queue')
@@ -66,8 +102,8 @@ describe('reference submission', () => {
})
expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot).toMatchObject({
- draft: `${PLACEHOLDER} `,
- occurrences: [{ source: 'reference', ref: mention, label: '@Research' }],
+ draft: '@Research ',
+ occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
})
expect(shell.notices.getSnapshot()).toMatchObject({
level: 'error',
@@ -101,7 +137,7 @@ describe('reference submission', () => {
expect(shell.snapshot.phase).toBe('plain')
})
expect(sink).not.toHaveBeenCalled()
- expect(shell.snapshot.draft).toBe(`${PLACEHOLDER} `)
+ expect(shell.snapshot.draft).toBe('@Research ')
expect(shell.snapshot.occurrences).toHaveLength(1)
expect(shell.notices.getSnapshot()).toMatchObject({
level: 'error',
diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml
index 12966cb57e..cfae1ce880 100644
--- a/packages/client/ui-input-trigger/README.i18n.yaml
+++ b/packages/client/ui-input-trigger/README.i18n.yaml
@@ -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: 917a0be02d48260704be8dc2c2f70504138c1957
-README.zh.md: cf33c51c40edd53a492416b9654cb9e69680aebd
+README.md: 248fd14e5c441ebb3ebf7806919d30a5f71a78e4
+README.zh.md: a04ce3efba5d3fa32e895429fb119d9b29983fb0
diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md
index 917a0be02d..248fd14e5c 100644
--- a/packages/client/ui-input-trigger/README.md
+++ b/packages/client/ui-input-trigger/README.md
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Enter adjudication also carries a `SubmitEnvelope` (the composer's image-attachment count) so a source can refuse a submission it cannot consume whole; a `CommandClaim` declares `images: true` when its command accepts composer images, and its `submit` then receives the serialized payloads as a third argument.
-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. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration.
+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); 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 textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
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.
diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md
index cf33c51c40..a04ce3efba 100644
--- a/packages/client/ui-input-trigger/README.zh.md
+++ b/packages/client/ui-input-trigger/README.zh.md
@@ -4,9 +4,9 @@
输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。回车裁决还携带 `SubmitEnvelope`(composer 的图片附件数量),使 source 能拒绝它无法整体消费的提交;命令接受 composer 图片时,`CommandClaim` 声明 `images: true`,其 `submit` 随之以第三个参数收到序列化后的图片载荷。
-分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。
+分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`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 显示其原名);列表高度受限于 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 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
`/client` 导出接口是插件主体(`apply`/`inject`)、`InputTriggerService`、`MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx
index f0edeed1cc..fbe1bf72b8 100644
--- a/packages/client/ui-input-trigger/src/client/MenuView.tsx
+++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx
@@ -81,7 +81,9 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) {
{/* Source names key the dictionary open-endedly: the lookup chain
returns an unknown key verbatim, so an unregistered source
shows its raw name — hence the cast past the typed key union. */}
- {t(group.source as MenuKey)}
+ {group.showGroupTitle === false || group.items.some(item => item.section !== undefined)
+ ? null
+ : {t(group.source as MenuKey)}
}
{group.status === 'pending'
? {t('loading')}
: group.items.map((item, index) => {
diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts
index 32c9a3cc68..68bb5a15cc 100644
--- a/packages/client/ui-input-trigger/src/client/controller.ts
+++ b/packages/client/ui-input-trigger/src/client/controller.ts
@@ -112,7 +112,7 @@ export class InputTriggerController {
return
}
if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) {
- this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name)))
+ this.menu.set(seedGroups(this.menu.getSnapshot(), roster))
}
this.reduce({ type: 'hit', hit })
this.fetchCandidates(hit, roster)
@@ -140,7 +140,7 @@ export class InputTriggerController {
this.stopFetch()
this.hit = hit
this.launcher.set(source)
- this.menu.set(seedGroups(this.menu.getSnapshot(), [source]))
+ this.menu.set(seedGroups(this.menu.getSnapshot(), [match]))
this.reduce({ type: 'hit', hit })
this.fetchCandidates(hit, [match])
}
diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts
index f936e1c0eb..9c11c01324 100644
--- a/packages/client/ui-input-trigger/src/core/contract.ts
+++ b/packages/client/ui-input-trigger/src/core/contract.ts
@@ -36,6 +36,8 @@ export interface MenuState {
readonly generation: number
readonly groups: readonly {
readonly source: string
+ /** False when candidate section rows own all visible group labeling. */
+ readonly showGroupTitle?: boolean
readonly status: 'pending' | 'ready'
readonly items: readonly InputTriggerCandidate[]
}[]
diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts
index 7174fef303..3fe40ee18c 100644
--- a/packages/client/ui-input-trigger/src/core/menu.ts
+++ b/packages/client/ui-input-trigger/src/core/menu.ts
@@ -10,7 +10,7 @@
* while open (query refinement) resets the existing groups to pending under
* a new generation. Auto-close and explicit close drop the groups.
*/
-import type { InputTriggerCandidate } from '../types.ts'
+import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts'
import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
/** Closed rest state with generation 0; store initializer and test seed. */
@@ -21,11 +21,23 @@ export const MENU_CLOSED: MenuState = { open: false, hit: null, generation: 0, g
* Shell-side step before dispatching `hit` on a fresh menu open.
*
* @param state - Current menu state.
- * @param sources - Source names registered for the hit trigger, menu order.
+ * @param sources - Sources registered for the hit trigger, in menu order.
* @returns State carrying the new pending roster; highlight cleared.
*/
-export function seedGroups(state: MenuState, sources: readonly string[]): MenuState {
- return { ...state, groups: sources.map(source => ({ source, status: 'pending', items: [] })), highlight: null }
+export function seedGroups(
+ state: MenuState,
+ sources: readonly Pick[],
+): MenuState {
+ return {
+ ...state,
+ groups: sources.map(source => ({
+ source: source.name,
+ ...(source.showGroupTitle === false ? { showGroupTitle: false } : {}),
+ status: 'pending',
+ items: [],
+ })),
+ highlight: null,
+ }
}
/** Close, preserving the generation so in-flight settlements stay droppable. */
@@ -83,7 +95,7 @@ export const menuReduce: MenuReduce = (state, ev) => {
open: true,
hit: ev.hit,
generation: state.generation + 1,
- groups: state.groups.map(g => ({ source: g.source, status: 'pending', items: [] })),
+ groups: state.groups.map(g => ({ ...g, status: 'pending', items: [] })),
highlight: null,
}
}
@@ -93,7 +105,7 @@ export const menuReduce: MenuReduce = (state, ev) => {
if (idx < 0) return state
const items: readonly InputTriggerCandidate[] = ev.items ?? []
const groups = state.groups.map((g, i) =>
- i === idx ? { source: g.source, status: 'ready' as const, items } : g)
+ i === idx ? { ...g, status: 'ready' as const, items } : g)
if (allReadyEmpty(groups)) return closed(state)
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
return { ...state, groups, highlight }
diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts
index 6d0edcb645..5f2dd6dfe6 100644
--- a/packages/client/ui-input-trigger/src/types.ts
+++ b/packages/client/ui-input-trigger/src/types.ts
@@ -35,7 +35,7 @@ export interface InputTriggerCandidate {
readonly description?: string
readonly icon?: string
readonly hint?: string
- /** Optional visual group heading shared by adjacent candidates. */
+ /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */
readonly section?: string
/** Opaque source-owned pick payload. */
readonly value?: string
@@ -82,15 +82,17 @@ export interface CommandClaim {
}
/**
- * Inline reference insertion. The draft holds one U+FFFC placeholder per
- * occurrence; the owner supplies both user-facing projections at insert time
+ * Inline reference insertion. The draft holds the complete display text while
+ * the occurrence retains its range; the owner supplies both user-facing projections at insert time
* (the model representation is serialized on submit via the source codec).
*/
export interface ReferenceInsert {
readonly source: string
readonly ref: string
- /** Chip display label (fallback-cached on the occurrence). */
+ /** Inline display label (fallback-cached on the occurrence). */
readonly label: string
+ /** Optional domain glyph shown beside the label. */
+ readonly appearance?: 'session' | 'file' | 'folder'
/** Clipboard / persistence projection, e.g. `/name` (never the model form). */
readonly clipboardText: string
}
@@ -177,6 +179,8 @@ export interface InputTriggerSource {
readonly name: string
/** Menu group display order (lower = higher in the list; default 0). */
readonly order?: number
+ /** Whether the menu renders the source-title row; defaults to true. */
+ readonly showGroupTitle?: boolean
candidates(session: ClientSessionContext, req: CandidateRequest): Promise
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
onPick(pick: InputTriggerPick): PickOutcome
diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
index 52872848d9..9ec2d30e91 100644
--- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
+++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
@@ -14,7 +14,7 @@ const hit = (query = ''): TriggerHit => ({
/** Seed sources onto the closed state and open a first generation. */
function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
- return menuReduce(seedGroups(MENU_CLOSED, sources), { type: 'hit', hit: h })
+ return menuReduce(seedGroups(MENU_CLOSED, sources.map(name => ({ name }))), { type: 'hit', hit: h })
}
const item = (name: string) => ({ name })
@@ -40,6 +40,14 @@ describe('menuReduce hit', () => {
expect(s.highlight).toBeNull()
})
+ it('preserves a hidden group title through re-hit and settlement', () => {
+ let s = menuReduce(seedGroups(MENU_CLOSED, [{ name: 'reference', showGroupTitle: false }]), { type: 'hit', hit: hit() })
+ expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'pending' })
+ s = menuReduce(s, { type: 'hit', hit: hit('r') })
+ s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'reference', items: [item('README.md')] })
+ expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'ready' })
+ })
+
it('null hit closes; closing an already-closed state is a no-op reference', () => {
const s = open(['command'])
const c = menuReduce(s, { type: 'hit', hit: null })
diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
index 399ad7f90e..5412d2d7e7 100644
--- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
+++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
@@ -88,6 +88,15 @@ describe('MenuView', () => {
expect(screen.queryByText('正在加载…')).not.toBeNull()
})
+ it('keeps an opted-out source title hidden while its candidates are pending', () => {
+ mount(openState({
+ groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
+ highlight: null,
+ }))
+ expect(screen.queryByText('reference')).toBeNull()
+ expect(screen.getByText('正在加载…')).toBeTruthy()
+ })
+
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
const { view } = mount(openState({
groups: [
@@ -113,6 +122,7 @@ describe('MenuView', () => {
}],
highlight: { source: 'reference', index: 0 },
}))
+ expect(screen.queryByText('reference')).toBeNull()
expect(screen.getAllByText('文件与文件夹')).toHaveLength(1)
expect(screen.getAllByText('Session 对话')).toHaveLength(1)
const options = screen.getAllByRole('option')
diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts
index dca9cab05f..a03839d7bd 100644
--- a/packages/client/ui-input-trigger/tests/service.client.spec.ts
+++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts
@@ -249,6 +249,16 @@ describe('track', () => {
expect(state.highlight).toEqual({ source: 'command', index: 0 })
})
+ it('carries source-title visibility from the roster through candidate settlement', async () => {
+ const reference = deferredSource('@', 'reference', { showGroupTitle: false })
+ const { controller } = controllerBench([reference.source])
+ controller.track('@r', 2, { tier: 'plain' }, 1)
+ expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'pending' })
+ reference.pending[0]!.resolve([{ name: 'README.md', section: '文件与文件夹' }])
+ await tick()
+ expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'ready' })
+ })
+
it('stamps the caller draftRev into the hit span', () => {
const cmd = deferredSource('/', 'command')
const { controller } = controllerBench([cmd.source])
diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml
index a2ccb405e1..a87d1d385b 100644
--- a/packages/client/ui-reference/README.i18n.yaml
+++ b/packages/client/ui-reference/README.i18n.yaml
@@ -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: 12a8e69624c0d7fe28c10ae708466fdda4a8480d
-README.zh.md: bfaee51af3947fe794afab3a99df194c543b0082
+README.md: 65387985b13a31c94d440c59f407811903645eac
+README.zh.md: 0d57694122313fe883886c7d0354d13874e7b046
diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md
index 12a8e69624..65387985b1 100644
--- a/packages/client/ui-reference/README.md
+++ b/packages/client/ui-reference/README.md
@@ -2,11 +2,11 @@
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, renders the rows under non-selectable file and session section headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only.
+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 insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory 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 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.
-Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`.
+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`.
The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect.
@@ -16,7 +16,7 @@ Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance an
#### KV Cache effect
-Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged.
+Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference context that follows that message; earlier target history remains unchanged.
## Known Limitations and Deferred Work
diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md
index bfaee51af3..0d57694122 100644
--- a/packages/client/ui-reference/README.zh.md
+++ b/packages/client/ui-reference/README.zh.md
@@ -2,11 +2,11 @@
[English](README.md) | 中文
-统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。
+统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。
-选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
+选择文件会把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。文件会关闭补全,并显示为文件图标加业务色文件名、无胶囊容器的原子行内引用。目录仍是带文件夹图标的可编辑路径纯文本,并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
-选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
+选择会话会插入一个原子的行内引用,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见形式为聊天气泡图标加业务色会话标题,不使用胶囊容器;序列化永远不会根据该标题重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。
@@ -16,7 +16,7 @@
#### KV 缓存影响
-浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。
+浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及紧随该消息、由宿主准备的会话引用上下文;目标会话更早的历史保持不变。
## 已知限制与暂缓事项
diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts
index fb356e73a8..312db17222 100644
--- a/packages/client/ui-reference/src/client/index.ts
+++ b/packages/client/ui-reference/src/client/index.ts
@@ -33,6 +33,7 @@ export function apply(ctx: ClientContext): void {
const source: InputTriggerSource = {
trigger: '@',
name: 'reference',
+ showGroupTitle: false,
async candidates(session: ClientSessionContext, { query, quoted, signal }) {
const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then(
result => result.ok ? result.value : [],
@@ -54,17 +55,25 @@ export function apply(ctx: ClientContext): void {
onPick({ candidate }) {
const value = parseCandidate(candidate.value)
if (value?.kind === 'file') {
- return {
- text: value.mention + (value.fileKind === 'file' ? ' ' : ''),
- ...value.fileKind === 'directory' ? { continue: true } : {},
- }
+ return value.fileKind === 'directory'
+ ? { text: value.mention, continue: true }
+ : {
+ insert: {
+ source: 'reference',
+ ref: value.mention,
+ label: value.label,
+ appearance: 'file',
+ clipboardText: value.mention,
+ },
+ }
}
if (value?.kind === 'session') {
return {
insert: {
source: 'reference',
ref: value.mention,
- label: `@${value.label}`,
+ label: value.label,
+ appearance: 'session',
clipboardText: value.mention,
},
}
@@ -83,7 +92,7 @@ export function apply(ctx: ClientContext): void {
type Translate = (key: ReferenceKey) => string
type ReferenceCandidateValue =
- | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; mention: string }
+ | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; label: string; mention: string }
| { kind: 'session'; label: string; mention: string }
function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) {
@@ -94,6 +103,7 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean
const value: ReferenceCandidateValue = {
kind: 'file',
fileKind: candidate.kind,
+ label: name,
mention,
}
return [{
diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts
index d75d68d50e..3e813b9e11 100644
--- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts
@@ -107,7 +107,7 @@ describe('apply', () => {
ctx.provide('locale', new LocaleRuntime(ctx))
const ownFiber = ctx.plugin({ inject: [...inject], apply })
await ownFiber.await()
- expect(registered).toMatchObject({ trigger: '@', name: 'reference' })
+ expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false })
await ownFiber.dispose()
expect(registered).toBeUndefined()
await fiber.dispose()
@@ -210,7 +210,15 @@ describe('candidates', () => {
position: 'inline',
via: 'menu',
span: { start: 0, end: 6, draftRev: 1 },
- })).toEqual({ text: '@"README.md" ' })
+ })).toEqual({
+ insert: {
+ source: 'reference',
+ ref: '@"README.md"',
+ label: 'README.md',
+ appearance: 'file',
+ clipboardText: '@"README.md"',
+ },
+ })
expect(sessions).not.toHaveBeenCalled()
await expect(source.candidates(session, request('research'))).resolves.toEqual([
expect.objectContaining({ name: 'Session · Research' }),
@@ -276,11 +284,19 @@ describe('pick and codec', () => {
span: { start: 0, end: 1, draftRev: 1 },
})
- it('inserts files as path text, keeping directory completion open', async () => {
+ it('inserts files as atomic icon labels while keeping 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(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' })
+ expect(pick(source, file!)).toEqual({
+ insert: {
+ source: 'reference',
+ ref: '@"docs/a b.md"',
+ label: 'a b.md',
+ appearance: 'file',
+ clipboardText: '@"docs/a b.md"',
+ },
+ })
const [quotedDirectory] = await source.candidates(session, request('', { quoted: true }))
expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true })
})
@@ -294,7 +310,8 @@ describe('pick and codec', () => {
insert: {
source: 'reference',
ref: mention,
- label: '@Research',
+ label: 'Research',
+ appearance: 'session',
clipboardText: mention,
},
})
diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml
index 489b884d57..f36342acf8 100644
--- a/packages/context/session-reference/README.i18n.yaml
+++ b/packages/context/session-reference/README.i18n.yaml
@@ -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/context/session-reference/README.md
-README.md: 13dfcf2fa99a118efa03f4f183b6b426bf704c2b
-README.zh.md: a81f8c62942ca9a9b3b10c1c109c1fb227971ec0
+README.md: 1ce5ab0a3b0cb5342c1087ddd644933a92b2a596
+README.zh.md: 2fc30fa01d0e428a2e7ef0be1680a1937f06660c
diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md
index 13dfcf2fa9..1ce5ab0a3b 100644
--- a/packages/context/session-reference/README.md
+++ b/packages/context/session-reference/README.md
@@ -14,7 +14,7 @@ English | [中文](README.zh.md)
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source when the target message reaches `agent/pre-step`. A queued message therefore captures the source state at model-step entry, and the resulting context is immutable after that point. Projection keeps only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
-The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately before the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records a sourced context `user/message` followed by the readable direct `user/message`; source mutation after capture cannot change target replay.
+The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately after the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records the readable direct `user/message` followed by its sourced context `user/message`; source mutation after capture cannot change target replay.
## Configuration
@@ -32,7 +32,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac
#### What the model sees
-The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
+The model sees two consecutive user-role messages: the current message with its readable `@label`, then the `## Referenced sessions` untrusted snapshot. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user explicitly repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
#### Token effect
@@ -40,7 +40,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps
#### KV Cache effect
-The snapshot and request are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
+The request and snapshot are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
## Known Limitations and Deferred Work
diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md
index a81f8c6294..2fc30fa01d 100644
--- a/packages/context/session-reference/README.zh.md
+++ b/packages/context/session-reference/README.zh.md
@@ -14,7 +14,7 @@
目标消息到达 `agent/pre-step` 时,准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`。因此,queued 消息在进入模型步骤时捕获源状态,此后生成的上下文保持不变。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。
-上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧前。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`;捕获后的源变更无法改变目标回放。
+上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧后。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录可读的直接 `user/message`,再记录其带来源信息的上下文 `user/message`;捕获后的源变更无法改变目标回放。
## 配置
@@ -32,7 +32,7 @@
#### 模型看到的内容
-模型会看到两条连续的 user 角色消息:先是 `## Referenced sessions` 不受信任快照,再是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
+模型会看到两条连续的 user 角色消息:先是带可读 `@label` 的当前消息,再是 `## Referenced sessions` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前用户明确重复这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
#### Token 影响
@@ -40,7 +40,7 @@
#### KV Cache 影响
-快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
+请求与快照是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
## 已知限制与暂缓事项
diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts
index 433ee1d0b1..36d443d67a 100644
--- a/packages/context/session-reference/src/index.ts
+++ b/packages/context/session-reference/src/index.ts
@@ -115,11 +115,11 @@ export class SessionReferenceResolver extends TypertRemoteService {
/**
* Replace canonical mentions in direct user messages and place each prepared
- * snapshot immediately before the message that cited it.
+ * snapshot immediately after the message that cited it.
* @param agent - agent entering the model step.
* @param messages - messages accepted by downstream pre-step listeners.
* @param signal - active turn cancellation.
- * @returns messages with session-reference context inserted in citation order.
+ * @returns direct messages followed by their session-reference context in citation order.
*/
private async prepareDirectMessages(
agent: Agent,
@@ -142,7 +142,7 @@ export class SessionReferenceResolver extends TypertRemoteService {
if (resolved.additionalContext === undefined) {
throw new Error('session-reference preparation omitted context for a canonical mention')
}
- return [resolved.additionalContext, direct]
+ return [direct, resolved.additionalContext]
}))
return prepared.flat()
}
diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts
index e1eac75658..921e1703c4 100644
--- a/packages/context/session-reference/tests/session-reference.spec.ts
+++ b/packages/context/session-reference/tests/session-reference.spec.ts
@@ -336,18 +336,18 @@ describe('session reference discovery and preparation', () => {
expect(decision.kind).toBe('enter')
if (decision.kind !== 'enter') throw new Error('expected entered pre-step')
expect(decision.messages).toHaveLength(4)
- expect(decision.messages[0]?.source).toMatchObject({
- kind: 'session-reference',
- references: [{ sessionId: source.id, label: 'Research' }],
- })
- expect(decision.messages[1]).toMatchObject({
+ expect(decision.messages[0]).toMatchObject({
id: direct.id,
content: [
{ type: 'text', text: 'compare @Research now' },
{ type: 'reasoning', text: 'preserve this non-text block' },
],
})
- expect(decision.messages[1]).not.toBe(direct)
+ expect(decision.messages[0]).not.toBe(direct)
+ expect(decision.messages[1]?.source).toMatchObject({
+ kind: 'session-reference',
+ references: [{ sessionId: source.id, label: 'Research' }],
+ })
expect(decision.messages[2]).toBe(ordinary)
expect(decision.messages[3]).toBe(plugin)
})
@@ -695,11 +695,11 @@ describe('session reference discovery and preparation', () => {
)
const context = prepared.additionalContext
if (context === undefined) throw new Error('expected prepared context')
- target.append('user/message', context, { surfaceOp: 'append' })
target.append('user/message', createUserMessage({
content: prepared.content,
source: { kind: 'user' },
}), { surfaceOp: 'append' })
+ target.append('user/message', context, { surfaceOp: 'append' })
const before = target.deriveMessages()
const later = source.append(