Explorar o código

fix(web): address review: index skill-name batches, bound slash tokens at whitespace, chip only the leading goal token

Yichen Jiang hai 1 día
pai
achega
1b470211ad

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf
+2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -67,7 +67,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 
 - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
 - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
-- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
+- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
 - Sending is the literal text (no more `<skill>` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
 - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -67,7 +67,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
-- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
+- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
 - 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。
 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b
-2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99
+2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf
+2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md

@@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events,
 
 ## Decision
 
-`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
+`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal.
 
 `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism).
 
@@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
 
 **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
 
-**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
+**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
 
 **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md

@@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
 
 ## 决策
 
-`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
+`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。
 
 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。
 
@@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
 
 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。*
 
-**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
+**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
 
 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
 

+ 167 - 42
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -521,67 +521,192 @@ function withSkillNames(
   return { ...candidate, data }
 }
 
+/** What skill-name batches are made of; every other Node is transparent. */
+type SlashEntryKind = 'message' | 'skill' | 'boundary'
+
+interface SlashEntry {
+  readonly key: string
+  readonly seq: number
+  readonly kind: SlashEntryKind
+  /** The injected skill name of a `skill` entry. */
+  readonly name: string | null
+}
+
 /**
- * Skill names each direct message's step loaded, keyed by message Node key.
- *
- * A step's `skill-invocation` injections follow the direct messages the host
- * scanned for `/name` gestures and precede the step's first non-message Node
- * (the assistant step, a Turn error, a command); every Node of another kind
- * therefore closes the batch. Names attach to every direct message of the
- * batch: the bubble decorates only the tokens its own text carries.
- * @param nodes - every materialized Chat Node, in any order.
- * @returns the loaded skill names per direct message key; absent for none.
+ * Classify one Node for batching: a direct message, a `skill-invocation`
+ * context, or a boundary of any other kind. A context that injects no skill
+ * (workspace rules, the catalog, a recall) is transparent and yields null.
  */
-function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map<string, readonly string[]> {
-  const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq)
-  const result = new Map<string, readonly string[]>()
-  let batchMessages: string[] = []
-  let batchNames: string[] = []
-  const flush = (): void => {
-    if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames)
-    batchMessages = []
-    batchNames = []
-  }
-  for (const node of ordered) {
-    const candidate = node as ChatNode
-    if (candidate.kind === 'user' || candidate.kind === 'steering') {
-      batchMessages.push(node.key)
-      continue
-    }
-    if (candidate.kind !== 'context') {
-      flush()
-      continue
-    }
+function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null {
+  const candidate = node as ChatNode
+  if (candidate.kind === 'user' || candidate.kind === 'steering') {
+    return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null }
+  }
+  if (candidate.kind === 'context') {
     const name = skillInvocationName(candidate.data.source)
-    if (name !== null && !batchNames.includes(name)) batchNames.push(name)
+    return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name }
   }
-  flush()
-  return result
+  return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null }
+}
+
+function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean {
+  return left.seq === right.seq && left.kind === right.kind && left.name === right.name
 }
 
-/** Attaches each direct message's step-loaded skill names to its Node. */
-class SkillNameProjector {
+/**
+ * Attaches each direct message's step-loaded skill names to its Node.
+ *
+ * A step's `skill-invocation` injections follow the direct messages the host
+ * scanned for `/name` gestures and precede the step's first Node of any other
+ * kind, so every non-message, non-context Node closes a batch. Every ended
+ * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a
+ * batch never spans Turns, and `step/start` precedes the direct message in
+ * the log, so no boundary separates a message from its injections. Names
+ * attach to every direct message of the batch: the bubble decorates only the
+ * tokens its own text carries.
+ *
+ * The index holds only messages, skill injections, and boundaries, ordered by
+ * `anchorSeq`. An apply re-reads just the batches around the Nodes whose
+ * classification changed and never scans the store, so an assistant
+ * streaming frame costs nothing here (the append hot path never scans the
+ * Chat Nodes).
+ */
+export class SkillNameProjector {
+  private readonly entries = new Map<string, SlashEntry>()
+  /** Every indexed entry in `anchorSeq` order. */
+  private sorted: SlashEntry[] = []
+
+  /**
+   * Rebuild the index from a whole Node set and attach names to its messages.
+   * @param nodes - every materialized Chat Node, in any order.
+   * @returns the same Nodes, direct messages carrying their batch's names.
+   */
   replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
-    const names = skillNamesByMessage(nodes)
+    this.entries.clear()
+    this.sorted = []
+    for (const node of nodes) {
+      const entry = slashEntryOf(node)
+      if (entry === null) continue
+      this.entries.set(entry.key, entry)
+      this.sorted.push(entry)
+    }
+    this.sorted.sort((left, right) => left.seq - right.seq)
+    const names = new Map<string, readonly string[]>()
+    for (let index = 0; index < this.sorted.length; index++) {
+      if (this.sorted[index]?.kind === 'boundary') continue
+      const end = this.runEnd(index)
+      this.assignRun(index, end, names)
+      index = end
+    }
     return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS))
   }
 
+  /**
+   * Fold one incremental upsert set: re-read only the batches around the
+   * Nodes whose classification changed.
+   * @param upserts - the changed Nodes.
+   * @param store - the resident Nodes, read by key for the messages of an affected batch.
+   * @returns the upserts plus any resident message whose names changed.
+   */
   apply(
     upserts: readonly ChatConversationViewNode[],
     store: ChatNodeStore,
   ): readonly ChatConversationViewNode[] {
+    const dirty: number[] = []
+    for (const node of upserts) {
+      const next = slashEntryOf(node)
+      const previous = this.entries.get(node.key)
+      if (previous !== undefined) {
+        if (next !== null && sameSlashEntry(previous, next)) continue
+        this.remove(previous)
+        dirty.push(previous.seq)
+      }
+      if (next === null) continue
+      this.insert(next)
+      dirty.push(next.seq)
+    }
+    if (dirty.length === 0) return upserts
+    const names = new Map<string, readonly string[]>()
+    for (const seq of dirty) this.collectAround(seq, names)
     const byKey = new Map(upserts.map(node => [node.key, node]))
-    const all = new Map(store.values().map(node => [node.key, node]))
-    for (const [key, node] of byKey) all.set(key, node)
-    const names = skillNamesByMessage([...all.values()])
-    for (const [key, node] of all) {
-      const candidate = node as ChatNode
-      if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue
-      const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS)
+    for (const [key, list] of names) {
+      const node = byKey.get(key) ?? store.get(key)
+      if (node === undefined) continue
+      const next = withSkillNames(node, list)
       if (next !== node || byKey.has(key)) byKey.set(key, next)
     }
     return [...byKey.values()]
   }
+
+  private insert(entry: SlashEntry): void {
+    this.sorted.splice(this.lowerBound(entry.seq), 0, entry)
+    this.entries.set(entry.key, entry)
+  }
+
+  private remove(entry: SlashEntry): void {
+    this.sorted.splice(this.sorted.indexOf(entry), 1)
+    this.entries.delete(entry.key)
+  }
+
+  /** First index whose seq is at least `seq`. */
+  private lowerBound(seq: number): number {
+    let low = 0
+    let high = this.sorted.length
+    while (low < high) {
+      const middle = (low + high) >>> 1
+      if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1
+      else high = middle
+    }
+    return low
+  }
+
+  /** Last index of the boundary-free run containing `index`. */
+  private runEnd(index: number): number {
+    let end = index
+    while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++
+    return end
+  }
+
+  /** First index of the boundary-free run containing `index`. */
+  private runStart(index: number): number {
+    let start = index
+    while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start--
+    return start
+  }
+
+  /** Record the names every message of the run `[start, end]` carries. */
+  private assignRun(start: number, end: number, names: Map<string, readonly string[]>): void {
+    const list: string[] = []
+    for (let index = start; index <= end; index++) {
+      const entry = this.sorted[index]
+      if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name)
+    }
+    for (let index = start; index <= end; index++) {
+      const entry = this.sorted[index]
+      if (entry?.kind === 'message') names.set(entry.key, list)
+    }
+  }
+
+  /**
+   * Re-read the run(s) around one changed seq: the run holding a message or
+   * skill entry, or — for a boundary, or a seq that left the index — the runs
+   * on both sides of that position.
+   */
+  private collectAround(seq: number, names: Map<string, readonly string[]>): void {
+    const at = this.lowerBound(seq)
+    const here = this.sorted[at]
+    if (here !== undefined && here.seq === seq && here.kind !== 'boundary') {
+      this.assignRun(this.runStart(at), this.runEnd(at), names)
+      return
+    }
+    if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') {
+      this.assignRun(this.runStart(at - 1), at - 1, names)
+    }
+    const right = here !== undefined && here.seq === seq ? at + 1 : at
+    if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') {
+      this.assignRun(right, this.runEnd(right), names)
+    }
+  }
 }
 
 interface LegacyContribution {

+ 88 - 0
packages/client/ui-chat/tests/skill-name-projector.client.spec.ts

@@ -0,0 +1,88 @@
+/**
+ * SkillNameProjector: the step's `skill-invocation` injections attach to the
+ * direct messages of the same batch, incrementally — an assistant-only apply
+ * must neither scan the store nor re-emit message Nodes.
+ */
+import { describe, expect, it } from 'vitest'
+import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts'
+import type { ChatNodeStore } from '../src/client/contract/snapshot.ts'
+import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
+
+function viewNode(kind: string, seq: number, data: Record<string, unknown>): ChatConversationViewNode {
+  return {
+    key: `${kind}:${seq}`,
+    kind,
+    id: String(seq),
+    target: 'chat',
+    anchorSeq: seq,
+    location: { kind: 'session' },
+    visibility: 'visible',
+    data: { kind, seq, time: seq, ...data },
+  } as unknown as ChatConversationViewNode
+}
+const user = (seq: number, text: string) =>
+  viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } })
+const skill = (seq: number, name: string) =>
+  viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } })
+const instructions = (seq: number) =>
+  viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } })
+const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text })
+
+function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } {
+  const byKey = new Map(nodes.map(node => [node.key, node]))
+  const store = {
+    valuesCalls: 0,
+    get: (key: string) => byKey.get(key),
+    values: () => {
+      store.valuesCalls += 1
+      return [...byKey.values()]
+    },
+    source: () => { throw new Error('unused') },
+    processSource: () => { throw new Error('unused') },
+  }
+  return store
+}
+
+const names = (node: ChatConversationViewNode | undefined) =>
+  (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames
+
+describe('SkillNameProjector', () => {
+  it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => {
+    const projector = new SkillNameProjector()
+    const answer = assistant(6)
+    const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')])
+    expect(names(out[0])).toEqual(['demo'])
+    expect(names(out[4])).toBeUndefined()
+    expect(out[3]).toBe(answer)
+  })
+
+  it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)])
+    const store = storeOf(replaced)
+    const frame = assistant(6, 'answer grows')
+    const out = projector.apply([frame], store)
+    expect(out).toEqual([frame])
+    expect(store.valuesCalls).toBe(0)
+  })
+
+  it('a late skill injection updates only the direct messages of its batch', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')])
+    const store = storeOf(replaced)
+    const injection = skill(3, 'demo')
+    const out = projector.apply([injection], store)
+    expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2'])
+    expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo'])
+  })
+
+  it('a boundary arriving inside a batch splits it and drops the names past it', () => {
+    const projector = new SkillNameProjector()
+    const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')])
+    expect(names(replaced[2])).toEqual(['demo'])
+    const store = storeOf(replaced)
+    const out = projector.apply([assistant(6)], store)
+    expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8'])
+    expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined()
+  })
+})

+ 6 - 7
packages/client/ui-conversation/src/client/input/decorations.ts

@@ -24,19 +24,18 @@ export interface TextRefRange {
 const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
 const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g
 /**
- * What may follow a `/name` token: whitespace or the draft end, optionally
- * after trailing sentence punctuation. The host skill gesture
- * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md`
- * is a path, never a reference.
+ * What may follow a `/name` token: whitespace or the draft end, the boundary
+ * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`,
+ * `/plan.md`, and `/plan。` are prose, never a reference.
  */
-const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/
+const SLASH_TOKEN_END_RE = /^(?:\s|$)/
 
 /**
  * Scan the draft for plain-text reference tokens against the hot lexicons.
  * Word-boundary discipline: the trigger must sit at the draft
  * start or after whitespace ('x/name' never matches); the name must be an
- * exact lexicon member; a `/name` token must end at whitespace, the draft
- * end, or trailing sentence punctuation ('/name/x' is a path).
+ * exact lexicon member; a `/name` token must end at whitespace or the draft
+ * end ('/name/x' is a path, '/name。' is prose).
  * @param draft - draft text.
  * @param lexicon - per-trigger name lists (a missing trigger scans nothing).
  * @returns matched ranges in draft order.

+ 2 - 2
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -351,8 +351,8 @@ describe('decorations: scanTextRefs', () => {
     expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([])
   })
 
-  it('a "/" token may end at trailing punctuation before whitespace', () => {
-    expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }])
+  it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => {
+    expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([])
   })
 
   it('word boundary: a trigger glued to text never matches', () => {

+ 9 - 3
packages/client/ui-goal/src/client/GoalCommandInputView.tsx

@@ -11,13 +11,18 @@ type GoalCommandInputViewProps =
 /**
  * Right-aligned `/goal` input bubble without ordinary message actions. The
  * echoed line decorates its leading `/goal` token as a command chip — the run
- * this Node projects is the fact that the token was a command — and keeps
- * the objective as plain text.
+ * this Node projects is the fact that that token was a command — and keeps
+ * the objective, `/goal` mentions included, as plain text.
  */
 export const GoalCommandInputView = memo(function GoalCommandInputView({
   node, t,
 }: GoalCommandInputViewProps) {
   const data: GoalCommandInputData = node.data
+  // Only the leading token is the executed command; the rest of the line is
+  // the objective, where a further `/goal` is prose.
+  const split = data.text.search(/\s/u)
+  const head = split === -1 ? data.text : data.text.slice(0, split)
+  const rest = split === -1 ? '' : data.text.slice(split)
   return (
     <div
       className={css.row}
@@ -27,7 +32,8 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({
     >
       <div className={css.stack}>
         <div className={css.bubble}>
-          {projectUserText(data.text, [], [GOAL_COMMAND], 'command')}
+          {projectUserText(head, [], [GOAL_COMMAND], 'command')}
+          {rest !== '' && projectUserText(rest, [])}
         </div>
       </div>
     </div>

+ 31 - 0
packages/client/ui-goal/tests/goal-command-input.client.spec.tsx

@@ -137,4 +137,35 @@ describe('goal command input projection', () => {
     const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
     expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']])
   })
+
+  it('renders a bare /goal as one command chip and nothing else', () => {
+    const t = makeTranslate(zh, commonZh)
+    const props = {
+      node: {
+        key: 'goal-command-input:bare',
+        data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 },
+      },
+      t,
+    } as unknown as Parameters<typeof GoalCommandInputView>[0]
+    const view = render(<GoalCommandInputView {...props} />)
+    const bubble = view.getByRole('group', { name: '指令输入' })
+    expect(bubble.textContent).toBe('/goal')
+    expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal'])
+  })
+
+  it('decorates only the leading command token: a /goal inside the objective stays plain', () => {
+    const t = makeTranslate(zh, commonZh)
+    const props = {
+      node: {
+        key: 'goal-command-input:two',
+        data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 },
+      },
+      t,
+    } as unknown as Parameters<typeof GoalCommandInputView>[0]
+    const view = render(<GoalCommandInputView {...props} />)
+    const bubble = view.getByRole('group', { name: '指令输入' })
+    expect(bubble.textContent).toBe('/goal 检查 /goal 的语法')
+    const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
+    expect(chips.map(chip => chip.textContent)).toEqual(['/goal'])
+  })
 })

+ 10 - 10
packages/client/ui-primitives/src/user-text.tsx

@@ -2,17 +2,17 @@
  * Display projection of reference forms in sent user text (bubble and queue
  * rows). The logged model text remains the single truth; this is presentation
  * only, and every part renders inline so a single-line message never breaks
- * across lines. Three decoration sources, by precedence: the wire session form
+ * across lines. Four decoration sources, by precedence: the wire session form
  * `@[label](dsh-session:...)` folds to its label; exact session labels
  * supplied by an adjacent recall decorate their bare `@label` mention; plain
  * `@name` word-boundary tokens decorate by shape alone; and a plain `/name`
- * token decorates only when the caller names it: a skill the host actually
+ * token decorates only when the caller names it a skill the host actually
  * loaded for that message (ui-chat reads the step's `skill-invocation`
- * injections) or the command a command-input bubble echoes, so `/123` or a
- * stray `/word` stays plain text. A `/name`
- * token is whitespace-bounded like the host skill gesture
- * (`dsh-tool-skill`), optionally before trailing sentence punctuation, so
- * slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name.
+ * injections) or the command a command-input bubble echoes so `/123` or a
+ * stray `/word` stays plain text. A `/name` token is whitespace-bounded like
+ * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the
+ * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued
+ * tokens (`/plan。`) stay plain even for a loaded name.
  */
 import type { ReactNode } from 'react'
 import clsx from 'clsx'
@@ -71,9 +71,9 @@ export function projectUserText(
       start = text.indexOf(label, start + label.length)
     }
   }
-  // The `/` alternative's lookahead admits the same trailing punctuation set
-  // TRAILING_PUNCTUATION_RE strips.
-  const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu
+  // A `/` token ends at whitespace or the text end like the host skill
+  // gesture; only `@` tokens shed sentence punctuation below.
+  const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu
   let m: RegExpExecArray | null
   while ((m = re.exec(text)) !== null) {
     const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start

+ 5 - 4
packages/client/ui-primitives/tests/user-text.client.spec.tsx

@@ -60,10 +60,11 @@ describe('projectUserText', () => {
     expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2)
   })
 
-  it('strips trailing punctuation and skips degenerate tokens', () => {
+  it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => {
+    // The host skill gesture ends at whitespace or the text end, so `/plan。`
+    // never loads a skill; the bubble must not suggest otherwise.
     const host = project('用 /plan。 试试 @。', [], ['plan'])
-    const chips = [...host.querySelectorAll('[data-ref-chip]')]
-    expect(chips.map(c => c.textContent)).toEqual(['/plan'])
+    expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
     expect(host.textContent).toBe('用 /plan。 试试 @。')
   })
 
@@ -85,7 +86,7 @@ describe('projectUserText', () => {
     expect(host.textContent).toBe('/goal ship it\nsecond line')
   })
 
-  it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => {
+  it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => {
     const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md'
     const host = project(text, [], ['nfs-hg', 'root-dir', 'plan'])
     expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)