mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(web): align reference input and recall ordering
This commit is contained in:
@@ -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: 78d18da23ef682df18653aea73467e281266de9c
|
||||
2026-07-21-cross-session-references.zh.md: 4fe53e21f85fab950b8480bb28869df6254eed0a
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的现有插件失败路径终止已经接受的轮次。
|
||||
|
||||
|
||||
+2
-2
@@ -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: 09619837b6eda304106276064ed5423b10053119
|
||||
2026-07-27-web-file-and-session-references.zh.md: 52cba697c76635ab60eb862475bb47c1a27f99a4
|
||||
|
||||
@@ -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. 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, decorates recognized file and session mentions as icon-and-text references, 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, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and durable chat order. 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.
|
||||
|
||||
## 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 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,将识别到的文件与会话 mention 装饰成图标加文字的引用,并把快照 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 标题的情况下渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -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 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',
|
||||
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$/ }).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 what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research'))
|
||||
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 what changed? {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Session recall Research":
|
||||
- img
|
||||
- text: Session recall Research
|
||||
- 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: f1a4e5de4056712feab7cc1627589909ef70f526
|
||||
README.zh.md: a942c994d39652b3811c88ce99bc149cd47b2118
|
||||
|
||||
@@ -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. 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 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)).
|
||||
|
||||
|
||||
@@ -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(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。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,42 @@ 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] ?? ''
|
||||
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' : /[./\\]/u.test(label.slice(1)) ? 'file' : 'session'
|
||||
: 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
|
||||
|
||||
@@ -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() === ''
|
||||
|
||||
@@ -103,7 +103,7 @@ export class SessionInputShell implements SessionInput {
|
||||
/** 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)
|
||||
|
||||
@@ -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;
|
||||
@@ -214,10 +205,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
|
||||
@@ -427,43 +415,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 === ' ') {
|
||||
@@ -285,6 +294,24 @@ export function InputBar({
|
||||
// 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
|
||||
|
||||
@@ -101,7 +101,8 @@ 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()
|
||||
})
|
||||
@@ -291,6 +292,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 +745,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 条'])
|
||||
|
||||
@@ -539,6 +539,26 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(users[1]?.data).not.toHaveProperty('referenceLabels')
|
||||
})
|
||||
|
||||
it('keeps a direct message before its following session-reference context', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', textMessage('citing-user', '@Research what changed?'), { surfaceOp: 'append' }),
|
||||
at(2, 'user/message', {
|
||||
...textMessage('reference-context', 'snapshot'),
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source-a', label: 'Research' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
const nodes = [...snapshot(value).nodes.values()]
|
||||
.filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
|
||||
expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
|
||||
expect(nodes[0]?.data).not.toHaveProperty('referenceLabels')
|
||||
})
|
||||
|
||||
it('keeps replacement copies out of Chat business nodes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
|
||||
@@ -1161,20 +1161,74 @@ 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('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 +1241,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,7 +8,6 @@ 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 commandImages = {
|
||||
@@ -22,7 +21,7 @@ function chip(shell: SessionInputShell): void {
|
||||
const accepted = shell.insertReference({
|
||||
source: 'reference',
|
||||
ref: mention,
|
||||
label: '@Research',
|
||||
label: 'Research',
|
||||
clipboardText: mention,
|
||||
}, {
|
||||
start: 0,
|
||||
@@ -55,8 +54,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 +65,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 +100,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
|
||||
|
||||
@@ -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`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。
|
||||
分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`ReferenceInsert.appearance` 可以把显示类型标为 `session`、`file` 或 `folder`,且不会改变其序列化 `ref`;图标与颜色由消费它的输入框负责。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。
|
||||
|
||||
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
|
||||
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
|
||||
|
||||
`/client` 导出接口是插件主体(`apply`/`inject`)、`InputTriggerService`、`MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。
|
||||
统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。
|
||||
|
||||
选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
|
||||
选择文件会把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。文件会关闭补全,并显示为文件图标加业务色文件名、无胶囊容器的原子行内引用。目录仍是带文件夹图标的可编辑路径纯文本,并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
|
||||
|
||||
选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
|
||||
选择会话会插入一个原子的行内引用,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见形式为聊天气泡图标加业务色会话标题,不使用胶囊容器;序列化永远不会根据该标题重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。
|
||||
|
||||
`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。
|
||||
浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及紧随该消息、由宿主准备的会话引用上下文;目标会话更早的历史保持不变。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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: 71ed6891a3a0bee480b2064d844df8758349ee38
|
||||
README.zh.md: 5a761115105914a47c1f3d2673df4b2f3e3e76da
|
||||
|
||||
@@ -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 citing user message requests their use. 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` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非引用它的 user 消息要求使用这些内容。标签、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(
|
||||
|
||||
Reference in New Issue
Block a user