diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 40f4c554ef..c59b8b4837 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index 774a948345..48a3241871 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fd864203d1..ef723115f0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -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。 [仅面向自动化的 ACP(Agent 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 快照会固定组装后的引用选择路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 46f6c117eb..f045d1ac7c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index ad8e5c5383..51447d0c22 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index acb016866e..f2f87aabaa 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -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 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签。 ## 后果 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index c67a975cbd..39e0707255 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -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\nsnapshot' }], + 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']) }) }) diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md index f42be2dc47..b5fccf69f5 100644 --- a/apps/web/tests/snapshots/reference-composer/menu.expected.md +++ b/apps/web/tests/snapshots/reference-composer/menu.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}}" diff --git a/apps/web/tests/snapshots/reference-composer/order.expected.md b/apps/web/tests/snapshots/reference-composer/order.expected.md new file mode 100644 index 0000000000..75543314b2 --- /dev/null +++ b/apps/web/tests/snapshots/reference-composer/order.expected.md @@ -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] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e1d7e10227..216c60587a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0 -README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0 +README.md: cbd57ba05a96da36ac9198817e78594c1a2cefff +README.zh.md: cbe13efd43abda020edae41a0adee09d991314fe diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7793273e05..cbd57ba05a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f45dfa6d72..cbe13efd43 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 889b15b085..3e9e0f10b1 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -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 ( } + icon={provenance.role === 'recall' + ? + : } chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 6aa1386a3e..54e5f6f98d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -273,18 +273,19 @@ } } -/* Reference chip projection inside a user bubble (`name` 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; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4cb553d38e..8a882a70c5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -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() + 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( - - {label} + + {referenceKind !== undefined && ( + + )} + {displayLabel} , ) cursor = end diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index be4e27111a..651fc780a3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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 = { ...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() + private readonly labelsByMessageSeq = new Map() + + 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() + 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 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 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( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 3f9f2b5ef1..c60bbf79f1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -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 = { } } const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true - const previous = reader.previous('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 = { time: event.time, content: event.data.content, source: event.data.source, - ...referenceLabels, } : { kind: 'user', @@ -82,7 +76,6 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, - ...referenceLabels, } }, update: context => context.state, diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 910acc9511..b9ace8b863 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -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 } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index bd3dd09040..1ae5e9404b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -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() === '' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e6a75f2ef8..cc42d0c1ad 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -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) } } } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 0f124e57ff..a1a49004a4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -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): 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= 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) diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx new file mode 100644 index 0000000000..fcc89fe30e --- /dev/null +++ b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx @@ -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 ( + + + + ) + case 'file': return + case 'folder': return + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index de3a1739fc..e5b85df3e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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); } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bdb5c592cd..14feaca291 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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): 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, 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): void => { @@ -496,16 +510,15 @@ export function InputBar({ ? null : - // 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. - {chip.label} + {chip.appearance === undefined + ? chip.text[0] + : ( + + {chip.text[0]} + + + )} + {chip.text.slice(1)} , ) - 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( - {draft.slice(b.ref.start, b.ref.end)} + {b.ref.appearance === 'folder' + ? ( + <> + + {text[0]} + + + {text.slice(1)} + + ) + : text} , ) cursor = b.ref.end @@ -621,7 +651,14 @@ export function InputBar({ which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
-
{backdrop}
+
+ {backdrop} +