diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml
new file mode 100644
index 0000000000..00e4d39595
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-10-09-composer-command-chip.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md
+/agent-note-claimed-commands-as-atomic-composer-chips:
+ en: 66d4f149698760c8
+ zh: de219f394dfd4a4c
+/agent-note-claimed-commands-as-atomic-composer-chips/problem:
+ en: 831c97e0de131639
+ zh: ea35b4b179ff33f0
+/agent-note-claimed-commands-as-atomic-composer-chips/decision:
+ en: bde405a14ce2011e
+ zh: 272df3039ae260df
+/agent-note-claimed-commands-as-atomic-composer-chips/alternatives-considered:
+ en: 9c694a21237d7772
+ zh: b35ba7bf8b73b679
+/agent-note-claimed-commands-as-atomic-composer-chips/consequences:
+ en: ca28dd0d4e561d2e
+ zh: 0bb86fa41622bf09
diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md
new file mode 100644
index 0000000000..366531ca5a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md
@@ -0,0 +1,40 @@
+# Agent Note: Claimed commands as atomic composer chips
+
+Status: implemented
+
+English | [中文](2026-10-09-composer-command-chip.zh.md)
+
+## Problem
+
+The [Lexical composer decision](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md) kept a claimed command as literal leading text. A TextNode transform styled the token and split typed overflow back out of it, and the submit machine held the claim while the draft string started with the token. Every edit at the token boundary ran through that transform. Lexical merges same-style neighbours before the overflow node's own transform runs, so the split and the merge looped until Lexical aborted the update with error #14 and the DOM diverged from the editor state (issue #4490). PR #5480 cleared the copied style for the reported gesture; pasting an image after a claim and IME composition reach the same transform and remained unverified. The plain-text reference entity also needed a precedence rule to leave the claimed token alone.
+
+## Decision
+
+A claim replaces the leading trigger span with `CommandChipNode`, an atomic inline `DecoratorNode`, followed by the token's argument separator as an ordinary text node.
+
+- The node carries the `CommandClaim`, including its submit closure. History restores nodes with their fields, so undoing a chip deletion restores the claim. JSON export writes the token text; JSON import yields a plain text node because the closure has no JSON form.
+- The detect projection counts the chip as one U+FFFC, so trigger detection never re-reads the command name. The clipboard projection is the token without its separator. The chip is not a reference occurrence. `EditorProjection.command` is the claim of a chip that opens the document.
+- Outside a submit transaction, the machine's claim follows the document: `draft-changed` carries the leading chip's claim, so deleting or displacing the chip releases the claim and a history restore re-enters claimed. A failed command submission returns to claimed only when the draft is unchanged and the same chip still leads. A claim adjudicated from typed text at Enter has no chip and returns to plain on failure.
+- A root transform keeps the chip on the leading seat. A chip with a preceding sibling or inside a later block becomes its literal token, which releases the claim as typing before the old token did.
+- Deletion takes two steps. A character deletion that reaches the chip from a collapsed caret marks it selected (`data-selected` on its host; the root's `data-composer-chip-selected` hides the caret), and the next deletion in the same direction removes it whole. Any other key, a click, blur, or a caret that leaves the chip clears the mark. The editor selection stays a collapsed range and `isKeyboardSelectable()` stays false for the reason recorded on reference chips. Escape still does not release a claim.
+- `claim-decor.ts` and the text-reference precedence rule are deleted.
+
+The `slash/input-*` events, `CommandClaim`, `InputState.claim`, and trigger sources keep their fields. `CommandClaim.token` still ends with the separator, and command arguments are still read from the clipboard text after it.
+
+## Alternatives considered
+
+**Keep literal text and repair each gesture.** Every gesture that reaches the boundary node needs its own proof, as #5480 showed, and the precedence rule with text references remains.
+
+**Reuse `ReferenceChipNode`.** Reference chips are occurrences serialized through their owner codec at submit; a command is not model content and must not enter `occurrences`.
+
+**Put the separator inside the chip text.** Lexical appends a managed line break after an inline decorator that ends its block, so the blank-arguments hint rendered by `p:last-child::after` would move to the next line. A separate text node keeps the hint on the chip's line.
+
+**Select the chip with a NodeSelection.** The plain-text binding's arrow, typing, and deletion handlers ignore non-range selections, so each of those gestures would need its own handling at the chip.
+
+**Keep claims in a shell map keyed by NodeKey.** The map duplicates the field the node already carries and needs pruning whenever history is cleared.
+
+## Consequences
+
+- Backspace no longer edits the command name character by character to reopen the menu. At the chip's edge, two Backspace or Delete presses remove the chip and leave the arguments as plain text.
+- A draft restored after reload is plain text; Enter adjudication still submits it as a command.
+- Whole-chip deletion through a real key event is observable only in the browser lane because jsdom lacks `Selection.modify`; `lifecycle-chrome.e2e.ts` pins the separator, select, and delete presses.
diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md
new file mode 100644
index 0000000000..1f9648a65b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md
@@ -0,0 +1,40 @@
+# Agent Note: 已认领命令作为输入框原子 chip
+
+Status: implemented
+
+[English](2026-10-09-composer-command-chip.md) | 中文
+
+## 问题
+
+[Lexical 输入框决策](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md)把已认领命令保留为行首字面文本。一个 TextNode transform 给 token 加样式,并把输入进去的溢出文字拆出来;提交状态机在草稿字符串以 token 开头时保持认领。token 交界处的每次编辑都会经过这个 transform。Lexical 会在溢出节点自身的 transform 执行前合并样式相同的相邻节点,于是拆分与合并循环往复,直到 Lexical 以错误码 14 中止这次更新,DOM 与编辑器状态随之分叉(issue #4490)。PR #5480 针对报告的手势清除了被复制的样式;认领后粘贴图片和 IME 组字会进入同一个 transform,仍未验证。纯文本引用实体也需要一条优先级规则来避开已认领的 token。
+
+## 决策
+
+认领把行首触发区间替换为 `CommandChipNode`(一个原子 inline `DecoratorNode`),其后是作为普通文本节点的 token 参数分隔符。
+
+- 节点携带 `CommandClaim`,包括其 submit 闭包。历史记录恢复节点时连同字段一起恢复,因此撤销 chip 删除会恢复认领。JSON 导出写入 token 文本;闭包没有 JSON 形式,因此 JSON 导入得到纯文本节点。
+- 检测投影把 chip 计为一个 U+FFFC,触发检测不会再读到命令名。剪贴板投影是不含分隔符的 token。chip 不是引用 occurrence。`EditorProjection.command` 是位于文档开头的 chip 所携带的认领。
+- 在提交事务之外,状态机的认领跟随文档:`draft-changed` 携带行首 chip 的认领,因此删除或挤开 chip 会释放认领,历史恢复会重新进入 claimed。命令提交失败时,只有草稿未变且同一个 chip 仍在行首才回到 claimed。Enter 时从输入文本裁决出的认领没有 chip,失败后回到 plain。
+- 一个根节点 transform 让 chip 保持在行首位置。前面有兄弟节点或位于后续块中的 chip 会变回字面 token,并像在旧 token 前输入那样释放认领。
+- 删除分两步。从折叠光标出发、方向触及 chip 的字符删除先把它标记为选中(宿主元素上的 `data-selected`;根元素的 `data-composer-chip-selected` 隐藏光标),同方向的下一次删除才整体移除它。其他按键、点击、失焦或光标离开 chip 都会清除标记。编辑器选区保持为折叠的 range,`isKeyboardSelectable()` 仍为 false,原因与引用 chip 记录的相同。Escape 仍不释放认领。
+- 删除 `claim-decor.ts` 和文本引用的优先级规则。
+
+`slash/input-*` 事件、`CommandClaim`、`InputState.claim` 与触发源的字段保持不变。`CommandClaim.token` 仍以分隔符结尾,命令参数仍从其后的剪贴板文本读取。
+
+## 考虑过的替代方案
+
+**保留字面文本并逐个修复手势。** 如 #5480 所示,每个触及交界节点的手势都需要单独证明,与文本引用之间的优先级规则也仍然存在。
+
+**复用 `ReferenceChipNode`。** 引用 chip 是在提交时经 owner codec 序列化的 occurrence;命令不是模型内容,不能进入 `occurrences`。
+
+**把分隔符放进 chip 文本。** inline decorator 位于块末尾时,Lexical 会在其后追加受管换行,由 `p:last-child::after` 渲染的空参数提示会移到下一行。独立的文本节点让提示留在 chip 所在行。
+
+**用 NodeSelection 选中 chip。** 纯文本绑定的方向键、输入和删除处理器都会忽略非 range 选区,这些手势在 chip 处都需要单独处理。
+
+**在 shell 中用按 NodeKey 索引的表保存认领。** 该表重复了节点已携带的字段,并且每次清空历史时都需要修剪。
+
+## 影响
+
+- Backspace 不再逐字编辑命令名并重新打开菜单。在 chip 边缘按两次 Backspace 或 Delete 会删除 chip,参数保留为普通文字。
+- 刷新后恢复的草稿是纯文本;Enter 裁决仍会把它作为命令提交。
+- 通过真实按键整体删除 chip 只能在浏览器 lane 中观察,因为 jsdom 缺少 `Selection.modify`;`lifecycle-chrome.e2e.ts` 固定了删除分隔符、选中、删除这三次按键。
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index a9226b7fb7..3a9ec7e07b 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -155,14 +155,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await inputPage.keyboard.insertText('这是任务')
await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
for (let i = 0; i < 5; i++) await input.press('Backspace')
- const tokenText = () => input.locator('[data-lexical-text][style*="business-primary"]').textContent()
+ const tokenText = () => input.locator('[data-composer-command]').textContent()
await expect.poll(() => input.textContent()).toBe(token)
await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
await expect.poll(tokenText).toBe(token)
await input.press('Space')
await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
await expect.poll(() => input.textContent()).toBe(`${token} `)
- await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token)
+ await expect.poll(tokenText).toBe(token)
const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content)
await expect.poll(shownHint).toBe(JSON.stringify(hint))
const cdp = await inputPage.context().newCDPSession(inputPage)
@@ -179,9 +179,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await expect.poll(shownHint).toBe('none')
await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
await expect.poll(shownHint).toBe(JSON.stringify(hint))
+ // Backspace removes the separator, then selects the whole chip, then deletes it.
await input.press('Backspace')
await input.press('Backspace')
+ await expect.poll(() => input.locator('[data-composer-command][data-selected]').count()).toBe(1)
+ await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
+ await input.press('Backspace')
await expect.poll(() => input.getAttribute('data-phase')).toBe('plain')
+ await expect.poll(() => input.textContent()).toBe('')
await writeComposerDraft(inputPage, input, '')
const placeholder = inputPage.locator('[data-composer-placeholder]').first()
await expect.poll(() => placeholder.isVisible()).toBe(true)
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index a22be7582c..e456dae371 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 1d2a3a7f98c2cc9b
zh: f895dd5e9f91e0a9
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: 50b5e00a5fb7c2e5
- zh: 91333db3947f395d
+ en: 84cd1e10c9022adf
+ zh: 9fd87eeebe1d0f78
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index ec4221995f..207d38343b 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -66,7 +66,7 @@ The composer registers the File command action and owns its label, availability,
Drafts retain plain text and file, folder, and Session reference chips across Session switching and reload. Public input methods accept text; internal restoration and Workspace transfer preserve structured references. The input model imports saved content before its first published read; React only mounts that editor and binds the existing Conversation store writer. The original storage key accepts legacy strings and writes semantic snapshots without runtime node IDs. A pending skill catalog does not block editing: slash names remain ordinary text until a matching catalog entry arrives. Arrival rematches the current text without changing chips, selection, or Undo history. Browser-owned attachment File objects are not persisted by this mechanism. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) defines ownership and restoration.
-Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
+A claimed command is one atomic chip at the start of the draft, and the claim lasts exactly as long as that chip. Deleting the arguments and separator keeps the claim. At the chip's edge, the first Backspace or Delete selects the whole chip and the next one removes it and releases the claim, leaving the arguments as plain text; any other key, a click, or a caret move clears the selection. Text or a line break inserted before the chip turns it back into its literal token and releases the claim, and undo restores a deleted chip together with its claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft content, including structured references, and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
@@ -82,7 +82,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
-The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), the claimed slash command is an atomic leading chip that carries its claim, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index bb0c423a36..806ad8c74c 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -66,7 +66,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。
-已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
+已认领的命令是草稿开头的一个原子 chip,认领与该 chip 同生共灭。删除参数和分隔空格不影响认领。光标到达 chip 边缘时,第一次 Backspace 或 Delete 选中整个 chip,再按一次才删除它并释放认领,参数保留为普通文字;按其他键、点击或移动光标会取消选中。在 chip 前插入文字或换行会把它还原为字面 token 并释放认领,撤销会连同认领一起恢复被删除的 chip。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿内容(包括结构化引用)和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
@@ -82,7 +82,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 是携带认领的行首原子 chip,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts
index 52ec80eda9..416e46b463 100644
--- a/packages/client/ui-conversation/src/client/contract/input.ts
+++ b/packages/client/ui-conversation/src/client/contract/input.ts
@@ -39,7 +39,7 @@ export interface SubmitOutcome {
export interface CommandClaim {
/** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */
readonly name: string
- /** Inserted command text with its argument separator; the bare complete name also retains the claim. */
+ /** Command spelling followed by its argument separator; the composer shows the spelling as a chip and inserts the separator after it. */
readonly token: string
readonly hint?: string
readonly attachments?: boolean
@@ -167,7 +167,7 @@ export type DraftAttachmentId = Branded<'DraftAttachmentId'>
* applied the edit after phase and span guards).
*/
export interface InputTarget {
- /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */
+ /** Replace the leading trigger span with a command chip and enter claimed (span-CAS'd). */
beginCommand(claim: CommandClaim, span: TokenSpan): boolean
/** Replace the trigger span with one reference chip (span-CAS'd). */
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean
@@ -310,19 +310,27 @@ export interface SubmitAttempt {
/**
* Submit-machine input events (the machine's single write path). Text
* mutation lives in the editor; the machine only observes the draft through
- * event payloads (claim integrity, enter snapshots, settlement decisions).
+ * event payloads (leading command chip, enter snapshots, settlement decisions).
*/
export type InputEvent =
- /** Clipboard projection changed: the claimed integrity watch runs (zero effects). */
- | { readonly type: 'draft-changed'; readonly draft: string }
- /** The editor applied a claim-token replacement: enter claimed. */
+ /** Editor content changed; carries the claim of the leading command chip, if any (zero effects). */
+ | { readonly type: 'draft-changed'; readonly command: CommandClaim | undefined }
+ /** The editor inserted a command chip: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */
| { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string; readonly submission?: MessageSubmission }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
- /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
- | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string }
+ /** Settlement carries the live clipboard projection and leading command chip claim for claim re-entry decisions. */
+ | {
+ readonly type: 'submit-settled'
+ readonly attempt: SubmitAttempt
+ readonly ok: boolean
+ readonly draft: string
+ readonly command: CommandClaim | undefined
+ readonly outcome?: SubmitOutcome
+ readonly message?: string
+ }
/** Settlement of one optimistic default send, independent of the frozen command slot. */
| { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
/** Commit an attachment-only send whose empty draft did not need an attempt. */
diff --git a/packages/client/ui-conversation/src/client/input/editor/CommandChip.tsx b/packages/client/ui-conversation/src/client/input/editor/CommandChip.tsx
new file mode 100644
index 0000000000..f4ef8f7658
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/editor/CommandChip.tsx
@@ -0,0 +1,28 @@
+/**
+ * Visual body of the claimed command chip: the CommandChipNode's React face.
+ * Pure display with the reference chip's treatment; the claim and its
+ * lifecycle live on the node and the shell.
+ */
+import clsx from 'clsx'
+import type { ReactNode } from 'react'
+import css from './ReferenceChip.module.css'
+import referenceCss from './composer-editor.module.css'
+
+/** Display inputs of the command chip. */
+export interface CommandChipProps {
+ /** Claimed token without its argument separator, e.g. `/goal`. */
+ readonly token: string
+}
+
+/**
+ * Render the claimed command chip.
+ * @param props - the token to display.
+ * @returns the chip body.
+ */
+export function CommandChip({ token }: CommandChipProps): ReactNode {
+ return (
+
+ {token}
+
+ )
+}
diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts
deleted file mode 100644
index e0ed9d3e57..0000000000
--- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Claim-token highlight: while a command claim holds, the draft's leading
- * token renders in the business accent. A TextNode transform keeps the token
- * in its own styled node (splitting when typing merges text into it), and the
- * shell nudges the first leaf dirty when the claim flips so entering and
- * leaving claimed restyles without a text edit.
- */
-import type { LexicalEditor, TextNode as TextNodeType } from 'lexical'
-import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical'
-
-/** Inline style carried by the claim-token node. */
-const TOKEN_STYLE = 'color: var(--dsw-alias-state-business-primary)'
-
-/** The document's first text leaf, or null (empty document / leading chip). */
-function firstTextLeaf(): TextNodeType | null {
- const block = $getRoot().getFirstChild()
- if (!$isElementNode(block)) return null
- const leaf = block.getFirstChild()
- return $isTextNode(leaf) ? leaf : null
-}
-
-/**
- * Register the claim-token styling transform.
- * @param editor - the shell-owned editor.
- * @param activeToken - live claim token accessor; null while unclaimed.
- * @returns the unregister disposer.
- */
-export function registerClaimDecoration(editor: LexicalEditor, activeToken: () => string | null): () => void {
- return editor.registerNodeTransform(TextNode, (node) => {
- const first = firstTextLeaf()
- if (first === null || node.getKey() !== first.getKey()) {
- // Off the token seat: clear a stale token style (a node can move here
- // by paragraph merges).
- if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
- return
- }
- const text = node.getTextContent()
- const active = activeToken()
- const token = text === active?.trimEnd() ? text : active
- if (token === null || !text.startsWith(token)) {
- if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
- return
- }
- if (text.length > token.length) {
- // Typing at the token boundary lands in the styled node; split the
- // overflow back out so only the token itself carries the color. The
- // split copies the token style onto the overflow; it is cleared in this
- // pass because Lexical's normalization merges same-style neighbours
- // before the overflow's own transform runs, which would rejoin the two
- // halves and loop until Lexical aborts the update.
- const [tokenNode, overflow] = node.splitText(token.length)
- if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE)
- overflow?.setStyle('')
- return
- }
- if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE)
- })
-}
-
-/**
- * Nudge the token seat dirty so the transform restyles after a claim flip
- * (claims change phase without a text edit; transforms only run on dirty
- * nodes).
- * @param editor - the shell-owned editor.
- */
-export function refreshClaimDecoration(editor: LexicalEditor): void {
- // Not discrete: a refresh can fire from inside an update listener, where a
- // synchronous nested commit would recurse; the queued update lands on the
- // next flush.
- editor.update(() => {
- firstTextLeaf()?.markDirty()
- })
-}
diff --git a/packages/client/ui-conversation/src/client/input/editor/command-chip-node.tsx b/packages/client/ui-conversation/src/client/input/editor/command-chip-node.tsx
new file mode 100644
index 0000000000..2b2760a36e
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/editor/command-chip-node.tsx
@@ -0,0 +1,149 @@
+/**
+ * CommandChipNode: the claimed slash command as one atomic Lexical decorator
+ * on the document's leading seat. The node carries the claim it was inserted
+ * for, so a history restore of the node restores the claim with it.
+ * `getTextContent()` answers the token without its argument separator; the
+ * separator is an ordinary text node after the chip. The detect projection
+ * counts the chip as one U+FFFC; see projection.ts.
+ */
+import type { JSX } from 'react'
+import type {
+ LexicalEditor, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
+} from 'lexical'
+import { $createTextNode, DecoratorNode, RootNode } from 'lexical'
+import { $dfs } from '@lexical/utils'
+import type { CommandClaim } from '../../contract/input.ts'
+import { CommandChip } from './CommandChip.tsx'
+
+/** JSON form of one command chip (Lexical node serialization contract). */
+export type SerializedCommandChipNode = Spread<{ text: string }, SerializedLexicalNode>
+
+/** One claimed command as an atomic decorator node. */
+export class CommandChipNode extends DecoratorNode {
+ /** The claim this chip anchors; its submit closure has no JSON form. */
+ __claim: CommandClaim
+
+ /** Lexical node registry type tag. */
+ static override getType(): string {
+ return 'command-chip'
+ }
+
+ /**
+ * Clone with identity (Lexical writable-copy contract).
+ * @param node - node to clone.
+ * @returns a copy carrying the same NodeKey and claim.
+ */
+ static override clone(node: CommandChipNode): CommandChipNode {
+ return new CommandChipNode(node.__claim, node.__key)
+ }
+
+ /**
+ * Rebuild one chip from its JSON form. JSON cannot carry the claim's
+ * submit closure, so an imported command is its literal token text.
+ * @param json - serialized chip.
+ * @returns a plain text node holding the token.
+ */
+ static override importJSON(json: SerializedCommandChipNode): LexicalNode {
+ return $createTextNode(json.text)
+ }
+
+ /**
+ * @param claim - the command claim the chip anchors.
+ * @param key - Lexical clone-path key; absent for fresh nodes.
+ */
+ constructor(claim: CommandClaim, key?: NodeKey) {
+ super(key)
+ this.__claim = claim
+ }
+
+ /** Serialize to the JSON node form (the token text alone). */
+ override exportJSON(): SerializedCommandChipNode {
+ return {
+ ...super.exportJSON(),
+ type: 'command-chip',
+ version: 1,
+ text: this.getTextContent(),
+ }
+ }
+
+ /**
+ * Mount the chip's host element; the decorator portal renders into it.
+ * @returns an inline, non-editable span carrying the test/e2e anchor.
+ */
+ override createDOM(): HTMLElement {
+ const el = document.createElement('span')
+ el.setAttribute('data-composer-command', this.__claim.name)
+ el.setAttribute('contenteditable', 'false')
+ return el
+ }
+
+ /** Host element never changes shape. */
+ override updateDOM(): boolean {
+ return false
+ }
+
+ /** The chip sits in the text line. */
+ override isInline(): boolean {
+ return true
+ }
+
+ /**
+ * Arrows step across the chip and Backspace/Delete remove it whole, for
+ * the reason documented on ReferenceChipNode.isKeyboardSelectable.
+ */
+ override isKeyboardSelectable(): boolean {
+ return false
+ }
+
+ /** Clipboard / persistence projection: the token without its separator. */
+ override getTextContent(): string {
+ return this.__claim.token.trimEnd()
+ }
+
+ /** The claim this chip anchors. */
+ getClaim(): CommandClaim {
+ return this.getLatest().__claim
+ }
+
+ /** React face rendered into the host element by the decorator portal. */
+ override decorate(): JSX.Element {
+ return
+ }
+}
+
+/**
+ * Mint one chip node for a claim.
+ * @param claim - the command claim.
+ * @returns the fresh node.
+ */
+export function $createCommandChipNode(claim: CommandClaim): CommandChipNode {
+ return new CommandChipNode(claim)
+}
+
+/**
+ * Command chip type guard.
+ * @param node - any node or nullish.
+ * @returns whether the node is a CommandChipNode.
+ */
+export function $isCommandChipNode(node: LexicalNode | null | undefined): node is CommandChipNode {
+ return node instanceof CommandChipNode
+}
+
+/**
+ * Keep command chips on the leading seat. A chip anywhere but the first
+ * child of the first block (text typed or a line break inserted before it)
+ * becomes its literal token, which releases the claim. The root transform
+ * runs whenever any attached node is dirty.
+ * @param editor - the shell-owned editor.
+ * @returns the unregister disposer.
+ */
+export function registerCommandChipSeat(editor: LexicalEditor): () => void {
+ return editor.registerNodeTransform(RootNode, (root) => {
+ for (const { node } of $dfs(root)) {
+ if ($isCommandChipNode(node)
+ && (node.getPreviousSibling() !== null || node.getParentOrThrow().getPreviousSibling() !== null)) {
+ node.replace($createTextNode(node.getTextContent()))
+ }
+ }
+ })
+}
diff --git a/packages/client/ui-conversation/src/client/input/editor/command-chip-selection.ts b/packages/client/ui-conversation/src/client/input/editor/command-chip-selection.ts
new file mode 100644
index 0000000000..a6c33f0597
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/editor/command-chip-selection.ts
@@ -0,0 +1,68 @@
+/**
+ * Two-step command chip deletion. A character deletion (Backspace, Delete,
+ * or an input-event deletion) whose direction reaches the command chip from a
+ * collapsed caret first selects the chip; the next deletion in that direction
+ * removes it. Any other key, a click, blur, or a caret that no longer touches
+ * the chip clears the selection. The selected state is presentation on the
+ * chip's host and the root element; the editor selection stays a collapsed
+ * range beside the chip, so typing and arrows keep their plain-text behavior.
+ */
+import type { LexicalEditor, NodeKey } from 'lexical'
+import {
+ $getAdjacentNode, $getSelection, $isRangeSelection, BLUR_COMMAND, CLICK_COMMAND, COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_HIGH, DELETE_CHARACTER_COMMAND, KEY_DOWN_COMMAND,
+} from 'lexical'
+import { mergeRegister } from '@lexical/utils'
+import { $isCommandChipNode } from './command-chip-node.tsx'
+import type { CommandChipNode } from './command-chip-node.tsx'
+
+/** Keys that keep a selected chip: deletions and bare modifiers. */
+const RETAINING_KEYS = new Set(['Backspace', 'Delete', 'Shift', 'Control', 'Alt', 'Meta'])
+
+/** The chip a collapsed caret deletes next in one direction, or null. */
+function $commandChipBesideCaret(isBackward: boolean): CommandChipNode | null {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
+ const node = $getAdjacentNode(selection.focus, isBackward)
+ return $isCommandChipNode(node) ? node : null
+}
+
+/**
+ * Register two-step deletion for the command chip.
+ * @param editor - the shell-owned editor.
+ * @returns the unregister disposer, which also clears a selected chip.
+ */
+export function registerCommandChipSelection(editor: LexicalEditor): () => void {
+ let selected: { readonly key: NodeKey; readonly isBackward: boolean } | null = null
+ const paint = (next: typeof selected): void => {
+ if (selected !== null && selected.key !== next?.key) {
+ editor.getElementByKey(selected.key)?.removeAttribute('data-selected')
+ }
+ selected = next
+ if (next !== null) editor.getElementByKey(next.key)?.setAttribute('data-selected', '')
+ editor.getRootElement()?.toggleAttribute('data-composer-chip-selected', next !== null)
+ }
+ const clear = (): false => {
+ if (selected !== null) paint(null)
+ return false
+ }
+ return mergeRegister(
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, (isBackward) => {
+ const chip = $commandChipBesideCaret(isBackward)
+ if (chip === null || (selected?.key === chip.getKey() && selected.isBackward === isBackward)) return clear()
+ paint({ key: chip.getKey(), isBackward })
+ return true
+ }, COMMAND_PRIORITY_HIGH),
+ editor.registerCommand(KEY_DOWN_COMMAND, event => RETAINING_KEYS.has(event.key) ? false : clear(), COMMAND_PRIORITY_CRITICAL),
+ editor.registerCommand(CLICK_COMMAND, clear, COMMAND_PRIORITY_CRITICAL),
+ editor.registerCommand(BLUR_COMMAND, clear, COMMAND_PRIORITY_CRITICAL),
+ editor.registerUpdateListener(({ editorState }) => {
+ const current = selected
+ if (current === null) return
+ const touching = editorState.read(() => $commandChipBesideCaret(current.isBackward)?.getKey() === current.key)
+ // A restore can remount the chip's host element; repaint keeps the mark on it.
+ paint(touching ? current : null)
+ }),
+ () => { paint(null) },
+ )
+}
diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
index 0eefd223a1..3d153065b4 100644
--- a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
+++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
@@ -18,6 +18,11 @@
cursor: pointer;
}
+/* A command chip selected by its first deletion keystroke keeps the hover tint until the next deletion removes it. */
+[data-composer-command][data-selected] > .reference {
+ background: var(--dsw-alias-state-business-tertiary);
+}
+
.textRef {
display: inline-block;
max-width: 100%;
diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts
index e105a1d6c0..b4fe576e19 100644
--- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts
@@ -115,7 +115,7 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
),
editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
// Escape layering: an open overlay closes; claimed without an overlay
- // does NOT release (backspacing the token is the only exit gesture).
+ // does NOT release (deleting the command chip is the only exit gesture).
handlers.dismissPopup()
if (handlers.arbitrate('escape', isComposingEvent(event, recentlyComposing)) === 'consumed') {
event.preventDefault()
@@ -127,7 +127,7 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
if (isComposingEvent(event, recentlyComposing)) return false
const consumed = handlers.space()
if (consumed) {
- event.preventDefault() // claim token already carries the trailing separator
+ event.preventDefault() // the command chip insertion already carries its separator
return true
}
return false
diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts
index f48a5b595d..c48c97ad8a 100644
--- a/packages/client/ui-conversation/src/client/input/editor/projection.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts
@@ -12,7 +12,9 @@ import {
$getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
} from 'lexical'
import type { Occurrence } from '../../contract/draft-editor.ts'
+import type { CommandClaim } from '../../contract/input.ts'
import { $isReferenceChipNode } from './chip-node.tsx'
+import { $isCommandChipNode } from './command-chip-node.tsx'
/** The detect-projection stand-in for one chip (object replacement character). */
export const ATOMIC_CHAR = ''
@@ -79,7 +81,7 @@ export function $composerLayout(): ComposerLayout {
const kids = element.getChildren()
children.set(element.getKey(), kids.map(kid => kid.getKey()))
for (const kid of kids) {
- if ($isReferenceChipNode(kid)) {
+ if ($isReferenceChipNode(kid) || $isCommandChipNode(kid)) {
pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent())
} else if ($isTextNode(kid)) {
const text = kid.getTextContent()
@@ -162,6 +164,8 @@ export interface EditorProjection {
readonly selection: { readonly start: number; readonly end: number } | null
/** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */
readonly caret: number | null
+ /** Claim of the command chip opening the document; undefined without one. */
+ readonly command: CommandClaim | undefined
}
/**
@@ -211,6 +215,7 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
...(chip.isInvalid() ? { invalid: true } : {}),
})
}
+ const lead = layout.segments[0]?.node
const selection = $getSelection()
let range: { start: number; end: number } | null = null
if ($isRangeSelection(selection)) {
@@ -226,5 +231,6 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
occurrences,
selection: range,
caret: range !== null && range.start === range.end ? range.start : null,
+ command: $isCommandChipNode(lead) ? lead.getClaim() : undefined,
}
}
diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts
index 7cbac8676b..1da9a1c10e 100644
--- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts
@@ -10,9 +10,11 @@ import { registerPlainText } from '@lexical/plain-text'
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
import { mergeRegister } from '@lexical/utils'
import type { DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts'
+import type { CommandClaim } from '../../contract/input.ts'
import { registerReferenceActivation } from './reference-activation.ts'
import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
-import { refreshClaimDecoration, registerClaimDecoration } from './claim-decor.ts'
+import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx'
+import { registerCommandChipSelection } from './command-chip-selection.ts'
import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './text-ref.ts'
import type { EditorProjection } from './projection.ts'
import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts'
@@ -25,7 +27,6 @@ type Lexicon = ReadonlyMap<'/' | '@', readonly string[]>
interface DraftEditorRuntimeDeps {
readonly onUpdate: () => void
readonly openReference: (source: string | undefined, reference: Pick) => boolean
- readonly activeClaimToken: () => string | null
readonly lexicon: () => Lexicon
readonly resolveLexicon: () => ObservableSnapshot | undefined
}
@@ -45,7 +46,9 @@ const HISTORY_MERGE_DELAY_MS = 1000
export class DraftEditorRuntime {
/** The editor bound by the Composer's contenteditable host. */
readonly editor: LexicalEditor
- private projected: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
+ private projected: EditorProjection = {
+ detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null, command: undefined,
+ }
/** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
private readonly occurrenceIds = new Map()
private occurrenceSeq = 0
@@ -57,7 +60,7 @@ export class DraftEditorRuntime {
constructor(private readonly deps: DraftEditorRuntimeDeps) {
this.editor = createEditor({
namespace: 'dsh-composer',
- nodes: [ReferenceChipNode, TextRefNode],
+ nodes: [ReferenceChipNode, CommandChipNode, TextRefNode],
onError: (error) => { throw error },
})
}
@@ -88,8 +91,9 @@ export class DraftEditorRuntime {
this.deps.openReference(source, reference)),
registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
this.editor.registerUpdateListener(() => { this.deps.onUpdate() }),
- registerClaimDecoration(this.editor, () => this.deps.activeClaimToken()),
- registerTextRefDecoration(this.editor, () => this.deps.lexicon(), () => this.deps.activeClaimToken()),
+ registerCommandChipSeat(this.editor),
+ registerCommandChipSelection(this.editor),
+ registerTextRefDecoration(this.editor, () => this.deps.lexicon()),
() => {
this.lexiconOff?.()
this.lexiconOff = undefined
@@ -261,6 +265,26 @@ export class DraftEditorRuntime {
return applied
}
+ /**
+ * Replace a span with a command chip followed by the claim token's
+ * argument separator, unless the separator already follows.
+ * @param span - detect-coordinate range.
+ * @param claim - the command claim the chip anchors.
+ * @param tail - the character following the range before editing.
+ * @returns whether the range mapped and the edit applied.
+ */
+ insertCommand(span: DetectSpan, claim: CommandClaim, tail: string): boolean {
+ const separator = claim.token.slice(claim.token.trimEnd().length)
+ let applied = false
+ this.applyEdit(() => {
+ const nodes = separator === '' || tail === separator.charAt(0)
+ ? [$createCommandChipNode(claim)]
+ : [$createCommandChipNode(claim), $createTextNode(separator)]
+ applied = $replaceDetectSpanWithNodes(span, nodes)
+ })
+ return applied
+ }
+
/**
* Insert an ordered file-reference batch after the live selection without deleting it.
* @param references - validated references in source order.
@@ -280,11 +304,6 @@ export class DraftEditorRuntime {
return applied
}
- /** Refresh claim-token decoration after the model's claim changes. */
- refreshClaimDecoration(): void {
- refreshClaimDecoration(this.editor)
- }
-
/**
* Clear committed content using the model's suffix decision inside the editor update.
* @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root.
diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts
index dc0dccc74b..5221b38a57 100644
--- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts
@@ -70,28 +70,18 @@ export class TextRefNode extends TextNode {
}
/**
- * Register the plain-text reference entity transform. The claim decoration
- * has precedence on the leading-token seat: while a command claim holds, the
- * claimed token must stay a plain TextNode (transforms register per concrete
- * node class, so a TextRefNode would never receive the TextNode claim
- * transform and the warn color would be lost).
+ * Register the plain-text reference entity transform.
* @param editor - the shell-owned editor.
* @param lexiconOf - live per-trigger name-roll accessor (the controller's aggregated store).
- * @param activeToken - live claim token accessor; null while unclaimed.
* @returns the unregister disposer.
*/
export function registerTextRefDecoration(
editor: LexicalEditor,
lexiconOf: () => ReadonlyMap<'/' | '@', readonly string[]>,
- activeToken: () => string | null,
): () => void {
const getMatch = (text: string): { start: number; end: number } | null => {
- const claim = activeToken()
- for (const range of scanTextRefs(text, lexiconOf())) {
- if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim.trimEnd()) continue
- return { start: range.start, end: range.end }
- }
- return null
+ const range = scanTextRefs(text, lexiconOf())[0]
+ return range === undefined ? null : { start: range.start, end: range.end }
}
return mergeRegister(
...registerLexicalTextEntity(
diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts
index 9f4a264e46..b3d7d74fd0 100644
--- a/packages/client/ui-conversation/src/client/input/facade.ts
+++ b/packages/client/ui-conversation/src/client/input/facade.ts
@@ -86,6 +86,7 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
/** Whether two projections differ in content (selection and caret excluded). */
function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean {
if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true
+ if (prev.command !== next.command) return true
if (prev.occurrences.length !== next.occurrences.length) return true
return next.occurrences.some((occ, i) => {
const old = prev.occurrences[i]
@@ -172,7 +173,6 @@ export class SessionInputShell implements SessionInput {
onUpdate: () => { this.onEditorUpdate() },
openReference: (source, reference) =>
this.deps.inputTriggers?.()?.openReference(source, reference) ?? false,
- activeClaimToken: () => this.activeClaimToken(),
lexicon: () => this.lexicon.getSnapshot(),
resolveLexicon: () => this.deps.inputTriggers?.()?.lexicon,
})
@@ -183,7 +183,7 @@ export class SessionInputShell implements SessionInput {
// ---- editor plumbing ----
- /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
+ /** Re-project, sync the claim with the leading command chip, publish, and feed trigger tracking after every editor commit. */
private onEditorUpdate(): void {
const prev = this.draftEditor.refreshProjection()
// Selection-only commits advance neither the revision nor the published
@@ -196,7 +196,7 @@ export class SessionInputShell implements SessionInput {
this.failedDetached.clear()
this.failedRestoreRev = undefined
}
- this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText }))
+ this.dispatchRun(({ type: 'draft-changed', command: this.projection.command }))
}
const caret = this.projection.caret
if (caret !== null) {
@@ -449,8 +449,8 @@ export class SessionInputShell implements SessionInput {
/**
* Apply one command claim (scoped begin-command event listener body): the
- * editor replaces [0, span.end) with the claim token, then the machine
- * enters claimed.
+ * editor replaces [0, span.end) with the command chip and its separator,
+ * then the machine enters claimed.
* @param claim - the command claim from the pick path.
* @param span - pick-time span snapshot (detect coordinates).
* @returns whether the edit applied (phase, span CAS, and leading guard passed).
@@ -460,9 +460,10 @@ export class SessionInputShell implements SessionInput {
if (phase !== 'plain' && phase !== 'claimed') return false
if (span.draftRev !== this.rev) return false
// Leading-trigger contract: only whitespace may precede the span; the
- // whitespace prefix is dropped so the claimed watch (startsWith) holds.
+ // whitespace prefix is dropped so the chip takes the leading seat.
if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
- const applied = this.draftEditor.replaceText({ start: 0, end: span.end }, claim.token)
+ const tail = this.projection.detectText.slice(span.end, span.end + 1)
+ const applied = this.draftEditor.insertCommand({ start: 0, end: span.end }, claim, tail)
if (!applied) return false
this.dispatchRun(({ type: 'claim', claim }))
return true
@@ -616,20 +617,9 @@ export class SessionInputShell implements SessionInput {
// ---- effect executor ----
- /** The claim token the decoration transform styles; null while unclaimed. */
- private activeClaimToken(): string | null {
- const core = this.core.state
- return (core.phase === 'claimed' || core.phase === 'submitting') && core.claim !== undefined
- ? core.claim.token
- : null
- }
-
- /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
+ /** Dispatch one machine event and execute its effects. */
private dispatchRun(ev: Parameters[0]): void {
- const beforeToken = this.activeClaimToken()
- const effects = this.core.dispatch(ev)
- this.run(effects)
- if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
+ this.run(this.core.dispatch(ev))
}
private run(effects: readonly InputEffect[]): void {
@@ -857,7 +847,7 @@ export class SessionInputShell implements SessionInput {
}
this.dispatchRun(({
type: 'submit-settled', attempt, ok: outcome.kind === 'success',
- draft: this.projection.clipboardText, outcome,
+ draft: this.projection.clipboardText, command: this.projection.command, outcome,
...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}),
}))
},
@@ -866,7 +856,7 @@ export class SessionInputShell implements SessionInput {
const message = error instanceof Error ? error.message : String(error)
this.dispatchRun(({
type: 'submit-settled', attempt, ok: false,
- draft: this.projection.clipboardText, message,
+ draft: this.projection.clipboardText, command: this.projection.command, message,
}))
},
)
diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts
index 910a0a66fe..1eec268602 100644
--- a/packages/client/ui-conversation/src/client/input/machine.ts
+++ b/packages/client/ui-conversation/src/client/input/machine.ts
@@ -27,11 +27,6 @@ function argsAfter(draft: string, token: string): string {
return ''
}
-/** A claimed name may stand alone; arguments require the token's separator. */
-function retainsClaim(draft: string, token: string): boolean {
- return draft.startsWith(token) || draft === token.trimEnd()
-}
-
/** The submit-plane slice of the published InputState. */
export interface SubmitSnapshot {
readonly phase: InputState['phase']
@@ -75,7 +70,7 @@ export class SubmitMachine {
*/
dispatch(ev: InputEvent): readonly InputEffect[] {
switch (ev.type) {
- case 'draft-changed': return this.onDraftChanged(ev.draft)
+ case 'draft-changed': return this.onDraftChanged(ev.command)
case 'claim': return this.onClaim(ev.claim)
case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
@@ -88,16 +83,19 @@ export class SubmitMachine {
}
}
- /** The complete command name retains its claim with or without the argument separator. */
- private onDraftChanged(draft: string): readonly InputEffect[] {
- if (this.phase === 'claimed' && this.claim !== undefined && !retainsClaim(draft, this.claim.token)) {
- this.phase = 'plain'
- this.claim = undefined
- }
+ /**
+ * Outside a transaction the claim follows the document's leading command
+ * chip: deleting or displacing the chip releases it, and a history restore
+ * of the chip re-enters claimed.
+ */
+ private onDraftChanged(command: CommandClaim | undefined): readonly InputEffect[] {
+ if (this.phase !== 'plain' && this.phase !== 'claimed') return []
+ this.claim = command
+ this.phase = command === undefined ? 'plain' : 'claimed'
return []
}
- /** The editor applied a claim-token replacement; busy phases refuse another claim. */
+ /** The editor inserted a command chip; busy phases refuse another claim. */
private onClaim(claim: CommandClaim): readonly InputEffect[] {
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
this.claim = claim
@@ -204,8 +202,7 @@ export class SubmitMachine {
return effects
}
const text = ev.message ?? ev.outcome?.text
- if (ev.draft === flight.attempt.draftSnapshot
- && this.claim !== undefined && retainsClaim(ev.draft, this.claim.token)) {
+ if (ev.draft === flight.attempt.draftSnapshot && this.claim !== undefined && ev.command === this.claim) {
this.phase = 'claimed'
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 4676f4cd7d..123a0fcbea 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -202,6 +202,11 @@
content: none;
}
+/* A selected command chip stands in for the caret until the next key. */
+.input[data-composer-chip-selected] {
+ caret-color: transparent;
+}
+
.input[data-composer-composing] + .placeholder {
visibility: hidden;
}
diff --git a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts
index 56f1902291..7f115ff744 100644
--- a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts
+++ b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts
@@ -37,7 +37,7 @@ function fixture(start = 1, end = 4) {
const onUpdate = vi.fn()
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
onUpdate: () => { runtime.refreshProjection(); onUpdate() },
- openReference: () => false, activeClaimToken: () => null,
+ openReference: () => false,
lexicon: () => new Map(), resolveLexicon: () => undefined,
})
const dispose = runtime.register()
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index 7ba82035d5..f7d55f9375 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -1425,12 +1425,12 @@ describe('machine pending lock', () => {
})
describe('decorations', () => {
- /** The claim-token styled leaf (the transform's inline accent color). */
- function tokenSpanOf(container: HTMLElement): HTMLElement | null {
- return container.querySelector('[data-lexical-text][style*="business-primary"]')
+ /** The claimed command chip's host element. */
+ function commandChipOf(container: HTMLElement): HTMLElement | null {
+ return container.querySelector('[data-composer-command]')
}
- it('claimed token styles the leading leaf and sets the blank-args hint variable', () => {
+ it('a claim renders the leading command chip and sets the blank-args hint variable', () => {
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
const { view, shell, textarea } = bench({ t: makeTranslate({}) })
act(() => {
@@ -1439,15 +1439,14 @@ describe('decorations', () => {
{ name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
- shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
})
- expect(tokenSpanOf(view.container)?.textContent).toBe('/goal ')
+ expect(commandChipOf(view.container)?.textContent).toBe('/goal')
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
- // Args typed: the hint disappears, the token style stays.
- act(() => { shell.setDraft('/goal 发布') })
- act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+ // Args typed: the hint disappears, the chip stays.
+ act(() => { shell.actions.insertText('发布', shell.actions.captureInsertion()) })
+ expect(shell.snapshot.draft).toBe('/goal 发布')
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
- expect(tokenSpanOf(view.container)).not.toBeNull()
+ expect(commandChipOf(view.container)).not.toBeNull()
})
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
index e7c997cb61..81bbe030b3 100644
--- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* Impact-matrix projection tests (row by row): what each
- * phase projects onto the InputBar — enter routing, visuals (token color /
+ * phase projects onto the InputBar — enter routing, visuals (command chip /
* hint / pending), edit freedom, and the published currency's claim seat.
* React over jsdom per the client testing discipline; the machine is real.
*/
@@ -121,7 +121,11 @@ function bench(over?: {
)
})
}
- return { view, textarea, shell, wiring, sink, claim, serialize, release }
+ /** Type at the live caret, as keyboard input after the claimed chip does. */
+ const typeAtCaret = (text: string) => {
+ act(() => { shell.actions.insertText(text, shell.actions.captureInsertion()) })
+ }
+ return { view, textarea, shell, wiring, sink, claim, typeAtCaret, serialize, release }
}
describe('matrix row: plain', () => {
@@ -158,26 +162,29 @@ describe('matrix row: plain', () => {
})
describe('matrix row: claimed', () => {
- it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
- const { view, textarea, shell, claim } = bench()
+ it('publishes the claim currency, renders the command chip, hints while args are blank, and edits stay free', () => {
+ const { view, textarea, shell, claim, typeAtCaret } = bench()
claim()
- act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' })
- expect(view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ')
+ const chip = view.container.querySelector('[data-composer-command="goal"]')
+ expect(chip?.textContent).toBe('/goal')
+ expect(chip?.getAttribute('contenteditable')).toBe('false')
+ expect(shell.snapshot.draft).toBe('/goal ')
// The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
expect(textarea.getAttribute('contenteditable')).toBe('true')
- // Free editing beyond the token: hint drops, claim holds.
- act(() => { shell.setDraft('/goal 发布版本') })
+ // Free editing after the chip: hint drops, claim holds.
+ typeAtCaret('发布版本')
+ expect(shell.snapshot.draft).toBe('/goal 发布版本')
expect(shell.snapshot.phase).toBe('claimed')
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
})
it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
- const { view, textarea, shell, sink, claim } = bench({ submit })
+ const { view, textarea, shell, sink, claim, typeAtCaret } = bench({ submit })
claim()
- act(() => { shell.setDraft('/goal 发布') })
+ typeAtCaret('发布')
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) })
@@ -186,14 +193,13 @@ describe('matrix row: claimed', () => {
expect(view.getByText('完成')).toBeTruthy()
})
- it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
+ it('replacing the whole draft removes the chip and releases to plain', () => {
const { view, shell, claim } = bench()
claim()
- act(() => { shell.setDraft('/goa 发布') }) // token broken
+ act(() => { shell.setDraft('/goal 发布') })
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.claim).toBeUndefined()
- act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
- expect(view.container.querySelector('[data-lexical-text][style*="business-primary"]')).toBeNull()
+ expect(view.container.querySelector('[data-composer-command]')).toBeNull()
})
})
diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
index ca03589a52..d30124bf24 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
@@ -12,7 +12,7 @@ import './control-row-dom.ts'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
-import { $getRoot, $isElementNode, $isTextNode } from 'lexical'
+import { $createTextNode, $getRoot, $isElementNode, DELETE_CHARACTER_COMMAND, UNDO_COMMAND } from 'lexical'
import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -205,7 +205,32 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
const type = (text: string): void => {
act(() => { shell.setDraft(text) })
}
- return { runtime, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release }
+ /** Press Space and let the claim the key handler applied commit (Lexical commits it on the next microtask). */
+ const space = async (): Promise => {
+ fireEvent.keyDown(textarea, { key: ' ', keyCode: 32 })
+ await act(async () => {})
+ }
+ /** Type at the live caret, as keyboard input after the claimed chip does. */
+ const append = (text: string): void => {
+ act(() => { shell.actions.insertText(text, shell.actions.captureInsertion()) })
+ }
+ /** Dispatch one character deletion and commit it (the command's update lands on the next flush). */
+ const deleteCharacter = (isBackward: boolean): void => {
+ act(() => {
+ shell.editor.dispatchCommand(DELETE_CHARACTER_COMMAND, isBackward)
+ shell.editor.update(() => {}, { discrete: true })
+ })
+ }
+ /** Delete one detect span through the editor, as a Backspace over it does. */
+ const erase = (start: number, end: number): void => {
+ act(() => {
+ shell.editor.update(() => { $replaceDetectSpanWithText({ start, end }, '') }, { discrete: true })
+ })
+ }
+ return {
+ runtime, inputTriggers, controller, shell, wiring, view, textarea,
+ type, space, append, erase, deleteCharacter, sink, serialize, release,
+ }
}
async function bench(executeImpl?: (line: string) => Promise) {
@@ -230,12 +255,11 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
act(() => { b.controller.pick('command', 0) })
expect(b.shell.snapshot.phase).toBe('claimed')
expect(b.shell.snapshot.draft).toBe('/goal ')
- act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
- expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ')
+ expect(b.view.container.querySelector('[data-composer-command]')?.textContent).toBe('/goal')
// The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
expect(b.textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
// Continue typing args; hint drops; claim holds.
- b.type('/goal 发布 v1')
+ b.append('发布 v1')
expect(b.shell.snapshot.phase).toBe('claimed')
// Enter: submitting → command execute → commit clears.
fireEvent.keyDown(b.textarea, { key: 'Enter' })
@@ -316,71 +340,170 @@ describe('scenario: images ride an accepting command through the real pipeline',
})
})
-describe('scenario H: backspace breaks the token', () => {
- it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed when its arguments and separator are deleted', async (name) => {
+describe('scenario H: the command chip deletes whole', () => {
+ it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed until the chip itself is deleted', async (name) => {
const { source } = commandSource([{ name, description: name, input: { hint: '目标内容' } }],
() => Promise.resolve({ kind: 'success' }))
const b = await scopedBench((triggers) => { triggers.registerSource(source) })
b.type(`/${name}`)
- fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
+ await b.space()
expect(b.shell.snapshot.phase).toBe('claimed')
- b.type(`/${name} 这是目标`)
+ b.append('这是目标')
for (let i = 0; i < 5; i++) {
- act(() => {
- b.shell.editor.update(() => {
- const end = b.shell.snapshot.draft.length
- $replaceDetectSpanWithText({ start: end - 1, end }, '')
- }, { discrete: true })
- })
+ const { end } = b.shell.caretSpan()
+ b.erase(end - 1, end)
}
expect(b.shell.snapshot.draft).toBe(`/${name}`)
expect(b.shell.snapshot.phase).toBe('claimed')
- expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe(`/${name}`)
- b.type(`/${name} `)
+ expect(b.view.container.querySelector('[data-composer-command]')?.textContent).toBe(`/${name}`)
+ b.append(' ')
await act(async () => {})
expect(b.shell.snapshot.phase).toBe('claimed')
expect(b.shell.snapshot.draft).toBe(`/${name} `)
- expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe(`/${name} `)
- b.type(`/${name}x`)
+ b.erase(0, 1)
expect(b.shell.snapshot.phase).toBe('plain')
+ expect(b.shell.snapshot.draft).toBe(' ')
+ expect(b.view.container.querySelector('[data-composer-command]')).toBeNull()
})
- it('keeps text typed at the end of the claimed token when the browser inserts it natively', async () => {
+ it('the first deletion at the chip edge selects the chip and the second removes it', async () => {
const b = await bench()
b.type('/goal')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
- fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
+ await b.space()
+ b.append('发布')
+ for (let i = 0; i < 3; i++) {
+ const { end } = b.shell.caretSpan()
+ b.erase(end - 1, end)
+ }
+ expect(b.shell.snapshot.draft).toBe('/goal')
+ const chip = (): Element | null => b.view.container.querySelector('[data-composer-command]')
+ b.deleteCharacter(true)
+ expect(chip()?.hasAttribute('data-selected')).toBe(true)
+ expect(b.textarea.hasAttribute('data-composer-chip-selected')).toBe(true)
expect(b.shell.snapshot.phase).toBe('claimed')
- b.type('/goal 发布')
- // A caret at the token/argument boundary sits at the end of the styled
- // token node; native insertion writes there and Lexical syncs the DOM
- // text with setTextContent plus a caret select.
+ b.deleteCharacter(true)
+ expect(chip()).toBeNull()
+ expect(b.shell.snapshot.phase).toBe('plain')
+ expect(b.textarea.hasAttribute('data-composer-chip-selected')).toBe(false)
+ })
+
+ it('another key or typing clears a selected chip without deleting it', async () => {
+ const b = await bench()
+ b.type('/goal')
+ await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
+ await b.space()
+ b.erase(1, 2)
+ const chip = (): Element | null => b.view.container.querySelector('[data-composer-command]')
+ b.deleteCharacter(true)
+ expect(chip()?.hasAttribute('data-selected')).toBe(true)
+ fireEvent.keyDown(b.textarea, { key: 'Shift' })
+ expect(chip()?.hasAttribute('data-selected')).toBe(true)
+ fireEvent.keyDown(b.textarea, { key: 'Escape' })
+ expect(chip()?.hasAttribute('data-selected')).toBe(false)
+ expect(b.textarea.hasAttribute('data-composer-chip-selected')).toBe(false)
+ b.deleteCharacter(true)
+ expect(chip()?.hasAttribute('data-selected')).toBe(true)
+ b.append('发布')
+ expect(chip()?.hasAttribute('data-selected')).toBe(false)
+ expect(b.shell.snapshot.draft).toBe('/goal发布')
+ expect(b.shell.snapshot.phase).toBe('claimed')
+ })
+
+ it('Delete before the chip selects it first and removes it on the second press', async () => {
+ const b = await bench()
+ b.type('/goal')
+ await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
+ await b.space()
+ b.append('发布')
act(() => {
b.shell.editor.update(() => {
const paragraph = $getRoot().getFirstChild()
- const token = $isElementNode(paragraph) ? paragraph.getFirstChild() : null
- if (!$isTextNode(token)) throw new Error('missing token node')
- token.setTextContent('/goal 立即')
- token.select(8, 8)
+ if (!$isElementNode(paragraph)) throw new Error('missing paragraph')
+ paragraph.select(0, 0)
}, { discrete: true })
})
- expect(b.shell.snapshot.draft).toBe('/goal 立即发布')
- expect(b.shell.snapshot.phase).toBe('claimed')
- act(() => { b.shell.editor.update(() => {}, { discrete: true }) })
- expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ')
+ const chip = (): Element | null => b.view.container.querySelector('[data-composer-command]')
+ b.deleteCharacter(false)
+ expect(chip()?.hasAttribute('data-selected')).toBe(true)
+ b.deleteCharacter(false)
+ expect(chip()).toBeNull()
+ expect(b.shell.snapshot.draft).toBe(' 发布')
+ expect(b.shell.snapshot.phase).toBe('plain')
})
- it('claim releases automatically; the enter after that goes through adjudication again', async () => {
+ it('keeps text written at the chip boundary out of the chip', async () => {
+ const b = await bench()
+ b.type('/goal')
+ await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
+ await b.space()
+ b.append('发布')
+ // The caret right after the chip: Lexical writes into the argument text.
+ act(() => {
+ b.shell.editor.update(() => {
+ const paragraph = $getRoot().getFirstChild()
+ if (!$isElementNode(paragraph)) throw new Error('missing paragraph')
+ paragraph.select(1, 1).insertText('立即')
+ }, { discrete: true })
+ })
+ expect(b.shell.snapshot.draft).toBe('/goal立即 发布')
+ expect(b.shell.snapshot.phase).toBe('claimed')
+ expect(b.view.container.querySelector('[data-composer-command]')?.textContent).toBe('/goal')
+ })
+
+ it('deleting the chip releases the claim; the arguments left behind send as a plain message', async () => {
const b = await bench()
b.type('/goal')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
// Space adjudication claims (space column, leadingInput).
- fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
+ await b.space()
expect(b.shell.snapshot.phase).toBe('claimed')
- // Backspace into the token: watch break → plain, visuals gone.
- b.type('/goa ')
+ b.append('发布')
+ b.erase(0, 1)
expect(b.shell.snapshot.phase).toBe('plain')
- expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')).toBeNull()
+ expect(b.shell.snapshot.draft).toBe(' 发布')
+ expect(b.view.container.querySelector('[data-composer-command]')).toBeNull()
+ fireEvent.keyDown(b.textarea, { key: 'Enter' })
+ expect(b.sink).toHaveBeenCalledWith('发布', [], 'queue', expect.any(AbortSignal))
+ expect(b.execute).not.toHaveBeenCalled()
+ })
+
+ it('typing before the chip turns it into its literal token and releases the claim', async () => {
+ const b = await bench()
+ b.type('/goal')
+ await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
+ await b.space()
+ b.append('发布')
+ act(() => {
+ b.shell.editor.update(() => {
+ const paragraph = $getRoot().getFirstChild()
+ if (!$isElementNode(paragraph)) throw new Error('missing paragraph')
+ paragraph.getFirstChild()?.insertBefore($createTextNode('看看 '))
+ }, { discrete: true })
+ })
+ expect(b.shell.snapshot.phase).toBe('plain')
+ expect(b.shell.snapshot.draft).toBe('看看 /goal 发布')
+ expect(b.view.container.querySelector('[data-composer-command]')).toBeNull()
+ })
+
+ it('undoing the chip deletion restores the chip and its claim', async () => {
+ const b = await bench()
+ b.type('/goal')
+ await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
+ await b.space()
+ b.append('发布')
+ b.erase(0, 1)
+ expect(b.shell.snapshot.phase).toBe('plain')
+ act(() => {
+ b.shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
+ b.shell.editor.update(() => {}, { discrete: true }) // the history restore commits on the next flush
+ })
+ expect(b.shell.snapshot.phase).toBe('claimed')
+ expect(b.shell.snapshot.claim?.name).toBe('goal')
+ expect(b.shell.snapshot.draft).toBe('/goal 发布')
+ expect(b.view.container.querySelector('[data-composer-command]')?.textContent).toBe('/goal')
+ fireEvent.keyDown(b.textarea, { key: 'Enter' })
+ await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) })
})
})
diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
index cdee0965ba..0922129b23 100644
--- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
- * Composer editor pure core: ReferenceChipNode semantics, the three-view
- * projections, and detect-span application. Headless editors drive every
+ * Composer editor pure core: ReferenceChipNode and CommandChipNode
+ * semantics, the three-view projections, and detect-span application. Headless editors drive every
* case — no React tree, no contenteditable; the chip's visual face has its
* own component spec.
*/
@@ -13,11 +13,14 @@ import {
$isTextNode, $setSelection,
} from 'lexical'
import type { ReferenceInsert } from '../src/client/contract/draft-editor.ts'
+import type { CommandClaim } from '../src/client/contract/input.ts'
import {
$createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode,
} from '../src/client/input/editor/chip-node.tsx'
-import { registerClaimDecoration } from '../src/client/input/editor/claim-decor.ts'
-import { registerTextRefDecoration, TextRefNode } from '../src/client/input/editor/text-ref.ts'
+import {
+ $createCommandChipNode, $isCommandChipNode, CommandChipNode, registerCommandChipSeat,
+} from '../src/client/input/editor/command-chip-node.tsx'
+import { TextRefNode } from '../src/client/input/editor/text-ref.ts'
import type { SerializedReferenceChipNode } from '../src/client/input/editor/chip-node.tsx'
import {
$composerLayout, $detectOffsetOfPoint, $projectComposer, ATOMIC_CHAR,
@@ -44,7 +47,7 @@ const SKILL_REF: ReferenceInsert = {
function makeEditor(): LexicalEditor {
return createHeadlessEditor({
namespace: 'core-spec',
- nodes: [ReferenceChipNode, TextRefNode],
+ nodes: [ReferenceChipNode, CommandChipNode, TextRefNode],
onError: (error) => { throw error },
})
}
@@ -425,44 +428,109 @@ describe('detect-span application', () => {
})
})
-describe('claim precedence over text-ref entities', () => {
- const TOKEN_STYLE = 'color: var(--dsw-alias-state-business-primary)'
- const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]])
+const GOAL_CLAIM: CommandClaim = {
+ name: 'goal',
+ token: '/goal ',
+ submit: () => Promise.resolve({ kind: 'success' }),
+}
- it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and accent-styled until release', (token) => {
+/** Seed one paragraph opening with the command chip, followed by `rest`. */
+function seedCommand(editor: LexicalEditor, rest = ' ship'): void {
+ editor.update(() => {
+ const paragraph = $createParagraphNode()
+ paragraph.append($createCommandChipNode(GOAL_CLAIM))
+ if (rest !== '') paragraph.append($createTextNode(rest))
+ $getRoot().clear().append(paragraph)
+ }, { discrete: true })
+}
+
+/** The first paragraph of the document. */
+function $firstParagraph(): ParagraphNode {
+ return $getRoot().getFirstChild() as ParagraphNode
+}
+
+describe('CommandChipNode', () => {
+ it('projects as one atomic character, the token without its separator, and the leading claim', () => {
const editor = makeEditor()
- let claim: string | null = token
- registerClaimDecoration(editor, () => claim)
- registerTextRefDecoration(editor, () => LEXICON, () => claim)
+ seedCommand(editor)
+ editor.read(() => {
+ const projection = $projectComposer(idAssigner())
+ expect(projection.detectText).toBe(`${ATOMIC_CHAR} ship`)
+ expect(projection.clipboardText).toBe('/goal ship')
+ expect(projection.occurrences).toEqual([])
+ expect(projection.command).toBe(GOAL_CLAIM)
+ })
+ })
+
+ it('keeps its claim across writable clones', () => {
+ const editor = makeEditor()
+ seedCommand(editor)
editor.update(() => {
- const paragraph = $createParagraphNode()
- paragraph.append($createTextNode('/plan rest'))
- $getRoot().clear().append(paragraph)
+ const chip = $firstParagraph().getFirstChild()
+ if (!$isCommandChipNode(chip)) throw new Error('missing chip')
+ chip.markDirty()
}, { discrete: true })
- const leaf = (): { type: string; style: string; text: string } | null =>
- editor.getEditorState().read(() => {
- const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild()
- return first === null ? null : {
- type: first.getType(),
- style: $isTextNode(first) ? first.getStyle() : '',
- text: first.getTextContent(),
- }
- })
- // Claimed: the entity transform yields the seat, the claim transform styles it.
- expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
- // Released (the shell's refresh nudges the seat dirty): the entity captures it.
- claim = null
+ editor.read(() => {
+ const chip = $firstParagraph().getFirstChild()
+ expect($isCommandChipNode(chip) && chip.getClaim()).toBe(GOAL_CLAIM)
+ })
+ })
+
+ it('serializes the token text and imports it back as plain text', () => {
+ const editor = makeEditor()
+ seedCommand(editor, '')
+ const json = editor.getEditorState().toJSON()
+ expect(json.root.children[0]).toMatchObject({ children: [{ type: 'command-chip', text: '/goal' }] })
+ editor.parseEditorState(json).read(() => {
+ const first = $firstParagraph().getFirstChild()
+ expect($isTextNode(first) && first.getTextContent()).toBe('/goal')
+ })
+ })
+
+ it('becomes its literal token when text lands before it', () => {
+ const editor = makeEditor()
+ registerCommandChipSeat(editor)
+ seedCommand(editor)
editor.update(() => {
- const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild()
- if ($isTextNode(first)) first.markDirty()
+ $firstParagraph().getFirstChild()?.insertBefore($createTextNode('x'))
}, { discrete: true })
- expect(leaf()).toEqual({ type: 'composer-text-ref', style: '', text: '/plan' })
- // Re-claimed: the entity reverts to plain text and the warn style returns.
- claim = token
+ editor.read(() => {
+ const projection = $projectComposer(idAssigner())
+ expect(projection.detectText).toBe('x/goal ship')
+ expect(projection.command).toBeUndefined()
+ })
+ })
+
+ it('becomes its literal token behind a line break or on a later paragraph', () => {
+ const editor = makeEditor()
+ registerCommandChipSeat(editor)
+ seedCommand(editor)
editor.update(() => {
- const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild()
- if ($isTextNode(first)) first.markDirty()
+ $firstParagraph().getFirstChild()?.insertBefore($createLineBreakNode())
}, { discrete: true })
- expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
+ editor.read(() => {
+ expect($projectComposer(idAssigner()).detectText).toBe('\n/goal ship')
+ })
+ seedCommand(editor)
+ editor.update(() => {
+ $firstParagraph().insertBefore($createParagraphNode())
+ }, { discrete: true })
+ editor.read(() => {
+ const projection = $projectComposer(idAssigner())
+ expect(projection.detectText).toBe('\n/goal ship')
+ expect(projection.command).toBeUndefined()
+ })
+ })
+
+ it('stays a chip on the leading seat', () => {
+ const editor = makeEditor()
+ registerCommandChipSeat(editor)
+ seedCommand(editor)
+ editor.update(() => {
+ $firstParagraph().getLastChild()?.remove()
+ }, { discrete: true })
+ editor.read(() => {
+ expect($projectComposer(idAssigner()).command).toBe(GOAL_CLAIM)
+ })
})
})
diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts
index 9bda0536aa..4ecb8c2e92 100644
--- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts
+++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts
@@ -1,9 +1,9 @@
/**
* SubmitMachine behavior: enter routing, adjudication outcomes, the claimed
- * lifecycle and its integrity watch, settlement (commit-draft and claim
- * re-entry decisions), anti-backwash, and per-session isolation. Text-edit
- * semantics live in the editor (lexical-editor-core spec) — the machine only
- * observes drafts through event payloads.
+ * lifecycle that follows the leading command chip, settlement (commit-draft
+ * and claim re-entry decisions), anti-backwash, and per-session isolation.
+ * Text-edit semantics live in the editor (lexical-editor-core spec) — the
+ * machine only observes the document through event payloads.
*/
import { describe, expect, it } from 'vitest'
import type { CommandClaim } from '../src/client/contract/input.ts'
@@ -192,41 +192,45 @@ describe('submit-machine: claimed lifecycle', () => {
expect(m.state.phase).toBe('submitting')
})
- it('editing the command name releases back to plain', () => {
+ it('holds the claim while the leading command chip stays and releases it when the chip goes', () => {
const m = new SubmitMachine()
- m.dispatch({ type: 'claim', claim: claimOf('goal') })
- m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
+ const claim = claimOf('goal')
+ m.dispatch({ type: 'claim', claim })
+ m.dispatch({ type: 'draft-changed', command: claim })
expect(m.state.phase).toBe('claimed')
- m.dispatch({ type: 'draft-changed', draft: '/goa' })
+ m.dispatch({ type: 'draft-changed', command: undefined })
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
+ it('re-enters claimed when a history restore brings the command chip back', () => {
+ const m = new SubmitMachine()
+ const claim = { ...claimOf('goal'), token: '/目标 ' }
+ m.dispatch({ type: 'draft-changed', command: claim })
+ expect(m.state.phase).toBe('claimed')
+ expect(m.state.claim?.token).toBe('/目标 ')
+ })
+
it.each([
['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '],
['feedback', '/feedback '], ['feedback', '/反馈 '],
- ])('retains %s as %s without its separator and submits an empty argument', (name, token) => {
+ ])('submits %s claimed as %s without its separator with an empty argument', (name, token) => {
const m = new SubmitMachine()
- m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } })
- for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) {
- m.dispatch({ type: 'draft-changed', draft })
- expect(m.state.phase).toBe('claimed')
- expect(m.state.claim?.name).toBe(name)
- }
+ const claim = { ...claimOf(name), token }
+ m.dispatch({ type: 'claim', claim })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() })
const begin = effectAt(fx, 0, 'begin-submit')
expect(begin.args).toBe('')
- m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() })
+ m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd(), command: claim })
expect(m.state.phase).toBe('claimed')
expect(m.state.claim?.name).toBe(name)
})
- it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => {
+ it('reads arguments typed directly against the chip without a separator', () => {
const m = new SubmitMachine()
m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
- m.dispatch({ type: 'draft-changed', draft })
- expect(m.state.phase).toBe('plain')
- expect(m.state.claim).toBeUndefined()
+ const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/目标发布' })
+ expect(effectAt(fx, 0, 'begin-submit').args).toBe('发布')
})
it('explicit release returns to plain when nothing is in flight', () => {
@@ -250,7 +254,7 @@ describe('submit-machine: submitting transaction', () => {
const m = new SubmitMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
- m.dispatch({ type: 'draft-changed', draft: 'typed during flight' })
+ m.dispatch({ type: 'draft-changed', command: undefined })
expect(m.state.phase).toBe('submitting')
})
@@ -258,7 +262,7 @@ describe('submit-machine: submitting transaction', () => {
const m = new SubmitMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
const fx = m.dispatch({
- type: 'submit-settled', attempt, ok: true, draft: '/goal x',
+ type: 'submit-settled', attempt, ok: true, draft: '/goal x', command: undefined,
outcome: { kind: 'success', text: 'goal saved' },
})
expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
@@ -271,49 +275,48 @@ describe('submit-machine: submitting transaction', () => {
const m = new SubmitMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
const fx = m.dispatch({
- type: 'submit-settled', attempt, ok: false, draft: 'deviated',
+ type: 'submit-settled', attempt, ok: false, draft: 'deviated', command: undefined,
outcome: { kind: 'error', text: 'rejected' },
})
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'rejected' })
expect(m.state.phase).toBe('plain')
})
- it('rollback with an undeviated draft keeps the claim and re-enters claimed', () => {
+ it('rollback with an undeviated draft and chip keeps the claim and re-enters claimed', () => {
const m = new SubmitMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', message: 'transport' })
+ const { attempt, claim } = enterSubmitting(m, 'goal', 'x')
+ m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', command: claim, message: 'transport' })
expect(m.state.phase).toBe('claimed')
expect(m.state.claim?.token).toBe('/goal ')
})
it('rollback with a deviated draft only notices — the newer input wins', () => {
const m = new SubmitMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: 'rewritten', message: 'transport' })
+ const { attempt, claim } = enterSubmitting(m, 'goal', 'x')
+ const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: 'rewritten', command: claim, message: 'transport' })
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
- it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
+ it('enter-path rollback returns to plain because an adjudicated claim has no command chip', () => {
const m = new SubmitMachine()
- const attempt = enterAdjudicating(m, ' /goal x')
+ const attempt = enterAdjudicating(m, '/goal x')
m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
- m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: ' /goal x', message: 'nope' })
- // The snapshot carries leading whitespace the token never had: plain, claim cleared.
+ m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', command: undefined, message: 'nope' })
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
const m = new SubmitMachine()
- const { attempt: first } = enterSubmitting(m, 'goal', 'x')
- m.dispatch({ type: 'submit-settled', attempt: first, ok: false, draft: '/goal x', message: 'try again' })
+ const { attempt: first, claim } = enterSubmitting(m, 'goal', 'x')
+ m.dispatch({ type: 'submit-settled', attempt: first, ok: false, draft: '/goal x', command: claim, message: 'try again' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })
const second = effectAt(fx, 0, 'begin-submit').attempt
- expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, draft: '/goal x' })).toEqual([])
+ expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, draft: '/goal x', command: claim })).toEqual([])
expect(m.state.phase).toBe('submitting')
- m.dispatch({ type: 'submit-settled', attempt: second, ok: true, draft: '/goal x' })
+ m.dispatch({ type: 'submit-settled', attempt: second, ok: true, draft: '/goal x', command: claim })
expect(m.state.phase).toBe('plain')
})
@@ -322,7 +325,7 @@ describe('submit-machine: submitting transaction', () => {
const { attempt } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'release' })
expect(attempt.signal.aborted).toBe(true)
- expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '' })).toEqual([])
+ expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '', command: undefined })).toEqual([])
expect(m.state.phase).toBe('plain')
})
@@ -343,7 +346,7 @@ describe('submit-machine: per-session isolation', () => {
const { attempt } = enterSubmitting(a, 'goal', 'x')
const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
- a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' })
+ a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x', command: undefined })
expect(a.state.phase).toBe('plain')
expect(b.state.phase).toBe('plain')
})