Merge pull request #2769 from deepseek-harness/worktree/web-reference-ui-polish

fix(web): align reference input and recall ordering
This commit is contained in:
Yichen Jiang
2026-08-19 18:12:46 +08:00
committed by GitHub
50 changed files with 1058 additions and 341 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
2026-07-21-cross-session-references.md: 774a948345e3d45adbba47ef6a7edd3e6f0740b2
2026-07-21-cross-session-references.zh.md: fd864203d17954167646edf3b1a62946f8fa3f23
2026-07-21-cross-session-references.md: 48a3241871f0b65270aaf74e42c9d02c45eaf027
2026-07-21-cross-session-references.zh.md: ef723115f0de4312c0f4b1d215303b4a6a31be47
@@ -22,11 +22,11 @@ Preparation deduplicates in first-appearance order, rejects the target id, enfor
Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery.
One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope.
One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately after the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope.
## Message ownership
The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately before that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering.
The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately after that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering.
Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path.
@@ -34,7 +34,7 @@ Reference preparation is not a new delivery protocol and does not create a turn
The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id.
Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON.
Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with the direct message immediately before it and renders a compact source summary instead of exposing the snapshot JSON.
The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services.
@@ -55,7 +55,7 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b
## Verification
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path.
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, Chat-projected following-recall association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path.
## Consequences
@@ -22,11 +22,11 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但
投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。
系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message`。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。
系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message`。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。
## 消息所有权
该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。
该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。
引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。
@@ -34,7 +34,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但
统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。
Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。
Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与紧邻其前的直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。
[仅面向自动化的 ACPAgent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。
@@ -55,7 +55,7 @@ Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,
## 验证
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、Chat 投影的后继召回关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。
## 后果
@@ -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 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md
2026-07-27-web-file-and-session-references.md: ad8e5c53832a567bd38d1d1e560122cb8b630daa
2026-07-27-web-file-and-session-references.zh.md: acb016866efc42ef3ea9f661cb10ee1459cf1a6b
2026-07-27-web-file-and-session-references.md: 51447d0c22c2e5beb4eae03e7f955239525fc16d
2026-07-27-web-file-and-session-references.zh.md: f2f87aabaa818fe5d104b0ee1488658c792f59b9
@@ -10,20 +10,20 @@ The Web composer had a reusable slash/reference trigger pipeline, but its `@` so
## Decision
Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other.
Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. The source suppresses its raw group title through loading and settled states because those section headings own the visible grouping. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other.
The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash.
The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick is an atomic composer reference with a file glyph and filename; its serialized form remains path-only prompt text. A directory stays editable path text with a folder glyph and retriggers completion below its trailing slash.
A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. Ordinary `session.prompt` delivery carries that mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately before that message. The API Proxy contains no reference-specific route, dependency, or error code.
A session pick is a structured composer reference. Its visible form uses a chat-bubble glyph and business-color session title without a capsule, while its clipboard and model form is the canonical `@[label](dsh-session:…)` mention produced by the Host. The complete `@label` display text remains in the transparent textarea, and the same-size backdrop colors that range and replaces its leading marker with the domain glyph. Native glyph metrics therefore determine width, wrapping, selection, and caret placement without truncation. The occurrence range retains reference identity for serialization; Backspace or Delete at its boundary removes it whole, and editing inside it turns the remaining characters into ordinary text. Ordinary `session.prompt` delivery carries the canonical mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately after that message. The recalled-context row uses the same chat glyph while other context keeps the document glyph. The API Proxy contains no reference-specific route, dependency, or error code.
The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The concrete user and steering chat-node definition associates labels from an immediately preceding session-reference context, so the renderer receives the association from its own node data and shows a compact source summary instead of snapshot JSON.
The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Its session-store mirror persists each occurrence's canonical clipboard projection, so remounting without the occurrence table retains a parseable reference instead of a display-only label. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order and associates exact session labels only from the immediately following sourced recall, which preserves multi-word titles and keeps consecutive references independent. It decorates recognized file and session mentions as icon-and-text references, treats unquoted `@path` tokens including extensionless basenames as files, leaves sentence punctuation outside the reference range, and keeps snapshot JSON behind the collapsed recall row.
## Reference transaction
```text
type @ → parallel file/session Remote calls → pick path text or canonical session chip
type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference
→ serialize draft → ordinary session.prompt enqueue
→ agent/pre-step parses mentions → capture sources → context + readable prompt
→ agent/pre-step parses mentions → capture sources → readable prompt + context
```
File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination.
@@ -42,7 +42,7 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess
## Verification
Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, pre-step preparation, downstream rejection, and chat-node-owned label association. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition.
Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, disabled-layer ownership, canonical draft persistence across remount, adjacent-reference and adjacent-text projection, extensionless file and sentence-punctuation rendering, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and following-recall association for multi-word and consecutive labels. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition, and replays a multi-word session label in direct-before-recall order.
## Consequences
@@ -10,20 +10,20 @@ Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的
## 决策
Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file``@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。
Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file``@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。该 source 在加载和已结算状态下都会隐藏原始组标题,因为可见分组由这些分组标题拥有。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。
文件功能遵循由三个包构成的 seam`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。
文件功能遵循由三个包构成的 seam`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件会创建带文件图标与文件名的原子输入框引用,其序列化形式仍只是路径提示词文本。目录保持为带文件夹图标的可编辑路径文本,并在尾部斜杠后重新触发补全。
选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。普通 `session.prompt` 投递会原样携带 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧。API Proxy 不包含引用专用路由、依赖或错误码。
选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。完整的 `@label` 展示文本会保留在透明 textarea 中,同尺寸 backdrop 会为这段范围着色,并把开头的 marker 替换为对应领域图标。因此宽度、换行、选择区与光标位置都由原生字形度量决定,不会截断。occurrence 范围会保留引用身份以供序列化;在边界按 Backspace 或 Delete 会整段删除引用,在范围内部编辑则会把剩余字符转为普通文本。普通 `session.prompt` 投递会原样携带规范 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧后。召回上下文行使用同一个聊天图标,其他上下文保留文档图标。API Proxy 不包含引用专用路由、依赖或错误码。
输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。具体的 user 和 steering chat-node 定义会关联紧邻前一条 session-reference 上下文中的标签,因此渲染器会从自身节点数据接收关联信息,并显示精简的来源摘要,而不是快照 JSON
输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。它写入会话 store 的镜像会持久化每个 occurrence 的规范剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,仍会保留可解析的引用,而不是仅供显示的标签。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,并且只从紧随其后的带来源召回中关联准确的会话标签,因此既能保留多词标题,也能让连续引用彼此独立。它会把识别到的文件与会话 mention 装饰成图标加文字的引用,把包括无扩展名 basename 在内的未加引号 `@path` token 视为文件,将句末标点留在引用范围之外,并把快照 JSON 保留在默认收起的召回行中
## 引用事务
```text
type @ → parallel file/session Remote calls → pick path text or canonical session chip
type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference
→ serialize draft → ordinary session.prompt enqueue
→ agent/pre-step parses mentions → capture sources → context + readable prompt
→ agent/pre-step parses mentions → capture sources → readable prompt + context
```
文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。
@@ -42,7 +42,7 @@ type @ → parallel file/session Remote calls → pick path text or canonical se
## 验证
包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 准备、下游拒绝,以及 chat node 自有的标签关联。无密钥的装配 Web 快照会渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用。
包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、禁用状态下的层级归属、跨重新挂载的规范草稿持久化、相邻引用及相邻文本条件下的引用投影、无扩展名文件与句末标点渲染、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及多词与连续标签的后继召回关联。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签
## 后果
+80 -4
View File
@@ -1,6 +1,6 @@
// 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 back into the composer without issuing a model call.
// and projects each pick as a complete inline range without issuing a model call.
import { writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -13,6 +13,7 @@ import {
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-reference/types'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
@@ -28,8 +29,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url))
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md')
const MODE = webSnapshotMode()
const SOURCE_SESSION_ID = 'reference-source-session'
const TARGET_SESSION_ID = 'reference-order-target-session'
/** Build one closed source session with a stable title for reference discovery. */
function sourceSessionFixture(): string {
@@ -60,6 +63,53 @@ function sourceSessionFixture(): string {
].join('\n')
}
/** Build one target log with the direct message durably before its recalled context. */
function targetSessionFixture(): string {
const session = Session.create(SessionId(TARGET_SESSION_ID))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '@Research notes what changed?' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '## Referenced sessions\n\n<referenced-sessions>snapshot</referenced-sessions>' }],
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [{
sessionId: SOURCE_SESSION_ID,
label: 'Research notes',
capturedThroughSeq: 4,
compacted: false,
originalMessages: 2,
retainedMessages: 2,
omittedMessages: 0,
omittedBytes: 0,
truncated: false,
inputIndex: 0,
}],
},
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Reference order target',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -69,6 +119,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID)
await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -83,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await scaffold?.close()
})
it('groups both sources and projects file text plus an atomic session chip', async () => {
it('groups both sources and projects files and sessions as structured inline icon labels', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer'))
const input = page.locator('textarea').first()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
@@ -94,20 +145,45 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Files & folders')
expect(snapshot).toContain('Session conversations')
expect(snapshot).not.toContain('text: reference Files & folders')
expect(snapshot).toContain('File \u00b7 reference.txt')
expect(snapshot).toContain('Session \u00b7 Research notes')
expect(snapshot).not.toContain('text: Subagents')
await input.fill('@reference')
await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click()
const fileReference = page.locator('[data-reference-appearance="file"]')
await expect.poll(() => fileReference.textContent()).toBe('@reference.txt')
await expect.poll(() => fileReference.locator('svg').count()).toBe(1)
await expect.poll(() => input.inputValue()).toBe('@reference.txt ')
await input.fill('@Research')
await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click()
await expect.poll(() => page.locator('[data-decoration="chip"]').textContent()).toBe('@Research notes')
const sessionReference = page.locator('[data-reference-appearance="session"]')
await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
await expect.poll(() => input.inputValue()).toBe('@Research notes ')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
})
it('renders the durable direct-message then recall order', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
await group.waitFor({ timeout: 15_000 })
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first()
await target.waitFor({ timeout: 15_000 })
await target.click()
await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(TARGET_SESSION_ID).join('{{targetId}}')
await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE)
expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
})
})
@@ -1,5 +1,6 @@
- listbox "Trigger suggestions":
- text: reference Files & folders
- text: Files & folders
- option "File · reference.txt reference.txt" [selected]
- text: Session conversations
- option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}"
- option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}"
@@ -0,0 +1,23 @@
- banner:
- navigation "Session hierarchy":
- button "Reference order target" [disabled]
- button "Session log":
- text: Session log
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Research notes what changed? Referenced session · Research notes {{clock}}
- button "Copy":
- img
- button "Session recall Research notes":
- img
- text: Session recall Research notes
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
@@ -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-conversation/README.md
README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0
README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0
README.md: cbd57ba05a96da36ac9198817e78594c1a2cefff
README.zh.md: cbe13efd43abda020edae41a0adee09d991314fe
+1 -1
View File
@@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
+1 -1
View File
@@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。
会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
@@ -37,7 +38,9 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
icon={provenance.role === 'recall'
? <span data-context-recall-icon><ReferenceIcon kind="session" /></span>
: <IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
@@ -273,18 +273,19 @@
}
}
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
spans and metadata-confirmed sessions render as chips; free geometry means
the textarea overlay's metric pairing does not apply here). */
/* Inline references use domain glyphs and business-color text without another
container inside the user bubble. */
.refChip {
display: inline-block;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 2px;
padding: 0 8px;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
color: var(--dsw-alias-label-primary);
font-size: 0.85em;
line-height: 1.6;
color: var(--dsw-alias-state-business-primary);
font-weight: 500;
white-space: nowrap;
vertical-align: baseline;
}
.refIcon {
flex: none;
}
@@ -10,6 +10,7 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
@@ -162,23 +163,46 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN
start = text.indexOf(label, start + label.length)
}
}
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g
const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const tokenStart = m.index + (m[1]?.length ?? 0)
const label = m[2] ?? ''
const rawLabel = m[2] ?? ''
const label = rawLabel.startsWith('@"')
? rawLabel
: rawLabel.replace(/[.,;:!?]+$/gu, '')
if (label.length <= 1) continue
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
}
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
ranges.sort((a, b) => a.start - b.start
|| (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1))
const parts: ReactNode[] = []
let cursor = 0
for (const range of ranges) {
if (range.start < cursor) continue
const { start: tokenStart, end, label, kind } = range
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
const referenceKind = kind === 'session'
? 'session'
: label.startsWith('@')
? label.endsWith('/') ? 'folder' : 'file'
: undefined
const displayLabel = referenceKind === undefined
? label
: referenceKind === 'session'
? label.slice(1)
: label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1)
parts.push(
<span key={tokenStart} className={css.refChip} data-ref-chip={kind === 'session' ? 'session' : label.startsWith('@') ? 'subagent' : 'skill'}>
{label}
<span
key={tokenStart}
className={css.refChip}
data-ref-chip={referenceKind ?? 'skill'}
title={label}
>
{referenceKind !== undefined && (
<ReferenceIcon kind={referenceKind} size={16} className={css.refIcon} />
)}
{displayLabel}
</span>,
)
cursor = end
@@ -5,6 +5,7 @@ import type {
ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice,
PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-runtime/client'
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
@@ -138,6 +139,99 @@ function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConvers
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
}
function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
const candidate = node as ChatNode
return candidate.kind === 'user' || candidate.kind === 'steering'
? candidate.data.seq
: undefined
}
function followingRecall(node: ChatConversationViewNode): {
readonly messageSeq: number
readonly labels: readonly string[]
} | undefined {
const candidate = node as ChatNode
if (candidate.kind !== 'context') return undefined
return {
messageSeq: candidate.data.seq - 1,
labels: sessionRecallLabels(candidate.data.source),
}
}
function withReferenceLabels(
node: ChatConversationViewNode,
labels: readonly string[],
): ChatConversationViewNode {
const candidate = node as ChatNode
if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node
const current = candidate.data.referenceLabels ?? EMPTY_KEYS
const hasLabels = Object.hasOwn(candidate.data, 'referenceLabels')
if (sameReferences(current, labels) && hasLabels === (labels.length > 0)) return node
const data: Record<string, unknown> = { ...candidate.data }
if (labels.length === 0) delete data.referenceLabels
else data.referenceLabels = labels
return { ...candidate, data }
}
/** Associates a direct message with the sourced recall event that immediately follows it. */
class ReferenceLabelProjector {
private readonly messagesBySeq = new Map<number, string>()
private readonly labelsByMessageSeq = new Map<number, readonly string[]>()
replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
this.messagesBySeq.clear()
this.labelsByMessageSeq.clear()
for (const node of nodes) {
const messageSeq = referenceMessageSeq(node)
if (messageSeq !== undefined) this.messagesBySeq.set(messageSeq, node.key)
const recall = followingRecall(node)
if (recall !== undefined && recall.labels.length > 0) {
this.labelsByMessageSeq.set(recall.messageSeq, recall.labels)
}
}
return nodes.map((node) => {
const messageSeq = referenceMessageSeq(node)
return messageSeq === undefined
? node
: withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS)
})
}
apply(
upserts: readonly ChatConversationViewNode[],
store: ChatNodeStore,
): readonly ChatConversationViewNode[] {
const byKey = new Map(upserts.map(node => [node.key, node]))
const affected = new Set<number>()
for (const node of upserts) {
const messageSeq = referenceMessageSeq(node)
if (messageSeq !== undefined) {
this.messagesBySeq.set(messageSeq, node.key)
affected.add(messageSeq)
}
const recall = followingRecall(node)
if (recall === undefined) continue
const current = this.labelsByMessageSeq.get(recall.messageSeq)
if (recall.labels.length === 0) this.labelsByMessageSeq.delete(recall.messageSeq)
else {
this.labelsByMessageSeq.set(
recall.messageSeq,
current !== undefined && sameReferences(current, recall.labels) ? current : recall.labels,
)
}
affected.add(recall.messageSeq)
}
for (const messageSeq of affected) {
const key = this.messagesBySeq.get(messageSeq)
if (key === undefined) continue
const node = byKey.get(key) ?? store.get(key)
if (node === undefined) continue
byKey.set(key, withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS))
}
return [...byKey.values()]
}
}
interface LegacyContribution {
readonly anchorSeq: number
readonly nodes: readonly ConversationNode[]
@@ -384,6 +478,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
private readonly store = new MutableChatNodeStore()
private readonly locations = new MutableChatLocationIndex()
private readonly legacy = new LegacySliceBuilder()
private readonly referenceLabels = new ReferenceLabelProjector()
private order: readonly string[] = EMPTY_KEYS
readonly empty: ChatSnapshot
@@ -395,19 +490,21 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
readonly nodes: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
this.store.replace(input.nodes)
this.order = orderedVisible(input.nodes).map(node => node.key)
const nodes = this.referenceLabels.replace(input.nodes)
this.store.replace(nodes)
this.order = orderedVisible(nodes).map(node => node.key)
this.locations.rebuild(this.order, this.store)
return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline))
return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
}
apply(input: {
readonly upserts: readonly ChatConversationViewNode[]
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
const upserts = this.referenceLabels.apply(input.upserts, this.store)
let structural = false
const contentOnly: ChatConversationViewNode[] = []
for (const node of input.upserts) {
for (const node of upserts) {
const previous = this.store.get(node.key)
const nodeStructural = previous === undefined
|| previous.anchorSeq !== node.anchorSeq
@@ -416,14 +513,14 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
}
this.store.upsert(input.upserts)
this.store.upsert(upserts)
if (structural) {
const next = orderedVisible(this.store.values()).map(node => node.key)
this.order = sameReferences(this.order, next) ? this.order : next
this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline))
return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
}
private snapshot(
@@ -3,18 +3,18 @@ import type {
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels,
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxState } from './inbox.ts'
import { chatNode } from './common.ts'
interface ReferencedUserMessageNode extends UserMessageNode {
/** Labels cited by the immediately preceding session-reference context. */
/** Labels cited by the immediately following session-reference context. */
readonly referenceLabels?: readonly string[]
}
interface ReferencedSteeringMessageNode extends SteeringMessageNode {
/** Labels cited by the immediately preceding session-reference context. */
/** Labels cited by the immediately following session-reference context. */
readonly referenceLabels?: readonly string[]
}
@@ -61,11 +61,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
}
}
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
const previous = reader.previous<MessageNode>('input-message')
const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq
? sessionRecallLabels(previous.state.source)
: []
const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels }
return claimed
? {
kind: 'steering',
@@ -74,7 +69,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
time: event.time,
content: event.data.content,
source: event.data.source,
...referenceLabels,
}
: {
kind: 'user',
@@ -82,7 +76,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
time: event.time,
content: event.data.content,
source: event.data.source,
...referenceLabels,
}
},
update: context => context.state,
@@ -149,9 +149,9 @@ export interface EditRange extends EditSelection {
}
/**
* One reference chip occurrence, backing exactly one U+FFFC placeholder in
* the draft. Identity is occurrenceId — same-named
* references stay independently addressable. label/clipboardText are the
* One reference occurrence backed by its complete inline display text in the
* draft. Identity is occurrenceId — same-named
* references stay independently addressable. label/appearance/clipboardText are the
* owner's insert-time projections, cached so the chip survives owner loss
* (invalid flips instead of dropping the occurrence).
*/
@@ -162,10 +162,14 @@ export interface Occurrence {
readonly source: string
/** Owner-scoped reference id. */
readonly ref: string
/** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */
/** Display-text offset in the draft. */
readonly offset: number
/** Chip display label (insert-time cache). */
/** Display-text length; the occurrence occupies exactly [offset, offset+length). */
readonly length: number
/** Inline display label (insert-time cache). */
readonly label: string
/** Optional domain glyph (insert-time cache). */
readonly appearance?: ReferenceInsert['appearance']
/** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
readonly clipboardText: string
/** Owner-resolution failure flag: chip renders invalid; serialization must fail. */
@@ -215,7 +219,7 @@ export interface InputState {
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
/** Chip occurrence table, sorted by offset (one U+FFFC per entry). */
/** Reference occurrence table, sorted by offset. */
readonly occurrences: readonly Occurrence[]
/** Live paste-match attempt (absent when no paste is matchable). */
readonly paste?: PasteAttemptState
@@ -248,7 +252,7 @@ export type InputEvent =
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
/** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
/** Delete a settled command token; success is observable as a draftRev advance. */
| { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard }
@@ -1,6 +1,6 @@
/**
* Draft decoration pure core (chips render from the occurrence
* table at placeholder offsets; the claim token renders as a mirror-layer
* Draft decoration pure core (references render from occurrence ranges; the
* claim token renders as a mirror-layer
* highlight, the claim hint as ghost text). Zero React — the skeleton renders
* the instructions; tests drive this directly.
*/
@@ -12,13 +12,19 @@ export interface TokenRange {
readonly end: number
}
/** One chip render instruction: the placeholder at `offset` draws as `label`. */
/** One structured inline-reference render instruction. */
export interface ChipRender {
/** Stable render key (same-labeled chips stay independent). */
readonly occurrenceId: number
/** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */
/** Display-text offset in the draft. */
readonly offset: number
/** Display-text length in the draft. */
readonly length: number
/** Exact inline text whose native glyph metrics determine layout. */
readonly text: string
readonly label: string
/** Optional domain glyph beside the label. */
readonly appearance?: 'session' | 'file' | 'folder'
/** Owner-resolution failure styling bit. */
readonly invalid: boolean
}
@@ -34,6 +40,8 @@ export interface TextRefRange {
readonly start: number
readonly end: number
readonly trigger: '/' | '@'
/** Optional icon domain for syntax-recognizable plain references. */
readonly appearance?: 'folder'
}
/** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */
@@ -42,7 +50,7 @@ export interface DraftDecorations {
readonly token: TokenRange | null
/** Chip render instructions in draft order (occurrence table is offset-sorted). */
readonly chips: readonly ChipRender[]
/** Scan-derived plain-text reference ranges (empty without a lexicon). */
/** Scan-derived lexicon tokens and syntax-recognizable folder ranges. */
readonly textRefs: readonly TextRefRange[]
/** Ghost hint shown while the claim's args are blank; null otherwise. */
readonly hint: string | null
@@ -50,6 +58,7 @@ export interface DraftDecorations {
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g
/**
* Scan the draft for plain-text reference tokens against the hot lexicons.
@@ -63,19 +72,31 @@ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
export function scanTextRefs(
draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>,
): TextRefRange[] {
if (lexicon.size === 0 || draft === '') return []
if (draft === '') return []
const out: TextRefRange[] = []
TEXT_REF_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
const trigger = m[2] as '/' | '@'
const name = m[3] ?? ''
if (lexicon.get(trigger)?.includes(name)) {
const start = m.index + (m[1]?.length ?? 0)
out.push({ start, end: start + 1 + name.length, trigger })
if (lexicon.size > 0) {
TEXT_REF_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
const trigger = m[2] as '/' | '@'
const name = m[3] ?? ''
if (lexicon.get(trigger)?.includes(name)) {
const start = m.index + (m[1]?.length ?? 0)
out.push({ start, end: start + 1 + name.length, trigger })
}
}
}
return out
FOLDER_REF_RE.lastIndex = 0
let folder: RegExpExecArray | null
while ((folder = FOLDER_REF_RE.exec(draft)) !== null) {
const token = folder[2] ?? ''
const start = folder.index + (folder[1]?.length ?? 0)
const end = start + token.length
if (!out.some(range => range.start < end && range.end > start)) {
out.push({ start, end, trigger: '@', appearance: 'folder' })
}
}
return out.sort((left, right) => left.start - right.start)
}
/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */
@@ -97,7 +118,10 @@ export function deriveDecorations(
const chips = occurrences.map(o => ({
occurrenceId: o.occurrenceId,
offset: o.offset,
length: o.length,
text: draft.slice(o.offset, o.offset + o.length),
label: o.label,
...o.appearance === undefined ? {} : { appearance: o.appearance },
invalid: o.invalid === true,
}))
const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === ''
@@ -17,7 +17,7 @@ import type {
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import { InputMachine } from './machine.ts'
import { InputMachine, projectClipboard } from './machine.ts'
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
export interface PopupDismissFace {
@@ -98,12 +98,12 @@ export class SessionInputShell implements SessionInput {
// production (the machine's no-clock default is a constant for pure tests).
private readonly core = new InputMachine({ now: () => Date.now() })
private noticeSeq = 0
private lastDraft = ''
private lastMirroredDraft = ''
private imageIds: readonly DraftAttachmentId[] = []
/** One image-only send at a time: Enter during the Host round-trip is a no-op. */
private imageSendInFlight = false
private disposed = false
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
/** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */
private mirrorFn: ((text: string) => void) | undefined
constructor(private readonly deps: SessionInputDeps) {
@@ -448,7 +448,7 @@ export class SessionInputShell implements SessionInput {
/**
* Prompt serialization before the sink: expand each
* placeholder to its owner's model form via the session controller's
* inline reference range to its owner's model form via the session controller's
* codec routing. Owner missing / serialize failure / disposal blocks the
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
@@ -464,17 +464,21 @@ export class SessionInputShell implements SessionInput {
const controller = new AbortController()
void Promise.all(occurrences.map(async (o) => {
if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
return { offset: o.offset, text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal) }
return {
offset: o.offset,
length: o.length,
text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal),
}
})).then(
(parts) => {
if (this.disposed) return
// Splice model forms over their placeholders (offsets are draft-time;
// Splice model forms over their display ranges (offsets are draft-time;
// parts arrive offset-sorted since the table is).
let out = ''
let cursor = 0
for (const part of parts) {
out += draft.slice(cursor, part.offset) + part.text
cursor = part.offset + 1
cursor = part.offset + part.length
}
out += draft.slice(cursor)
this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds)
@@ -592,9 +596,10 @@ export class SessionInputShell implements SessionInput {
private publish(): void {
const next = this.compose()
this.state.set(next)
if (next.draft !== this.lastDraft) {
this.lastDraft = next.draft
this.mirrorFn?.(next.draft)
const mirroredDraft = projectClipboard(next)
if (mirroredDraft !== this.lastMirroredDraft) {
this.lastMirroredDraft = mirroredDraft
this.mirrorFn?.(mirroredDraft)
}
}
}
@@ -4,8 +4,8 @@
* clock. Package-private the SessionInput shell is the only caller and the
* sole executor of the returned effects.
*
* Draft truth: the draft string holds one U+FFFC placeholder per chip; the
* occurrence table carries identity and the owner's cached projections. Every
* Draft truth: the draft string holds each reference's complete inline display
* text; the occurrence table carries identity, range, and the owner's cached projections. Every
* draft mutation is one transaction draft edit, occurrence reconciliation,
* and undo-log push are atomic inside dispatch() and bumps draftRev, which
* is what lets span CAS reduce to a revision-equality check: equal rev
@@ -20,9 +20,21 @@ import type {
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
} from './contract.ts'
/** The object-replacement character backing every chip occurrence in the draft. */
/** Legacy fixed-width object replacement character rejected from pasted text. */
export const PLACEHOLDER = ''
const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/**
* Build the inline draft text whose leading marker is decorated as the
* reference icon in the backdrop.
* @param reference - reference insertion with its cached display projection.
* @returns display text with one marker glyph followed by the complete label.
*/
export function referenceDraftText(reference: Pick<ReferenceInsert, 'label'>): string {
return `@${reference.label}`
}
/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */
const EMPTY_QUEUE: InputState['queue'] = []
@@ -67,10 +79,9 @@ function diffEdit(prev: string, next: string): EditRange {
}
/**
* Expand the draft's placeholders into their occurrences' clipboard text
* (the persistence mirror and clipboard both write this
* projection U+FFFC never leaves the machine). Table order is offset
* order, so one linear walk pairs placeholders with entries.
* Expand the draft's reference ranges into their occurrences' clipboard text
* for persistence and clipboard projection. Table order is offset order, so
* one linear walk pairs ranges with entries.
* @param state - published input state.
* @returns the plain-text projection of the draft.
*/
@@ -81,7 +92,7 @@ export function projectClipboard(state: Pick<InputState, 'draft' | 'occurrences'
let cursor = 0
for (const o of occurrences) {
out += draft.slice(cursor, o.offset) + o.clipboardText
cursor = o.offset + 1
cursor = o.offset + o.length
}
return out + draft.slice(cursor)
}
@@ -202,15 +213,15 @@ export class InputMachine {
/**
* Reconcile the occurrence table with one edit (old-draft coordinates):
* entries past the range shift by the length delta; entries whose
* placeholder sits inside the replaced range go away whole (a
* deletion/replacement intersecting a placeholder acts on the whole chip).
* entries past the range shift by the length delta; an edit that intersects
* a reference range removes its structured occurrence and leaves the edited
* characters as ordinary draft text.
*/
private reconcile(range: EditRange): void {
const delta = range.insertedLength - (range.end - range.start)
const kept: Occurrence[] = []
for (const o of this.occurrences) {
if (o.offset < range.start) kept.push(o)
if (o.offset + o.length <= range.start) kept.push(o)
else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta })
}
this.occurrences = kept
@@ -225,14 +236,16 @@ export class InputMachine {
}
/** Mint one occurrence at a draft offset. */
private mint(reference: ReferenceInsert, offset: number): Occurrence {
private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence {
this.occurrenceSeq += 1
return {
occurrenceId: this.occurrenceSeq,
source: reference.source,
ref: reference.ref,
offset,
length,
label: reference.label,
...reference.appearance === undefined ? {} : { appearance: reference.appearance },
clipboardText: reference.clipboardText,
}
}
@@ -293,19 +306,20 @@ export class InputMachine {
}
/**
* Shared chip-insertion transaction: replace [span) with one placeholder
* Shared reference-insertion transaction: replace [span) with one inline
* occurrence (insert-ref and paste-upgrade both land here). A separating
* space follows the chip unless one is already next.
* @returns the inserted length (placeholder plus optional gap).
* space follows the reference unless one is already next.
* @returns the inserted length (display text plus optional gap).
*/
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
this.pushTxn()
this.typingRun = undefined
const tail = this.draft.slice(span.end)
const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
const inserted = PLACEHOLDER + gap
const displayText = referenceDraftText(reference)
const inserted = displayText + gap
this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
this.withMinted([this.mint(reference, span.start)])
this.withMinted([this.mint(reference, span.start, displayText.length)])
this.adopt(this.draft.slice(0, span.start) + inserted + tail)
this.watchClaim()
return inserted.length
@@ -392,7 +406,7 @@ export class InputMachine {
// ---- paste plane ----
/**
* Paste as one transaction: the text (U+FFFC-sanitized) replaces the
* Paste as one transaction: the text (reference-placeholder-sanitized) replaces the
* selection; hot-snapshot sync matches componentize inside the SAME
* transaction (one undo returns to pre-paste); a match attempt opens for
* the async remainder while the phase still accepts reference mutations.
@@ -403,19 +417,20 @@ export class InputMachine {
): InputEffect[] {
const { start, end } = selection
if (start < 0 || start > end || end > this.draft.length) return []
const text = rawText.split(PLACEHOLDER).join('')
const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '')
this.pushTxn(selection)
this.typingRun = undefined
// Componentize: replace each matched token range (paste-text coordinates,
// disjoint by contract) with a placeholder while assembling the insert.
// disjoint by contract) with inline display text while assembling the insert.
const sorted = [...components].sort((a, b) => a.start - b.start)
const minted: Occurrence[] = []
let inserted = ''
let cursor = 0
for (const c of sorted) {
inserted += text.slice(cursor, c.start)
minted.push(this.mint(c.reference, start + inserted.length))
inserted += PLACEHOLDER
const displayText = referenceDraftText(c.reference)
minted.push(this.mint(c.reference, start + inserted.length, displayText.length))
inserted += displayText
cursor = c.end
}
inserted += text.slice(cursor)
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import {
IconBrowseOutline16, IconFolderClose16,
} from '@deepseek-ai/dsh-client-ui-primitives'
/** Reference domains with distinct composer and transcript glyphs. */
export type ReferenceIconKind = 'session' | 'file' | 'folder'
/** Props shared by inline reference glyphs. */
export interface ReferenceIconProps {
kind: ReferenceIconKind
size?: number
className?: string | undefined
}
/**
* Render the icon that identifies one inline reference domain.
* @param props - Reference kind, optional size, and optional CSS class.
* @returns The corresponding current-color SVG glyph.
*/
export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode {
switch (kind) {
case 'session':
return (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 0.597656C3.91296 0.597656 0.599716 3.91103 0.599609 7.99805C0.599609 9.13171 0.854567 10.2079 1.31152 11.1699L1.59277 11.7607L2.77441 11.1992L2.49414 10.6084L2.36035 10.3076C2.06865 9.59612 1.90723 8.81645 1.90723 7.99805C1.90733 4.63362 4.63554 1.90625 8 1.90625C11.3644 1.90635 14.0917 4.63368 14.0918 7.99805C14.0918 11.3625 11.3644 14.0907 8 14.0908C7.311 14.0908 6.80642 14.0414 6.35938 13.918C5.919 13.7963 5.50105 13.5929 5.00098 13.2441C4.26805 12.7329 3.21756 12.5526 2.35156 13.0996L2.33789 13.1084L2.32422 13.1182L1.74805 13.5234L2.18164 14.8184L3.05957 14.2002C3.37505 14.0068 3.84248 14.0319 4.25195 14.3174C4.84447 14.7307 5.39718 15.009 6.01172 15.1787C6.61963 15.3465 7.25579 15.3984 8 15.3984C12.087 15.3983 15.4004 12.0851 15.4004 7.99805C15.4003 3.9111 12.087 0.59776 8 0.597656ZM4.56836 8.50977V9.80371H8.12402V8.50977H4.56836ZM4.56836 7.30078H11.4619V6.00684H4.56836V7.30078Z"
fill="currentColor"
/>
</svg>
)
case 'file': return <IconBrowseOutline16 size={size} className={className} />
case 'folder': return <IconFolderClose16 size={size} className={className} />
}
}
@@ -1,12 +1,3 @@
/* One-glyph font: maps ONLY U+FFFC to a blank 4em-advance glyph (every other
codepoint falls through to the next family). Loaded first in the composer
font stack, it gives the placeholder a real cell width INSIDE the textarea,
so the backdrop chip (same char, same stack) matches it by construction
the two layers cannot drift and the chip gets a usable label cell. */
@font-face {
font-family: 'DshChipCell';
src: url('data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAD6AAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=') format('truetype');
}
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
@@ -145,9 +136,9 @@
position: relative;
}
/* Decoration backdrop: same metrics as the textarea, transparent glyphs; only
the highlight backgrounds and the ghost hint show through the transparent
textarea background above it. */
/* Decoration backdrop: same metrics as the transparent-text textarea. It owns
every visible glyph plus the range colors and ghost hint; the textarea above
retains the native selection and caret. */
.backdrop {
position: absolute;
inset: 0;
@@ -156,6 +147,11 @@
pointer-events: none;
}
.backdropDisabled,
.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) {
color: var(--dsw-alias-label-tertiary);
}
.hlToken {
background-color: transparent;
color: var(--dsw-alias-state-warn-label);
@@ -200,6 +196,7 @@
outline: none;
background: transparent;
color: transparent;
-webkit-text-fill-color: transparent;
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
caret-color: var(--dsw-alias-state-business-primary);
}
@@ -214,10 +211,7 @@
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px;
/* DshChipCell first: ONLY U+FFFC resolves there (4em blank cell the chip
slot); everything else falls through to the app stack. All three layers
share the stack, so placeholder advances agree by construction. */
font-family: 'DshChipCell', var(--dsw-font-family);
font-family: var(--dsw-font-family);
font-size: inherit;
/* Three consumers, not two: the mirror sizes the stack, the layers must break
lines identically, and the caret reveal parses this value to step one line
@@ -242,12 +236,15 @@
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
.input::placeholder {
color: var(--dsw-alias-label-caption);
-webkit-text-fill-color: var(--dsw-alias-label-caption);
user-select: none;
}
/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */
/* The backdrop owns disabled draft color; the textarea remains caret-only so
its marker glyphs cannot cover the reference icons beneath it. */
.input:disabled {
color: var(--dsw-alias-label-tertiary);
color: transparent;
-webkit-text-fill-color: transparent;
cursor: not-allowed;
}
@@ -427,43 +424,50 @@
display: none;
}
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard
alignment constraint: the chip's advance must equal the textarea's U+FFFC
advance EXACTLY or every glyph after it drifts (caret/selection follow the
textarea character stream). The ::before renders the same U+FFFC through
the same font stack (DshChipCell 4em cell), so both layers agree by
construction no measured widths. The label overlays the cell, clipped
with an ellipsis; the full name rides the title tooltip. */
.chip {
.textRefTrigger {
position: relative;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
}
.chip::before {
content: '\FFFC';
.textRefTriggerGlyph {
color: transparent;
}
.chipLabel {
/* Compensated-scale centering: overflow clipping happens BEFORE transform,
so the box is laid out at 1/0.72 of the cell and scaled back down the
clip edge then lands on the visual cell edge, not mid-glyph. */
.textRefIcon {
position: absolute;
left: 50%;
top: 50%;
width: calc(100% / 0.72 - 10px);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
transform: translate(-50%, -50%) scale(0.72);
left: 50%;
transform: translate(-50%, -50%);
}
/* Structured references use the same inline-backdrop technique as /skill:
their complete display text remains in the textarea, so wrapping and caret
geometry come from the browser's native glyph metrics. The leading marker
reserves the icon's advance while the backdrop paints the domain glyph. */
.chip {
position: relative;
color: var(--dsw-alias-state-business-primary);
background: transparent;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.chipTrigger {
position: relative;
}
.chipTriggerGlyph {
color: transparent;
}
.chipIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.chipInvalid {
background: rgba(216, 97, 97, 0.2);
text-decoration: line-through;
opacity: 0.7;
color: var(--dsw-alias-state-error-primary);
}
@@ -25,6 +25,7 @@ import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts'
@@ -268,6 +269,14 @@ export function InputBar({
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
/* oxlint-disable typescript/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* oxlint-enable typescript/no-unnecessary-condition */
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (workspaceTrigger) {
if (e.key === 'Enter' || e.key === ' ') {
@@ -278,13 +287,31 @@ export function InputBar({
}
// Absent machine without a Workspace recovery action stays disabled; the
// guard narrows the faces for the paths below.
if (keyboard === undefined || inputActions === undefined) return
if (input === undefined || keyboard === undefined || inputActions === undefined) return
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// oxlint-disable-next-line typescript/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (!composing && !machineBusy && !locked
&& (e.key === 'Backspace' || e.key === 'Delete')) {
const selection = selectionOf(e.currentTarget)
if (selection.start === selection.end) {
const occurrence = input.occurrences.find(o => e.key === 'Backspace'
? o.offset + o.length === selection.start
: o.offset === selection.start)
if (occurrence !== undefined) {
e.preventDefault()
const start = occurrence.offset
const end = occurrence.offset + occurrence.length
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
restoreCaret(e.currentTarget, start)
keyboard.track(keyboard.snapshot.draft, start)
return
}
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
return
@@ -351,45 +378,32 @@ export function InputBar({
keyboard.track(next, e.target.selectionStart ?? next.length)
}
// ---- chip atomicity (DOM layer; the machine sees only transactions) ----
// Placeholders occupy exactly one char, so caret positions are always
// BETWEEN them — what needs normalizing is deletion (whole chip per
// Backspace/Delete via native single-char semantics, which U+FFFC already
// gives us) and selection endpoints: Shift-extension snapping is native
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
/* oxlint-disable typescript/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
const slice = draft.slice(start, end)
const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end)
const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start)
if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine
e.preventDefault()
// Expand placeholders to their owner clipboard projections.
const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start)
const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end)
// Expand structured ranges to their owner clipboard projections.
let text = ''
let cursor = start
let cursor = copyStart
for (const o of touched) {
text += draft.slice(cursor, o.offset) + o.clipboardText
cursor = o.offset + 1
cursor = o.offset + o.length
}
text += draft.slice(cursor, end)
text += draft.slice(cursor, copyEnd)
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
restoreCaret(el, start)
keyboard.setDraft(
draft.slice(0, copyStart) + draft.slice(copyEnd),
{ start: copyStart, end: copyEnd, insertedLength: 0 },
)
restoreCaret(el, copyStart)
}
void slice
}
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
@@ -496,16 +510,15 @@ export function InputBar({
? null
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
// Mirror-layer decorations: a visible backdrop with transparent textarea
// text. Claim tokens and references retain the draft's own glyph metrics,
// so their decoration cannot drift from wrapping, selection, or the caret.
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
// text-ref range — merged in draft order (the sources never
// overlap: chips sit on placeholders, text-refs on plain tokens, the
// Segment boundaries: the token range end, every structured-reference
// offset, and every text-ref range — merged in draft order (the sources never
// overlap: structured references own their ranges, text-refs own plain tokens, the
// claim token only leads).
let cursor = 0
const pushPlain = (upTo: number): void => {
@@ -533,27 +546,44 @@ export function InputBar({
if (b.kind === 'chip') {
const chip = b.chip
backdrop.push(
// The cell's ::before renders U+FFFC itself so its advance equals the
// textarea's placeholder exactly (same char, same font); the label is
// a clipped overlay that never affects layout.
<span
key={`chip-${chip.occurrenceId}`}
className={clsx(css.chip, chip.invalid && css.chipInvalid)}
data-decoration="chip"
data-reference-appearance={chip.appearance}
data-occurrence={chip.occurrenceId}
data-invalid={chip.invalid || undefined}
title={chip.label}
>
<span className={css.chipLabel}>{chip.label}</span>
{chip.appearance === undefined
? chip.text[0]
: (
<span className={css.chipTrigger}>
<span className={css.chipTriggerGlyph}>{chip.text[0]}</span>
<ReferenceIcon kind={chip.appearance} size={16} className={css.chipIcon} />
</span>
)}
<span>{chip.text.slice(1)}</span>
</span>,
)
cursor = chip.offset + 1 // the placeholder char the chip stands for
cursor = chip.offset + chip.length
} else {
// Plain-range highlight: the glyphs stay the
// textarea's (advance untouched); the mark paints the chip look.
const text = draft.slice(b.ref.start, b.ref.end)
backdrop.push(
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
{draft.slice(b.ref.start, b.ref.end)}
{b.ref.appearance === 'folder'
? (
<>
<span className={css.textRefTrigger}>
<span className={css.textRefTriggerGlyph}>{text[0]}</span>
<ReferenceIcon kind="folder" size={16} className={css.textRefIcon} />
</span>
{text.slice(1)}
</>
)
: text}
</mark>,
)
cursor = b.ref.end
@@ -621,7 +651,14 @@ export function InputBar({
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
<div ref={scrollRef} className={css.scroll} data-input-scroll>
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<div
aria-hidden
className={clsx(css.backdrop, textareaDisabled && css.backdropDisabled)}
data-input-backdrop
data-disabled={textareaDisabled || undefined}
>
{backdrop}
</div>
<textarea
ref={inputRef}
className={css.input}
@@ -101,11 +101,47 @@ describe('MessageItem arms', () => {
}}
/>,
)
expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('@你好')
expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('你好')
expect(view.container.querySelector('[data-ref-chip="session"] svg')).not.toBeNull()
expect(view.getByText('这个在讲啥')).toBeTruthy()
expect(view.getByText('引用会话 · 你好')).toBeTruthy()
})
it('renders the complete metadata-confirmed multi-word session label', () => {
const view = render(
<MessageItem
t={t}
referenceLabels={['Research notes']}
node={{
kind: 'user',
seq: 1,
time: 1_000,
content: [{ type: 'text', text: '@Research notes what changed?' }] as never,
source: null,
}}
/>,
)
expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('Research notes')
expect(view.getByText('what changed?')).toBeTruthy()
expect(view.getByText('引用会话 · Research notes')).toBeTruthy()
})
it('renders no-extension paths as files and leaves sentence punctuation outside the reference', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'user',
seq: 1,
time: 1_000,
content: [{ type: 'text', text: 'Read @Dockerfile and @src/README.md, please.' }] as never,
source: null,
}} />,
)
const files = [...view.container.querySelectorAll('[data-ref-chip="file"]')]
expect(files.map(file => file.textContent)).toEqual(['Dockerfile', 'README.md'])
expect(files.every(file => file.querySelector('svg') !== null)).toBe(true)
expect(view.container.textContent).toContain('README.md, please.')
})
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
@@ -291,6 +327,7 @@ describe('MessageItem arms', () => {
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
expect(ctxView.container.querySelector('[data-context-recall-icon]')).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
@@ -743,6 +780,7 @@ describe('MessageItem arms', () => {
} as never}
/>,
)
expect(view.container.querySelector('[data-context-recall-icon]')).not.toBeNull()
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
@@ -528,29 +528,98 @@ describe('built-in conversation node Definitions', () => {
})
})
it('associates session-reference labels inside the adjacent direct-message node', () => {
const referenceSource = {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [
{ sessionId: 'source-a', label: 'Research' },
{ sessionId: 'source-b', label: 'Review' },
],
}
it('associates each direct message with its immediately following session recall', () => {
const value = assembler([
at(1, 'user/message', {
...textMessage('reference-context', 'snapshot'),
source: referenceSource,
at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
at(2, 'user/message', {
...textMessage('research-context', 'snapshot'),
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [{ sessionId: 'source-a', label: 'Research notes' }],
},
}, { surfaceOp: 'append' }),
at(2, 'user/message', textMessage('citing-user', '@Research and @Review'), { surfaceOp: 'append' }),
at(4, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
at(4, 'user/message', {
...textMessage('review-context', 'snapshot'),
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [{ sessionId: 'source-b', label: 'Review' }],
},
}, { surfaceOp: 'append' }),
at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
])
const current = snapshot(value)
const messages = [...current.nodes.values()]
.filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research', 'Review'] })
expect(users[1]?.data).not.toHaveProperty('referenceLabels')
expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
expect(users[2]?.data).not.toHaveProperty('referenceLabels')
})
it('updates an already published direct node when its following recall arrives', () => {
const value = assembler([
at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
])
const before = node(snapshot(value), 'user')
expect(before?.data).not.toHaveProperty('referenceLabels')
value.append(at(2, 'user/message', {
...textMessage('reference-context', 'snapshot'),
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [{ sessionId: 'source-a', label: 'Research notes' }],
},
}, { surfaceOp: 'append' }))
value.flush()
const current = snapshot(value)
const nodes = [...current.nodes.values()]
.filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
expect(nodes[0]?.key).toBe(before?.key)
expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
})
it('associates a claimed steering message with its following recall', () => {
const steering = textMessage('steering-reference', '@Research notes continue')
const value = assembler([
at(1, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
inserted: [steering],
}),
at(2, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
removedCount: 1,
inserted: [],
}),
at(3, 'user/message', steering, { surfaceOp: 'append' }),
at(4, 'user/message', {
...textMessage('steering-reference-context', 'snapshot'),
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [{ sessionId: 'source-a', label: 'Research notes' }],
},
}, { surfaceOp: 'append' }),
])
expect(node(snapshot(value), 'steering')?.data).toMatchObject({
messageId: 'steering-reference',
referenceLabels: ['Research notes'],
})
})
it('keeps replacement copies out of Chat business nodes', () => {
@@ -1161,20 +1161,89 @@ describe('decorations', () => {
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
})
it('an inserted reference renders as a chip at its placeholder offset', () => {
it('an inserted reference decorates its complete inline display range', () => {
const { view, shell } = bench()
const reference = {
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
}
act(() => {
shell.setDraft('参考 @w1 内容')
shell.insertReference(
{ source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
reference,
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const chip = view.container.querySelector('[data-decoration="chip"]')
expect(chip?.textContent).toBe('@w1')
expect(chip?.textContent).toBe('@会话一')
expect(chip?.getAttribute('data-reference-appearance')).toBe('session')
expect(chip?.querySelector('svg')).not.toBeNull()
expect(shell.snapshot.occurrences).toHaveLength(1)
// The draft carries exactly one placeholder char where the token was.
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
expect(shell.snapshot.draft).toBe('参考 @会话一 内容')
expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
})
it('keeps the textarea glyph layer transparent when a structured reference becomes disabled', () => {
const { view, shell, session, textarea } = bench()
act(() => {
shell.setDraft('@w1')
shell.insertReference({
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
}, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
session.set(snapshotOf({ removed: true }))
})
const backdrop = view.container.querySelector('[data-input-backdrop]')
expect(textarea.disabled).toBe(true)
expect(backdrop?.getAttribute('data-disabled')).toBe('true')
expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
})
it('Backspace and Delete remove a reference as one range at its boundaries', () => {
const reference = {
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
}
const backspace = bench()
act(() => {
backspace.shell.setDraft('前 @w1 后')
backspace.shell.insertReference(
reference,
{ start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev },
)
})
backspace.textarea.setSelectionRange(6, 6)
fireEvent.keyDown(backspace.textarea, { key: 'Backspace' })
expect(backspace.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
const forwardDelete = bench()
act(() => {
forwardDelete.shell.setDraft('前 @w1 后')
forwardDelete.shell.insertReference(
reference,
{ start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev },
)
})
forwardDelete.textarea.setSelectionRange(2, 2)
fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' })
expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
})
it('copy and cut expand a partial reference selection to its structured range', () => {
const { shell, textarea } = bench()
act(() => {
shell.setDraft('前 @w1 后')
shell.insertReference({
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
}, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
})
const setData = vi.fn()
textarea.setSelectionRange(3, 4)
fireEvent.copy(textarea, { clipboardData: { setData } })
expect(setData).toHaveBeenCalledWith('text/plain', '@w1')
expect(shell.snapshot.draft).toBe('前 @会话一 后')
textarea.setSelectionRange(3, 4)
fireEvent.cut(textarea, { clipboardData: { setData } })
expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1')
expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
})
it('a lexicon-matched plain token renders the text-ref mark', () => {
@@ -1187,6 +1256,15 @@ describe('decorations', () => {
act(() => { shell.setDraft('use /fixture-dem now') })
expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
})
it('a directory completion renders a folder glyph without changing its plain text', () => {
const { view, shell } = bench()
act(() => { shell.setDraft('see @src/components/') })
const mark = view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('@src/components/')
expect(mark?.querySelector('svg')).not.toBeNull()
expect(shell.snapshot.draft).toBe('see @src/components/')
})
})
describe('insertText (scoped event body)', () => {
@@ -10,10 +10,12 @@
import { describe, expect, it } from 'vitest'
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
import { InputMachine, PLACEHOLDER, projectClipboard } from '../src/client/input/machine.ts'
import {
InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText,
} from '../src/client/input/machine.ts'
import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
const P = PLACEHOLDER
const LEGACY_PLACEHOLDER = PLACEHOLDER
function claimOf(name: string, hint?: string): CommandClaim {
return {
@@ -252,15 +254,22 @@ describe('input-machine: begin-command CAS', () => {
})
describe('input-machine: insert-ref and the occurrence table', () => {
it('valid span becomes one placeholder + one occurrence with cached projections', () => {
it('valid span becomes one inline display range + one occurrence with cached projections', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
const fx = m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
const reference = { ...refOf('worker-1', 'reference'), appearance: 'session' as const }
const fx = m.dispatch({
type: 'insert-ref',
reference,
span: spanOf(m, 4, 8),
})
expect(fx).toEqual([])
expect(m.state.draft).toBe(`see ${P} now`)
const displayText = referenceDraftText(reference)
expect(m.state.draft).toBe(`see ${displayText} now`)
expect(m.state.occurrences).toEqual([{
occurrenceId: 1, source: 'subagent', ref: 'worker-1', offset: 4,
label: 'worker-1', clipboardText: '/worker-1',
occurrenceId: 1, source: 'reference', ref: 'worker-1', offset: 4,
length: displayText.length,
label: 'worker-1', appearance: 'session', clipboardText: '/worker-1',
}])
expect(m.state.phase).toBe('plain')
})
@@ -269,12 +278,23 @@ describe('input-machine: insert-ref and the occurrence table', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
expect(m.state.draft).toBe(`${P} and ${P} `)
const displayText = referenceDraftText(refOf('alpha'))
const secondDraft = `${displayText} and /alp`
const secondStart = secondDraft.lastIndexOf('/alp')
m.dispatch({
type: 'draft-changed',
draft: secondDraft,
editRange: { start: displayText.length, end: displayText.length + 1, insertedLength: ' and /alp'.length },
})
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, secondStart, secondStart + 4) })
expect(m.state.draft).toBe(`${displayText} and ${displayText} `)
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
// Delete the first chip whole; the second survives with its own identity.
m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } })
// Delete the first reference range whole; the second survives with its own identity.
m.dispatch({
type: 'draft-changed',
draft: ` and ${displayText} `,
editRange: { start: 0, end: displayText.length, insertedLength: 0 },
})
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
})
@@ -284,7 +304,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
expect(m.state.draft).toBe(`/goal ask ${P} `)
expect(m.state.draft).toBe(`/goal ask ${referenceDraftText(refOf('worker-1'))} `)
expect(m.state.phase).toBe('claimed')
expect(m.state.occurrences).toHaveLength(1)
})
@@ -300,7 +320,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
})
describe('input-machine: occurrence reconciliation on draft edits', () => {
/** Machine with one chip at offset 4 inside `see ${P} now`. */
/** Machine with one reference range at offset 4 inside `see @worker-1 now`. */
function withChip(): InputMachine {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
@@ -308,40 +328,56 @@ describe('input-machine: occurrence reconciliation on draft edits', () => {
return m
}
it('an edit before the placeholder shifts the offset by the length delta (explicit editRange)', () => {
it('an edit before the reference shifts the offset by the length delta (explicit editRange)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `I see ${P} now`, editRange: { start: 0, end: 0, insertedLength: 2 } })
m.dispatch({ type: 'draft-changed', draft: `I ${m.state.draft}`, editRange: { start: 0, end: 0, insertedLength: 2 } })
expect(m.state.occurrences[0]?.offset).toBe(6)
m.dispatch({ type: 'draft-changed', draft: `see ${P} now`, editRange: { start: 0, end: 2, insertedLength: 0 } })
m.dispatch({ type: 'draft-changed', draft: m.state.draft.slice(2), editRange: { start: 0, end: 2, insertedLength: 0 } })
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('an edit after the placeholder leaves the offset alone', () => {
it('an edit after the reference leaves the offset alone', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see ${P} later`, editRange: { start: 6, end: 9, insertedLength: 5 } })
const oldDraft = m.state.draft
const start = oldDraft.indexOf('now')
m.dispatch({
type: 'draft-changed',
draft: oldDraft.replace('now', 'later'),
editRange: { start, end: start + 3, insertedLength: 5 },
})
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('a deletion covering the placeholder removes the whole occurrence', () => {
it('a deletion covering the reference removes the whole occurrence', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now', editRange: { start: 4, end: 5, insertedLength: 0 } })
const occurrence = m.state.occurrences[0]!
m.dispatch({
type: 'draft-changed',
draft: m.state.draft.slice(0, occurrence.offset) + m.state.draft.slice(occurrence.offset + occurrence.length),
editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 0 },
})
expect(m.state.occurrences).toEqual([])
expect(m.state.draft).toBe('see now')
})
it('a replacement spanning the placeholder removes the occurrence and keeps the replacement text', () => {
it('a replacement spanning the reference removes the occurrence and keeps the replacement text', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see all of it now', editRange: { start: 4, end: 5, insertedLength: 9 } })
const occurrence = m.state.occurrences[0]!
m.dispatch({
type: 'draft-changed',
draft: 'see all of it now',
editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 9 },
})
expect(m.state.occurrences).toEqual([])
})
it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see there ${P} now` })
m.dispatch({ type: 'draft-changed', draft: m.state.draft.replace('see ', 'see there ') })
expect(m.state.occurrences[0]?.offset).toBe(10)
})
it('without editRange the diff scan detects placeholder deletion', () => {
it('without editRange the diff scan detects reference deletion', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now' })
expect(m.state.occurrences).toEqual([])
@@ -396,7 +432,7 @@ describe('input-machine: consume-token guards', () => {
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draft).toBe(`${P} `)
expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
expect(m.state.occurrences[0]?.offset).toBe(0)
})
})
@@ -473,10 +509,10 @@ describe('input-machine: undo / redo', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: m.state.draft.length, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`${P} `)
expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
expect(m.state.occurrences).toHaveLength(1)
})
@@ -519,7 +555,7 @@ describe('input-machine: paste plane', () => {
it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: `x${P}y`, selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-begin', text: `x${LEGACY_PLACEHOLDER}y`, selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('xy')
expect(m.state.occurrences).toEqual([])
})
@@ -531,9 +567,9 @@ describe('input-machine: paste plane', () => {
type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
components: [{ start: 0, end: 6, reference: refOf('alpha') }],
})
expect(m.state.draft).toBe(`hi ${P} x`)
expect(m.state.draft).toBe(`hi ${referenceDraftText(refOf('alpha'))} x`)
expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: 6 })
expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: m.state.draft.length })
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
})
@@ -543,7 +579,7 @@ describe('input-machine: paste plane', () => {
m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
expect(m.state.paste?.attemptId).toBe(1)
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.draft).toBe(`${P} rest`)
expect(m.state.draft).toBe(`${referenceDraftText(refOf('alpha'))} rest`)
expect(m.state.occurrences).toHaveLength(1)
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
@@ -555,11 +591,13 @@ describe('input-machine: paste plane', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
expect(m.state.draft).toBe(`${P} ${P} `)
const alpha = referenceDraftText(refOf('alpha'))
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: alpha.length + 6 })
const betaStart = m.state.draft.indexOf('/beta')
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, betaStart, betaStart + 5), reference: refOf('beta') })
expect(m.state.draft).toBe(`${alpha} ${referenceDraftText(refOf('beta'))} `)
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: m.state.draft.length })
})
it('a stale span CAS drops one upgrade without ending the attempt', () => {
@@ -608,8 +646,13 @@ describe('input-machine: set-invalid styling bits', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} /bet`, editRange: { start: 1, end: 1, insertedLength: 5 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 2, 6) })
const alpha = referenceDraftText(refOf('alpha'))
m.dispatch({
type: 'draft-changed',
draft: `${alpha} /bet`,
editRange: { start: alpha.length + 1, end: alpha.length + 1, insertedLength: 5 },
})
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, alpha.length + 1, alpha.length + 5) })
const rev = m.state.draftRev
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(m.state.draftRev).toBe(rev)
@@ -630,13 +673,20 @@ describe('input-machine: set-invalid styling bits', () => {
})
describe('input-machine: projectClipboard', () => {
it('expands each placeholder to its occurrence clipboardText in draft order', () => {
it('expands each reference range to its occurrence clipboardText in draft order', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
expect(m.state.draft).toBe(`use ${P} then ${P} `)
const alpha = referenceDraftText(refOf('alpha'))
const secondDraft = `use ${alpha} then /bet`
const secondStart = secondDraft.lastIndexOf('/bet')
m.dispatch({
type: 'draft-changed',
draft: secondDraft,
editRange: { start: 4 + alpha.length + 1, end: 4 + alpha.length + 1, insertedLength: 'then /bet'.length },
})
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, secondStart, secondStart + 4) })
expect(m.state.draft).toBe(`use ${alpha} then ${referenceDraftText(refOf('beta'))} `)
expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
})
@@ -662,6 +712,13 @@ describe('decorations: scanTextRefs', () => {
expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
})
it('recognizes directory paths independently of the dynamic lexicon', () => {
expect(scanTextRefs('open @src/components/ or @"docs/design notes/', new Map())).toEqual([
{ start: 5, end: 21, trigger: '@', appearance: 'folder' },
{ start: 25, end: 45, trigger: '@', appearance: 'folder' },
])
})
it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
})
@@ -690,11 +747,24 @@ describe('input-machine: decorations', () => {
it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
const reference = { ...refOf('alpha'), appearance: 'file' as const }
m.dispatch({
type: 'insert-ref',
reference,
span: spanOf(m, 0, 4),
})
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(deriveDecorations(m.state)).toEqual({
token: null,
chips: [{ occurrenceId: 1, offset: 0, label: 'alpha', invalid: true }],
chips: [{
occurrenceId: 1,
offset: 0,
length: referenceDraftText(reference).length,
text: referenceDraftText(reference),
label: 'alpha',
appearance: 'file',
invalid: true,
}],
textRefs: [],
hint: null,
})
@@ -766,8 +836,17 @@ describe('input-machine: submitting transaction', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P}/go`, editRange: { start: 1, end: 1, insertedLength: 3 } })
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
const refLength = referenceDraftText(refOf('worker-1')).length
m.dispatch({
type: 'draft-changed',
draft: `${referenceDraftText(refOf('worker-1'))}/go`,
editRange: { start: refLength + 1, end: refLength + 1, insertedLength: 3 },
})
m.dispatch({
type: 'draft-changed',
draft: '/go',
editRange: { start: 0, end: refLength + 1, insertedLength: 0 },
})
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
@@ -8,9 +8,9 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
import { PLACEHOLDER } from '../src/client/input/machine.ts'
const mention = '@[Research](dsh-session:InNvdXJjZSI)'
const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)'
const commandImages = {
serialize: () => Promise.resolve([]),
release: () => {},
@@ -22,7 +22,7 @@ function chip(shell: SessionInputShell): void {
const accepted = shell.insertReference({
source: 'reference',
ref: mention,
label: '@Research',
label: 'Research',
clipboardText: mention,
}, {
start: 0,
@@ -33,6 +33,42 @@ function chip(shell: SessionInputShell): void {
}
describe('reference submission', () => {
it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => {
const mirror = vi.fn()
const first = new SessionInputShell({
actx: {} as ClientContext,
defaultSink: vi.fn(),
commandImages,
})
first.bindMirror(mirror)
first.setDraft('@res')
expect(first.insertReference({
source: 'reference',
ref: spacedMention,
label: 'Research notes',
appearance: 'session',
clipboardText: spacedMention,
}, {
start: 0,
end: 4,
draftRev: first.snapshot.draftRev,
})).toBe(true)
expect(first.snapshot.draft).toBe('@Research notes ')
expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ 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',
@@ -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
+2 -2
View File
@@ -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.
@@ -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`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。
分层:`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 显示其原名)列表高度受限于 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 注册以闭包持有它。
@@ -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. */}
<div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source as MenuKey)}</div>
{group.showGroupTitle === false || group.items.some(item => item.section !== undefined)
? null
: <div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source as MenuKey)}</div>}
{group.status === 'pending'
? <div className={css.loading} data-source={group.source}>{t('loading')}</div>
: group.items.map((item, index) => {
@@ -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])
}
@@ -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[]
}[]
@@ -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<InputTriggerSource, 'name' | 'showGroupTitle'>[],
): 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 }
@@ -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<readonly InputTriggerCandidate[]>
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
onPick(pick: InputTriggerPick): PickOutcome
@@ -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 })
@@ -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')
@@ -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])
@@ -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
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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` 携带规范 mentionsession-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
选择会话会插入一个原子的行内引用,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见形式为聊天气泡图标加业务色会话标题,不使用胶囊容器;序列化永远不会根据该标重建身份。普通发送会通过 `session.prompt` 携带规范 mentionsession-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
`/client` 只导出插件主体(`apply``inject`);候选编码保留在注册 effect 内部。
@@ -16,7 +16,7 @@
#### KV 缓存影响
浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。
浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及紧随该消息、由宿主准备的会话引用上下文;目标会话更早的历史保持不变。
## 已知限制与暂缓事项
@@ -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 [{
@@ -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,
},
})
@@ -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
+3 -3
View File
@@ -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 `<referenced-sessions>` 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 `<referenced-sessions>` 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
@@ -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 在 `<referenced-sessions>` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
模型会看到两条连续的 user 角色消息:先是带可读 `@label` 的当前消息,再是 `## Referenced sessions` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前用户明确重复这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `<referenced-sessions>` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
#### Token 影响
@@ -40,7 +40,7 @@
#### KV Cache 影响
快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
请求与快照是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
## 已知限制与暂缓事项
@@ -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()
}
@@ -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(