fix(web): address reference review defects
This commit is contained in:
parent
bbc356144d
commit
1585e539d2
24 changed files with 334 additions and 87 deletions
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
|
||||
2026-07-21-cross-session-references.md: 78d18da23ef682df18653aea73467e281266de9c
|
||||
2026-07-21-cross-session-references.zh.md: 4fe53e21f85fab950b8480bb28869df6254eed0a
|
||||
2026-07-21-cross-session-references.md: 48a3241871f0b65270aaf74e42c9d02c45eaf027
|
||||
2026-07-21-cross-session-references.zh.md: ef723115f0de4312c0f4b1d215303b4a6a31be47
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Reference preparation is not a new delivery protocol and does not create a turn
|
|||
|
||||
The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id.
|
||||
|
||||
Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON.
|
||||
Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with the direct message immediately before it and renders a compact source summary instead of exposing the snapshot JSON.
|
||||
|
||||
The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services.
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b
|
|||
|
||||
## Verification
|
||||
|
||||
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path.
|
||||
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, Chat-projected following-recall association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但
|
|||
|
||||
统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。
|
||||
|
||||
Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。
|
||||
Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与紧邻其前的直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。
|
||||
|
||||
[仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,
|
|||
|
||||
## 验证
|
||||
|
||||
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。
|
||||
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、Chat 投影的后继召回关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。
|
||||
|
||||
## 后果
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 09619837b6eda304106276064ed5423b10053119
|
||||
2026-07-27-web-file-and-session-references.zh.md: 52cba697c76635ab60eb862475bb47c1a27f99a4
|
||||
2026-07-27-web-file-and-session-references.md: 51447d0c22c2e5beb4eae03e7f955239525fc16d
|
||||
2026-07-27-web-file-and-session-references.zh.md: f2f87aabaa818fe5d104b0ee1488658c792f59b9
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ The file capability follows the three-package seam: `@deepseek-ai/dsh-file-refer
|
|||
|
||||
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 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.
|
||||
The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Its session-store mirror persists each occurrence's canonical clipboard projection, so remounting without the occurrence table retains a parseable reference instead of a display-only label. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order and associates exact session labels only from the immediately following sourced recall, which preserves multi-word titles and keeps consecutive references independent. It decorates recognized file and session mentions as icon-and-text references, treats unquoted `@path` tokens including extensionless basenames as files, leaves sentence punctuation outside the reference range, and keeps snapshot JSON behind the collapsed recall row.
|
||||
|
||||
## Reference transaction
|
||||
|
||||
|
|
@ -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, 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.
|
||||
Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, disabled-layer ownership, canonical draft persistence across remount, adjacent-reference and adjacent-text projection, extensionless file and sentence-punctuation rendering, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and following-recall association for multi-word and consecutive labels. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition, and replays a multi-word session label in direct-before-recall order.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file`
|
|||
|
||||
选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[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、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,将识别到的文件与会话 mention 装饰成图标加文字的引用,并把快照 JSON 保留在默认收起的召回行中。
|
||||
输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。它写入会话 store 的镜像会持久化每个 occurrence 的规范剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,仍会保留可解析的引用,而不是仅供显示的标签。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,并且只从紧随其后的带来源召回中关联准确的会话标签,因此既能保留多词标题,也能让连续引用彼此独立。它会把识别到的文件与会话 mention 装饰成图标加文字的引用,把包括无扩展名 basename 在内的未加引号 `@path` token 视为文件,将句末标点留在引用范围之外,并把快照 JSON 保留在默认收起的召回行中。
|
||||
|
||||
## 引用事务
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ type @ → parallel file/session Remote calls → pick folder text or atomic fil
|
|||
|
||||
## 验证
|
||||
|
||||
包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及持久聊天顺序。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。
|
||||
包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、禁用状态下的层级归属、跨重新挂载的规范草稿持久化、相邻引用及相邻文本条件下的引用投影、无扩展名文件与句末标点渲染、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及多词与连续标签的后继召回关联。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签。
|
||||
|
||||
## 后果
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ 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?' }],
|
||||
content: [{ type: 'text', text: '@Research notes what changed?' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
|
|
@ -79,7 +79,7 @@ function targetSessionFixture(): string {
|
|||
version: 1,
|
||||
references: [{
|
||||
sessionId: SOURCE_SESSION_ID,
|
||||
label: 'Research',
|
||||
label: 'Research notes',
|
||||
capturedThroughSeq: 4,
|
||||
compacted: false,
|
||||
originalMessages: 2,
|
||||
|
|
@ -176,12 +176,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
|||
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 })
|
||||
await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(TARGET_SESSION_ID).join('{{targetId}}')
|
||||
await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot.indexOf('Research what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research'))
|
||||
expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Research what changed? {{clock}}
|
||||
- text: Research notes what changed? Referenced session · Research notes {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Session recall Research":
|
||||
- button "Session recall Research notes":
|
||||
- img
|
||||
- text: Session recall Research
|
||||
- text: Session recall Research notes
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
|
|
|||
|
|
@ -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: f1a4e5de4056712feab7cc1627589909ef70f526
|
||||
README.zh.md: a942c994d39652b3811c88ce99bc149cd47b2118
|
||||
README.md: cbd57ba05a96da36ac9198817e78594c1a2cefff
|
||||
README.zh.md: cbe13efd43abda020edae41a0adee09d991314fe
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
|
|
|
|||
|
|
@ -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(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 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 信号是它出现在轮次中途的位置。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
|
|
|
|||
|
|
@ -167,7 +167,11 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN
|
|||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const tokenStart = m.index + (m[1]?.length ?? 0)
|
||||
const label = m[2] ?? ''
|
||||
const rawLabel = m[2] ?? ''
|
||||
const label = rawLabel.startsWith('@"')
|
||||
? rawLabel
|
||||
: rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '')
|
||||
if (label.length <= 1) continue
|
||||
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
|
||||
}
|
||||
ranges.sort((a, b) => a.start - b.start
|
||||
|
|
@ -181,7 +185,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN
|
|||
const referenceKind = kind === 'session'
|
||||
? 'session'
|
||||
: label.startsWith('@')
|
||||
? label.endsWith('/') ? 'folder' : /[./\\]/u.test(label.slice(1)) ? 'file' : 'session'
|
||||
? label.endsWith('/') ? 'folder' : 'file'
|
||||
: undefined
|
||||
const displayLabel = referenceKind === undefined
|
||||
? label
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
|
||||
|
|
@ -138,6 +139,99 @@ function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConvers
|
|||
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
|
||||
}
|
||||
|
||||
function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
|
||||
const candidate = node as ChatNode
|
||||
return candidate.kind === 'user' || candidate.kind === 'steering'
|
||||
? candidate.data.seq
|
||||
: undefined
|
||||
}
|
||||
|
||||
function followingRecall(node: ChatConversationViewNode): {
|
||||
readonly messageSeq: number
|
||||
readonly labels: readonly string[]
|
||||
} | undefined {
|
||||
const candidate = node as ChatNode
|
||||
if (candidate.kind !== 'context') return undefined
|
||||
return {
|
||||
messageSeq: candidate.data.seq - 1,
|
||||
labels: sessionRecallLabels(candidate.data.source),
|
||||
}
|
||||
}
|
||||
|
||||
function withReferenceLabels(
|
||||
node: ChatConversationViewNode,
|
||||
labels: readonly string[],
|
||||
): ChatConversationViewNode {
|
||||
const candidate = node as ChatNode
|
||||
if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node
|
||||
const current = candidate.data.referenceLabels ?? EMPTY_KEYS
|
||||
const hasLabels = Object.hasOwn(candidate.data, 'referenceLabels')
|
||||
if (sameReferences(current, labels) && hasLabels === (labels.length > 0)) return node
|
||||
const data: Record<string, unknown> = { ...candidate.data }
|
||||
if (labels.length === 0) delete data.referenceLabels
|
||||
else data.referenceLabels = labels
|
||||
return { ...candidate, data }
|
||||
}
|
||||
|
||||
/** Associates a direct message with the sourced recall event that immediately follows it. */
|
||||
class ReferenceLabelProjector {
|
||||
private readonly messagesBySeq = new Map<number, string>()
|
||||
private readonly labelsByMessageSeq = new Map<number, readonly string[]>()
|
||||
|
||||
replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
|
||||
this.messagesBySeq.clear()
|
||||
this.labelsByMessageSeq.clear()
|
||||
for (const node of nodes) {
|
||||
const messageSeq = referenceMessageSeq(node)
|
||||
if (messageSeq !== undefined) this.messagesBySeq.set(messageSeq, node.key)
|
||||
const recall = followingRecall(node)
|
||||
if (recall !== undefined && recall.labels.length > 0) {
|
||||
this.labelsByMessageSeq.set(recall.messageSeq, recall.labels)
|
||||
}
|
||||
}
|
||||
return nodes.map((node) => {
|
||||
const messageSeq = referenceMessageSeq(node)
|
||||
return messageSeq === undefined
|
||||
? node
|
||||
: withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS)
|
||||
})
|
||||
}
|
||||
|
||||
apply(
|
||||
upserts: readonly ChatConversationViewNode[],
|
||||
store: ChatNodeStore,
|
||||
): readonly ChatConversationViewNode[] {
|
||||
const byKey = new Map(upserts.map(node => [node.key, node]))
|
||||
const affected = new Set<number>()
|
||||
for (const node of upserts) {
|
||||
const messageSeq = referenceMessageSeq(node)
|
||||
if (messageSeq !== undefined) {
|
||||
this.messagesBySeq.set(messageSeq, node.key)
|
||||
affected.add(messageSeq)
|
||||
}
|
||||
const recall = followingRecall(node)
|
||||
if (recall === undefined) continue
|
||||
const current = this.labelsByMessageSeq.get(recall.messageSeq)
|
||||
if (recall.labels.length === 0) this.labelsByMessageSeq.delete(recall.messageSeq)
|
||||
else {
|
||||
this.labelsByMessageSeq.set(
|
||||
recall.messageSeq,
|
||||
current !== undefined && sameReferences(current, recall.labels) ? current : recall.labels,
|
||||
)
|
||||
}
|
||||
affected.add(recall.messageSeq)
|
||||
}
|
||||
for (const messageSeq of affected) {
|
||||
const key = this.messagesBySeq.get(messageSeq)
|
||||
if (key === undefined) continue
|
||||
const node = byKey.get(key) ?? store.get(key)
|
||||
if (node === undefined) continue
|
||||
byKey.set(key, withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS))
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyContribution {
|
||||
readonly anchorSeq: number
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
|
|
@ -384,6 +478,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
|||
private readonly store = new MutableChatNodeStore()
|
||||
private readonly locations = new MutableChatLocationIndex()
|
||||
private readonly legacy = new LegacySliceBuilder()
|
||||
private readonly referenceLabels = new ReferenceLabelProjector()
|
||||
private order: readonly string[] = EMPTY_KEYS
|
||||
readonly empty: ChatSnapshot
|
||||
|
||||
|
|
@ -395,19 +490,21 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
|||
readonly nodes: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
this.store.replace(input.nodes)
|
||||
this.order = orderedVisible(input.nodes).map(node => node.key)
|
||||
const nodes = this.referenceLabels.replace(input.nodes)
|
||||
this.store.replace(nodes)
|
||||
this.order = orderedVisible(nodes).map(node => node.key)
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline))
|
||||
return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
|
||||
}
|
||||
|
||||
apply(input: {
|
||||
readonly upserts: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
const upserts = this.referenceLabels.apply(input.upserts, this.store)
|
||||
let structural = false
|
||||
const contentOnly: ChatConversationViewNode[] = []
|
||||
for (const node of input.upserts) {
|
||||
for (const node of upserts) {
|
||||
const previous = this.store.get(node.key)
|
||||
const nodeStructural = previous === undefined
|
||||
|| previous.anchorSeq !== node.anchorSeq
|
||||
|
|
@ -416,14 +513,14 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
|||
structural ||= nodeStructural
|
||||
if (!nodeStructural) contentOnly.push(node)
|
||||
}
|
||||
this.store.upsert(input.upserts)
|
||||
this.store.upsert(upserts)
|
||||
if (structural) {
|
||||
const next = orderedVisible(this.store.values()).map(node => node.key)
|
||||
this.order = sameReferences(this.order, next) ? this.order : next
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
}
|
||||
this.locations.touch(contentOnly)
|
||||
return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline))
|
||||
return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
|
||||
}
|
||||
|
||||
private snapshot(
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import type {
|
|||
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels,
|
||||
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxState } from './inbox.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
interface ReferencedUserMessageNode extends UserMessageNode {
|
||||
/** Labels cited by the immediately preceding session-reference context. */
|
||||
/** Labels cited by the immediately following session-reference context. */
|
||||
readonly referenceLabels?: readonly string[]
|
||||
}
|
||||
|
||||
interface ReferencedSteeringMessageNode extends SteeringMessageNode {
|
||||
/** Labels cited by the immediately preceding session-reference context. */
|
||||
/** Labels cited by the immediately following session-reference context. */
|
||||
readonly referenceLabels?: readonly string[]
|
||||
}
|
||||
|
||||
|
|
@ -61,11 +61,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
|||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
|
||||
const previous = reader.previous<MessageNode>('input-message')
|
||||
const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq
|
||||
? sessionRecallLabels(previous.state.source)
|
||||
: []
|
||||
const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels }
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
|
|
@ -74,7 +69,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
|||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
...referenceLabels,
|
||||
}
|
||||
: {
|
||||
kind: 'user',
|
||||
|
|
@ -82,7 +76,6 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
|||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
...referenceLabels,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type {
|
|||
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
|
||||
} from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import { InputMachine } from './machine.ts'
|
||||
import { InputMachine, projectClipboard } from './machine.ts'
|
||||
|
||||
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
|
||||
export interface PopupDismissFace {
|
||||
|
|
@ -98,7 +98,7 @@ export class SessionInputShell implements SessionInput {
|
|||
// production (the machine's no-clock default is a constant for pure tests).
|
||||
private readonly core = new InputMachine({ now: () => Date.now() })
|
||||
private noticeSeq = 0
|
||||
private lastDraft = ''
|
||||
private lastMirroredDraft = ''
|
||||
private imageIds: readonly DraftAttachmentId[] = []
|
||||
/** One image-only send at a time: Enter during the Host round-trip is a no-op. */
|
||||
private imageSendInFlight = false
|
||||
|
|
@ -596,9 +596,10 @@ export class SessionInputShell implements SessionInput {
|
|||
private publish(): void {
|
||||
const next = this.compose()
|
||||
this.state.set(next)
|
||||
if (next.draft !== this.lastDraft) {
|
||||
this.lastDraft = next.draft
|
||||
this.mirrorFn?.(next.draft)
|
||||
const mirroredDraft = projectClipboard(next)
|
||||
if (mirroredDraft !== this.lastMirroredDraft) {
|
||||
this.lastMirroredDraft = mirroredDraft
|
||||
this.mirrorFn?.(mirroredDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdropDisabled,
|
||||
.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.hlToken {
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
|
|
@ -191,6 +196,7 @@
|
|||
outline: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
|
@ -230,12 +236,15 @@
|
|||
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
|
||||
.input::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
-webkit-text-fill-color: var(--dsw-alias-label-caption);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */
|
||||
/* The backdrop owns disabled draft color; the textarea remains caret-only so
|
||||
its marker glyphs cannot cover the reference icons beneath it. */
|
||||
.input:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ export function InputBar({
|
|||
}
|
||||
// Absent machine without a Workspace recovery action stays disabled; the
|
||||
// guard narrows the faces for the paths below.
|
||||
if (keyboard === undefined || inputActions === undefined) return
|
||||
if (input === undefined || keyboard === undefined || inputActions === undefined) return
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
|
|
@ -651,7 +651,14 @@ export function InputBar({
|
|||
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
|
||||
<div ref={scrollRef} className={css.scroll} data-input-scroll>
|
||||
<div className={css.grow}>
|
||||
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className={clsx(css.backdrop, textareaDisabled && css.backdropDisabled)}
|
||||
data-input-backdrop
|
||||
data-disabled={textareaDisabled || undefined}
|
||||
>
|
||||
{backdrop}
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={css.input}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,41 @@ describe('MessageItem arms', () => {
|
|||
expect(view.getByText('引用会话 · 你好')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the complete metadata-confirmed multi-word session label', () => {
|
||||
const view = render(
|
||||
<MessageItem
|
||||
t={t}
|
||||
referenceLabels={['Research notes']}
|
||||
node={{
|
||||
kind: 'user',
|
||||
seq: 1,
|
||||
time: 1_000,
|
||||
content: [{ type: 'text', text: '@Research notes what changed?' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('Research notes')
|
||||
expect(view.getByText('what changed?')).toBeTruthy()
|
||||
expect(view.getByText('引用会话 · Research notes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders no-extension paths as files and leaves sentence punctuation outside the reference', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user',
|
||||
seq: 1,
|
||||
time: 1_000,
|
||||
content: [{ type: 'text', text: 'Read @Dockerfile and @src/README.md, please.' }] as never,
|
||||
source: null,
|
||||
}} />,
|
||||
)
|
||||
const files = [...view.container.querySelectorAll('[data-ref-chip="file"]')]
|
||||
expect(files.map(file => file.textContent)).toEqual(['Dockerfile', 'README.md'])
|
||||
expect(files.every(file => file.querySelector('svg') !== null)).toBe(true)
|
||||
expect(view.container.textContent).toContain('README.md, please.')
|
||||
})
|
||||
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
|
|
|
|||
|
|
@ -528,49 +528,98 @@ describe('built-in conversation node Definitions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('associates session-reference labels inside the adjacent direct-message node', () => {
|
||||
const referenceSource = {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [
|
||||
{ sessionId: 'source-a', label: 'Research' },
|
||||
{ sessionId: 'source-b', label: 'Review' },
|
||||
],
|
||||
}
|
||||
it('associates each direct message with its immediately following session recall', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', {
|
||||
...textMessage('reference-context', 'snapshot'),
|
||||
source: referenceSource,
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(2, 'user/message', textMessage('citing-user', '@Research and @Review'), { surfaceOp: 'append' }),
|
||||
at(4, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
const current = snapshot(value)
|
||||
const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
|
||||
expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research', 'Review'] })
|
||||
expect(users[1]?.data).not.toHaveProperty('referenceLabels')
|
||||
})
|
||||
|
||||
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(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
|
||||
at(2, 'user/message', {
|
||||
...textMessage('reference-context', 'snapshot'),
|
||||
...textMessage('research-context', 'snapshot'),
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source-a', label: 'Research' }],
|
||||
references: [{ sessionId: 'source-a', label: 'Research notes' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
|
||||
at(4, 'user/message', {
|
||||
...textMessage('review-context', 'snapshot'),
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source-b', label: 'Review' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
const current = snapshot(value)
|
||||
const messages = [...current.nodes.values()]
|
||||
.filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
|
||||
const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
|
||||
expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
|
||||
expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
|
||||
expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
|
||||
expect(users[2]?.data).not.toHaveProperty('referenceLabels')
|
||||
})
|
||||
|
||||
it('updates an already published direct node when its following recall arrives', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
|
||||
])
|
||||
const before = node(snapshot(value), 'user')
|
||||
expect(before?.data).not.toHaveProperty('referenceLabels')
|
||||
|
||||
value.append(at(2, 'user/message', {
|
||||
...textMessage('reference-context', 'snapshot'),
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source-a', label: 'Research notes' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const current = snapshot(value)
|
||||
const nodes = [...current.nodes.values()]
|
||||
.filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
|
||||
expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
|
||||
expect(nodes[0]?.key).toBe(before?.key)
|
||||
expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
|
||||
expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
|
||||
})
|
||||
|
||||
it('associates a claimed steering message with its following recall', () => {
|
||||
const steering = textMessage('steering-reference', '@Research notes continue')
|
||||
const value = assembler([
|
||||
at(1, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
inserted: [steering],
|
||||
}),
|
||||
at(2, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
removedCount: 1,
|
||||
inserted: [],
|
||||
}),
|
||||
at(3, 'user/message', steering, { surfaceOp: 'append' }),
|
||||
at(4, 'user/message', {
|
||||
...textMessage('steering-reference-context', 'snapshot'),
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source-a', label: 'Research notes' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
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')
|
||||
expect(node(snapshot(value), 'steering')?.data).toMatchObject({
|
||||
messageId: 'steering-reference',
|
||||
referenceLabels: ['Research notes'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps replacement copies out of Chat business nodes', () => {
|
||||
|
|
|
|||
|
|
@ -1182,6 +1182,21 @@ describe('decorations', () => {
|
|||
expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
|
||||
})
|
||||
|
||||
it('keeps the textarea glyph layer transparent when a structured reference becomes disabled', () => {
|
||||
const { view, shell, session, textarea } = bench()
|
||||
act(() => {
|
||||
shell.setDraft('@w1')
|
||||
shell.insertReference({
|
||||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
||||
}, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
|
||||
session.set(snapshotOf({ removed: true }))
|
||||
})
|
||||
const backdrop = view.container.querySelector('[data-input-backdrop]')
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(backdrop?.getAttribute('data-disabled')).toBe('true')
|
||||
expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Backspace and Delete remove a reference as one range at its boundaries', () => {
|
||||
const reference = {
|
||||
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SessionInputShell } from '../src/client/input/facade.ts'
|
|||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
|
||||
const mention = '@[Research](dsh-session:InNvdXJjZSI)'
|
||||
const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)'
|
||||
const commandImages = {
|
||||
serialize: () => Promise.resolve([]),
|
||||
release: () => {},
|
||||
|
|
@ -32,6 +33,42 @@ function chip(shell: SessionInputShell): void {
|
|||
}
|
||||
|
||||
describe('reference submission', () => {
|
||||
it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => {
|
||||
const mirror = vi.fn()
|
||||
const first = new SessionInputShell({
|
||||
actx: {} as ClientContext,
|
||||
defaultSink: vi.fn(),
|
||||
commandImages,
|
||||
})
|
||||
first.bindMirror(mirror)
|
||||
first.setDraft('@res')
|
||||
expect(first.insertReference({
|
||||
source: 'reference',
|
||||
ref: spacedMention,
|
||||
label: 'Research notes',
|
||||
appearance: 'session',
|
||||
clipboardText: spacedMention,
|
||||
}, {
|
||||
start: 0,
|
||||
end: 4,
|
||||
draftRev: first.snapshot.draftRev,
|
||||
})).toBe(true)
|
||||
expect(first.snapshot.draft).toBe('@Research notes ')
|
||||
expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
|
||||
|
||||
const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
|
||||
const restored = new SessionInputShell({
|
||||
actx: {} as ClientContext,
|
||||
defaultSink: sink,
|
||||
commandImages,
|
||||
})
|
||||
restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
|
||||
restored.submit()
|
||||
await vi.waitFor(() => {
|
||||
expect(sink).toHaveBeenCalledWith(spacedMention, [], 'queue', expect.any(AbortSignal))
|
||||
})
|
||||
})
|
||||
|
||||
it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
|
||||
const serializeReference = vi.fn(() => Promise.resolve(mention))
|
||||
const sink = vi.fn<(
|
||||
|
|
|
|||
|
|
@ -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: 71ed6891a3a0bee480b2064d844df8758349ee38
|
||||
README.zh.md: 5a761115105914a47c1f3d2673df4b2f3e3e76da
|
||||
README.md: 1ce5ab0a3b0cb5342c1087ddd644933a92b2a596
|
||||
README.zh.md: 2fc30fa01d0e428a2e7ef0be1680a1937f06660c
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
The model sees two consecutive user-role messages: the current message with its readable `@label`, then the `## Referenced sessions` untrusted snapshot. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user explicitly repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到两条连续的 user 角色消息:先是带可读 `@label` 的当前消息,再是 `## Referenced sessions` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非引用它的 user 消息要求使用这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `<referenced-sessions>` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
|
||||
模型会看到两条连续的 user 角色消息:先是带可读 `@label` 的当前消息,再是 `## Referenced sessions` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前用户明确重复这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `<referenced-sessions>` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue