Merge pull request #5935 from deepseek-harness/worktree/command-delete-as-whole-21017f
feat(web): claim slash commands as atomic composer chips
This commit is contained in:
commit
034f2cf8bd
43 files changed
+1317
-326
No files matched your search
@@ -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: 6ac53f6cd1307897
|
||||
zh: 2f1dbe762b52f18e
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/alternatives-considered:
|
||||
en: 9c694a21237d7772
|
||||
zh: b35ba7bf8b73b679
|
||||
/agent-note-claimed-commands-as-atomic-composer-chips/consequences:
|
||||
en: 089365d6a4be0ebb
|
||||
zh: b30384c717a7ea1d
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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 settles to the same rule, so it returns to claimed exactly when a chip still leads; a claim adjudicated from typed text at Enter has no chip and returns to plain.
|
||||
- Node transforms keep 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. The chip's own transform sees sibling insertions and moves between blocks; a paragraph transform sees a block inserted before the chip's block, so no update walks the whole document.
|
||||
- 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. Deletions are decided both on the character-deletion command and on deletion input events, because iOS applies a ranged `deleteContentBackward` without that command. A key whose dispatch produced no character deletion, a click, blur, or a caret that leaves the chip clears the mark, so platform deletion shortcuts such as macOS Ctrl+H keep it. 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.
|
||||
- The persisted draft records the leading chip's claim token (`DraftSnapshot.command`), never the claim. On restore the input asks the trigger pipeline's `resolveClaim`, which polls the optional source hook `matchClaim`: it awaits the source's catalog and answers only a claim, never invoking or executing the command, unlike Enter's `matchEnter`. The chip is rebuilt only at the revision the restore produced and merged into the seed's history entry; an earlier edit, an unclaimed token, or a catalog failure keeps the literal token, which Enter still adjudicates.
|
||||
- `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 reload restores the chip after the command catalog loads; until then, and when restoration does not apply, the draft shows the literal token.
|
||||
- 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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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 自身的 transform 覆盖兄弟节点插入和跨块移动;段落 transform 覆盖在 chip 所在块之前插入块的情况,因此没有任何更新需要遍历整个文档。
|
||||
- 删除分两步。从折叠光标出发、方向触及 chip 的字符删除先把它标记为选中(宿主元素上的 `data-selected`;根元素的 `data-composer-chip-selected` 隐藏光标),同方向的下一次删除才整体移除它。字符删除命令和删除类输入事件都会参与判断,因为 iOS 处理带范围的 `deleteContentBackward` 时不经过该命令。分发过程中没有产生字符删除的按键、点击、失焦或光标离开 chip 会清除标记,因此 macOS Ctrl+H 这类平台删除快捷键会保留它。编辑器选区保持为折叠的 range,`isKeyboardSelectable()` 仍为 false,原因与引用 chip 记录的相同。Escape 仍不释放认领。
|
||||
- 持久化的草稿记录行首 chip 的认领 token(`DraftSnapshot.command`),从不保存认领本身。恢复时输入框调用触发管线的 `resolveClaim`,它轮询可选的 source 钩子 `matchClaim`:等待 source 的目录就绪,只应答认领,与 Enter 的 `matchEnter` 不同,从不调用或执行命令。chip 只在恢复产生的那个版本上重建,并合入种子的历史条目;在此之前发生的编辑、无人认领的 token 或目录加载失败都会保留字面 token,Enter 仍会对其裁决。
|
||||
- 删除 `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,参数保留为普通文字。
|
||||
- 刷新后,命令目录加载完成即恢复 chip;在此之前以及恢复不适用时,草稿显示字面 token。
|
||||
- 通过真实按键整体删除 chip 只能在浏览器 lane 中观察,因为 jsdom 缺少 `Selection.modify`;`lifecycle-chrome.e2e.ts` 固定了删除分隔符、选中、删除这三次按键。
|
||||
@@ -8,7 +8,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
import { newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SCREENSHOTS = fileURLToPath(new URL('../../../.artifacts/screenshots', import.meta.url))
|
||||
const FILE_NAME = 'notes 雪.md'
|
||||
@@ -28,6 +28,7 @@ interface DraftSnapshot {
|
||||
clipboardText: string
|
||||
invalid?: boolean
|
||||
}[]
|
||||
command?: { token: string }
|
||||
}
|
||||
|
||||
const EMPTY: DraftSnapshot = { text: '', references: [] }
|
||||
@@ -315,6 +316,27 @@ it('preserves drafts through explicit Workspace draft navigation and treats empt
|
||||
expect(console.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('rebuilds a claimed command chip from the stored draft after reload without submitting', async () => {
|
||||
const { scaffold, page, console, firstId } = await launchDraftFixture()
|
||||
const input = composer(page)
|
||||
await writeComposerDraft(page, input, '/')
|
||||
await page.getByRole('listbox').getByRole('option', { name: 'Goal Set or view the goal for a long-running task', exact: true }).click()
|
||||
await expect.poll(() => input.textContent(), SETTLE).toBe('/goal ')
|
||||
await input.press('End')
|
||||
await page.keyboard.insertText('ship')
|
||||
const stored = { text: '/goal ship', references: [], command: { token: '/goal ' } }
|
||||
await expect.poll(() => storedDraft(page, firstId), SETTLE).toEqual(stored)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await expect.poll(() => selectedSession(page), SETTLE).toBe(firstId)
|
||||
await expect.poll(() => composer(page).locator('[data-composer-command]').textContent(), SETTLE).toBe('/goal')
|
||||
await expect.poll(() => composer(page).getAttribute('data-phase'), SETTLE).toBe('claimed')
|
||||
expect(await composer(page).textContent()).toBe('/goal ship')
|
||||
expect(await storedDraft(page, firstId)).toEqual(stored)
|
||||
await assertUnsubmitted(scaffold, [firstId])
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('restores repeated file, folder and Session capsules across edits, Workspace switches and reload without submitting', async () => {
|
||||
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
|
||||
for (const workspace of [first, second]) {
|
||||
|
||||
@@ -151,18 +151,22 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await writeComposerDraft(inputPage, input, '/')
|
||||
await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click()
|
||||
await expect.poll(() => input.textContent()).toBe(`${token} `)
|
||||
const inputHeight = () => input.evaluate(element => element.getBoundingClientRect().height)
|
||||
const claimedHeight = await inputHeight()
|
||||
await input.press('End')
|
||||
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)
|
||||
// The chip now ends its block, where Lexical appends a managed line break; it must not add a line.
|
||||
expect(await inputHeight()).toBe(claimedHeight)
|
||||
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 +183,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)
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 4feecdbec0c3f39e
|
||||
zh: 618944ecade8a3a2
|
||||
/deepseek-ai-dsh-client-ui-commands/understand-the-implementation:
|
||||
en: dbd2d83fb5e57ff3
|
||||
zh: 6e2e837f6a08f2fb
|
||||
en: a44d56484412ac85
|
||||
zh: 6d6012485211908c
|
||||
/deepseek-ai-dsh-client-ui-commands/further-exploration:
|
||||
en: 409c04f534d23de7
|
||||
zh: 165022d7a0a554eb
|
||||
|
||||
@@ -57,7 +57,7 @@ Popup selection panels fill the composer overlay's width, matching the slash men
|
||||
|
||||
`src/client/contract.ts` defines contribution and decoration registration, plus `dismiss(name)`, which closes that command's open popups and confirmations: dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
|
||||
|
||||
Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions.
|
||||
Each command-directory fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `commands.list`. A temporary `commandCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC, so background catalog refreshes do not reopen closed Sessions. A reloaded draft that opens with a claimed command rebuilds the claim through the `matchClaim` hook, which awaits the same directory and answers only a `leadingInput` claim without invoking or executing anything.
|
||||
|
||||
Popup selection uses the [standard menu inset and item radii](../../../docs/ui-radius.md#nested-hover).
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
|
||||
|
||||
`src/client/contract.ts` 定义贡献项和装饰的注册接口,以及 `dismiss(name)`:它关闭该命令已打开的弹窗与确认对话框,中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
|
||||
|
||||
每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。
|
||||
每次命令目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `commands.list`。临时的 `commandCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC,因此后台目录刷新不会重新打开已关闭的会话。刷新后以已认领命令开头的草稿经 `matchClaim` 钩子重建认领:它等待同一个目录就绪,只应答 `leadingInput` 认领,不调用也不执行任何东西。
|
||||
|
||||
弹出选择面板使用[标准菜单的内缩与菜单项圆角](../../../docs/ui-radius.zh.md#nested-hover)。
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
candidates: (session, req) => this.candidates(session, req),
|
||||
onPick: pick => this.dispatch(pick),
|
||||
matchSpace: (session, token) => this.matchSpace(session, token),
|
||||
matchClaim: (session, token, signal) => this.matchClaim(session, token, signal),
|
||||
matchEnter: (session, line, signal, envelope) => this.matchEnter(session, line, signal, envelope),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
@@ -276,11 +277,27 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
|
||||
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
|
||||
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
|
||||
const claim = this.claimOf(session, token)
|
||||
return claim === undefined ? undefined : { claim }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore column: the space column's answer once the session's catalog is
|
||||
* ready (a warmup failure rejects). It only resolves; nothing is invoked or
|
||||
* executed.
|
||||
*/
|
||||
private async matchClaim(session: ClientSessionContext, token: string, signal: AbortSignal): Promise<CommandClaim | undefined> {
|
||||
await this.directory.ensureReady(session.sessionId, signal)
|
||||
return this.claimOf(session, token)
|
||||
}
|
||||
|
||||
/** The host leadingInput claim a token names in the hot catalog; popup and action kinds never claim. */
|
||||
private claimOf(session: ClientSessionContext, token: string): CommandClaim | undefined {
|
||||
if (!token.startsWith('/')) return undefined
|
||||
if (this.live.contributions.has(token.slice(1))) return undefined // popup and action kinds never claim on space
|
||||
if (this.live.contributions.has(token.slice(1))) return undefined
|
||||
const desc = this.directory.resolve(session.sessionId, token.slice(1))
|
||||
if (desc === undefined || desc.input === undefined) return undefined
|
||||
return { claim: this.leadingClaim(desc, session, token.slice(1)) }
|
||||
return this.leadingClaim(desc, session, token.slice(1))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -731,6 +731,34 @@ describe('matchEnter (enter column)', () => {
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom')
|
||||
})
|
||||
|
||||
it('matchClaim waits for a cold catalog and answers only the space column claim without acting', async () => {
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
const { command, source, mint, executeCalls } = await bench({
|
||||
commands: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const run = vi.fn()
|
||||
command.register({ name: 'file', label: () => 'file', available: () => true, ui: { kind: 'action', run } })
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
mint('s1').ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true })
|
||||
const wait = source.matchClaim!(proj('s1'), '/goal', signal())
|
||||
await vi.waitFor(() => { expect(release).toBeTypeOf('function') })
|
||||
release({ commands: S1_CMDS })
|
||||
await expect(wait).resolves.toMatchObject({ name: 'goal', token: '/goal ' })
|
||||
for (const token of ['/plan', '/file', '/xyz', 'goal']) {
|
||||
await expect(source.matchClaim!(proj('s1'), token, signal())).resolves.toBeUndefined()
|
||||
}
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(consumes).toEqual([])
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('matchClaim rejects when warmup fails', async () => {
|
||||
const { source } = await bench({
|
||||
commands: () => Promise.reject(new Error('warmup boom')),
|
||||
})
|
||||
await expect(source.matchClaim!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
|
||||
})
|
||||
|
||||
it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 9912b286384c41d7
|
||||
zh: 3e994887c1948677
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: aab84874b0a00ea5
|
||||
zh: 642bacaf76385e83
|
||||
en: c4d4aaef46daab72
|
||||
zh: 0dac50689d806ecf
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -78,7 +78,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 persisted draft records the chip's token, so a reload rebuilds the chip once the command catalog resolves the claim; editing the draft first keeps the literal token, which Enter still submits as the command. 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.
|
||||
|
||||
@@ -94,7 +94,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 use the [UserText Factory](#sent-user-text) (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 use the [UserText Factory](#sent-user-text) (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.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
|
||||
|
||||
草稿中的普通文字及文件、目录、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。持久化的草稿记录 chip 的 token,刷新后命令目录解析出认领即重建 chip;在此之前先编辑了草稿则保留字面 token,Enter 仍会把它作为命令提交。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
|
||||
|
||||
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿内容(包括结构化引用)和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
|
||||
|
||||
@@ -94,7 +94,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 预览使用 [UserText Factory](#sent-user-text)(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 预览使用 [UserText Factory](#sent-user-text)(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 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
|
||||
|
||||
|
||||
@@ -22,13 +22,25 @@ export interface ReferenceInsert {
|
||||
/** Persisted reference data without the current editor's occurrence identity. */
|
||||
export type DraftReference = Omit<Occurrence, 'occurrenceId'>
|
||||
|
||||
/**
|
||||
* Persisted command chip opening a draft. The claim itself (its submit
|
||||
* closure) is not persisted; the input rebuilds it from the token through the
|
||||
* trigger pipeline when the draft is restored.
|
||||
*/
|
||||
export interface DraftCommand {
|
||||
/** Claim token with its argument separator, e.g. `/goal `; the draft text starts with the token without the separator. */
|
||||
readonly token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor-independent content. Reference spans are ordered, non-overlapping UTF-16
|
||||
* ranges whose text equals clipboardText; runtime node identities are excluded.
|
||||
* ranges whose text equals clipboardText and that follow a leading command
|
||||
* token; runtime node identities are excluded.
|
||||
*/
|
||||
export interface DraftSnapshot {
|
||||
readonly text: string
|
||||
readonly references: readonly DraftReference[]
|
||||
readonly command?: DraftCommand
|
||||
}
|
||||
|
||||
/** Plain initial text or an existing document with explicit reference identities. */
|
||||
|
||||
@@ -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
|
||||
@@ -119,6 +119,13 @@ export interface InputTriggerController {
|
||||
signal: AbortSignal,
|
||||
envelope: { readonly attachments: number },
|
||||
): Promise<PickOutcome>
|
||||
/**
|
||||
* Resolve the claim a persisted command chip names without acting on it.
|
||||
* @param token - command spelling without its argument separator, e.g. '/goal'.
|
||||
* @param signal - restore-attempt abort.
|
||||
* @returns the claim, or undefined when no source claims the token; rejects when catalog warmup fails.
|
||||
*/
|
||||
resolveClaim(token: string, signal: AbortSignal): Promise<CommandClaim | undefined>
|
||||
/**
|
||||
* @param source - chip owner, or undefined for a text reference.
|
||||
* @param reference - source id and glyph.
|
||||
@@ -167,7 +174,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 +317,26 @@ 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 leading command chip's claim, which the claim follows after a failure. */
|
||||
| {
|
||||
readonly type: 'submit-settled'
|
||||
readonly attempt: SubmitAttempt
|
||||
readonly ok: boolean
|
||||
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. */
|
||||
|
||||
@@ -13,18 +13,25 @@ const referenceSchema = z.object({
|
||||
invalid: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const commandSchema = z.object({
|
||||
token: z.string(),
|
||||
})
|
||||
|
||||
const snapshotSchema = z.object({
|
||||
text: z.string(),
|
||||
references: z.array(referenceSchema),
|
||||
command: commandSchema.optional(),
|
||||
}).refine((draft) => {
|
||||
let end = 0
|
||||
const spelling = draft.command?.token.trimEnd()
|
||||
if (spelling !== undefined && (!/^\/\S/u.test(spelling) || !draft.text.startsWith(spelling))) return false
|
||||
let end = spelling?.length ?? 0
|
||||
for (const reference of draft.references) {
|
||||
if (reference.offset < end || reference.length !== reference.clipboardText.length) return false
|
||||
end = reference.offset + reference.length
|
||||
if (end > draft.text.length || draft.text.slice(reference.offset, end) !== reference.clipboardText) return false
|
||||
}
|
||||
return true
|
||||
}, 'Draft references must identify ordered, non-overlapping text spans')
|
||||
}, 'Draft references must identify ordered, non-overlapping text spans after the leading command token')
|
||||
|
||||
/**
|
||||
* Copy input into the semantic draft representation without editor runtime identities.
|
||||
@@ -33,7 +40,11 @@ const snapshotSchema = z.object({
|
||||
*/
|
||||
export function resolveDraftInput(input: DraftInput): DraftSnapshot {
|
||||
if (typeof input === 'string') return { text: input, references: [] }
|
||||
return { text: input.text, references: input.references.map(copyReference) }
|
||||
return {
|
||||
text: input.text,
|
||||
references: input.references.map(copyReference),
|
||||
...(input.command === undefined ? {} : { command: { token: input.command.token } }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +63,7 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined {
|
||||
...(appearance === undefined ? {} : { appearance }),
|
||||
...(invalid === undefined ? {} : { invalid }),
|
||||
})),
|
||||
...(parsed.data.command === undefined ? {} : { command: { token: parsed.data.command.token } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +71,14 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined {
|
||||
* Export reference identities without their editor-local occurrence ids.
|
||||
* @param text - current clipboard projection.
|
||||
* @param occurrences - matching reference projections from the same document revision.
|
||||
* @param commandToken - claim token of the command chip opening the document, if any.
|
||||
* @returns an editor-independent snapshot.
|
||||
*/
|
||||
export function snapshotDraft(text: string, occurrences: readonly Occurrence[]): DraftSnapshot {
|
||||
export function snapshotDraft(text: string, occurrences: readonly Occurrence[], commandToken?: string): DraftSnapshot {
|
||||
return {
|
||||
text,
|
||||
references: occurrences.map(copyReference),
|
||||
...(commandToken === undefined ? {} : { command: { token: commandToken } }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<span className={clsx(referenceCss.reference, css.chip)}>
|
||||
<span className={css.label}>{token}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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, ParagraphNode } from 'lexical'
|
||||
import { mergeRegister } 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<JSX.Element> {
|
||||
/** 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 the default character deletion removes
|
||||
* it whole, for the reason documented on ReferenceChipNode.isKeyboardSelectable;
|
||||
* registerCommandChipSelection inserts the selecting step before that deletion.
|
||||
*/
|
||||
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 <CommandChip token={this.getTextContent()} />
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** Replace a displaced chip with its literal token. */
|
||||
function $releaseChip(chip: CommandChipNode): void {
|
||||
chip.replace($createTextNode(chip.getTextContent()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep command chips on the leading seat. A chip anywhere but the first
|
||||
* child of the first block (text, a line break, or a block inserted before
|
||||
* it) becomes its literal token, which releases the claim. Inserting a
|
||||
* sibling before the chip or moving it to another block marks the chip dirty;
|
||||
* a block inserted before the chip's block marks only that block dirty.
|
||||
* @param editor - the shell-owned editor.
|
||||
* @returns the unregister disposer.
|
||||
*/
|
||||
export function registerCommandChipSeat(editor: LexicalEditor): () => void {
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CommandChipNode, (chip) => {
|
||||
if (chip.getPreviousSibling() !== null || chip.getParentOrThrow().getPreviousSibling() !== null) $releaseChip(chip)
|
||||
}),
|
||||
editor.registerNodeTransform(ParagraphNode, (block) => {
|
||||
const first = block.getFirstChild()
|
||||
if ($isCommandChipNode(first) && block.getPreviousSibling() !== null) $releaseChip(first)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Two-step command chip deletion. A character deletion whose direction
|
||||
* reaches the command chip from a collapsed caret first selects the chip; the
|
||||
* next deletion in that direction removes it. Deletions arrive as the
|
||||
* character-deletion command (Backspace, Delete, and the platform shortcuts
|
||||
* the engine maps to them) or as a deletion input event, which some platforms
|
||||
* apply without that command. A key that produces no character deletion, 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, BEFORE_INPUT_COMMAND, 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 never clear a selected chip by themselves: bare modifiers, and
|
||||
* IME or virtual-keyboard keys whose deletion arrives later as an input event.
|
||||
*/
|
||||
const PASSIVE_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'Unidentified', 'Process'])
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Deletion direction of an input event type; null for other input. */
|
||||
function deletionDirection(inputType: string): boolean | null {
|
||||
if (inputType === 'deleteContentBackward') return true
|
||||
if (inputType === 'deleteContentForward') return false
|
||||
return 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
|
||||
/** A keydown with a selected chip whose dispatch has produced no character deletion yet. */
|
||||
let keyWithoutDeletion = false
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Decide one character deletion.
|
||||
* @param isBackward - deletion direction.
|
||||
* @param clearOnPass - whether letting the deletion run clears the selection here;
|
||||
* an input event leaves it for the deletion that follows.
|
||||
* @returns true when the deletion only selected the chip and must be consumed.
|
||||
*/
|
||||
const $decide = (isBackward: boolean, clearOnPass: boolean): boolean => {
|
||||
keyWithoutDeletion = false
|
||||
const chip = $commandChipBesideCaret(isBackward)
|
||||
if (chip === null) return clear()
|
||||
if (selected?.key === chip.getKey() && selected.isBackward === isBackward) return clearOnPass ? clear() : false
|
||||
paint({ key: chip.getKey(), isBackward })
|
||||
return true
|
||||
}
|
||||
return mergeRegister(
|
||||
editor.registerCommand(BEFORE_INPUT_COMMAND, (event) => {
|
||||
const isBackward = deletionDirection(event.inputType)
|
||||
if (isBackward === null || !$decide(isBackward, false)) return false
|
||||
event.preventDefault()
|
||||
return true
|
||||
}, COMMAND_PRIORITY_HIGH),
|
||||
editor.registerCommand(DELETE_CHARACTER_COMMAND, isBackward => $decide(isBackward, true), COMMAND_PRIORITY_HIGH),
|
||||
editor.registerCommand(KEY_DOWN_COMMAND, (event) => {
|
||||
if (selected === null || PASSIVE_KEYS.has(event.key)) return false
|
||||
// Key bindings dispatch their deletion synchronously inside this
|
||||
// keydown, so the microtask sees whether the key deleted anything.
|
||||
keyWithoutDeletion = true
|
||||
queueMicrotask(() => {
|
||||
if (!keyWithoutDeletion) return
|
||||
keyWithoutDeletion = false
|
||||
clear()
|
||||
})
|
||||
return false
|
||||
}, 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) },
|
||||
)
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<ReferenceInsert, 'ref' | 'appearance'>) => boolean
|
||||
readonly activeClaimToken: () => string | null
|
||||
readonly lexicon: () => Lexicon
|
||||
readonly resolveLexicon: () => ObservableSnapshot<Lexicon> | 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<NodeKey, number>()
|
||||
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,28 @@ 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.
|
||||
* @param mergeHistory - merge into the previous history entry (a restored
|
||||
* draft's chip is part of its seed, not an undoable step).
|
||||
* @returns whether the range mapped and the edit applied.
|
||||
*/
|
||||
insertCommand(span: DetectSpan, claim: CommandClaim, tail: string, mergeHistory = false): 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)
|
||||
}, mergeHistory ? HISTORY_MERGE_TAG : undefined)
|
||||
return applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an ordered file-reference batch after the live selection without deleting it.
|
||||
* @param references - validated references in source order.
|
||||
@@ -280,11 +306,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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
@@ -149,6 +150,10 @@ export class SessionInputShell implements SessionInput {
|
||||
private disposed = false
|
||||
/** Conversation store writer for the current semantic document. */
|
||||
private persistDraft: ((draft: DraftSnapshot) => void) | undefined
|
||||
/** A restored draft's command token waiting for the trigger pipeline, with the revision its restore produced. */
|
||||
private pendingCommand: { readonly token: string; readonly rev: number } | undefined
|
||||
/** The in-flight claim resolution for a restored command chip. */
|
||||
private commandRestore: AbortController | undefined
|
||||
/** The mounted composer's file-picker opener (scoped pick-files event target). */
|
||||
private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
|
||||
/** Default sends retained until admission settles or scope disposal releases their attachments. */
|
||||
@@ -172,7 +177,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 +187,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 +200,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) {
|
||||
@@ -216,19 +220,59 @@ export class SessionInputShell implements SessionInput {
|
||||
*/
|
||||
setDraft(text: DraftInput): void {
|
||||
const draft = resolveDraftInput(text)
|
||||
this.cancelCommandRestore()
|
||||
if (draft.references.length === 0) this.draftEditor.setDraft(draft.text)
|
||||
else this.draftEditor.restoreDraft(draft.text, draft.references)
|
||||
if (draft.command === undefined) return
|
||||
this.pendingCommand = { token: draft.command.token, rev: this.rev }
|
||||
this.restoreCommand()
|
||||
}
|
||||
|
||||
/** Reconnect optional catalog notifications without waiting for another edit. */
|
||||
/**
|
||||
* Reconnect optional catalog notifications and resume a pending command
|
||||
* chip restore without waiting for another edit.
|
||||
*/
|
||||
refreshLexiconSubscription(): void {
|
||||
if (!this.disposed) this.draftEditor.refreshLexiconSubscription()
|
||||
if (this.disposed) return
|
||||
this.draftEditor.refreshLexiconSubscription()
|
||||
this.restoreCommand()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the command chip a restored draft carried once the trigger
|
||||
* pipeline can resolve its claim. The rebuild applies only at the revision
|
||||
* the restore produced: an edit made while the claim resolves keeps the
|
||||
* literal token, which Enter still adjudicates as the command.
|
||||
*/
|
||||
private restoreCommand(): void {
|
||||
const pending = this.pendingCommand
|
||||
const inputTriggers = this.deps.inputTriggers?.()
|
||||
if (pending === undefined || inputTriggers === undefined || this.disposed) return
|
||||
this.pendingCommand = undefined
|
||||
const restore = new AbortController()
|
||||
this.commandRestore = restore
|
||||
const spelling = pending.token.trimEnd()
|
||||
inputTriggers.resolveClaim(spelling, restore.signal).then((claim) => {
|
||||
if (restore.signal.aborted) return
|
||||
this.commandRestore = undefined
|
||||
if (claim !== undefined) this.applyClaim(claim, { start: 0, end: spelling.length, draftRev: pending.rev }, true)
|
||||
}, (_error: unknown) => {
|
||||
// A catalog warmup failure or an abort leaves the literal token, which Enter still adjudicates.
|
||||
if (this.commandRestore === restore) this.commandRestore = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Drop a pending or in-flight command chip restore. */
|
||||
private cancelCommandRestore(): void {
|
||||
this.pendingCommand = undefined
|
||||
this.commandRestore?.abort()
|
||||
this.commandRestore = undefined
|
||||
}
|
||||
|
||||
/** Current semantic document, stable until its content revision changes. */
|
||||
get draftSnapshot(): DraftSnapshot {
|
||||
if (this.draftSnapshotCache?.revision === this.rev) return this.draftSnapshotCache.value
|
||||
const value = snapshotDraft(this.projection.clipboardText, this.projection.occurrences)
|
||||
const value = snapshotDraft(this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token)
|
||||
this.draftSnapshotCache = { revision: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -449,20 +493,30 @@ 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).
|
||||
*/
|
||||
beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
|
||||
return this.applyClaim(claim, span, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace [0, span.end) with the command chip and enter claimed.
|
||||
* @param mergeHistory - merge the edit into the previous history entry.
|
||||
* @returns whether the edit applied.
|
||||
*/
|
||||
private applyClaim(claim: CommandClaim, span: TokenSpan, mergeHistory: boolean): boolean {
|
||||
const phase = this.core.state.phase
|
||||
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, mergeHistory)
|
||||
if (!applied) return false
|
||||
this.dispatchRun(({ type: 'claim', claim }))
|
||||
return true
|
||||
@@ -558,6 +612,7 @@ export class SessionInputShell implements SessionInput {
|
||||
flight.controller.abort()
|
||||
}
|
||||
this.disposed = true
|
||||
this.cancelCommandRestore()
|
||||
this.dispatchRun(({ type: 'release' }))
|
||||
this.unsubscribeInbox?.()
|
||||
this.unregister()
|
||||
@@ -616,20 +671,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<SubmitMachine['dispatch']>[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 +901,7 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
this.dispatchRun(({
|
||||
type: 'submit-settled', attempt, ok: outcome.kind === 'success',
|
||||
draft: this.projection.clipboardText, outcome,
|
||||
command: this.projection.command, outcome,
|
||||
...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}),
|
||||
}))
|
||||
},
|
||||
@@ -866,7 +910,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,
|
||||
command: this.projection.command, message,
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,8 +70,8 @@ export class SubmitMachine {
|
||||
*/
|
||||
dispatch(ev: InputEvent): readonly InputEffect[] {
|
||||
switch (ev.type) {
|
||||
case 'draft-changed': return this.onDraftChanged(ev.draft)
|
||||
case 'claim': return this.onClaim(ev.claim)
|
||||
case 'draft-changed': return this.followCommand(ev.command)
|
||||
case 'claim': return this.followCommand(ev.claim)
|
||||
case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
|
||||
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
|
||||
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
|
||||
@@ -88,20 +83,17 @@ 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
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** The editor applied a claim-token replacement; busy phases refuse another claim. */
|
||||
private onClaim(claim: CommandClaim): readonly InputEffect[] {
|
||||
/**
|
||||
* 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. The claim event applies the same rule to a
|
||||
* chip the editor just inserted, before its update commits; busy phases
|
||||
* refuse both.
|
||||
*/
|
||||
private followCommand(command: CommandClaim | undefined): readonly InputEffect[] {
|
||||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||||
this.claim = claim
|
||||
this.phase = 'claimed'
|
||||
this.claim = command
|
||||
this.phase = command === undefined ? 'plain' : 'claimed'
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -189,7 +181,7 @@ export class SubmitMachine {
|
||||
return [{ type: 'notice', level: 'error', text: message }]
|
||||
}
|
||||
|
||||
/** Claimed command settlement retains the frozen transaction semantics. */
|
||||
/** Claimed command settlement; after a failure the claim follows the leading command chip again. */
|
||||
private onSubmitSettled(ev: Extract<InputEvent, { type: 'submit-settled' }>): readonly InputEffect[] {
|
||||
const flight = this.inflight
|
||||
if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
|
||||
@@ -204,13 +196,8 @@ 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)) {
|
||||
this.phase = 'claimed'
|
||||
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
|
||||
}
|
||||
this.phase = 'plain'
|
||||
this.claim = undefined
|
||||
this.claim = ev.command
|
||||
this.phase = ev.command === undefined ? 'plain' : 'claimed'
|
||||
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { $getRoot, $nodesOfType, REDO_COMMAND, UNDO_COMMAND } from 'lexical'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { DraftReference, DraftSnapshot, Occurrence } from '../src/client/contract/draft-editor.ts'
|
||||
import type { DraftAttachmentId, InputTriggerController, PickOutcome, SubmitOutcome } from '../src/client/contract/input.ts'
|
||||
import type {
|
||||
CommandClaim, DraftAttachmentId, InputTriggerController, PickOutcome, SubmitOutcome,
|
||||
} from '../src/client/contract/input.ts'
|
||||
import { parseStoredDraft, resolveDraftInput, snapshotDraft } from '../src/client/draft.ts'
|
||||
import { scanTextRefs } from '../src/client/input/decorations.ts'
|
||||
import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx'
|
||||
import { CommandChipNode } from '../src/client/input/editor/command-chip-node.tsx'
|
||||
import { TextRefNode } from '../src/client/input/editor/text-ref.ts'
|
||||
import { SessionInputShell, type SessionInputDeps } from '../src/client/input/facade.ts'
|
||||
|
||||
@@ -78,11 +81,43 @@ function triggerProvider(lexicon: InputTriggerController['lexicon']): InputTrigg
|
||||
onSpace: () => false,
|
||||
serializeReference: (_source, ref) => Promise.resolve(ref),
|
||||
adjudicate: () => Promise.resolve(undefined),
|
||||
resolveClaim: () => Promise.resolve(undefined),
|
||||
openReference: () => false,
|
||||
toggleSource: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
/** A goal claim whose submissions are recorded. */
|
||||
function goalClaim(): CommandClaim & { readonly submitted: string[] } {
|
||||
const submitted: string[] = []
|
||||
return {
|
||||
name: 'goal',
|
||||
token: '/goal ',
|
||||
submitted,
|
||||
submit: (args) => {
|
||||
submitted.push(args)
|
||||
return Promise.resolve({ kind: 'success' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of command chips in the shell's document. */
|
||||
function commandChips(shell: SessionInputShell): number {
|
||||
return shell.editor.getEditorState().read(() => $nodesOfType(CommandChipNode).length)
|
||||
}
|
||||
|
||||
/** The persisted form of `/goal ship` claimed as a command chip. */
|
||||
function storedGoalDraft(): DraftSnapshot {
|
||||
const original = makeShell()
|
||||
original.setDraft('/goal ')
|
||||
expect(original.beginCommand(goalClaim(), { start: 0, end: '/goal '.length, draftRev: original.snapshot.draftRev })).toBe(true)
|
||||
expect(original.actions.insertText('ship', original.actions.captureInsertion())).toBe(true)
|
||||
const stored = parseStoredDraft(JSON.parse(JSON.stringify(original.draftSnapshot)))
|
||||
if (stored === undefined) throw new Error('Expected a stored draft')
|
||||
expect(stored).toEqual({ text: '/goal ship', references: [], command: { token: '/goal ' } })
|
||||
return stored
|
||||
}
|
||||
|
||||
function expectInitializationBlocked(shell: SessionInputShell): void {
|
||||
const draft = shell.draftSnapshot
|
||||
const attachmentIds = shell.snapshot.attachmentIds
|
||||
@@ -159,6 +194,32 @@ describe('draft document values', () => {
|
||||
expect(parsed?.references[0]).not.toHaveProperty('nodeKey')
|
||||
})
|
||||
|
||||
it('carries a leading command token through copy, export, and storage', () => {
|
||||
const input = { text: `/goal ${file.clipboardText}`, references: [{ ...file, offset: 6, length: file.clipboardText.length }], command: { token: '/goal ' } }
|
||||
const resolved = resolveDraftInput(input)
|
||||
expect(resolved).toEqual(input)
|
||||
expect(resolved.command).not.toBe(input.command)
|
||||
const exported = snapshotDraft(input.text, input.references.map(reference => ({ ...reference, occurrenceId: 7 })), '/goal ')
|
||||
expect(exported).toEqual(input)
|
||||
expect(parseStoredDraft(JSON.parse(JSON.stringify(exported)))).toEqual(input)
|
||||
expect(parseStoredDraft({ text: '/目标', references: [], command: { token: '/目标 ' } })).toEqual(
|
||||
{ text: '/目标', references: [], command: { token: '/目标 ' } },
|
||||
)
|
||||
})
|
||||
|
||||
it.each<{ name: string; value: unknown }>([
|
||||
{ name: 'a token the text does not open', value: { text: 'ship /goal', references: [], command: { token: '/goal ' } } },
|
||||
{ name: 'a token without its trigger', value: { text: 'goal ship', references: [], command: { token: 'goal ' } } },
|
||||
{ name: 'a token without a name', value: { text: '/ ship', references: [], command: { token: '/ ' } } },
|
||||
{ name: 'a non-string token', value: { text: '/goal', references: [], command: { token: 1 } } },
|
||||
{ name: 'a reference overlapping the token', value: {
|
||||
text: '/goal', references: [{ source: 'skill', ref: 'goal', label: 'goal', clipboardText: '/goal', offset: 0, length: 5 }],
|
||||
command: { token: '/goal ' },
|
||||
} },
|
||||
])('rejects a stored command with $name', ({ value }) => {
|
||||
expect(parseStoredDraft(value)).toBeUndefined()
|
||||
})
|
||||
|
||||
const valid = documentOf(file)
|
||||
const reference = { ...file, offset: 0, length: file.clipboardText.length }
|
||||
it.each<{ name: string; value: unknown }>([
|
||||
@@ -218,6 +279,83 @@ describe('draft documents in the input shell', () => {
|
||||
expect(chips(restored)).toEqual(chips(original))
|
||||
})
|
||||
|
||||
it('rebuilds a persisted command chip once the trigger pipeline resolves its claim', async () => {
|
||||
const stored = storedGoalDraft()
|
||||
const claim = goalClaim()
|
||||
const resolution = Promise.withResolvers<CommandClaim | undefined>()
|
||||
const resolveClaim = vi.fn<InputTriggerController['resolveClaim']>(() => resolution.promise)
|
||||
const provider: InputTriggerController = { ...triggerProvider(createSnapshotStore(new Map())), resolveClaim }
|
||||
const shell = makeShell({ inputTriggers: () => provider })
|
||||
shell.setDraft(stored)
|
||||
expect(resolveClaim).toHaveBeenCalledWith('/goal', expect.any(AbortSignal))
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
expect(commandChips(shell)).toBe(0)
|
||||
resolution.resolve(claim)
|
||||
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('claimed') })
|
||||
expect(commandChips(shell)).toBe(1)
|
||||
expect(shell.draftSnapshot).toEqual(stored)
|
||||
// The chip belongs to the restored seed, so undo has no step to revert.
|
||||
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
shell.editor.update(() => {}, { discrete: true })
|
||||
expect(commandChips(shell)).toBe(1)
|
||||
shell.submit()
|
||||
await vi.waitFor(() => { expect(claim.submitted).toEqual(['ship']) })
|
||||
})
|
||||
|
||||
it('waits for the trigger pipeline before resolving a restored command chip', async () => {
|
||||
const stored = storedGoalDraft()
|
||||
const resolveClaim = vi.fn<InputTriggerController['resolveClaim']>(() => Promise.resolve(goalClaim()))
|
||||
const pipeline: { provider?: InputTriggerController } = {}
|
||||
const shell = makeShell({ inputTriggers: () => pipeline.provider })
|
||||
shell.setDraft(stored)
|
||||
shell.refreshLexiconSubscription()
|
||||
expect(commandChips(shell)).toBe(0)
|
||||
pipeline.provider = { ...triggerProvider(createSnapshotStore(new Map())), resolveClaim }
|
||||
shell.refreshLexiconSubscription()
|
||||
shell.refreshLexiconSubscription()
|
||||
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('claimed') })
|
||||
expect(resolveClaim).toHaveBeenCalledTimes(1)
|
||||
expect(commandChips(shell)).toBe(1)
|
||||
})
|
||||
|
||||
it.each<{ name: string; settle: (shell: SessionInputShell, resolution: PromiseWithResolvers<CommandClaim | undefined>) => void }>([
|
||||
{ name: 'the draft changed while the claim resolved', settle: (shell, resolution) => {
|
||||
expect(shell.actions.insertText(' now', shell.actions.captureInsertion())).toBe(true)
|
||||
resolution.resolve(goalClaim())
|
||||
} },
|
||||
{ name: 'no source claims the token', settle: (_shell, resolution) => { resolution.resolve(undefined) } },
|
||||
{ name: 'catalog warmup fails', settle: (_shell, resolution) => { resolution.reject(new Error('catalog unavailable')) } },
|
||||
])('keeps the literal command token when $name', async ({ settle }) => {
|
||||
const resolution = Promise.withResolvers<CommandClaim | undefined>()
|
||||
const provider: InputTriggerController = {
|
||||
...triggerProvider(createSnapshotStore(new Map())), resolveClaim: () => resolution.promise,
|
||||
}
|
||||
const shell = makeShell({ inputTriggers: () => provider })
|
||||
shell.setDraft(storedGoalDraft())
|
||||
const literal = shell.snapshot.draft
|
||||
settle(shell, resolution)
|
||||
await setImmediate()
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
expect(commandChips(shell)).toBe(0)
|
||||
expect(shell.snapshot.draft.startsWith(literal)).toBe(true)
|
||||
expect(shell.notices.getSnapshot()).toBeNull()
|
||||
})
|
||||
|
||||
it('aborts a pending command chip restore when a new draft replaces it or the shell disposes', () => {
|
||||
const signals: AbortSignal[] = []
|
||||
const provider: InputTriggerController = {
|
||||
...triggerProvider(createSnapshotStore(new Map())),
|
||||
resolveClaim: (_token, signal) => { signals.push(signal); return new Promise(() => {}) },
|
||||
}
|
||||
const shell = makeShell({ inputTriggers: () => provider })
|
||||
shell.setDraft(storedGoalDraft())
|
||||
shell.setDraft('plain replacement')
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
shell.setDraft(storedGoalDraft())
|
||||
shell.dispose()
|
||||
expect(signals[1]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'before the first reference', prefix: '\uE100\uFFFC🙂 before ', middle: ' between ' },
|
||||
{ name: 'between references', prefix: '🙂 before ', middle: '\uE11D between \uFFFC' },
|
||||
@@ -421,7 +559,7 @@ describe('draft documents in the input shell', () => {
|
||||
expectInitializationBlocked(shell)
|
||||
outcome.resolve({ kind: 'error', text: 'command refused' })
|
||||
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('claimed') })
|
||||
expect(shell.draftSnapshot).toEqual(draft)
|
||||
expect(shell.draftSnapshot).toEqual({ ...draft, command: { token: '/pending ' } })
|
||||
} finally {
|
||||
shell.dispose()
|
||||
outcome.resolve({ kind: 'error' })
|
||||
|
||||
@@ -96,6 +96,7 @@ function controller(lexicon: () => InputTriggerController['lexicon']): InputTrig
|
||||
onSpace: () => false,
|
||||
serializeReference: (_source, ref) => Promise.resolve(ref),
|
||||
adjudicate: () => Promise.resolve(undefined),
|
||||
resolveClaim: () => Promise.resolve(undefined),
|
||||
openReference: () => false,
|
||||
toggleSource: () => {},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1507,12 +1507,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(() => {
|
||||
@@ -1521,15 +1521,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)', () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -122,7 +122,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', () => {
|
||||
@@ -159,26 +163,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, []) })
|
||||
@@ -187,14 +194,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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ it.each(['handled', 'claim', 'message'] as const)('counts only a message after a
|
||||
track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
|
||||
serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
|
||||
adjudicate: () => pending.promise,
|
||||
resolveClaim: () => Promise.resolve(undefined),
|
||||
}
|
||||
const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers,
|
||||
submissionState, messageSubmitted: report, defaultSink: sink, commandAttachments })
|
||||
@@ -373,6 +374,7 @@ it('retains occurrence time and Session facts across arbitration and independent
|
||||
track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
|
||||
serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
|
||||
adjudicate: () => pending.promise,
|
||||
resolveClaim: () => Promise.resolve(undefined),
|
||||
}),
|
||||
defaultSink: sink,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,10 @@ 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, BEFORE_INPUT_COMMAND, COMMAND_PRIORITY_LOW, 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'
|
||||
@@ -206,7 +209,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<void> => {
|
||||
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<SubmitOutcome>) {
|
||||
@@ -231,12 +259,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' })
|
||||
@@ -317,71 +344,224 @@ 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('a key that deletes nothing, 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' })
|
||||
await act(async () => {})
|
||||
expect(chip()?.hasAttribute('data-selected')).toBe(true)
|
||||
fireEvent.keyDown(b.textarea, { key: 'Escape' })
|
||||
await act(async () => {})
|
||||
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('a platform deletion shortcut keeps the selection its first press made and deletes on the second', 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]')
|
||||
// macOS maps Ctrl+H to a backward character deletion inside the same keydown.
|
||||
const pressCtrlH = async (): Promise<void> => {
|
||||
act(() => {
|
||||
fireEvent.keyDown(b.textarea, { key: 'h', ctrlKey: true })
|
||||
b.shell.editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true)
|
||||
b.shell.editor.update(() => {}, { discrete: true })
|
||||
})
|
||||
await act(async () => {})
|
||||
}
|
||||
await pressCtrlH()
|
||||
expect(chip()?.hasAttribute('data-selected')).toBe(true)
|
||||
await pressCtrlH()
|
||||
expect(chip()).toBeNull()
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('a deletion input event selects the chip first and lets the second one delete 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]')
|
||||
// Lexical's default input handling runs below this probe; iOS deletes a ranged
|
||||
// deleteContentBackward there directly, so the selecting step must consume it first.
|
||||
const reachedDefault = vi.fn(() => false)
|
||||
onTestFinished(b.shell.editor.registerCommand(BEFORE_INPUT_COMMAND, reachedDefault, COMMAND_PRIORITY_LOW))
|
||||
const deleteByInput = (): InputEvent => {
|
||||
const event = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', cancelable: true })
|
||||
act(() => {
|
||||
b.shell.editor.dispatchCommand(BEFORE_INPUT_COMMAND, event)
|
||||
b.shell.editor.update(() => {}, { discrete: true })
|
||||
})
|
||||
return event
|
||||
}
|
||||
expect(deleteByInput().defaultPrevented).toBe(true)
|
||||
expect(reachedDefault).not.toHaveBeenCalled()
|
||||
expect(chip()?.hasAttribute('data-selected')).toBe(true)
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
deleteByInput()
|
||||
expect(reachedDefault).toHaveBeenCalledTimes(1)
|
||||
expect(chip()).toBeNull()
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
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 发布', []) })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, 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, command: undefined,
|
||||
outcome: { kind: 'success', text: 'goal saved' },
|
||||
})
|
||||
expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
|
||||
@@ -271,49 +275,49 @@ 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, 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('a failed settlement re-enters claimed while the command chip still leads', () => {
|
||||
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')
|
||||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, command: claim, message: 'transport' })
|
||||
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: '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', () => {
|
||||
it('a failed settlement without the command chip only notices and returns to plain', () => {
|
||||
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 fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, command: undefined, 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, 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, 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, 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, command: claim })
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -322,7 +326,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, command: undefined })).toEqual([])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -343,7 +347,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, command: undefined })
|
||||
expect(a.state.phase).toBe('plain')
|
||||
expect(b.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: c32eed8d234a108d
|
||||
zh: e0d192dca9c6a1f6
|
||||
/deepseek-ai-dsh-client-ui-input-trigger/use-this-package/keyboard-and-mouse:
|
||||
en: b198d6256a961f99
|
||||
zh: 292e0bb94c18c9ca
|
||||
en: ba909f11dc3f1acc
|
||||
zh: ce56946c7a884026
|
||||
/deepseek-ai-dsh-client-ui-input-trigger/understand-the-implementation:
|
||||
en: 3b69752e15d35f56
|
||||
zh: 17cc44ae44cfb3db
|
||||
|
||||
@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
|
||||
|
||||
### Keyboard and mouse
|
||||
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Opening it from the launcher button puts the keyboard back in the editor before the menu opens, so the arrows drive it there too. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight Tab passes untouched so native focus traversal survives. Escape and Shift+Tab leave the menu instead, never settling: the exit gesture cannot consume or rewrite the draft. A dismissed menu stays dismissed: the same token re-tracked with the same query keeps its menu closed, so restoring the caret after a pointer dismissal — or after a settled command closes the surface it opened — cannot bring the menu back; typing a new query or moving to another token re-arms it. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Opening it from the launcher button puts the keyboard back in the editor before the menu opens, so the arrows drive it there too. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Restoring a persisted command chip polls the optional `matchClaim` hook through `resolveClaim`: it awaits the source's own warmup and answers only a claim, never opening a popup, running an action, or executing the command. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight Tab passes untouched so native focus traversal survives. Escape and Shift+Tab leave the menu instead, never settling: the exit gesture cannot consume or rewrite the draft. A dismissed menu stays dismissed: the same token re-tracked with the same query keeps its menu closed, so restoring the caret after a pointer dismissal — or after a settled command closes the surface it opened — cannot bring the menu back; typing a new query or moving to another token re-arms it. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
|
||||
A source may implement `openReference(session, reference)` to open a draft reference without submitting it. Acceptance may precede asynchronous catalog loading. Chips route by source name; editable tokens route through the current source lexicon. Returning `false`, a missing source, or a disposed controller leaves the editor gesture unchanged.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 键盘与鼠标
|
||||
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。从 launcher 按钮打开菜单时会先把键盘还给编辑器再开菜单,因此方向键在那里同样可用。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。Escape 与 Shift+Tab 则是离开菜单——它们从不选定,因此退出动作不会消耗或改写草稿。被关闭的菜单保持关闭:同一个 token 以同一查询重新 track 时菜单不会重开——因此指针关闭之后恢复光标、或已选定的命令关闭它自己打开的界面,都不会把菜单召回来;输入新的查询或移到另一个 token 才会重新武装。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。从 launcher 按钮打开菜单时会先把键盘还给编辑器再开菜单,因此方向键在那里同样可用。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。恢复持久化的命令 chip 时,`resolveClaim` 轮询可选的 `matchClaim` 钩子:它等待 source 自身预热完成,只应答 claim,从不打开弹窗、运行动作或执行命令。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。Escape 与 Shift+Tab 则是离开菜单——它们从不选定,因此退出动作不会消耗或改写草稿。被关闭的菜单保持关闭:同一个 token 以同一查询重新 track 时菜单不会重开——因此指针关闭之后恢复光标、或已选定的命令关闭它自己打开的界面,都不会把菜单召回来;输入新的查询或移到另一个 token 才会重新武装。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
|
||||
来源可以实现 `openReference(session, reference)`,打开草稿引用而不提交。来源可以先接受预览请求,再异步加载目录。标签按来源名称路由;可编辑文本按来源当前的词表路由。返回 `false`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, PickOutcome, ReferenceInsert,
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, PickOutcome, ReferenceInsert,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { detectTrigger } from '../core/detect.ts'
|
||||
@@ -396,6 +396,28 @@ export class InputTriggerController {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the claim a persisted command chip names, polling sources'
|
||||
* matchClaim in registration order; the first claim wins. Never acts on the
|
||||
* token (see {@link InputTriggerSource.matchClaim}).
|
||||
* @param token - command spelling without its argument separator, e.g. '/goal'.
|
||||
* @param signal - abort for the caller's restore attempt.
|
||||
* @returns the claim, or undefined when no source claims the token. Rejects
|
||||
* when a polled source's warmup fails or `signal` aborts.
|
||||
*/
|
||||
async resolveClaim(token: string, signal: AbortSignal): Promise<CommandClaim | undefined> {
|
||||
const projection = this.project()
|
||||
for (const src of this.deps.roster.all()) {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error ? signal.reason : new Error('command restore aborted')
|
||||
}
|
||||
if (src.matchClaim === undefined || !token.startsWith(src.trigger)) continue
|
||||
const claim = await src.matchClaim(projection, token, signal)
|
||||
if (claim !== undefined) return claim
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the menu group of a disposed source (root registry change notification).
|
||||
* @param source - the source whose registration was disposed.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
import type {
|
||||
PickOutcome, ReferenceInsert, TokenSpan,
|
||||
CommandClaim, PickOutcome, ReferenceInsert, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -198,6 +198,14 @@ export interface InputTriggerSource {
|
||||
signal: AbortSignal,
|
||||
envelope: SubmitEnvelope,
|
||||
): Promise<PickOutcome>
|
||||
/**
|
||||
* Restore-time claim resolution for a persisted command chip. Answers the
|
||||
* claim `matchSpace` would make for `token` (e.g. '/goal') after awaiting
|
||||
* the source's own warmup, and never acts: a token that would open a
|
||||
* popup, run an action, or execute detached answers undefined. Rejects when
|
||||
* the warmup fails or `signal` aborts.
|
||||
*/
|
||||
matchClaim?(session: ClientSessionContext, token: string, signal: AbortSignal): Promise<CommandClaim | undefined>
|
||||
/**
|
||||
* Scope-birth prewarm hook (fire-and-forget): the per-session controller
|
||||
* calls it once when the session scope comes alive so sources can fetch
|
||||
|
||||
@@ -1248,6 +1248,72 @@ describe('adjudicate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveClaim', () => {
|
||||
const claimSource = (
|
||||
trigger: TriggerChar, name: string,
|
||||
matchClaim?: InputTriggerSource['matchClaim'],
|
||||
): InputTriggerSource => ({
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
...(matchClaim !== undefined ? { matchClaim } : {}),
|
||||
})
|
||||
|
||||
it('polls matchClaim in registration order with the projection and token; the first claim wins', async () => {
|
||||
const calls: string[] = []
|
||||
const claim = claimOf('/goal ')
|
||||
const signal = new AbortController().signal
|
||||
const atHook = vi.fn(() => Promise.resolve(claimOf('@goal ')))
|
||||
const { controller } = controllerBench([
|
||||
claimSource('@', 'subagent', atHook),
|
||||
claimSource('/', 'silent'),
|
||||
claimSource('/', 'first', (session, token, received) => {
|
||||
expect(session).toEqual({ sessionId: sid('a') })
|
||||
expect(received).toBe(signal)
|
||||
calls.push(`first:${token}`)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
claimSource('/', 'second', (_session, token) => {
|
||||
calls.push(`second:${token}`)
|
||||
return Promise.resolve(claim)
|
||||
}),
|
||||
claimSource('/', 'third', () => {
|
||||
calls.push('third')
|
||||
return Promise.resolve(claimOf('/plan '))
|
||||
}),
|
||||
])
|
||||
await expect(controller.resolveClaim('/goal', signal)).resolves.toBe(claim)
|
||||
expect(calls).toEqual(['first:/goal', 'second:/goal'])
|
||||
expect(atHook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers undefined when no source claims the token', async () => {
|
||||
const { controller } = controllerBench([claimSource('/', 'command', () => Promise.resolve(undefined))])
|
||||
await expect(controller.resolveClaim('/xyz', new AbortController().signal)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('a rejecting source rejects the whole resolution', async () => {
|
||||
const { controller } = controllerBench([
|
||||
claimSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
|
||||
claimSource('/', 'late', () => Promise.resolve(claimOf('/goal '))),
|
||||
])
|
||||
await expect(controller.resolveClaim('/goal', new AbortController().signal)).rejects.toThrow('warmup failed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'its own reason', reason: new Error('restore superseded'), message: 'restore superseded' },
|
||||
{ name: 'a fallback error for a non-error reason', reason: 'superseded', message: 'command restore aborted' },
|
||||
])('an aborted restore stops the poll with $name', async ({ reason, message }) => {
|
||||
const hook = vi.fn(() => Promise.resolve(claimOf('/goal ')))
|
||||
const { controller } = controllerBench([claimSource('/', 'command', hook)])
|
||||
const abort = new AbortController()
|
||||
abort.abort(reason)
|
||||
await expect(controller.resolveClaim('/goal', abort.signal)).rejects.toThrow(message)
|
||||
expect(hook).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference activation', () => {
|
||||
it('routes chips by owner and text by the live lexicon without picking or serializing', () => {
|
||||
const openReference = vi.fn(() => true)
|
||||
|
||||
Reference in new issue
Block a user