Merge pull request #5505 from deepseek-harness/session-prompt-prefill

feat(web): initialize prompts and preserve structured session drafts
This commit is contained in:
imccyu authored and GitHub committed 2026-09-30 16:21:17 +08:00
commit c222ea968b
51 files changed
+2617 -128

No files matched your search

@@ -0,0 +1,34 @@
# Bilingual-pair consistency record for 2026-09-30-structured-draft-initialization.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-09-30-structured-draft-initialization.md
/agent-note-initial-draft-content-and-structured-reference-restoration:
en: 88202480bcf49e10
zh: 3f2aff8ad2be0faa
/agent-note-initial-draft-content-and-structured-reference-restoration/problem:
en: 6aa396e12264ee48
zh: 56849a18acaf830a
/agent-note-initial-draft-content-and-structured-reference-restoration/decision:
en: 9cf2afd9d5e663e9
zh: ad59b1bec6ec4666
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model:
en: 7f33895364a9f5cc
zh: f1c79ea15f5c151e
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods:
en: ca81423a57f898a0
zh: 98962cc1c6bce113
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow:
en: dd57e639e9328f1d
zh: 5e16cb2e3a73597c
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/asynchronous-skill-matching:
en: 3b6a99c417384470
zh: b62951995eda2fb0
/agent-note-initial-draft-content-and-structured-reference-restoration/alternatives-considered:
en: eddaec471425fafc
zh: b206ecdb12e8c33d
/agent-note-initial-draft-content-and-structured-reference-restoration/verification:
en: 341f4f36936f686a
zh: 479fbe04e810f640
/agent-note-initial-draft-content-and-structured-reference-restoration/consequences:
en: 2ffae2dca902341d
zh: fb33a64aaabfd89c
@@ -0,0 +1,88 @@
# Agent Note: Initial draft content and structured reference restoration
Status: implemented
English | [中文](2026-09-30-structured-draft-initialization.zh.md)
## Problem
New-task entry points need to place unsent content in the composer. A draft contains not only text but also file, folder, and Session references the user has selected. Saving only their text projection loses the chips' source, target, and presentation fields when the input object is disposed. Filling the composer after mounting also cannot guarantee that the first input-model read contains the complete document.
## Decision
Keep the existing `SessionInputShell`, `DraftEditorRuntime`, Conversation store, and Session reference lifetime. Plain text and structured drafts enter through the input model. Initial import finishes before `InputHub.shellFor()` returns, without React effects restoring text. Existing input objects are not restored again. React mounts the same editor and binds the existing persistence callback.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt` and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. Without clearing, existing content is preserved and only an empty draft adopts the prompt. Explicit clearing replaces the target Session's text document, without deleting attachments or changing other Sessions. Prefilling does not send, execute commands, or change `blank`.
### Data model
`DraftSnapshot` contains `text` and `references`. Each reference retains `offset`, `length`, `source`, `ref`, `label`, `appearance`, `clipboardText`, and its existing invalid flag. Positions use JavaScript string coordinates. Reference spans must be ordered, non-overlapping, and equal the corresponding `clipboardText`. Snapshots exclude Lexical NodeKeys, DOM, runtime occurrenceIds, functions, and temporary highlights.
A string represents input without structured references; an ambiguous `@` query never selects a file automatically. The public `SessionInput.setDraft` and `InputActions.setDraft` methods accept only text. Internal structured restoration reuses the editor's reference restoration without listing directories or reading files. Plain text and file, folder, and Session chips all pass through the registered editor transforms and projection.
The existing `dsh.conversation.<sessionId>` key remains. Readers accept both legacy strings and structured `draft` values; new writes store structured values. The original Conversation store still writes the complete record, without a second writer for that key. Reference changes must reach persistence even when the visible text does not change.
### Classes and methods
| Location | Change and responsibility |
| --- | --- |
| `UiWorkspace` / `UiWorkspaceService.startSession` | The second parameter is `StartSessionOptions`; the existing `beforeOpen` preparation point passes initialization to the exact target binding. Creation, reuse, and navigation cancellation remain unchanged |
| `SessionInputResolver` / `InputHub.requestDraftInitialization` | Accept the binding and options and delegate to that binding's input object, without looking up a global current Session |
| `InputHub.shellFor` | After registering a new object and its cleanup, read the Session's saved draft, import it through the input model, and return. Reused objects are not restored again |
| `readConversationDraft(sessionId)` in `stores.ts` / `parseStoredDraft` in `draft.ts` | Read the original storage key, accept legacy strings, and check structured reference fields and positions without creating a store or another writer |
| `SessionInputShell.setDraft` | The concrete internal method accepts a string or `DraftSnapshot` and imports one document through the editor; public input methods accept only text. Neither path simulates keyboard events or submits |
| `SessionInputShell.requestDraftInitialization` | Apply clear, preserve, and prefill rules to the restored model; refuse changes during submission without clearing first |
| `SessionInputShell.draftSnapshot` / `persistCurrentDraft()` | Cache snapshots without runtime IDs by content revision; `InputActions.persistDraft()` delegates explicit saving, while `bindDraftPersistence(write)` only binds the callback |
| `DraftEditorRuntime.restoreDraft` | Rebuild ReferenceChipNodes from semantic reference fields; existing failed-send restoration uses the same implementation |
| `SessionInputShell.refreshLexiconSubscription()` / `DraftEditorRuntime.refreshLexiconSubscription()` | `InputHub` owns the Session-scope `inject` callback that connects the lexicon after caching the shell, reconnects when the service arrives or is replaced, and unsubscribes on disposal. Both connection and catalog updates rescan the current editor without writing old text back; `setDraft` does not establish the subscription |
| `ConversationStoreState` / `createConversationStore` | Support structured input and legacy strings in draft storage, keeping View state and the original key |
| `DefaultConversationViews` | Remove restoration and prefill effects, retaining persistence binding and unbinding. Initial saving reads the owner's current snapshot, never text captured during render |
| The existing `selectWorkspace` callback in `apply.ts` | Keep the existing cross-Workspace draft-transfer rules, passing `draftSnapshot` instead of plain text so transfer retains references |
There is no new draft service, storage key, general task framework, or Session creation entry point. SlotRenderer, Host Session logs, command execution, and file-discovery RPCs are unchanged. `InputActions` remains the shared input-action entry; components receive no separate content-initialization callback.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`, whose fields are `prompt?: string` and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
### Data flow
```text
stored draft / startSession options
→ DraftInput
→ SessionInputShell.setDraft
→ DraftEditorRuntime → Lexical → InputState
→ InputHub.shellFor returns
→ React mounts the editor
user edits / input actions
→ editor → projection
→ DraftSnapshot
→ Conversation store
```
### Asynchronous skill matching
Skills remain editable text, not file chips. A pending lexicon never locks the draft. Slash names remain ordinary text until a catalog supplies a matching name; an absent provider, unavailable catalog, or unmatched name produces no skill highlight. The existing subscription rescans the current document on arrival instead of saving and restoring the text present when the request began. This changes neither text nor structured references, does not take the caret, and adds no user Undo step. Commands retain their existing validation and execution path at submission.
## Alternatives considered
**Calling setDraft after mounting.** The first model read and render can still be empty, and later restoration can overwrite a clear. Initialization cannot depend on React-effect ordering.
**Saving only clipboardText and searching references again.** Text does not identify a reference's source and presentation fields. Searching candidates does not restore the user's previous selection; existing structure should be restored directly.
**Waiting for all remote lexicons before allowing input.** File references already have their data, and skill highlighting is not an editing prerequisite. Blocking the draft on those queries adds unnecessary waiting.
**Splitting persistence keys or adding a draft-management service.** The existing input owner and store can perform the work by sharing the input representation and import path.
## Verification
The core model invariants are semantic equivalence after import, export, and JSON round trips without comparing runtime node IDs; a complete document on the first input-object read; and stable draft snapshots across selection or skill-highlight changes. [Document-model cases](../../../../packages/client/ui-conversation/tests/draft-document.client.spec.ts) cover strings, empty drafts, multiline Unicode text, file/folder/Session references, duplicates, invalid flags, placeholder sanitization, and invalid stored spans. [Input-lifecycle cases](../../../../packages/client/ui-conversation/tests/draft-hub.client.spec.ts) cover first reads, reuse, lexicon-service arrival, unloading, reconnection, and retired bindings. Initialization is refused during submission, and lexicon rescans add no Undo step.
[Navigation cases](../../../../packages/client/ui-workspace/tests/workspaces-service.client.spec.ts) check exact retained targets, option copying before asynchronous creation, superseded requests without draft writes, and refusal without changing selection. [Assembly cases](../../../../packages/client/ui-conversation/tests/apply-inject.client.spec.tsx) check that existing Workspace transfer retains structured references. The related regression run passed 708 cases in 44 files; after the shared draft module moved to its owning location, the three affected model and assembly files passed another 66 cases.
The [browser e2e](../../../../apps/web/tests/draft-initialization.e2e.ts) observes the shipped Workspace API through the existing module-loader test instrumentation and calls public `startSession` with text options, without an additional test plugin. Structured fixtures enter through the existing saved-draft restoration path. The cases cover absent, empty, and nonempty prompts; explicit and inherited Workspaces; preservation and clearing; creation and reuse; repeated real-UI Session switching and editing; reload; legacy strings; and Workspace-picker transfer. Both orders of delayed creation responses preserve the newer target and leave the other draft unchanged. A delayed real skill-catalog response rematches current text without replacing chips, selection, or Undo history. Assertions inspect chip fields, file previews, and persisted snapshots. Before sending, Host logs contain neither `user/message` nor `turn/start`.
## Consequences
Missing reference information in legacy strings cannot be reconstructed losslessly, so they restore as plain text without searching and guessing files. Attachment File objects, upload state, cross-browser synchronization, multiple independent editors, and persistent Undo history are outside this change.
This decision shares input ownership with the [editor-isolation proposal](../../proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) without implementing its multi-editor goal. [Reference previews](../feature/2026-09-10-composer-reference-previews.md) and [Session reference lifetimes](2026-09-15-client-session-references.md) continue to govern source routing and generation cleanup independently; neither is superseded.
@@ -0,0 +1,88 @@
# Agent Note: 草稿初始内容与结构化引用恢复
Status: implemented
[English](2026-09-30-structured-draft-initialization.md) | 中文
## 问题
新任务入口需要把一段尚未发送的内容放入输入框。草稿不仅包含文字,也包含用户已经选定的文件、目录和 Session 引用;只保存引用的文字投影,会在输入对象销毁后丢失胶囊的来源、目标和展示字段。页面挂载后的填字也不能保证第一次读取输入模型时已经得到完整内容。
## 决策
保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。
`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。
### 数据模型
`DraftSnapshot` 由 `text` 和 `references` 组成。每个引用保留 `offset`、`length`、`source`、`ref`、`label`、`appearance`、`clipboardText` 及已有的无效标记;位置使用 JavaScript 字符串坐标。引用位置必须有序、不重叠,且对应的正文片段与 `clipboardText` 一致。不保存 Lexical NodeKey、DOM、运行时 occurrenceId、函数或临时高亮。
字符串等价于不含引用结构的输入;不会据模糊的 `@` 查询擅自选中某个文件。公开的 `SessionInput.setDraft` 与 `InputActions.setDraft` 仅接受文本。内部结构化恢复直接复用编辑器已有的引用恢复能力,无需查询文件目录或读取文件内容。恢复普通文字、文件胶囊、目录胶囊和 Session 胶囊都经过已注册的编辑器变换和投影流程。
现有 `dsh.conversation.<sessionId>` key 保留,`draft` 可读取旧字符串,也可读取新的结构化值;新写入保存结构化值。仍由原 Conversation store 写整个记录,不增加第二个同 key 写入者。引用变化即使没有改变显示文字,也必须进入持久化快照。
### Class 与方法
| 位置 | 改动与职责 |
| --- | --- |
| `UiWorkspace`/`UiWorkspaceService.startSession` | 第二参数类型为 `StartSessionOptions`;在原 `beforeOpen` 准备点向准确的目标 binding 传入初始化请求。创建、复用与导航取消路径不变 |
| `SessionInputResolver`/`InputHub.requestDraftInitialization` | 接收 binding 和 options,交给该 binding 的输入对象,不重新查找全局当前 Session |
| `InputHub.shellFor` | 新对象登记及清理接线后,读取该 Session 保存的草稿,通过输入模型导入,然后返回;复用对象不再恢复 |
| `stores.ts` 的 `readConversationDraft(sessionId)`/`draft.ts` 的 `parseStoredDraft` | 只读原存储 key;兼容字符串并检查结构化引用的位置和字段,不创建 store 或增加写入者 |
| `SessionInputShell.setDraft` | 具体类的内部方法接受字符串或 `DraftSnapshot`,向编辑器发送一次文档导入;公开输入方法仅接受文本。两条路径均不模拟键盘事件、不提交内容 |
| `SessionInputShell.requestDraftInitialization` | 在已经恢复的模型上执行清空/保留/填入规则;提交中拒绝修改,失败前不先清稿 |
| `SessionInputShell.draftSnapshot`/`persistCurrentDraft()` | 按内容修订号缓存不含运行时 ID 的快照;`InputActions.persistDraft()` 委托显式保存方法;`bindDraftPersistence(write)` 仍只绑定回调 |
| `DraftEditorRuntime.restoreDraft` | 接受引用的语义字段,重建 ReferenceChipNode;既有发送失败恢复继续使用同一实现 |
| `SessionInputShell.refreshLexiconSubscription()`/`DraftEditorRuntime.refreshLexiconSubscription()` | `InputHub` 拥有 Session scope 的 `inject` 回调,在缓存输入对象后连接词典,服务到达或替换时重接,销毁时退订。连接和词典更新都会重扫当前 editor,不覆盖旧文本;`setDraft` 不负责建立订阅 |
| `ConversationStoreState`/`createConversationStore` | draft 支持兼容旧字符串的结构化输入,View 状态及原 key 保持 |
| `DefaultConversationViews` | 删除恢复/预填 effect;保留持久化回调的绑定和解绑。初始保存只能读取 owner 的当前快照,不能把 render 时的旧文字写回 |
| `apply.ts` 中现有 `selectWorkspace` 回调 | 沿用原有跨工作区携带规则,但传递 `draftSnapshot` 而不是纯文本,防止携带时再次丢失引用 |
不新增草稿服务、存储 key、通用任务框架或另一套 Session 创建入口;不改 SlotRenderer、Host Session 日志、命令执行和文件发现 RPC。`InputActions` 保留统一输入操作入口,组件不单独注入另一条内容初始化回调。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`,字段为 `prompt?: string` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
### 数据流
```text
stored draft / startSession options
→ DraftInput
→ SessionInputShell.setDraft
→ DraftEditorRuntime → Lexical → InputState
→ InputHub.shellFor returns
→ React mounts the editor
user edits / input actions
→ editor → projection
→ DraftSnapshot
→ Conversation store
```
### 异步 skill 匹配
skill 是可编辑文字,不转为文件胶囊。词典尚未返回时不锁草稿;斜杠名称先保持普通文字,词典提供匹配名称后才高亮。提供方缺席、词典不可用或名称不匹配时不显示 skill 高亮。数据到达后通过现有订阅机制重匹配当前文档,而不是保存请求发出时的文字并回写。此过程不修改正文和结构化引用,不抢光标、不增加用户 Undo 步骤。命令仍只在用户提交时走既有校验和执行路径。
## 考虑过的替代方案
**挂载后调用 setDraft。** 首次模型读取与显示仍可能为空,清空还可能被稍后的旧稿恢复覆盖;初始化责任不能依赖 React effect 的先后顺序。
**只保存 clipboardText,再重新搜索引用。** 文字不足以表达引用的来源和展示字段;搜索候选也不等于恢复用户已经作出的选择。已有结构应直接恢复。
**等待所有远程词典再开放输入。** 文件引用已有数据,skill 高亮也不是编辑前提;让网络查询阻塞草稿会引入不必要的等待。
**拆分持久化 key 或新增草稿管理服务。** 现有输入 owner 和 store 已能承担职责,只需统一输入数据与导入路径。
## 验证
数据模型的核心约束是:有效草稿经过导入、导出和 JSON 往返后语义相等,运行时节点 ID 不参与比较;初次获取输入对象即具有完整文档;选区和 skill 高亮变化不改变草稿快照。[文档模型用例](../../../../packages/client/ui-conversation/tests/draft-document.client.spec.ts)覆盖字符串、空稿、多行与 Unicode、文件/目录/Session 引用、重复引用、无效标记、占位字符净化和非法存储跨度;[输入生命周期用例](../../../../packages/client/ui-conversation/tests/draft-hub.client.spec.ts)验证首次读取、复用、词典服务到达/卸载/重连和旧 binding 失效。提交中拒绝初始化,词典重匹配不增加 Undo 步骤。
[导航用例](../../../../packages/client/ui-workspace/tests/workspaces-service.client.spec.ts)验证初始化作用于准确的保留对象、参数在异步创建前复制、导航取消不写稿、拒绝时保持原选择。[组合用例](../../../../packages/client/ui-conversation/tests/apply-inject.client.spec.tsx)验证现有工作区携带路径不把引用变成纯文本。相关回归共 44 个文件、708 项通过;共享草稿模块归位后,受影响的三个模型与组合文件再验证 66 项通过。
[浏览器 e2e](../../../../apps/web/tests/draft-initialization.e2e.ts)通过已有模块加载观测方式取得实际 Workspace API,调用公开 `startSession` 传入文本选项,不新增测试插件。结构化 fixture 经既有已存草稿路径恢复。用例覆盖缺省、空字符串和非空提示词,明确传入及沿用当前工作区,保留与清空、创建与复用、真实界面的 Session 多轮切换和编辑、刷新、旧字符串及工作区选择器携带。延迟创建响应的两种返回顺序都保持较新的目标选择,并且不改动另一份草稿。延迟返回的真实 skill 目录只重匹配当前文字,不替换胶囊、选区或 Undo 历史。断言检查胶囊字段、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。
## 后果与边界
旧字符串草稿缺失的引用信息不能无损推断,按普通文字恢复;不为兼容旧数据自动查询并猜测文件。附件文件对象、上传状态、跨浏览器同步、多个独立编辑器和完整 Undo 持久化不在本次范围。
本决策与[编辑器隔离方案](../../proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md)共享输入 owner 的归属,但不实现其多编辑器目标;[引用预览](../feature/2026-09-10-composer-reference-previews.zh.md)和[Session 引用生命周期](2026-09-15-client-session-references.zh.md)继续各自约束来源路由和代次清理,不被本次决策取代。
+731
View File
@@ -0,0 +1,731 @@
/** Unsubmitted drafts initialized through the shipped Workspace API and restored by the real Web profile. */
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
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'
const SCREENSHOTS = fileURLToPath(new URL('../../../.artifacts/screenshots', import.meta.url))
const FILE_NAME = 'notes 雪.md'
const FILE_BODY = '# Draft reference preview\n\nUnsubmitted file reference: 雪 🧭.\n'
const SETTLE = { timeout: 20_000 }
// This Host-plane spec cannot import the Client program's Cordis declarations.
interface DraftSnapshot {
text: string
references: {
offset: number
length: number
source: 'reference'
ref: string
label: string
appearance: 'file' | 'folder' | 'session'
clipboardText: string
invalid?: boolean
}[]
}
const EMPTY: DraftSnapshot = { text: '', references: [] }
interface DraftOptions {
prompt?: string
clearPreviousDraft?: boolean
}
interface DraftApiObservation {
startSession?: (workspaceId: string | undefined, options: DraftOptions) => void
}
interface WorkspaceApplyContext {
get(name: 'uiWorkspace'): { startSession: NonNullable<DraftApiObservation['startSession']> } | undefined
}
async function observeDraftApi(page: Page): Promise<void> {
// Observe one shipped module's apply, using the page-owned loader interception from default-product-isolation.e2e.ts.
await page.addInitScript(() => {
const observation: DraftApiObservation = {}
Reflect.set(globalThis, '__dshDraftApiObservation', observation)
Object.defineProperty(globalThis, '__ModuleLoader__', {
configurable: true,
set(target: ClientModuleLoaderTarget) {
Object.defineProperty(globalThis, '__ModuleLoader__', { configurable: true, writable: true, value: target })
let load = target.load.bind(target)
const observeLoad: ClientModuleLoaderTarget['load'] = (registration) => {
if (registration.id !== '@deepseek-ai/dsh-client-ui-workspace' || registration.chunk !== undefined) {
load(registration)
return
}
load({
...registration,
factory(require) {
const exports = registration.factory(require)
if (typeof exports.apply !== 'function') {
throw new Error(`ui-workspace factory returned keys ${Object.keys(exports).join(',')}; apply is ${typeof exports.apply}`)
}
const originalApply = exports.apply as (ctx: WorkspaceApplyContext) => unknown
return {
...exports,
apply(ctx: WorkspaceApplyContext) {
const result = originalApply.call(exports, ctx)
observation.startSession = (workspaceId, options) => {
const workspace = ctx.get('uiWorkspace')
if (workspace === undefined) throw new Error('The shipped uiWorkspace service is unavailable')
workspace.startSession(workspaceId, options)
}
return result
},
}
},
})
}
// The facade replaces load when its pending registration queue becomes live.
Object.defineProperty(target, 'load', {
configurable: true,
get: () => observeLoad,
set(next: ClientModuleLoaderTarget['load']) { load = next.bind(target) },
})
},
})
})
}
async function startSession(page: Page, workspaceId: string | undefined, options: DraftOptions): Promise<void> {
await page.evaluate(({ workspaceId, options }) => {
const observation = Reflect.get(globalThis, '__dshDraftApiObservation') as DraftApiObservation | undefined
if (observation?.startSession === undefined) throw new Error('The shipped Workspace apply has not been observed')
observation.startSession(workspaceId, options)
}, { workspaceId, options })
}
function composer(page: Page) {
return page.locator('[data-composer-input][contenteditable="true"]').first()
}
function selectedSession(page: Page): Promise<string | null> {
return page.evaluate(() => {
const raw = localStorage.getItem('dsh.sessions.current')
const value: unknown = raw === null ? null : JSON.parse(raw)
return typeof value === 'object' && value !== null && 'sessionId' in value
&& typeof value.sessionId === 'string' ? value.sessionId : null
})
}
function storedDraft(page: Page, sessionId: SessionId): Promise<unknown> {
return page.evaluate((id) => {
const raw = localStorage.getItem(`dsh.conversation.${id}`)
const value: unknown = raw === null ? null : JSON.parse(raw)
return typeof value === 'object' && value !== null && 'draft' in value ? value.draft : null
}, sessionId)
}
async function writeStoredDrafts(page: Page, drafts: readonly { sessionId: SessionId; draft: string | DraftSnapshot }[]): Promise<void> {
await page.evaluate((drafts) => {
for (const { sessionId, draft } of drafts) {
const key = `dsh.conversation.${sessionId}`
const raw = localStorage.getItem(key)
const saved: unknown = raw === null ? { view: null, viewRequest: null } : JSON.parse(raw)
if (typeof saved !== 'object' || saved === null || Array.isArray(saved)) {
throw new Error(`Unexpected conversation record for ${sessionId}`)
}
localStorage.setItem(key, JSON.stringify({ ...saved, draft }))
}
}, drafts)
}
async function restoreStoredDrafts(page: Page, drafts: readonly { sessionId: SessionId; draft: string | DraftSnapshot }[]): Promise<void> {
await writeStoredDrafts(page, drafts)
await page.reload({ waitUntil: 'load' })
}
function editorSelection(page: Page) {
return composer(page).evaluate((element) => {
const selection = element.ownerDocument.getSelection()
if (selection?.anchorNode === null || selection?.focusNode === null || selection === null
|| !element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
throw new Error('The selection is outside the draft editor')
}
const offset = (node: Node, position: number): number => {
const range = element.ownerDocument.createRange()
range.selectNodeContents(element)
range.setEnd(node, position)
return range.toString().length
}
return {
anchor: offset(selection.anchorNode, selection.anchorOffset),
focus: offset(selection.focusNode, selection.focusOffset),
text: selection.toString(),
}
})
}
async function assertDraft(page: Page, sessionId: SessionId, expected: DraftSnapshot, message = 'persisted draft'): Promise<void> {
await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId)
await composer(page).waitFor()
await expect.poll(() => storedDraft(page, sessionId), { ...SETTLE, message }).toEqual(expected)
const chips = composer(page).locator('[data-composer-chip]')
await expect.poll(() => chips.allTextContents(), SETTLE).toEqual(expected.references.map(reference => reference.label))
expect(await chips.evaluateAll(elements => elements.map(element => ({
source: element.getAttribute('data-composer-chip'), editable: element.getAttribute('contenteditable'),
})))).toEqual(expected.references.map(() => ({ source: 'reference', editable: 'false' })))
let displayed = expected.text
for (const reference of [...expected.references].reverse()) {
displayed = displayed.slice(0, reference.offset) + reference.label
+ displayed.slice(reference.offset + reference.length)
}
await expect.poll(() => composer(page).evaluate(element =>
[...element.children].map(paragraph => paragraph.textContent).join('\n')), SETTLE).toBe(displayed)
}
async function initialize(page: Page, prompt: string, workspaceId?: string, clearPreviousDraft = false) {
await startSession(page, workspaceId, { prompt, clearPreviousDraft })
}
async function openWorkspaceSession(page: Page, workspace: { readonly title: string }, sessionId: SessionId) {
const row = page.getByRole('tree', { name: 'Sessions' }).getByText(workspace.title, { exact: true })
.locator('xpath=ancestor::*[@role="treeitem"][1]')
await row.hover()
await row.getByRole('button', { name: `New session in ${workspace.title}`, exact: true }).click()
await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId)
}
async function assertUnsubmitted(scaffold: WebScaffold, ids: readonly SessionId[]): Promise<void> {
expect(ids.length).toBeGreaterThan(0)
await scaffold.ctx.sessionPersistence.flush()
for (const id of ids) {
const handle = await scaffold.ctx.sessionPersistence.open(id, 'read')
try {
const { events } = await handle.read()
expect(events.filter(event => event.type === 'user/message' || event.type === 'turn/start'), id).toEqual([])
} finally {
await handle.close()
}
}
}
async function launchDraftFixture(beforeBrowserOpen?: (page: Page, scaffold: WebScaffold) => Promise<void>) {
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const first = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'Draft origin')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const console = watchConsole(page)
page.on('pageerror', (error) => { globalThis.console.error(error.stack ?? error.message) })
await observeDraftApi(page)
onTestFinished(async ({ task }) => {
if (task.result?.state !== 'fail') return
await mkdir(SCREENSHOTS, { recursive: true })
const directory = await mkdtemp(join(SCREENSHOTS, 'draft-initialization-'))
try {
await page.screenshot({ path: join(directory, 'failure.png'), fullPage: true })
} catch (error: unknown) {
// A closed browser must not replace the test's original failure.
globalThis.console.warn('Draft initialization failure screenshot unavailable:', error)
}
})
await beforeBrowserOpen?.(page, scaffold)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await expect.poll(() => page.evaluate(() => {
const observation = Reflect.get(globalThis, '__dshDraftApiObservation') as DraftApiObservation | undefined
return typeof observation?.startSession
}), SETTLE).toBe('function')
await composer(page).waitFor()
await expect.poll(() => first.sessionIds.length, SETTLE).toBe(1)
const firstId = first.sessionIds[0]!
await expect.poll(() => selectedSession(page), SETTLE).toBe(firstId)
const secondPath = await mkdtemp(join(scaffold.workspaceCwd, 'draft-target-'))
const second = await scaffold.ctx.workspaceRegistry.create(secondPath, 'Draft target')
await page.getByText('Draft target', { exact: true }).first().waitFor()
expect(second.sessionIds).toEqual([])
return { scaffold, page, console, first, firstId, second }
}
function structuredDraft(sessionId: SessionId, title: string): DraftSnapshot {
const file = `@"${FILE_NAME}"`
const folder = '@"目录/"'
const session = formatSessionReferenceMention({ sessionId, label: '关联会话 🧩' })
const references: DraftSnapshot['references'] = []
let text = `${title} 🧭 e\u0301 雪\n`
for (let repeat = 0; repeat < 2; repeat++) {
for (const item of [
{ ref: file, label: FILE_NAME, appearance: 'file' },
{ ref: folder, label: '目录/', appearance: 'folder' },
{ ref: session, label: '关联会话 🧩', appearance: 'session' },
] as const) {
references.push({
...item, offset: text.length, length: item.ref.length,
source: 'reference', clipboardText: item.ref,
})
text += `${item.ref} `
}
text += '\n'
}
// Equal mention text without a reference entry remains ordinary text.
text += `普通文字 ${file},末尾 🦉`
return { text, references }
}
it('preserves drafts through real New Session navigation and treats empty prompts according to clearPreviousDraft', async () => {
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
const initial = { text: '纯文字 🧭\n第二行 e\u0301', references: [] }
await initialize(page, initial.text, second.id)
await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1)
const secondId = second.sessionIds[0]!
expect(secondId).not.toBe(firstId)
await assertDraft(page, secondId, initial)
await openWorkspaceSession(page, first, firstId)
await openWorkspaceSession(page, second, secondId)
await assertDraft(page, secondId, initial)
await openWorkspaceSession(page, first, firstId)
await initialize(page, '已有草稿不能被覆盖', second.id)
await assertDraft(page, secondId, initial)
expect(second.sessionIds).toEqual([secondId])
await openWorkspaceSession(page, first, firstId)
await initialize(page, '', second.id, false)
await assertDraft(page, secondId, initial)
await initialize(page, '', undefined, true)
await assertDraft(page, secondId, EMPTY)
await openWorkspaceSession(page, first, firstId)
await initialize(page, '', second.id, false)
await assertDraft(page, secondId, EMPTY)
const replacement = { text: '显式替换 🧪', references: [] }
await initialize(page, replacement.text, undefined, true)
await assertDraft(page, secondId, replacement)
expect(second.sessionIds).toEqual([secondId])
await startSession(page, undefined, { clearPreviousDraft: true })
await assertDraft(page, secondId, EMPTY)
for (let round = 0; round < 2; round++) {
await openWorkspaceSession(page, first, firstId)
await openWorkspaceSession(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
}
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, EMPTY)
await assertUnsubmitted(scaffold, [firstId, secondId])
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]) {
await mkdir(join(workspace.path, '目录'))
await writeFile(join(workspace.path, FILE_NAME), FILE_BODY)
}
await initialize(page, '', second.id)
await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1)
const secondId = second.sessionIds[0]!
await assertDraft(page, secondId, EMPTY)
const firstDraft = structuredDraft(secondId, '工作区甲')
const secondDraft = structuredDraft(firstId, '工作区乙')
await restoreStoredDrafts(page, [
{ sessionId: firstId, draft: firstDraft },
{ sessionId: secondId, draft: secondDraft },
])
await assertDraft(page, secondId, secondDraft)
expect(await composer(page).evaluate(element => ({
paragraphs: [...element.children].map(paragraph => paragraph.textContent),
capsules: [...element.querySelectorAll('[data-composer-chip]')].map(chip => ({
source: chip.getAttribute('data-composer-chip'),
editable: chip.getAttribute('contenteditable'),
label: chip.textContent,
})),
}))).toMatchInlineSnapshot(`
{
"capsules": [
{
"editable": "false",
"label": "notes 雪.md",
"source": "reference",
},
{
"editable": "false",
"label": "目录/",
"source": "reference",
},
{
"editable": "false",
"label": "关联会话 🧩",
"source": "reference",
},
{
"editable": "false",
"label": "notes 雪.md",
"source": "reference",
},
{
"editable": "false",
"label": "目录/",
"source": "reference",
},
{
"editable": "false",
"label": "关联会话 🧩",
"source": "reference",
},
],
"paragraphs": [
"工作区乙 🧭 é 雪",
"notes 雪.md 目录/ 关联会话 🧩 ",
"notes 雪.md 目录/ 关联会话 🧩 ",
"普通文字 @\"notes 雪.md\",末尾 🦉",
],
}
`)
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, firstDraft)
const drafts = [
{ id: firstId, workspace: first, draft: firstDraft },
{ id: secondId, workspace: second, draft: secondDraft },
]
for (let round = 0; round < 3; round++) {
for (const item of drafts) {
await openWorkspaceSession(page, item.workspace, item.id)
await assertDraft(page, item.id, item.draft)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+End')
const suffix = ` · 编辑${round} 🧪`
await page.keyboard.insertText(suffix)
item.draft = { ...item.draft, text: item.draft.text + suffix }
await assertDraft(page, item.id, item.draft)
}
}
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, drafts[1]!.draft)
for (const item of drafts) {
await openWorkspaceSession(page, item.workspace, item.id)
await assertDraft(page, item.id, item.draft)
}
await composer(page).locator('[data-composer-chip]').first().click()
await expect.poll(() => page.locator('[data-document-markdown]').textContent(), SETTLE)
.toContain('Unsubmitted file reference: 雪 🧭.')
await assertDraft(page, secondId, drafts[1]!.draft)
await startSession(page, undefined, { clearPreviousDraft: true })
await assertDraft(page, secondId, EMPTY)
for (let round = 0; round < 2; round++) {
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, drafts[0]!.draft)
await openWorkspaceSession(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
}
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, EMPTY)
expect(first.sessionIds).toEqual([firstId])
expect(second.sessionIds).toEqual([secondId])
await assertUnsubmitted(scaffold, [firstId, secondId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('reads a legacy string from the existing conversation key and saves ordinary edits as a structured draft', async () => {
const { scaffold, page, console, firstId } = await launchDraftFixture()
const legacy = '旧字符串草稿 🧭\n普通 @notes,不是胶囊'
await restoreStoredDrafts(page, [{ sessionId: firstId, draft: legacy }])
await expect.poll(() => selectedSession(page), SETTLE).toBe(firstId)
await composer(page).waitFor()
await expect.poll(() => composer(page).evaluate(element =>
[...element.children].map(paragraph => paragraph.textContent).join('\n')), SETTLE).toBe(legacy)
expect(await composer(page).locator('[data-composer-chip]').count()).toBe(0)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+End')
await page.keyboard.insertText(',继续编辑')
await assertDraft(page, firstId, { text: `${legacy},继续编辑`, references: [] })
await assertUnsubmitted(scaffold, [firstId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('rematches the current draft after a delayed real skills catalog without replacing text, selection or undo history', async () => {
const releaseCatalog = Promise.withResolvers<undefined>()
let captured: { status: number; body: string; request: string | null } | undefined
let catalogRequests = 0
const { scaffold, page, console, firstId } = await launchDraftFixture(async (_page, scaffold) => {
for (const name of ['draft-late-original', 'draft-late-current']) {
const directory = join(scaffold.workspaceCwd, '.agents', 'skills', name)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---', `name: ${name}`, `description: Draft catalog fixture ${name}`, '---',
'', `# ${name}`, '', 'Keep the supplied draft unchanged.', '',
].join('\n'))
}
await writeFile(join(scaffold.workspaceCwd, FILE_NAME), FILE_BODY)
})
const file = `@"${FILE_NAME}"`
const originalPrefix = '/draft-late-original\n'
const currentPrefix = '/draft-late-current /draft-late-missing\n'
const initial: DraftSnapshot = {
text: `${originalPrefix}${file} abcdef`,
references: [{
offset: originalPrefix.length, length: file.length, source: 'reference', ref: file,
label: FILE_NAME, appearance: 'file', clipboardText: file,
}],
}
await writeStoredDrafts(page, [{ sessionId: firstId, draft: initial }])
// Only the new document's catalog may wait on this gate; the preceding page has no held routes.
await page.goto('about:blank', { waitUntil: 'load' })
let intercepted = false
await page.route(url => url.pathname === '/api/skills/list', async (route) => {
catalogRequests++
if (intercepted) {
await route.continue()
return
}
intercepted = true
const response = await route.fetch()
try {
captured = { status: response.status(), body: await response.text(), request: route.request().postData() }
await releaseCatalog.promise
await route.fulfill({ response })
} finally {
await response.dispose()
}
})
onTestFinished(async () => {
releaseCatalog.resolve(undefined)
await page.unrouteAll({ behavior: 'wait' })
})
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await assertDraft(page, firstId, initial)
expect(await composer(page).locator('[data-composer-text-ref]').count()).toBe(0)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+Home')
await page.keyboard.press('End')
await expect.poll(() => captured?.request ?? '', SETTLE).toContain(firstId)
expect(captured?.status).toBe(200)
expect(captured?.body).toContain('draft-late-original')
expect(captured?.body).toContain('draft-late-current')
await page.keyboard.press('Escape')
const chip = await composer(page).locator('[data-composer-chip]').elementHandle()
if (chip === null) throw new Error('The initialized file capsule is missing')
await composer(page).click()
await page.keyboard.press('ControlOrMeta+Home')
await page.keyboard.press('Shift+End')
await page.keyboard.insertText(currentPrefix.trimEnd())
const edited: DraftSnapshot = {
text: `${currentPrefix}${file} abcdef`,
references: [{ ...initial.references[0]!, offset: currentPrefix.length }],
}
await assertDraft(page, firstId, edited)
await page.keyboard.press('ControlOrMeta+End')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('Shift+ArrowLeft')
await page.keyboard.press('Shift+ArrowLeft')
const selection = await editorSelection(page)
expect(selection.text).toBe('cd')
expect(catalogRequests).toBe(1)
await expect.poll(() => composer(page).locator('[data-composer-text-ref]').allTextContents(), SETTLE)
.toEqual([])
const delivered = page.waitForResponse(response => new URL(response.url()).pathname === '/api/skills/list')
releaseCatalog.resolve(undefined)
expect((await delivered).status()).toBe(200)
await expect.poll(() => composer(page).locator('[data-composer-text-ref]').allTextContents(), SETTLE)
.toEqual(['/draft-late-current'])
await assertDraft(page, firstId, edited)
expect(await editorSelection(page)).toEqual(selection)
expect(await chip.evaluate(element => element.isConnected)).toBe(true)
await page.keyboard.press('ControlOrMeta+z')
await assertDraft(page, firstId, initial)
await page.keyboard.press('ControlOrMeta+Shift+z')
await assertDraft(page, firstId, edited)
await assertUnsubmitted(scaffold, [firstId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('carries all reference capsules through the real Workspace picker and leaves the source draft empty', async () => {
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
for (const workspace of [first, second]) {
await mkdir(join(workspace.path, '目录'))
await writeFile(join(workspace.path, FILE_NAME), FILE_BODY)
}
const draft = structuredDraft(firstId, '切换工作区携带')
await restoreStoredDrafts(page, [{ sessionId: firstId, draft }])
await assertDraft(page, firstId, draft)
await page.getByRole('button', { name: 'Choose workspace', exact: true }).click()
await page.getByRole('menuitem', { name: second.title, exact: true }).click()
await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1)
const secondId = second.sessionIds[0]!
await assertDraft(page, secondId, draft)
await expect.poll(() => storedDraft(page, firstId), SETTLE).toEqual(EMPTY)
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, EMPTY)
await openWorkspaceSession(page, second, secondId)
await assertDraft(page, secondId, draft)
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, draft)
await composer(page).locator('[data-composer-chip]').first().click()
await expect.poll(() => page.locator('[data-document-markdown]').textContent(), SETTLE)
.toContain('Unsubmitted file reference: 雪 🧭.')
await assertUnsubmitted(scaffold, [firstId, secondId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it('applies a parameter transition matrix to one reusable target without changing another Workspace draft', async () => {
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
const source = structuredDraft(firstId, '源草稿保持不变')
const structured = structuredDraft(firstId, '目标结构草稿')
const restored = structuredDraft(firstId, '目标恢复草稿')
const replacement = { text: '目标替换文字 🧭', references: [] }
const plain = { text: '已有目标文字 🧭', references: [] }
const literalReferences = { text: structured.text, references: [] }
await initialize(page, '', second.id, true)
await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1)
const secondId = second.sessionIds[0]!
await assertDraft(page, secondId, EMPTY)
await restoreStoredDrafts(page, [
{ sessionId: firstId, draft: source },
{ sessionId: secondId, draft: structured },
])
await assertDraft(page, secondId, structured)
const transitions: {
name: string
target: 'explicit' | 'current'
options: DraftOptions
expected: DraftSnapshot
restore?: DraftSnapshot
reload?: boolean
}[] = [
{ name: 'nonempty text preserves existing capsules', target: 'current', options: { prompt: 'ignored', clearPreviousDraft: false }, expected: structured },
{ name: 'empty text preserves existing capsules', target: 'explicit', options: { prompt: '', clearPreviousDraft: false }, expected: structured },
{ name: 'text replaces a nonempty structured draft', target: 'current', options: { prompt: plain.text, clearPreviousDraft: true }, expected: plain, reload: true },
{ name: 'nonempty text preserves existing text', target: 'explicit', options: { prompt: structured.text, clearPreviousDraft: false }, expected: plain },
{ name: 'empty text explicitly clears existing text', target: 'current', options: { prompt: '', clearPreviousDraft: true }, expected: EMPTY },
{ name: 'reference-like text fills an empty target without creating capsules', target: 'explicit', options: { prompt: structured.text, clearPreviousDraft: false }, expected: literalReferences },
{ name: 'nonempty text explicitly replaces existing text', target: 'current', options: { prompt: replacement.text, clearPreviousDraft: true }, expected: replacement, reload: true },
{ name: 'absent prompt and false clear preserve restored capsules', target: 'explicit', options: { clearPreviousDraft: false }, expected: restored, restore: restored },
{ name: 'empty text explicitly clears capsules', target: 'current', options: { prompt: '', clearPreviousDraft: true }, expected: EMPTY, reload: true },
]
for (const transition of transitions) {
if (transition.restore !== undefined) {
await restoreStoredDrafts(page, [{ sessionId: secondId, draft: transition.restore }])
await assertDraft(page, secondId, transition.restore)
}
if (transition.target === 'explicit') {
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, source)
}
await startSession(page, transition.target === 'explicit' ? second.id : undefined, transition.options)
await assertDraft(page, secondId, transition.expected, transition.name)
expect(await storedDraft(page, firstId), transition.name).toEqual(source)
expect(second.sessionIds, transition.name).toEqual([secondId])
if (transition.reload) {
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, transition.expected, transition.name)
}
}
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, source)
await openWorkspaceSession(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
await assertUnsubmitted(scaffold, [firstId, secondId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
})
it.each(['older-first', 'newer-first'] as const)(
'initializes only the latest target when two real Session creations overlap (%s)', async (completionOrder) => {
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
const thirdPath = await mkdtemp(join(scaffold.workspaceCwd, 'draft-latest-'))
const third = await scaffold.ctx.workspaceRegistry.create(thirdPath, 'Draft latest')
await page.getByText(third.title, { exact: true }).first().waitFor()
const source = structuredDraft(firstId, '交错请求的源草稿')
const latest = { text: '只有最后目标收到文字草稿 🧭', references: [] }
await restoreStoredDrafts(page, [{ sessionId: firstId, draft: source }])
await assertDraft(page, firstId, source)
interface HeldCreation {
workspaceId: string
release: ReturnType<typeof Promise.withResolvers<undefined>>
response?: { status: number; body: string }
}
const older: HeldCreation = { workspaceId: second.id, release: Promise.withResolvers<undefined>() }
const newer: HeldCreation = { workspaceId: third.id, release: Promise.withResolvers<undefined>() }
await page.route(url => url.pathname === '/api/session/create', async (route) => {
const request = route.request().postData() ?? ''
const held = [older, newer].find(candidate => request.includes(candidate.workspaceId))
if (held === undefined) {
await route.continue()
return
}
const response = await route.fetch()
try {
held.response = { status: response.status(), body: await response.text() }
await held.release.promise
await route.fulfill({ response })
} finally {
await response.dispose()
}
})
onTestFinished(async () => {
older.release.resolve(undefined)
newer.release.resolve(undefined)
await page.unrouteAll({ behavior: 'wait' })
})
const deliver = async (held: HeldCreation): Promise<void> => {
const delivered = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/create'
&& (response.request().postData() ?? '').includes(held.workspaceId))
held.release.resolve(undefined)
const response = await delivered
expect(response.status()).toBe(200)
await response.finished()
// Observe rendered navigation after the received RPC body's promise callbacks.
await page.evaluate(() => new Promise<undefined>(resolve => requestAnimationFrame(() => { resolve(undefined) })))
}
await startSession(page, second.id, { prompt: '过期请求不得写入任何草稿', clearPreviousDraft: true })
await expect.poll(() => older.response?.status, SETTLE).toBe(200)
await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1)
const secondId = second.sessionIds[0]!
expect(older.response?.body).toContain(secondId)
await assertDraft(page, firstId, source)
await startSession(page, third.id, { prompt: latest.text, clearPreviousDraft: true })
await expect.poll(() => newer.response?.status, SETTLE).toBe(200)
await expect.poll(() => third.sessionIds.length, SETTLE).toBe(1)
const thirdId = third.sessionIds[0]!
expect(newer.response?.body).toContain(thirdId)
await assertDraft(page, firstId, source)
if (completionOrder === 'older-first') {
await deliver(older)
await assertDraft(page, firstId, source)
expect(await storedDraft(page, secondId)).toBeNull()
await deliver(newer)
} else {
await deliver(newer)
await assertDraft(page, thirdId, latest)
await deliver(older)
}
await assertDraft(page, thirdId, latest)
expect(await storedDraft(page, secondId)).toBeNull()
expect(await storedDraft(page, firstId)).toEqual(source)
await openWorkspaceSession(page, second, secondId)
await expect.poll(() => composer(page).textContent(), SETTLE).toBe('')
expect(await composer(page).locator('[data-composer-chip]').count()).toBe(0)
await openWorkspaceSession(page, third, thirdId)
await assertDraft(page, thirdId, latest)
await openWorkspaceSession(page, first, firstId)
await assertDraft(page, firstId, source)
await page.reload({ waitUntil: 'load' })
await assertDraft(page, firstId, source)
await openWorkspaceSession(page, third, thirdId)
await assertDraft(page, thirdId, latest)
await assertUnsubmitted(scaffold, [firstId, secondId, thirdId])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
},
)
+1
View File
@@ -73,6 +73,7 @@
"tests/plugin-install-registry.e2e.ts",
"tests/plugin-install-github.e2e.ts",
"tests/client-plugin-live.e2e.ts",
"tests/draft-initialization.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/session-log-upload.e2e.ts",
"tests/caption-overlays.e2e.ts",
@@ -330,6 +330,7 @@ describe('ImageGallery', () => {
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: vi.fn(),
persistDraft: vi.fn(),
addAttachments: vi.fn(() => true),
removeAttachment: vi.fn(),
pruneAttachments: vi.fn(),
@@ -431,6 +431,7 @@ function makeHarness(
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: () => {},
persistDraft: () => {},
addAttachments: () => true,
removeAttachment: () => {},
pruneAttachments: () => {},
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 2f44e26f8ff84268
zh: 879534cee58aaed5
en: dfccf6560c8022d4
zh: 42e07e8c57f85a82
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+4 -2
View File
@@ -56,9 +56,11 @@ The composer registers the File command action and owns its label, availability,
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
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.
Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
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.
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
@@ -72,7 +74,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 clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
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.
+4 -2
View File
@@ -56,9 +56,11 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。
已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿内容(包括结构化引用)和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
@@ -72,7 +74,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
@@ -330,7 +330,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => {
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
const draft = from.draftSnapshot
const attachmentIds = from.snapshot.attachmentIds
const next = inputHub.shell(nextId)
if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) {
@@ -338,7 +338,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
throw new Error(`ui-conversation: session "${nextId}" resolved no binding`)
}
concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds)
if (draft !== '') {
if (draft.text !== '') {
next.setDraft(draft)
from.setDraft('')
}
@@ -383,7 +383,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
},
},
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
bindDraftPersistence: write => inputHub.shell(sessionId).bindDraftPersistence(write),
openView,
}
},
@@ -19,6 +19,21 @@ export interface ReferenceInsert {
readonly clipboardText: string
}
/** Persisted reference data without the current editor's occurrence identity. */
export type DraftReference = Omit<Occurrence, 'occurrenceId'>
/**
* Editor-independent content. Reference spans are ordered, non-overlapping UTF-16
* ranges whose text equals clipboardText; runtime node identities are excluded.
*/
export interface DraftSnapshot {
readonly text: string
readonly references: readonly DraftReference[]
}
/** Plain initial text or an existing document with explicit reference identities. */
export type DraftInput = string | DraftSnapshot
/** Keyboard keys intercepted by an open trigger menu. */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab' | 'tabBack'
@@ -7,6 +7,7 @@
* here is the submit plane (phase, claim, attempt) alone.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -174,7 +175,7 @@ export interface InputTarget {
/** Per-session input facade owned by the conversation wiring layer. */
export interface SessionInput extends InputTarget {
/** Replace the whole draft (persisted-draft seed and programmatic writes). */
/** Replace the whole draft with plain text, removing inline reference chips. */
setDraft(text: string): void
/** Append ordered browser-owned attachment ids; busy admission phases refuse. */
addAttachments(ids: readonly DraftAttachmentId[]): boolean
@@ -211,8 +212,25 @@ export interface SessionInput extends InputTarget {
export interface SessionInputResolver {
/** Resolve the facade for one session-scope ctx. */
for(actx: Context): SessionInput
/**
* Prepare the exact retained Session before navigation publishes it.
* @param binding - target Session generation, already retained by the caller.
* @param options - content to adopt and explicit permission to replace existing text.
* @returns applied, preserved, or blocked by a pending submission or disposal.
* @throws when binding is not the currently retained Session generation.
*/
requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions): DraftInitializationResult
}
/** Initial text for a target Session; attachments remain under their existing owner. */
export interface DraftInitializationOptions {
readonly prompt?: string
readonly clearPreviousDraft?: boolean
}
/** An initialization request never waits for a React mount or a remote catalog. */
export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked'
/**
* The public input action face provided to every session-scope slot
* component: stable-identity void callbacks, mirroring the
@@ -229,8 +247,10 @@ export interface InputActions {
* @returns false when the draft changed or submission locked the editor.
*/
insertText(text: string, span: TokenSpan): boolean
/** Replace the whole draft (persisted-draft seed and programmatic writes). */
/** Replace the whole draft with plain text, removing inline reference chips. */
setDraft(text: string): void
/** Write the current semantic document through the mounted persistence callback. */
persistDraft(): void
/** Append ordered browser-owned attachment ids; busy admission phases refuse. */
addAttachments(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned attachment id; busy admission phases refuse. */
@@ -17,7 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ComposerBlock } from './composer-blocks.ts'
import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts'
import type { ComposerKeyboard, EditSelection } from './draft-editor.ts'
import type { ComposerKeyboard, DraftSnapshot, EditSelection } from './draft-editor.ts'
import type { createConversationStore } from '../stores.ts'
import type { BusyEnterBehavior } from './composer-submission.ts'
import type { ConversationSnapshot } from './snapshot.ts'
@@ -341,7 +341,7 @@ export interface ConversationSessionInjected {
readonly inspectCall: ObservableSnapshot<ConvViewOwnerProps['inspectCall']>
}
/** Bind input draft persistence to the Session-owned store instance. */
bindDraftMirror: (write: (text: string) => void) => () => void
bindDraftPersistence: (write: (draft: DraftSnapshot) => void) => () => void
/** Select and activate one View while addressing an opaque focus request to it. */
openView: (view: string, focus: string) => void
}
@@ -1,4 +1,5 @@
/** Conversation view and session-local presentation state. */
import type { DraftInput } from './draft-editor.ts'
/**
* One conversation view tab, projected from a 'conversation.view' slot
@@ -17,7 +18,7 @@ export interface ConversationViewRequest {
/** Per-session state owned by the target-neutral Conversation shell. */
export interface ConversationStoreState {
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
draft: DraftInput
/** Preferred `conversation.view` entry id; null resolves to Chat when registered. */
view: string | null
/** Focus request consumed and acknowledged by the addressed View. */
@@ -0,0 +1,82 @@
/** Semantic draft snapshots shared by storage, programmatic input, and editor restoration. */
import { z } from 'zod'
import type { DraftInput, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts'
const referenceSchema = z.object({
source: z.string(),
ref: z.string(),
offset: z.number().int().nonnegative(),
length: z.number().int().positive(),
label: z.string(),
appearance: z.enum(['session', 'file', 'folder']).optional(),
clipboardText: z.string(),
invalid: z.boolean().optional(),
})
const snapshotSchema = z.object({
text: z.string(),
references: z.array(referenceSchema),
}).refine((draft) => {
let end = 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')
/**
* Copy input into the semantic draft representation without editor runtime identities.
* @param input - plain text or caller-owned structured content.
* @returns an owned snapshot ready for editor import.
*/
export function resolveDraftInput(input: DraftInput): DraftSnapshot {
if (typeof input === 'string') return { text: input, references: [] }
return { text: input.text, references: input.references.map(copyReference) }
}
/**
* Decode persisted drafts, accepting the earlier plain-string representation.
* @param value - JSON value read from browser storage.
* @returns the decoded draft, or undefined for invalid data.
*/
export function parseStoredDraft(value: unknown): DraftSnapshot | undefined {
if (typeof value === 'string') return resolveDraftInput(value)
const parsed = snapshotSchema.safeParse(value)
if (!parsed.success) return undefined
return {
text: parsed.data.text,
references: parsed.data.references.map(({ appearance, invalid, ...reference }) => ({
...reference,
...(appearance === undefined ? {} : { appearance }),
...(invalid === undefined ? {} : { invalid }),
})),
}
}
/**
* Export reference identities without their editor-local occurrence ids.
* @param text - current clipboard projection.
* @param occurrences - matching reference projections from the same document revision.
* @returns an editor-independent snapshot.
*/
export function snapshotDraft(text: string, occurrences: readonly Occurrence[]): DraftSnapshot {
return {
text,
references: occurrences.map(copyReference),
}
}
function copyReference(reference: DraftReference): DraftReference {
return {
source: reference.source,
ref: reference.ref,
offset: reference.offset,
length: reference.length,
label: reference.label,
clipboardText: reference.clipboardText,
...(reference.appearance === undefined ? {} : { appearance: reference.appearance }),
...(reference.invalid === undefined ? {} : { invalid: reference.invalid }),
}
}
@@ -71,11 +71,12 @@ export type {
UseConversationViews,
} from './contract/slots.ts'
export type {
BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions,
BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, DraftInitializationOptions,
DraftInitializationResult, InputActions,
InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput,
SessionInputResolver, SubmitAttachment, SubmitOutcome,
} from './contract/input.ts'
export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
declare module '@deepseek-ai/cordis' {
@@ -37,7 +37,7 @@ const SLASH_TOKEN_END_RE = /^(?:\s|$)/
* exact lexicon member; a `/name` token must end at whitespace or the draft
* end ('/name/x' is a path, '/name。' is prose).
* @param draft - draft text.
* @param lexicon - per-trigger name lists (a missing trigger scans nothing).
* @param lexicon - per-trigger name lists; missing catalogs match no named tokens.
* @returns matched ranges in draft order.
*/
export function scanTextRefs(
@@ -45,17 +45,15 @@ export function scanTextRefs(
): TextRefRange[] {
if (draft === '') return []
const out: TextRefRange[] = []
if (lexicon.size > 0) {
TEXT_REF_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
const trigger = m[2] as '/' | '@'
const name = m[3] ?? ''
if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue
if (lexicon.get(trigger)?.includes(name)) {
const start = m.index + (m[1]?.length ?? 0)
out.push({ start, end: start + 1 + name.length, trigger })
}
TEXT_REF_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
const trigger = m[2] as '/' | '@'
const name = m[3] ?? ''
if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue
if (lexicon.get(trigger)?.includes(name)) {
const start = m.index + (m[1]?.length ?? 0)
out.push({ start, end: start + 1 + name.length, trigger })
}
}
FOLDER_REF_RE.lastIndex = 0
@@ -9,7 +9,7 @@ import {
import { registerPlainText } from '@lexical/plain-text'
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
import { mergeRegister } from '@lexical/utils'
import type { Occurrence, ReferenceInsert } from '../../contract/draft-editor.ts'
import type { DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts'
import { registerReferenceActivation } from './reference-activation.ts'
import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
import { refreshClaimDecoration, registerClaimDecoration } from './claim-decor.ts'
@@ -51,6 +51,7 @@ export class DraftEditorRuntime {
private occurrenceSeq = 0
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
private lexiconSource: ObservableSnapshot<Lexicon> | undefined
/** @param deps - model callbacks used by editor listeners and transforms. */
constructor(private readonly deps: DraftEditorRuntimeDeps) {
@@ -89,7 +90,11 @@ export class DraftEditorRuntime {
this.editor.registerUpdateListener(() => { this.deps.onUpdate() }),
registerClaimDecoration(this.editor, () => this.deps.activeClaimToken()),
registerTextRefDecoration(this.editor, () => this.deps.lexicon(), () => this.deps.activeClaimToken()),
() => { this.lexiconOff?.() },
() => {
this.lexiconOff?.()
this.lexiconOff = undefined
this.lexiconSource = undefined
},
)
return () => {
unregister()
@@ -123,16 +128,16 @@ export class DraftEditorRuntime {
}
/**
* Subscribe the text-ref re-scan to the controller's lexicon once the
* controller resolves. The deps thunk cannot resolve at construction (the
* shell is created inside the sessions provide materialization), so the
* first interactive updates retry until it can.
* Reconnect decorations when the optional trigger provider changes.
* The rescan reads the current editor and does not replace its content.
*/
private ensureLexiconSubscription(): void {
if (this.lexiconOff !== undefined) return
refreshLexiconSubscription(): void {
const lexicon = this.deps.resolveLexicon()
if (lexicon === undefined) return
this.lexiconOff = lexicon.subscribe(() => { rescanTextRefs(this.editor) })
if (lexicon === this.lexiconSource) return
this.lexiconOff?.()
this.lexiconSource = lexicon
this.lexiconOff = lexicon?.subscribe(() => { rescanTextRefs(this.editor) })
rescanTextRefs(this.editor)
}
/**
@@ -140,7 +145,6 @@ export class DraftEditorRuntime {
* @returns the projection preceding this read.
*/
refreshProjection(): EditorProjection {
this.ensureLexiconSubscription()
const prev = this.projected
this.projected = this.editor.getEditorState().read(() =>
$projectComposer(key => this.occurrenceIdOf(key)))
@@ -163,7 +167,7 @@ export class DraftEditorRuntime {
*/
setDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === this.projection.clipboardText) return
if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0) return
this.editor.update(() => {
const root = $getRoot()
root.clear()
@@ -302,18 +306,18 @@ export class DraftEditorRuntime {
}
/**
* Rebuild one model-selected failure snapshot, creating fresh reference nodes.
* Import semantic content or a model-selected failure snapshot with fresh reference nodes.
* @param draft - clipboard text.
* @param occurrences - reference occurrences in clipboard order.
*/
restoreDraft(draft: string, occurrences: readonly Occurrence[]): void {
restoreDraft(draft: string, occurrences: readonly DraftReference[]): void {
this.editor.update(() => {
const root = $getRoot()
root.clear()
let paragraph = $createParagraphNode()
root.append(paragraph)
const appendText = (text: string): void => {
const lines = text.split('\n')
const lines = text.replace(REFERENCE_PLACEHOLDER_RE, '').split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (line !== '') paragraph.append($createTextNode(line))
@@ -4,7 +4,7 @@ import type { EditorConfig, LexicalEditor, SerializedTextNode } from 'lexical'
import { TextNode } from 'lexical'
import { registerLexicalTextEntity } from '@lexical/text'
import { mergeRegister } from '@lexical/utils'
import { $getRoot } from 'lexical'
import { $getRoot, HISTORY_MERGE_TAG } from 'lexical'
import { scanTextRefs } from '../decorations.ts'
import css from './composer-editor.module.css'
@@ -112,5 +112,5 @@ export function registerTextRefDecoration(
export function rescanTextRefs(editor: LexicalEditor): void {
editor.update(() => {
for (const node of $getRoot().getAllTextNodes()) node.markDirty()
})
}, { tag: HISTORY_MERGE_TAG })
}
@@ -15,15 +15,16 @@ import {
} from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor } from 'lexical'
import type {
CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
CommandClaim, ConsumeTokenRequest, DraftAttachmentId, DraftInitializationOptions, DraftInitializationResult,
InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
} from '../contract/input.ts'
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
import { resolveDraftInput, snapshotDraft } from '../draft.ts'
import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts'
@@ -33,10 +34,9 @@ export interface PopupDismissFace {
}
/**
* Construction dependencies of one facade. The slash/popup faces are THUNKS: the
* shell is created inside the sessions provide materialization (before the
* scope record is queryable), where `slash.sessionOf`/`command.popupFor`
* cannot resolve yet — resolution defers to first interactive use.
* Construction dependencies of one input model. Trigger and popup callbacks
* resolve the currently available Session-scoped services on demand. InputHub
* owns the lexicon subscription through the Session scope's inject lifecycle.
*/
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
@@ -95,7 +95,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection
const EMPTY_QUEUE: InboxState['next-turn'] = []
/** No-pipeline lexicon: zero text-ref decorations. */
/** Unavailable catalogs contain no named text references. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/** Editor and attachment snapshot owned by one detached default send. */
@@ -128,6 +128,7 @@ export class SessionInputShell implements SessionInput {
return this.draftEditor.insertAsyncText(span, text)
},
setDraft: (text) => { this.setDraft(text) },
persistDraft: () => { this.persistCurrentDraft() },
addAttachments: ids => this.addAttachments(ids),
removeAttachment: (id) => { this.removeAttachment(id) },
pruneAttachments: (ids) => { this.pruneAttachments(ids) },
@@ -142,11 +143,12 @@ export class SessionInputShell implements SessionInput {
private rev = 0
private readonly unregister: () => void
private noticeSeq = 0
private lastMirroredDraft = ''
private lastPublishedDraft: DraftSnapshot | undefined
private draftSnapshotCache: { revision: number; value: DraftSnapshot } | undefined
private attachmentIds: readonly DraftAttachmentId[] = []
private disposed = false
/** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
private mirrorFn: ((text: string) => void) | undefined
/** Conversation store writer for the current semantic document. */
private persistDraft: ((draft: DraftSnapshot) => void) | 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. */
@@ -210,10 +212,52 @@ export class SessionInputShell implements SessionInput {
* Replace the whole draft (persisted-draft seed and programmatic writes).
* Placeholder-sanitized; newlines split paragraphs; the caret lands at the
* end. Merged into history so a seed is not an undoable step of its own.
* @param text - the full next draft.
* @param text - plain text or the complete semantic document.
*/
setDraft(text: string): void {
this.draftEditor.setDraft(text)
setDraft(text: DraftInput): void {
const draft = resolveDraftInput(text)
if (draft.references.length === 0) this.draftEditor.setDraft(draft.text)
else this.draftEditor.restoreDraft(draft.text, draft.references)
}
/** Reconnect optional catalog notifications without waiting for another edit. */
refreshLexiconSubscription(): void {
if (!this.disposed) this.draftEditor.refreshLexiconSubscription()
}
/** 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)
this.draftSnapshotCache = { revision: this.rev, value }
return value
}
/** Persist the latest document without changing the editor or binding a writer. */
persistCurrentDraft(): void {
this.persistDraft?.(this.draftSnapshot)
}
/**
* Apply one new-task request to this already-initialized input model.
* @param options - replacement content and explicit permission to clear existing text.
* @returns applied when the requested text is adopted, even if unchanged;
* preserved when the current draft is kept; blocked after disposal or during a pending submission.
*/
requestDraftInitialization(options: DraftInitializationOptions): DraftInitializationResult {
if (this.draftInitializationBlocked()) return 'blocked'
if (options.prompt === undefined && options.clearPreviousDraft !== true) return 'preserved'
if (options.clearPreviousDraft !== true && (this.snapshot.draft !== '' || this.attachmentIds.length > 0)) {
return 'preserved'
}
this.setDraft(options.prompt ?? '')
return 'applied'
}
private draftInitializationBlocked(): boolean {
const { phase } = this.core.state
return this.disposed || phase === 'adjudicating' || phase === 'submitting'
|| this.detachedDrafts.size > 0 || this.attachmentFlights.size > 0
}
/** Append ordered attachment ids unless an admission transaction is locked. */
@@ -529,17 +573,14 @@ export class SessionInputShell implements SessionInput {
}
/**
* Bind the draft persistence mirror (Conversation store write). Adopt-on-bind: the
* store draft may hold a persisted value from a previous mount; the caller
* seeds it via setDraft BEFORE binding, and afterwards every editor-adopted
* draft mirrors out.
* @param write - store draft write.
* Bind the Conversation store writer without importing or saving content.
* @param write - semantic draft writer; persistCurrentDraft flushes the initial value.
* @returns the unbind disposer.
*/
bindMirror(write: (text: string) => void): () => void {
this.mirrorFn = write
bindDraftPersistence(write: (draft: DraftSnapshot) => void): () => void {
this.persistDraft = write
return () => {
if (this.mirrorFn === write) this.mirrorFn = undefined
if (this.persistDraft === write) this.persistDraft = undefined
}
}
@@ -852,9 +893,10 @@ export class SessionInputShell implements SessionInput {
private publish(): void {
const next = this.compose()
this.state.set(next)
if (next.draft !== this.lastMirroredDraft) {
this.lastMirroredDraft = next.draft
this.mirrorFn?.(next.draft)
const draft = this.draftSnapshot
if (draft !== this.lastPublishedDraft) {
this.lastPublishedDraft = draft
this.persistDraft?.(draft)
}
}
}
@@ -1,11 +1,8 @@
/**
* InputHub: the SessionInputResolver implementation (`ctx.conversation.input`) — one
* SessionInputShell per session, created inside the uiSession provide
* materialization (the 'input' standard-kit entry IS the
* creation trigger) and torn down by the scope disposer (instance-and-scope
* share one lifecycle). The hub registers the scoped input-mutation
* listeners on each Session context and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
* Session-bound input registry and default-send routing for Conversation.
* Each retained Session binding owns one shell, its input listeners, and its
* catalog subscription. Saved drafts enter the model before the first lookup
* returns; Session-scope disposal releases the shell and its resources.
*/
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
@@ -20,7 +17,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type {
DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
DraftAttachmentId, DraftAttachmentSerializationResult, DraftInitializationOptions, DraftInitializationResult, InputTriggerController,
SessionInputResolver, SessionInput, SubmitOutcome,
} from '../contract/input.ts'
import type { ComposerKeyboard } from '../contract/draft-editor.ts'
@@ -28,6 +25,7 @@ import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
import { readConversationDraft } from '../stores.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
@@ -81,11 +79,17 @@ export class InputHub implements SessionInputResolver {
return this.shellFor(binding)
}
requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions): DraftInitializationResult {
if (this.sessions().binding(binding.sessionId) !== binding) {
throw new Error('conversation.input.requestDraftInitialization requires a retained Session binding')
}
return this.shellFor(binding).requestDraftInitialization(options)
}
/**
* Resident shell for one session binding — the provide-channel entry
* (called during scope materialization, BEFORE the scope record is
* queryable, hence binding-fed and hence the thunked slash/popup deps).
* Wires the scoped event listeners + teardown into the session scope.
* Resolve the resident shell for an already-retained, addressable Session binding.
* Draft import completes before return. The Session scope owns input listeners,
* the inject-managed catalog subscription, and shell teardown.
* @param binding - session assembly handle.
* @returns the shell.
*/
@@ -157,6 +161,13 @@ export class InputHub implements SessionInputResolver {
for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId)
}
}, 'conversation.input: session shell')
actx.inject(['inputTriggers'], (scope) => {
scope.effect(() => {
shell.refreshLexiconSubscription()
return () => { shell.refreshLexiconSubscription() }
}, 'conversation.input: reference catalogs')
})
shell.setDraft(readConversationDraft(binding.sessionId))
return shell
}
@@ -6,13 +6,13 @@ import css from './ConversationRoot.module.css'
/**
* Renders the active Session view inside the resident scrollport and keeps
* the input draft mirrored while blank Hero chrome is visible.
* the input draft persisted while blank Hero chrome is visible.
* @param props - Strict Session input/store, view ledger, and render shares.
* @returns the active view area, or null while the Session remains blank.
*/
export function DefaultConversationViews({
view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
renderSlot, bindDraftMirror, openView, useInspectCall,
view, useSession, useConversation, useConversationViews, inputActions, useStore, actions,
renderSlot, bindDraftPersistence, openView, useInspectCall,
}: ConversationSessionSlotProps) {
const tabs = useConversationViews(value => value)
const inspectCall = useInspectCall(value => value)
@@ -20,16 +20,13 @@ export function DefaultConversationViews({
const active = resolveActiveView(tabs, selectedId)
const session = useSession(s => s)
const conversation = useConversation(s => s)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
const viewRequest = useStore(s => s.viewRequest ?? null)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
const unmirror = bindDraftMirror(actions.setDraft)
return () => { unmirror() }
// Mount-only (deps pinned to inputActions): later store writes come from
// the machine mirror, not this seed effect.
const unbindDraftPersistence = bindDraftPersistence(actions.setDraft)
inputActions.persistDraft()
return () => { unbindDraftPersistence() }
// The Session input owns content before this persistence writer is attached.
}, [inputActions])
if (session.blank && conversationPhase(session, conversation) === 'blank') return null
@@ -2,12 +2,14 @@
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ConversationStoreState } from './contract/views.ts'
import type { DraftInput, DraftSnapshot } from './contract/draft-editor.ts'
import { parseStoredDraft } from './draft.ts'
const CONVERSATION_STORE_KEY = 'dsh.conversation'
/** Declared write set for the Conversation shell. */
type ConversationActions = {
setDraft: (draft: ConversationStoreState, text: string) => void
setDraft: (draft: ConversationStoreState, text: DraftInput) => void
setView: (draft: ConversationStoreState, view: string) => void
openView: (draft: ConversationStoreState, view: string, focus: string) => void
completeViewRequest: (draft: ConversationStoreState) => void
@@ -22,7 +24,7 @@ export function createConversationStore(): EngineStoreHandle<ConversationStoreSt
init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }),
persist: CONVERSATION_STORE_KEY,
actions: {
setDraft: (d, text: string) => { d.draft = text },
setDraft: (d, text: DraftInput) => { d.draft = text },
setView: (d, view: string) => { d.view = view },
openView: (d, view: string, focus: string) => {
d.view = view
@@ -33,6 +35,26 @@ export function createConversationStore(): EngineStoreHandle<ConversationStoreSt
})
}
/**
* Read a Session's draft before its input source is published to React.
* @param sessionId - Session-scoped persistence suffix.
* @returns saved semantic content, or an empty document when storage is absent or invalid.
*/
export function readConversationDraft(sessionId: SessionId): DraftSnapshot {
const empty: DraftSnapshot = { text: '', references: [] }
if (typeof localStorage === 'undefined') return empty
try {
const raw = localStorage.getItem(`${CONVERSATION_STORE_KEY}.${sessionId}`)
if (raw === null) return empty
const stored: unknown = JSON.parse(raw)
if (typeof stored !== 'object' || stored === null || !('draft' in stored)) return empty
return parseStoredDraft(stored.draft) ?? empty
} catch (_error: unknown) {
// Unavailable browser storage or malformed JSON has no usable saved draft.
return empty
}
}
/**
* Read the persisted View preference before the Slot store is materialized.
* @param sessionId - Session-scoped persistence suffix.
@@ -20,6 +20,7 @@ import {
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { createConversationStore } from '../src/client/stores.ts'
import type { SessionInputShell } from '../src/client/input/facade.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
usePinnedBrowserLanguages('zh-CN')
@@ -192,7 +193,7 @@ describe('Conversation inject API', () => {
const b = await bench()
const { injected } = b.conversationApi(ROOT)
expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror', 'openView'])
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftPersistence', 'openView'])
expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
await b.runtime.dispose()
})
@@ -364,7 +365,7 @@ describe('Conversation inject API', () => {
expect(state.getSnapshot().draft).toBe('retry me')
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
const unbind = injected.bindDraftPersistence(draft => mirrored.push(draft.text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
@@ -544,6 +545,26 @@ describe('Conversation inject API', () => {
await b.runtime.dispose()
})
it('carries structured references through Workspace selection without flattening them', async () => {
const b = await bench()
const draft = {
text: 'carry @one.ts',
references: [{ source: 'reference', ref: '@one.ts', label: 'one.ts', appearance: 'file' as const,
clipboardText: '@one.ts', offset: 6, length: 7 }],
}
const shell = b.inputApi(ROOT).actions as SessionInputShell
shell.setDraft(draft)
const other = 'structured-target' as SessionId
await b.runtime.sessions.add({ id: other, session: {} })
b.connectWorkspace.mockResolvedValueOnce(other)
await b.residentApi(ROOT).selectWorkspace('workspace-structured' as WorkspaceId)
expect(b.inputApi(other).state.getSnapshot().draft).toBe(draft.text)
expect(b.inputApi(other).state.getSnapshot().occurrences).toMatchObject(draft.references)
expect(b.inputApi(ROOT).state.getSnapshot().draft).toBe('')
expect(b.inputApi(ROOT).state.getSnapshot().occurrences).toEqual([])
await b.runtime.dispose()
})
it('supports no-Session navigation and propagates Workspace connection failure', async () => {
const b = await bench()
b.connectWorkspace.mockResolvedValueOnce(ROOT)
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createConversationStore, readConversationViewPreference } from '../src/client/stores.ts'
import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts'
import { createConversationStore, readConversationDraft, readConversationViewPreference } from '../src/client/stores.ts'
const KEY = 'dsh.conversation'
@@ -9,6 +10,11 @@ beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('createConversationStore', () => {
it('owns draft, selected View, and one-shot View requests', () => {
const store = createConversationStore().create()
@@ -65,4 +71,94 @@ describe('createConversationStore', () => {
localStorage.setItem(`${KEY}.${sessionId}`, '{invalid')
expect(readConversationViewPreference(sessionId)).toBeNull()
})
it('restores structured references from the Session store and persists their removal', () => {
const sessionId = 'structured' as SessionId
const draft: DraftSnapshot = {
text: '🙂 @file @file',
references: [3, 9].map(offset => ({
source: 'reference', ref: '@file', label: 'file', appearance: 'file',
clipboardText: '@file', offset, length: 5, invalid: true,
})),
}
const store = createConversationStore().create(sessionId)
store.actions.setDraft(draft)
store.actions.setView('trajectory')
expect(JSON.parse(localStorage.getItem(`${KEY}.${sessionId}`)!)).toEqual({
draft, view: 'trajectory', viewRequest: null,
})
expect(readConversationDraft(sessionId)).toEqual(draft)
expect(createConversationStore().create(sessionId).getSnapshot().draft).toEqual(draft)
expect(readConversationDraft('another-session' as SessionId)).toEqual({ text: '', references: [] })
store.actions.setDraft({ text: '', references: [] })
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(createConversationStore().create(sessionId).getSnapshot().draft).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBe('trajectory')
})
it('reads a legacy draft without rewriting the saved record', () => {
const sessionId = 'legacy' as SessionId
const raw = JSON.stringify({ draft: '🙂 draft\nsecond line', view: 'chat' })
localStorage.setItem(`${KEY}.${sessionId}`, raw)
expect(readConversationDraft(sessionId)).toEqual({ text: '🙂 draft\nsecond line', references: [] })
expect(localStorage.getItem(`${KEY}.${sessionId}`)).toBe(raw)
})
it.each(['null', '42', '"text"', '[]', '{}', '{invalid'])('ignores unusable saved records: %s', (raw) => {
const sessionId = 'invalid-record' as SessionId
localStorage.setItem(`${KEY}.${sessionId}`, raw)
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBeNull()
expect(localStorage.getItem(`${KEY}.${sessionId}`)).toBe(raw)
})
it.each([
null,
42,
{ text: 'missing references' },
{ text: '@entry', references: [{
source: 'reference', ref: '@entry', label: 'entry', clipboardText: '@entry', offset: 1, length: 6,
}] },
])('ignores an invalid saved draft while retaining a usable View: %j', (draft) => {
const sessionId = 'invalid-draft' as SessionId
localStorage.setItem(`${KEY}.${sessionId}`, JSON.stringify({ draft, view: 'chat' }))
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBe('chat')
})
it.each([{}, { view: null }, { view: 7 }])('reads a draft independently of an unusable View: %j', (view) => {
const sessionId = 'invalid-view' as SessionId
localStorage.setItem(`${KEY}.${sessionId}`, JSON.stringify({ draft: 'saved', ...view }))
expect(readConversationDraft(sessionId)).toEqual({ text: 'saved', references: [] })
expect(readConversationViewPreference(sessionId)).toBeNull()
})
it('returns empty preferences for a missing record', () => {
const sessionId = 'missing' as SessionId
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBeNull()
})
it('returns empty preferences when browser storage is absent', () => {
vi.stubGlobal('localStorage', undefined)
try {
const sessionId = 'unavailable' as SessionId
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBeNull()
} finally { vi.unstubAllGlobals() }
})
it('returns empty preferences when browser storage denies access', () => {
const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('Storage access denied', 'SecurityError')
})
try {
const sessionId = 'denied' as SessionId
expect(readConversationDraft(sessionId)).toEqual({ text: '', references: [] })
expect(readConversationViewPreference(sessionId)).toBeNull()
expect(read.mock.calls).toEqual([[`${KEY}.${sessionId}`], [`${KEY}.${sessionId}`]])
} finally { read.mockRestore() }
})
})
@@ -0,0 +1,623 @@
// @vitest-environment jsdom
/** Structured draft import, persistence, and reference restoration through the real input shell. */
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
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 { 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 { TextRefNode } from '../src/client/input/editor/text-ref.ts'
import { SessionInputShell, type SessionInputDeps } from '../src/client/input/facade.ts'
type ReferenceContent = Omit<DraftReference, 'offset' | 'length'>
const file: ReferenceContent = {
source: 'reference', ref: '@src/main.ts', label: 'main.ts', appearance: 'file', clipboardText: '@src/main.ts',
}
const folder: ReferenceContent = {
source: 'reference', ref: '@src/', label: 'src/', appearance: 'folder', clipboardText: '@src/',
}
const session: ReferenceContent = {
source: 'reference', ref: '@[研究记录](dsh-session:c291cmNl)', label: '研究记录', appearance: 'session',
clipboardText: '@[研究记录](dsh-session:c291cmNl)',
}
function documentOf(...parts: readonly (string | ReferenceContent)[]): DraftSnapshot {
let text = ''
const references: DraftReference[] = []
for (const part of parts) {
if (typeof part === 'string') text += part
else {
references.push({ ...part, offset: text.length, length: part.clipboardText.length })
text += part.clipboardText
}
}
return { text, references }
}
function makeShell(overrides: Partial<SessionInputDeps> = {}): SessionInputShell {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const shell = new SessionInputShell({
actx: ctx,
defaultSink: () => Promise.resolve<SubmitOutcome>({ kind: 'success' }),
commandAttachments: {
serialize: () => Promise.resolve([]),
release: () => {},
unsupportedNotice: token => token,
},
...overrides,
})
onTestFinished(() => { shell.dispose() })
shell.refreshLexiconSubscription()
return shell
}
function chips(shell: SessionInputShell) {
return shell.editor.getEditorState().read(() => $nodesOfType(ReferenceChipNode).map(node => ({
source: node.getSource(), ref: node.getReference(), label: node.getLabel(),
appearance: node.getAppearance(), clipboardText: node.getTextContent(), invalid: node.isInvalid(),
})))
}
function textReferences(shell: SessionInputShell): string[] {
return shell.editor.getEditorState().read(() => $getRoot().getAllTextNodes()
.filter(node => node instanceof TextRefNode).map(node => node.getTextContent()))
}
function triggerProvider(lexicon: InputTriggerController['lexicon']): InputTriggerController {
return {
lexicon,
launcher: createSnapshotStore<string | null>(null),
track: () => {},
arbitrate: () => 'pass',
onSpace: () => false,
serializeReference: (_source, ref) => Promise.resolve(ref),
adjudicate: () => Promise.resolve(undefined),
openReference: () => false,
toggleSource: () => {},
}
}
function expectInitializationBlocked(shell: SessionInputShell): void {
const draft = shell.draftSnapshot
const attachmentIds = shell.snapshot.attachmentIds
const references = chips(shell)
const write = vi.fn<(value: DraftSnapshot) => void>()
const unbind = shell.bindDraftPersistence(write)
try {
expect(shell.requestDraftInitialization({ clearPreviousDraft: true })).toBe('blocked')
expect(shell.requestDraftInitialization({ prompt: 'replacement @src/', clearPreviousDraft: true })).toBe('blocked')
expect(shell.draftSnapshot).toBe(draft)
expect(shell.snapshot.attachmentIds).toBe(attachmentIds)
expect(chips(shell)).toEqual(references)
expect(write).not.toHaveBeenCalled()
} finally { unbind() }
}
describe('draft document values', () => {
it.each(['', 'plain text', 'first\nsecond\n', '\n\n', '🙂 中文 e\u0301\n第二行'])('round-trips plain text %j', (text) => {
const expected = { text, references: [] }
expect(resolveDraftInput(text)).toEqual(expected)
expect(parseStoredDraft(text)).toEqual(expected)
expect(parseStoredDraft(JSON.parse(JSON.stringify(expected)))).toEqual(expected)
const shell = makeShell()
shell.setDraft(text)
expect(shell.snapshot.draft).toBe(text)
expect(shell.draftSnapshot).toEqual(expected)
expect(chips(shell)).toEqual([])
})
it('copies a structured input independently of its caller', () => {
const reference = { ...file, offset: 0, length: file.clipboardText.length }
const input = { text: file.clipboardText, references: [reference] }
const resolved = resolveDraftInput(input)
expect(resolved).toEqual(input)
expect(resolved).not.toBe(input)
expect(resolved.references).not.toBe(input.references)
expect(resolved.references[0]).not.toBe(reference)
reference.label = 'changed by caller'
input.references.length = 0
expect(resolved.references).toEqual([{ ...file, offset: 0, length: file.clipboardText.length }])
})
it('exports repeated references without editor-local occurrence identities', () => {
const draft = documentOf('🙂 ', file, '\n', file)
const occurrences: Occurrence[] = draft.references.map((reference, index) => ({ ...reference, occurrenceId: index + 41 }))
const exported = snapshotDraft(draft.text, occurrences)
expect(exported).toEqual(draft)
expect(exported.references).toHaveLength(2)
expect(exported.references.every(reference => !('occurrenceId' in reference))).toBe(true)
expect(JSON.stringify(exported)).not.toContain('occurrenceId')
expect(parseStoredDraft(JSON.parse(JSON.stringify(exported)))).toEqual(draft)
})
it('accepts touching reference spans and references without optional metadata', () => {
const draft = documentOf(file, folder, { source: 'custom', ref: 'entry', label: 'Entry', clipboardText: '@entry' })
expect(parseStoredDraft(JSON.parse(JSON.stringify(draft)))).toEqual(draft)
const shell = makeShell()
shell.setDraft(draft)
expect(shell.draftSnapshot).toEqual(draft)
expect(chips(shell)).toHaveLength(3)
})
it('decodes explicit false metadata and discards persisted editor-local fields', () => {
const draft = documentOf({ ...file, invalid: false })
const stored = {
...draft,
revision: 17,
references: draft.references.map(reference => ({ ...reference, occurrenceId: 42, nodeKey: 'old-node' })),
}
const parsed = parseStoredDraft(JSON.parse(JSON.stringify(stored)))
expect(parsed).toEqual(draft)
expect(parsed?.references[0]).toHaveProperty('invalid', false)
expect(parsed?.references[0]).not.toHaveProperty('occurrenceId')
expect(parsed?.references[0]).not.toHaveProperty('nodeKey')
})
const valid = documentOf(file)
const reference = { ...file, offset: 0, length: file.clipboardText.length }
it.each<{ name: string; value: unknown }>([
{ name: 'null', value: null },
{ name: 'missing value', value: undefined },
{ name: 'scalar number', value: 7 },
{ name: 'array root', value: [] },
{ name: 'missing text', value: { references: [] } },
{ name: 'missing references', value: { text: '' } },
{ name: 'non-string text', value: { text: 3, references: [] } },
{ name: 'non-array references', value: { text: '', references: {} } },
{ name: 'null reference', value: { ...valid, references: [null] } },
{ name: 'negative offset', value: { ...valid, references: [{ ...reference, offset: -1 }] } },
{ name: 'fractional offset', value: { ...valid, references: [{ ...reference, offset: 0.5 }] } },
{ name: 'empty span', value: { ...valid, references: [{ ...reference, length: 0 }] } },
{ name: 'fractional length', value: { ...valid, references: [{ ...reference, length: 1.5 }] } },
{ name: 'span beyond text', value: { ...valid, references: [{ ...reference, offset: 1 }] } },
{ name: 'length differing from clipboard text', value: { ...valid, references: [{ ...reference, length: 1 }] } },
{ name: 'mismatched clipboard text', value: { text: 'x'.repeat(valid.text.length), references: [reference] } },
{ name: 'overlapping references', value: { ...valid, references: [reference, { ...reference }] } },
{ name: 'unordered references', value: { text: `${valid.text} ${valid.text}`, references: [
{ ...reference, offset: valid.text.length + 1 }, reference,
] } },
{ name: 'unsupported appearance', value: { ...valid, references: [{ ...reference, appearance: 'image' }] } },
{ name: 'non-boolean invalid flag', value: { ...valid, references: [{ ...reference, invalid: 'true' }] } },
{ name: 'non-string source', value: { ...valid, references: [{ ...reference, source: 1 }] } },
])('rejects stored $name', ({ value }) => {
expect(parseStoredDraft(value)).toBeUndefined()
})
})
describe('draft documents in the input shell', () => {
it('imports file, folder, session, repeated, and invalid references on the first document', () => {
const draft = documentOf('🙂 文件 ', file, '\n目录 ', folder, '\n会话 ', session, ' 再次 ', file, ' ', {
...session, invalid: true,
})
const shell = makeShell()
shell.setDraft(draft)
expect(shell.draftSnapshot).toEqual(draft)
expect(shell.snapshot.draft).toBe(draft.text)
expect(chips(shell)).toEqual(draft.references.map(reference => ({
source: reference.source, ref: reference.ref, label: reference.label,
appearance: reference.appearance, clipboardText: reference.clipboardText, invalid: reference.invalid ?? false,
})))
expect(new Set(shell.snapshot.occurrences.map(occurrence => occurrence.occurrenceId)).size).toBe(5)
})
it('restores semantic references after JSON persistence into another shell', () => {
const original = makeShell()
original.setDraft(documentOf(file, '\n', folder, '\n', session, ' ', { ...file, invalid: true }))
const stored = parseStoredDraft(JSON.parse(JSON.stringify(original.draftSnapshot)))
expect(stored).toBeDefined()
if (stored === undefined) throw new Error('Expected a stored draft')
const restored = makeShell()
restored.setDraft(stored)
expect(restored.draftSnapshot).toEqual(original.draftSnapshot)
expect(chips(restored)).toEqual(chips(original))
})
it.each([
{ name: 'before the first reference', prefix: '\uE100\uFFFC🙂 before ', middle: ' between ' },
{ name: 'between references', prefix: '🙂 before ', middle: '\uE11D between \uFFFC' },
])('sanitizes reserved placeholders $name and recalculates reference offsets', ({ prefix, middle }) => {
const shell = makeShell()
const input = documentOf(prefix, file, middle, session, ' after')
shell.setDraft(input)
const expected = documentOf('🙂 before ', file, ' between ', session, ' after')
expect(shell.snapshot.draft).toBe(expected.text)
expect(shell.draftSnapshot).toEqual(expected)
expect(shell.draftSnapshot.references.map(reference => reference.offset)).toEqual(
expected.references.map(reference => reference.offset),
)
expect(shell.draftSnapshot.references.map(reference => reference.ref)).toEqual(
input.references.map(reference => reference.ref),
)
expect(chips(shell)).toMatchObject([
{ source: file.source, ref: file.ref, clipboardText: file.clipboardText },
{ source: session.source, ref: session.ref, clipboardText: session.clipboardText },
])
})
it('keeps snapshot identity stable through reads and selection-only editor updates', () => {
const shell = makeShell()
shell.setDraft(documentOf('before ', file))
const draft = shell.draftSnapshot
expect(shell.draftSnapshot).toBe(draft)
shell.editor.update(() => { $getRoot().selectStart() }, { discrete: true })
expect(shell.draftSnapshot).toBe(draft)
})
it('publishes reference-only changes even when the text stays equal', () => {
const shell = makeShell()
const write = vi.fn<(draft: DraftSnapshot) => void>()
shell.bindDraftPersistence(write)
const first = documentOf(file)
shell.setDraft(first)
const previous = shell.draftSnapshot
const changed = documentOf({ ...file, ref: '@src/other.ts', label: 'renamed', invalid: true })
shell.setDraft(changed)
expect(shell.snapshot.draft).toBe(first.text)
expect(shell.draftSnapshot).toEqual(changed)
expect(shell.draftSnapshot).not.toBe(previous)
expect(write).toHaveBeenLastCalledWith(changed)
expect(chips(shell)).toMatchObject([{ ref: '@src/other.ts', label: 'renamed', invalid: true }])
shell.setDraft(first.text)
expect(shell.draftSnapshot).toEqual({ text: first.text, references: [] })
expect(chips(shell)).toEqual([])
expect(write).toHaveBeenLastCalledWith({ text: first.text, references: [] })
})
it('binds persistence without saving and explicitly saves the current document', () => {
const shell = makeShell()
shell.setDraft(documentOf('read ', session))
const write = vi.fn<(draft: DraftSnapshot) => void>()
const unbind = shell.bindDraftPersistence(write)
expect(write).not.toHaveBeenCalled()
shell.persistCurrentDraft()
expect(write).toHaveBeenLastCalledWith(shell.draftSnapshot)
shell.actions.persistDraft()
expect(write).toHaveBeenCalledTimes(2)
unbind()
shell.setDraft('edited without a view')
expect(write).toHaveBeenCalledTimes(2)
const nextWrite = vi.fn<(draft: DraftSnapshot) => void>()
shell.bindDraftPersistence(nextWrite)
expect(nextWrite).not.toHaveBeenCalled()
shell.actions.persistDraft()
expect(nextWrite).toHaveBeenLastCalledWith({ text: 'edited without a view', references: [] })
})
it('keeps the replacement persistence writer when an earlier binding is disposed', () => {
const shell = makeShell()
const first = vi.fn<(draft: DraftSnapshot) => void>()
const second = vi.fn<(draft: DraftSnapshot) => void>()
shell.actions.persistDraft()
const unbindFirst = shell.bindDraftPersistence(first)
const unbindSecond = shell.bindDraftPersistence(second)
unbindFirst()
const draft = documentOf('retained ', file)
shell.setDraft(draft)
shell.actions.persistDraft()
expect(first).not.toHaveBeenCalled()
expect(second.mock.calls).toEqual([[draft], [draft]])
unbindSecond()
shell.actions.persistDraft()
expect(second).toHaveBeenCalledTimes(2)
})
it('applies initial text without waiting for a view or inferring reference chips', () => {
const shell = makeShell()
const prompt = `read ${file.clipboardText}`
expect(shell.requestDraftInitialization({ prompt })).toBe('applied')
expect(shell.draftSnapshot).toEqual({ text: prompt, references: [] })
expect(chips(shell)).toEqual([])
})
it('preserves existing text or attachments unless target text is explicitly cleared', () => {
const shell = makeShell()
const old = documentOf('existing ', session)
shell.setDraft(old)
expect(shell.requestDraftInitialization({ prompt: 'replacement' })).toBe('preserved')
expect(shell.draftSnapshot).toEqual(old)
const attachment = 'draft-image' as DraftAttachmentId
shell.addAttachments([attachment])
shell.setDraft('')
expect(shell.requestDraftInitialization({ prompt: 'replacement' })).toBe('preserved')
expect(shell.snapshot.draft).toBe('')
expect(shell.requestDraftInitialization({ prompt: 'replacement', clearPreviousDraft: true })).toBe('applied')
expect(shell.snapshot.draft).toBe('replacement')
expect(shell.snapshot.attachmentIds).toEqual([attachment])
expect(shell.requestDraftInitialization({ clearPreviousDraft: true })).toBe('applied')
expect(shell.snapshot.draft).toBe('')
expect(shell.snapshot.attachmentIds).toEqual([attachment])
})
it('persists clearing so a persistence rebind and a new shell do not restore references', () => {
const shell = makeShell()
shell.setDraft(documentOf(file, ' ', session))
const write = vi.fn<(draft: DraftSnapshot) => void>()
const unbind = shell.bindDraftPersistence(write)
shell.persistCurrentDraft()
expect(shell.requestDraftInitialization({ clearPreviousDraft: true })).toBe('applied')
expect(write).toHaveBeenLastCalledWith({ text: '', references: [] })
unbind()
shell.bindDraftPersistence(write)
shell.persistCurrentDraft()
const saved = write.mock.calls.at(-1)?.[0]
const parsed = parseStoredDraft(JSON.parse(JSON.stringify(saved)))
if (parsed === undefined) throw new Error('Expected the cleared document')
const restored = makeShell()
restored.setDraft(parsed)
expect(restored.draftSnapshot).toEqual({ text: '', references: [] })
expect(chips(restored)).toEqual([])
})
it('leaves a document unchanged when no initialization option changes text', () => {
const shell = makeShell()
shell.setDraft(documentOf(file))
const draft = shell.draftSnapshot
expect(shell.requestDraftInitialization({})).toBe('preserved')
expect(shell.requestDraftInitialization({ clearPreviousDraft: false })).toBe('preserved')
expect(shell.draftSnapshot).toBe(draft)
})
it('rejects initialization after disposal', () => {
const shell = makeShell()
shell.setDraft('keep this')
shell.dispose()
expect(shell.requestDraftInitialization({ prompt: 'replace', clearPreviousDraft: true })).toBe('blocked')
expect(shell.draftSnapshot).toEqual({ text: 'keep this', references: [] })
})
it('refuses draft initialization while slash adjudication retains the document', async () => {
const adjudication = Promise.withResolvers<PickOutcome>()
const started = Promise.withResolvers<undefined>()
const lexicon = createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map())
const provider: InputTriggerController = {
...triggerProvider(lexicon),
adjudicate: () => { started.resolve(undefined); return adjudication.promise },
}
const shell = makeShell({ inputTriggers: () => provider })
try {
const draft = documentOf('/pending ', file)
shell.setDraft(draft)
shell.addAttachments(['pending-image' as DraftAttachmentId])
shell.submit()
await started.promise
expect(shell.snapshot.phase).toBe('adjudicating')
expectInitializationBlocked(shell)
adjudication.reject(new Error('catalog unavailable'))
await vi.waitFor(() => {
expect(shell.snapshot.phase).toBe('plain')
expect(shell.notices.getSnapshot()?.text).toBe('catalog unavailable')
})
expect(shell.draftSnapshot).toEqual(draft)
} finally {
shell.dispose()
adjudication.resolve(undefined)
await adjudication.promise.catch(() => undefined)
}
})
it('refuses draft initialization while a claimed command is submitting', async () => {
const outcome = Promise.withResolvers<SubmitOutcome>()
const started = Promise.withResolvers<undefined>()
const shell = makeShell()
try {
const draft = documentOf('/pending ', session)
shell.setDraft(draft)
expect(shell.beginCommand({
name: 'pending', token: '/pending ', attachments: true,
submit: () => { started.resolve(undefined); return outcome.promise },
}, { start: 0, end: '/pending '.length, draftRev: shell.snapshot.draftRev })).toBe(true)
shell.addAttachments(['command-image' as DraftAttachmentId])
shell.submit()
await started.promise
expect(shell.snapshot.phase).toBe('submitting')
expectInitializationBlocked(shell)
outcome.resolve({ kind: 'error', text: 'command refused' })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('claimed') })
expect(shell.draftSnapshot).toEqual(draft)
} finally {
shell.dispose()
outcome.resolve({ kind: 'error' })
await outcome.promise
}
})
it.each(['detached default-send', 'attachment'] as const)(
'refuses draft initialization during a %s flight despite its editable phase', async (kind) => {
const outcome = Promise.withResolvers<SubmitOutcome>()
const started = Promise.withResolvers<undefined>()
const sink = vi.fn<SessionInputDeps['defaultSink']>(() => { started.resolve(undefined); return outcome.promise })
const shell = makeShell({ defaultSink: sink })
try {
const sentAttachment = 'sent-image' as DraftAttachmentId
if (kind === 'attachment') shell.addAttachments([sentAttachment])
else shell.setDraft('outbound message')
shell.submit()
await started.promise
expect(sink).toHaveBeenCalledOnce()
expect(sink.mock.calls[0]?.[1]).toEqual(kind === 'attachment' ? [sentAttachment] : [])
shell.setDraft(documentOf('next message ', file))
shell.addAttachments(['next-image' as DraftAttachmentId])
expect(shell.snapshot.phase).toBe('plain')
expectInitializationBlocked(shell)
outcome.resolve({ kind: 'error', text: 'flight rejected' })
await vi.waitFor(() => { expect(shell.notices.getSnapshot()?.text).toBe('flight rejected') })
expect(shell.requestDraftInitialization({ prompt: 'after settlement', clearPreviousDraft: true })).toBe('applied')
} finally {
shell.dispose()
outcome.resolve({ kind: 'error' })
await outcome.promise
}
},
)
it('matches a delayed skill lexicon against the current editable document without changing references', async () => {
const lexicon = createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map())
const provider = triggerProvider(lexicon)
const shell = makeShell({ inputTriggers: () => provider })
const previous = documentOf('/older ', file)
shell.setDraft(previous)
const current = documentOf('/current ', file)
expect(shell.actions.insertText('/current', {
start: 0, end: '/older'.length, draftRev: shell.snapshot.draftRev,
})).toBe(true)
const draft = shell.draftSnapshot
expect(textReferences(shell)).toEqual([])
lexicon.set(new Map([['/', ['older']]]))
await vi.waitFor(() => { expect(textReferences(shell)).toEqual([]) })
expect(shell.draftSnapshot).toBe(draft)
lexicon.set(new Map([['/', ['older', 'current']]]))
await vi.waitFor(() => { expect(textReferences(shell)).toEqual(['/current']) })
expect(shell.draftSnapshot).toBe(draft)
expect(shell.snapshot.phase).toBe('plain')
expect(chips(shell)).toHaveLength(1)
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(previous) })
// History retains the decoration state saved before the catalog was available.
expect(textReferences(shell)).toEqual([])
lexicon.set(new Map([['/', ['older', 'current', 'edited']]]))
await vi.waitFor(() => { expect(textReferences(shell)).toEqual(['/older']) })
expect(shell.draftSnapshot).toEqual(previous)
shell.editor.dispatchCommand(REDO_COMMAND, undefined)
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(current) })
expect(textReferences(shell)).toEqual(['/current'])
expect(shell.actions.insertText('/edited', {
start: 0, end: '/current'.length, draftRev: shell.snapshot.draftRev,
})).toBe(true)
expect(shell.draftSnapshot).toEqual(documentOf('/edited ', file))
lexicon.set(new Map([['/', ['edited']]]))
await vi.waitFor(() => { expect(textReferences(shell)).toEqual(['/edited']) })
expect(shell.draftSnapshot).toEqual(documentOf('/edited ', file))
expect(shell.snapshot.phase).toBe('plain')
expect(chips(shell)).toMatchObject([{ ref: file.ref, appearance: 'file' }])
})
it('restores and persists failed structured sends in submission order after reversed settlement', async () => {
const first = Promise.withResolvers<SubmitOutcome>()
const second = Promise.withResolvers<SubmitOutcome>()
const provider = triggerProvider(createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map()))
const sink = vi.fn<SessionInputDeps['defaultSink']>()
.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const shell = makeShell({ inputTriggers: () => provider, defaultSink: sink })
const write = vi.fn<(draft: DraftSnapshot) => void>()
shell.bindDraftPersistence(write)
const firstDraft = documentOf('first 🙂 ', file)
const secondDraft = documentOf('second ', session)
try {
shell.setDraft(firstDraft)
shell.submit()
shell.setDraft(secondDraft)
shell.submit()
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
expect(shell.draftSnapshot).toEqual({ text: '', references: [] })
second.resolve({ kind: 'error', text: 'second rejected' })
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(secondDraft) })
expect(write).toHaveBeenLastCalledWith(secondDraft)
first.resolve({ kind: 'error', text: 'first rejected' })
const expected = documentOf('first 🙂 ', file, '\n\nsecond ', session)
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(expected) })
expect(write).toHaveBeenLastCalledWith(expected)
expect(parseStoredDraft(JSON.parse(JSON.stringify(write.mock.calls.at(-1)?.[0])))).toEqual(expected)
expect(chips(shell)).toMatchObject([{ ref: file.ref }, { ref: session.ref }])
expect(shell.requestDraftInitialization({ prompt: 'next', clearPreviousDraft: true })).toBe('applied')
} finally {
shell.dispose()
first.resolve({ kind: 'error' })
second.resolve({ kind: 'error' })
await Promise.all([first.promise, second.promise])
await setImmediate()
}
})
it.each(['reject', 'dispose then resolve', 'dispose then reject'] as const)(
'contains pending reference serialization when it must %s', async (settlement) => {
const serialization = Promise.withResolvers<string>()
const started = Promise.withResolvers<AbortSignal>()
const provider: InputTriggerController = {
...triggerProvider(createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map())),
serializeReference: (_source, _ref, signal) => {
started.resolve(signal)
return serialization.promise
},
}
const sink = vi.fn<SessionInputDeps['defaultSink']>(() => Promise.resolve({ kind: 'success' }))
const shell = makeShell({ inputTriggers: () => provider, defaultSink: sink })
const write = vi.fn<(draft: DraftSnapshot) => void>()
shell.bindDraftPersistence(write)
const draft = documentOf('pending ', file)
const attachment = 'pending-serialization-image' as DraftAttachmentId
try {
shell.setDraft(draft)
shell.addAttachments([attachment])
shell.submit()
const signal = await started.promise
expect(signal.aborted).toBe(false)
expect(shell.snapshot.phase).toBe('plain')
expectInitializationBlocked(shell)
shell.bindDraftPersistence(write)
if (settlement === 'reject') {
serialization.reject('reference unavailable')
await vi.waitFor(() => { expect(shell.notices.getSnapshot()?.text).toBe('reference unavailable') })
expect(shell.draftSnapshot).toEqual(draft)
expect(shell.snapshot.attachmentIds).toEqual([attachment])
expect(write).toHaveBeenLastCalledWith(draft)
} else {
expect(shell.dispose()).toEqual([attachment])
expect(signal.aborted).toBe(true)
const disposedDraft = shell.draftSnapshot
const writes = write.mock.calls.length
if (settlement === 'dispose then resolve') serialization.resolve('resolved reference')
else serialization.reject('late reference failure')
await serialization.promise.catch(() => undefined)
// The shell exposes no settlement promise; this checkpoint drains its queued continuations.
await setImmediate()
expect(shell.draftSnapshot).toBe(disposedDraft)
expect(shell.notices.getSnapshot()).toBeNull()
expect(write).toHaveBeenCalledTimes(writes)
}
expect(sink).not.toHaveBeenCalled()
} finally {
shell.dispose()
serialization.resolve('settled during teardown')
await serialization.promise.catch(() => undefined)
await setImmediate()
}
},
)
})
describe('plain-text reference catalogs', () => {
it('leaves named tokens plain until their own catalog includes them', () => {
const unavailable = new Map<'/' | '@', readonly string[]>()
expect(scanTextRefs('', unavailable)).toEqual([])
expect(scanTextRefs('/plan /plan.md /plan/path /plan。 x/plan @person', unavailable)).toEqual([])
expect(scanTextRefs('/plan @person', new Map([['/', []]]))).toEqual([])
expect(scanTextRefs('/plan @person', new Map([['@', ['person']]]))).toEqual([
{ start: 6, end: 13, trigger: '@' },
])
expect(scanTextRefs('/plan @person', new Map([['/', ['plan']]]))).toEqual([
{ start: 0, end: 5, trigger: '/' },
])
})
it('orders folder and catalog references without overlapping ranges', () => {
const text = '@src/ /plan @other/ @src/'
expect(scanTextRefs(text, new Map([['@', ['src']], ['/', ['plan']]]))).toEqual([
{ start: 0, end: 4, trigger: '@' },
{ start: 6, end: 11, trigger: '/' },
{ start: 12, end: 19, trigger: '@' },
{ start: 20, end: 24, trigger: '@' },
])
expect(scanTextRefs('@"some folder/ trailing', new Map())).toEqual([
{ start: 0, end: 14, trigger: '@' },
])
})
})
@@ -0,0 +1,309 @@
// @vitest-environment jsdom
/** InputHub restores drafts before views and follows Session-owned lexicon subscriptions. */
import { randomUUID } from 'node:crypto'
import { Context, Service } from '@deepseek-ai/cordis'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { makeTranslate, TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { $nodesOfType } from 'lexical'
import { expect, it, onTestFinished, vi } from 'vitest'
import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts'
import type { InputTriggerController } from '../src/client/contract/input.ts'
import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx'
import { TextRefNode } from '../src/client/input/editor/text-ref.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { zh } from '../src/client/locales.ts'
const mention = '@src/main.ts'
const saved: DraftSnapshot = {
text: `/saved ${mention}`,
references: [{
source: 'reference', ref: mention, label: 'main.ts', appearance: 'file', clipboardText: mention,
offset: '/saved '.length, length: mention.length,
}],
}
async function storedSession(draft: unknown) {
const id = SessionId(`draft-hub-${randomUUID()}`)
const key = `dsh.conversation.${id}`
const previous = localStorage.getItem(key)
onTestFinished(() => {
if (previous === null) localStorage.removeItem(key)
else localStorage.setItem(key, previous)
})
const raw = JSON.stringify({ draft, view: 'trajectory', viewRequest: null })
localStorage.setItem(key, raw)
const ctx = new Context()
const sessions = new TestSessions(async (operation) => { await operation() }, ctx)
onTestFinished(async () => {
await sessions.disposeScopes()
await ctx.fiber.dispose()
})
ctx.provide('sessions', sessions)
await sessions.add({ id })
const reference = sessions.retain(id)
onTestFinished(() => { reference.release() })
await reference.ready
const binding = reference.binding
const hub = new InputHub(ctx, makeTranslate(zh, {}))
return { ctx, sessions, id, key, raw, reference, binding, hub }
}
async function fixture(draft: unknown) {
const b = await storedSession(draft)
return { ...b, shell: b.hub.shellFor(b.binding) }
}
function textReferences(shell: SessionInputShell): string[] {
return shell.editor.getEditorState().read(() => $nodesOfType(TextRefNode).map(node => node.getTextContent()))
}
function catalog(initial: readonly string[]) {
const store = createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map([['/', initial]]))
const listeners = new Set<() => void>()
let subscriptions = 0
let releases = 0
const source: InputTriggerController['lexicon'] = {
getSnapshot: () => store.getSnapshot(),
subscribe: (listener) => {
subscriptions++
listeners.add(listener)
const stop = store.subscribe(listener)
return () => {
releases++
listeners.delete(listener)
stop()
}
},
}
return {
source,
set: (names: readonly string[]) => { store.set(new Map([['/', names]])) },
get subscribers() { return listeners.size },
get subscriptions() { return subscriptions },
get releases() { return releases },
}
}
function controller(lexicon: () => InputTriggerController['lexicon']): InputTriggerController {
return {
get lexicon() { return lexicon() },
launcher: createSnapshotStore<string | null>(null),
track: () => {},
arbitrate: () => 'pass',
onSpace: () => false,
serializeReference: (_source, ref) => Promise.resolve(ref),
adjudicate: () => Promise.resolve(undefined),
openReference: () => false,
toggleSource: () => {},
}
}
class TriggerProvider extends Service {
constructor(ctx: Context, private readonly config: {
readonly controller: InputTriggerController
readonly initialize?: () => void
}) {
super(ctx, 'inputTriggers')
}
sessionOf(): InputTriggerController {
this.config.initialize?.()
return this.config.controller
}
}
it('leaves trigger subscription to lifecycle activation rather than construction or draft writes', async () => {
const b = await storedSession(saved)
const source = catalog(['saved'])
const triggers = controller(() => source.source)
const resolve = vi.fn(() => triggers)
const shell = new SessionInputShell({
actx: b.binding.ctx,
inputTriggers: resolve,
defaultSink: () => Promise.resolve({ kind: 'success' }),
commandAttachments: {
serialize: () => Promise.resolve([]),
release: () => {},
unsupportedNotice: token => token,
},
})
onTestFinished(() => { shell.dispose() })
expect(resolve).not.toHaveBeenCalled()
shell.setDraft(saved)
expect(source.subscribers).toBe(0)
expect(shell.draftSnapshot).toEqual(saved)
shell.refreshLexiconSubscription()
expect(source.subscribers).toBe(1)
expect(shell.draftSnapshot).toEqual(saved)
})
it('reuses one shell during synchronous catalog retention and restores its initial draft before returning', async () => {
const b = await storedSession(saved)
const source = catalog(['saved'])
const retained: SessionReference[] = []
onTestFinished(() => { for (const reference of retained) reference.release() })
const reentered: SessionInputShell[] = []
let warming = false
let initialized = false
let observed = false
const stop = b.sessions.retainInfo(b.id).subscribe(() => {
// One reentry exposes duplicate shells without exhausting the stack on a regression.
if (!warming || observed) return
observed = true
reentered.push(b.hub.shellFor(b.binding))
})
onTestFinished(stop)
const provider = b.ctx.plugin(TriggerProvider, {
controller: controller(() => source.source),
initialize: () => {
if (initialized) return
initialized = true
warming = true
try {
retained.push(b.sessions.retain(b.id))
} finally { warming = false }
},
})
await provider.await()
const first = b.hub.shellFor(b.binding)
const initial = first.draftSnapshot
expect(initial).toEqual(saved)
expect(first.editor.getEditorState().read(() => $nodesOfType(ReferenceChipNode).length)).toBe(1)
await vi.waitFor(() => {
expect(observed).toBe(true)
expect(source.subscribers).toBe(1)
})
expect(reentered).toHaveLength(1)
expect(retained).toHaveLength(1)
expect(retained[0]?.binding).toBe(b.binding)
expect(new Set([first, ...reentered, b.hub.shellFor(b.binding)]).size).toBe(1)
expect(source.subscribers).toBe(1)
expect(first.draftSnapshot).toBe(initial)
expect(reentered[0]?.draftSnapshot).toBe(first.draftSnapshot)
expect(first.editor.getEditorState().read(() => $nodesOfType(ReferenceChipNode).length)).toBe(1)
expect(localStorage.getItem(b.key)).toBe(b.raw)
})
it('restores legacy text before a view and reuses the live draft on later shell lookups', async () => {
const b = await fixture('/saved legacy\n🙂 中文')
expect(b.shell.draftSnapshot).toEqual({ text: '/saved legacy\n🙂 中文', references: [] })
expect(textReferences(b.shell)).toEqual([])
expect(localStorage.getItem(b.key)).toBe(b.raw)
b.shell.setDraft('edited before mounting a view')
expect(b.hub.shellFor(b.binding)).toBe(b.shell)
expect(b.hub.for(b.binding.ctx)).toBe(b.shell)
expect(b.shell.draftSnapshot).toEqual({ text: 'edited before mounting a view', references: [] })
expect(localStorage.getItem(b.key)).toBe(b.raw)
})
it('imports reference chips on the first Hub shell and preserves them without an explicit clear', async () => {
const b = await fixture(saved)
expect(b.shell.draftSnapshot).toEqual(saved)
expect(textReferences(b.shell)).toEqual([])
expect(b.shell.editor.getEditorState().read(() => $nodesOfType(ReferenceChipNode).length)).toBe(1)
expect(b.hub.requestDraftInitialization(b.binding, { prompt: 'new text' })).toBe('preserved')
expect(b.shell.draftSnapshot).toEqual(saved)
expect(b.hub.requestDraftInitialization(b.binding, { clearPreviousDraft: true })).toBe('applied')
expect(b.hub.shellFor(b.binding).draftSnapshot).toEqual({ text: '', references: [] })
})
it('subscribes when a provider arrives and disconnects each withdrawn provider', async () => {
const b = await fixture(saved)
expect(b.shell.actions.insertText('/current', {
start: 0, end: '/saved'.length, draftRev: b.shell.snapshot.draftRev,
})).toBe(true)
const current = b.shell.draftSnapshot
expect(textReferences(b.shell)).toEqual([])
const first = catalog([])
const firstProvider = b.ctx.plugin(TriggerProvider, { controller: controller(() => first.source) })
await firstProvider.await()
await vi.waitFor(() => {
expect(first.subscribers).toBe(1)
expect(textReferences(b.shell)).toEqual([])
})
expect(b.shell.draftSnapshot).toBe(current)
first.set(['current'])
await vi.waitFor(() => { expect(textReferences(b.shell)).toEqual(['/current']) })
await firstProvider.dispose()
await vi.waitFor(() => {
expect(first.subscribers).toBe(0)
expect(textReferences(b.shell)).toEqual([])
})
expect(first.releases).toBe(first.subscriptions)
expect(b.sessions.binding(b.id)).toBe(b.binding)
expect(b.hub.shellFor(b.binding)).toBe(b.shell)
const second = catalog(['saved'])
const secondProvider = b.ctx.plugin(TriggerProvider, { controller: controller(() => second.source) })
await secondProvider.await()
await vi.waitFor(() => {
expect(second.subscribers).toBe(1)
expect(textReferences(b.shell)).toEqual([])
})
first.set(['current'])
expect(first.subscribers).toBe(0)
expect(b.shell.draftSnapshot).toBe(current)
second.set(['current'])
await vi.waitFor(() => { expect(textReferences(b.shell)).toEqual(['/current']) })
await secondProvider.dispose()
await vi.waitFor(() => {
expect(second.subscribers).toBe(0)
expect(textReferences(b.shell)).toEqual([])
})
expect(second.releases).toBe(second.subscriptions)
expect(b.shell.draftSnapshot).toBe(current)
expect(b.sessions.binding(b.id)).toBe(b.binding)
expect(b.shell.requestDraftInitialization({})).toBe('preserved')
})
it('reconnects a replacement lexicon source on an explicit subscription refresh', async () => {
const b = await fixture(saved)
const first = catalog(['saved'])
const second = catalog([])
let current = first.source
const provider = b.ctx.plugin(TriggerProvider, { controller: controller(() => current) })
await provider.await()
await vi.waitFor(() => { expect(first.subscribers).toBe(1) })
const draft = b.shell.draftSnapshot
const subscriptions = first.subscriptions
b.shell.refreshLexiconSubscription()
b.shell.refreshLexiconSubscription()
expect(first.subscriptions).toBe(subscriptions)
expect(first.releases).toBe(0)
current = second.source
b.shell.refreshLexiconSubscription()
await vi.waitFor(() => {
expect(first.subscribers).toBe(0)
expect(second.subscribers).toBe(1)
expect(textReferences(b.shell)).toEqual([])
})
expect(first.releases).toBe(first.subscriptions)
second.set(['saved'])
await vi.waitFor(() => { expect(textReferences(b.shell)).toEqual(['/saved']) })
expect(b.shell.draftSnapshot).toBe(draft)
expect(draft.references).toEqual(saved.references)
})
it('unsubscribes the lexicon and refuses the old binding after Session disposal', async () => {
const b = await fixture(saved)
const source = catalog(['saved'])
const provider = b.ctx.plugin(TriggerProvider, { controller: controller(() => source.source) })
await provider.await()
await vi.waitFor(() => { expect(source.subscribers).toBe(1) })
b.reference.release()
await b.sessions.disposeScopes()
expect(source.subscribers).toBe(0)
expect(source.releases).toBe(source.subscriptions)
const draft = b.shell.draftSnapshot
b.shell.refreshLexiconSubscription()
source.set([])
expect(source.subscribers).toBe(0)
expect(source.releases).toBe(source.subscriptions)
expect(b.shell.draftSnapshot).toBe(draft)
expect(b.shell.requestDraftInitialization({ prompt: 'late edit', clearPreviousDraft: true })).toBe('blocked')
expect(() => b.hub.requestDraftInitialization(b.binding, { prompt: 'late edit' })).toThrow('retained Session binding')
})
@@ -40,7 +40,7 @@ describe('reference submission', () => {
defaultSink: vi.fn(),
commandAttachments,
})
first.bindMirror(mirror)
first.bindDraftPersistence((draft) => { mirror(draft.text) })
first.setDraft('@res')
expect(first.insertReference({
source: 'reference',
@@ -136,6 +136,10 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG)))
const release = vi.fn()
const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
actx.effect(() => {
shell.refreshLexiconSubscription()
return () => { shell.dispose() }
}, 'test input shell')
// The hub's listener wiring, verbatim.
actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined)
@@ -354,8 +358,8 @@ describe('scenario H: backspace breaks the token', () => {
})
})
describe('scenario: reference decoration lights up when the lexicon settles', () => {
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
describe('scenario: reference decoration follows the current lexicon', () => {
it('decorates only the current text when its catalog arrives and clears decoration for an empty catalog', async () => {
let roll: readonly string[] | undefined
let notify: (() => void) | undefined
const b = await scopedBench((inputTriggers) => {
@@ -370,17 +374,32 @@ describe('scenario: reference decoration lights up when the lexicon settles', ()
},
} as never)
})
// Typed before the catalog settled: a plain token, no decoration.
b.type('/older now')
expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull()
b.type('/deploy now')
const draft = b.shell.draftSnapshot
expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull()
// The catalog settles (ui-skill's settle path fires the same notification).
act(() => {
roll = ['deploy']
roll = ['older']
notify?.()
})
act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued re-scan
const mark = b.view.container.querySelector('[data-composer-text-ref]')
expect(mark?.textContent).toBe('/deploy')
expect(b.controller.lexicon.getSnapshot().get('/')).toEqual(['older'])
expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull()
act(() => {
roll = ['older', 'deploy']
notify?.()
})
await vi.waitFor(() => {
expect(b.view.container.querySelector('[data-composer-text-ref]')?.textContent).toBe('/deploy')
})
expect(b.shell.draftSnapshot).toBe(draft)
act(() => {
roll = []
notify?.()
})
await vi.waitFor(() => { expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() })
expect(b.shell.draftSnapshot).toBe(draft)
expect(b.shell.snapshot.draft).toBe('/deploy now')
})
})
@@ -178,6 +178,7 @@ function mount(
const store = createConversationStore().create()
store.actions.setDraft('ordinary draft')
const { wiring, sink } = fakeWiring()
wiring.setDraft(store.store.getSnapshot().draft)
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
@@ -257,7 +258,7 @@ function mount(
useStore={bindSnapshotSelector(store)}
actions={store.actions}
renderSlot={renderSlot as never}
bindDraftMirror={write => wiring.bindMirror(write)}
bindDraftPersistence={write => wiring.bindDraftPersistence(write)}
openView={(view, focus) => { store.actions.openView(view, focus) }}
/>
)
@@ -467,7 +468,7 @@ describe('ConversationRoot resident composer', () => {
const box = b.view.getByRole('textbox')
expect(b.wiring.snapshot.draft).toBe('ordinary draft')
act(() => { b.wiring.setDraft('ordinary revised') })
expect(b.store.store.getSnapshot().draft).toBe('ordinary revised')
expect(b.store.store.getSnapshot().draft).toEqual({ text: 'ordinary revised', references: [] })
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal))
// The current crumb is plain text (a drag surface on darwin), not a button.
@@ -556,7 +557,7 @@ describe('ConversationRoot resident composer', () => {
const box = b.view.getByRole('textbox')
expect(host?.contains(box)).toBe(true)
act(() => { b.wiring.setDraft('draft in hero') })
expect(b.store.store.getSnapshot().draft).toBe('draft in hero')
expect(b.store.store.getSnapshot().draft).toEqual({ text: 'draft in hero', references: [] })
// Picker: open through the chip; a pick switches to the other
// workspace's blank session (draft carry is apply-layer wiring).
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
@@ -629,7 +630,7 @@ describe('ConversationRoot resident composer', () => {
const after = b.view.getByRole('textbox')
expect(after).toBe(before)
expect(b.wiring.snapshot.draft).toBe('kept across flip')
expect(b.store.store.getSnapshot().draft).toBe('kept across flip')
expect(b.store.store.getSnapshot().draft).toEqual({ text: 'kept across flip', references: [] })
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByTestId('hero-headline')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
@@ -362,8 +362,10 @@ describe('decorations: scanTextRefs', () => {
])
})
it('a cold (empty) lexicon scans nothing lexicon-based', () => {
it('matches slash names only after their catalog is available', () => {
expect(scanTextRefs('/goal x', new Map())).toEqual([])
expect(scanTextRefs('/goal x', new Map([['/', []]]))).toEqual([])
expect(scanTextRefs('/goal x', new Map([['/', ['goal']]]))).toEqual([{ start: 0, end: 5, trigger: '/' }])
})
it('recognizes directory paths independently of the dynamic lexicon', () => {
@@ -21,7 +21,7 @@ const standard: GlobalStandardProps & SessionStandardProps = {
useInput: unused, useChat: unused, useTrajectory: unused,
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused,
useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
}
@@ -31,7 +31,7 @@ const standard: GlobalStandardProps & SessionStandardProps = {
useInput: unused, useChat: unused, useTrajectory: unused,
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused,
useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
}
@@ -19,7 +19,7 @@ const standard: GlobalStandardProps & SessionStandardProps = {
useInput: unused, useChat: unused, useTrajectory: unused,
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused,
useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused,
inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
}
const choices: TerminalLaunchShells = {
@@ -99,6 +99,7 @@ function props(
captureInsertion: unused,
insertText: unused,
setDraft: unused,
persistDraft: unused,
addAttachments: unused,
removeAttachment: unused,
pruneAttachments: unused,
@@ -217,6 +217,7 @@ function standaloneProps(
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: () => {},
persistDraft: () => {},
addAttachments: () => false,
removeAttachment: () => {},
pruneAttachments: () => {},
@@ -345,6 +346,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: vi.fn(),
persistDraft: vi.fn(),
addAttachments: vi.fn(() => false),
removeAttachment: vi.fn(),
pruneAttachments: vi.fn(),
@@ -417,7 +419,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
useStore={bindSnapshotSelector(conversation)}
actions={conversation.actions}
renderSlot={renderSlot}
bindDraftMirror={() => () => {}}
bindDraftPersistence={() => () => {}}
openView={conversation.actions.openView}
useInspectCall={selector => selector(undefined)}
/>
@@ -131,6 +131,7 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: () => { throw new Error('unused') },
persistDraft: () => { throw new Error('unused') },
addAttachments: () => { throw new Error('unused') },
removeAttachment: () => { throw new Error('unused') },
pruneAttachments: () => { throw new Error('unused') },
@@ -130,6 +130,7 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'useQuestion
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: () => { throw new Error('unused') },
persistDraft: () => { throw new Error('unused') },
addAttachments: () => { throw new Error('unused') },
removeAttachment: () => { throw new Error('unused') },
pruneAttachments: () => { throw new Error('unused') },
@@ -328,6 +328,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
insertText: () => false,
setDraft: () => {},
persistDraft: () => {},
addAttachments: () => false,
removeAttachment: () => {},
pruneAttachments: () => {},
@@ -36,8 +36,8 @@
en: dd0a7799c76c0f94
zh: 48d1cdacc9168aa2
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 21590da1f7488361
zh: 95916475cced409e
en: 0cc718002a249cc9
zh: 969b2a034331ab56
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
en: 978415ec6b2e0cc0
zh: 0d5c705ff34fb1da
+2
View File
@@ -89,6 +89,8 @@ The package is one composition: both target slots are declared by other plugins,
The browser entry also declares two root-scoped `list` child seats on each Session row: `sidebar.session.row.leading`, rendered only while that row's primary status is idle and left blank on an archived row, and `sidebar.session.row.hover`, mounted only while that row's hover card is open. Both take the row's Session identity and nothing else, so an occupant reads its own data by that id; a Session-scoped seat would force a Session binding, which would activate and retain every listed Session.
Client callers can pass `prompt` and `clearPreviousDraft` in the second argument to `uiWorkspace.startSession`. A prompt is plain text; structured drafts are handled internally during restoration and Workspace transfer. Without clearing, existing text or attachments are preserved; explicit clearing replaces only the target Session's text and inline references, leaving attachments intact. Option values are captured before asynchronous creation and applied to the exact retained binding before navigation commits. Superseded navigation does not write content; a submission-blocked input refuses preparation and keeps the previous selection. Calls without options retain the normal New Session flow. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) describes the input owner's data flow.
### The directory-flow hole
Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the `-native` backend's renderless OS-chooser driver, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied; an empty hole means the composition has no picking affordance. This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed.
@@ -89,6 +89,8 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
浏览器入口还为每个 Session 行声明两个 root 作用域的 `list` 子 slot:`sidebar.session.row.leading` 仅在该行主状态为 idle 时渲染、归档行留空,`sidebar.session.row.hover` 仅在该行的悬浮卡片打开时挂载。两者只接收行的 Session 身份,占用方据此读取自己的数据;Session 作用域的 slot 会强制建立 Session 绑定,从而激活并保留列表中每个 Session。
客户端调用方可以在 `uiWorkspace.startSession` 的第二参数中传入 `prompt` 与 `clearPreviousDraft`。提示词仅接受普通文字;结构化草稿由内部恢复和工作区携带流程处理。不清空时保留已有文字或附件;显式清空只替换目标 Session 的文字和内联引用,不删除附件。在异步创建前捕获选项值,在导航提交前应用到准确保留的 binding。已被后续操作取代的导航不写内容;输入处于提交阻塞状态时拒绝准备并保留原选择。不传选项时保持正常新会话流程。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)说明输入 owner 的数据流。
### 目录流子 slot
每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——`-native` 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的**添加工作区…** 操作仅在当前界面的 slot 被占用时渲染;slot 为空意味着该组合没有目录选择能力。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace。
@@ -54,7 +54,7 @@ import { RowActionToast } from './session-actions/RowActionToast.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
import { en, zh, type WorkspaceKey } from './locales.ts'
export type { UiWorkspace } from './navigation.ts'
export type { StartSessionOptions, UiWorkspace } from './navigation.ts'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
MenuOpenState, RowToast, SessionRenameTarget, SessionRowOwnerProps, UseMenuOpenState, WorkspaceBrowserInjected,
@@ -7,6 +7,8 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'defaultWorkspace.failed': '无法创建默认工作区,请通过“选择工作区”选择文件夹',
'draft.workspaceRequired': '请先选择工作区',
'draft.initializationFailed': '暂时无法填入草稿,请稍后重试',
'group.ungrouped': '未分组',
'session.new': '新会话',
'session.untitled': '未命名',
@@ -126,6 +128,8 @@ export type WorkspaceKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.',
'draft.workspaceRequired': 'Choose a workspace first.',
'draft.initializationFailed': 'Could not fill the draft. Please try again shortly.',
'group.ungrouped': 'Ungrouped',
'session.new': 'New Session',
'session.untitled': 'Untitled',
@@ -5,6 +5,7 @@ import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai
import type {
ISessions,
SessionCreateError,
SessionBinding,
SessionReference,
SessionTarget,
SessionListState,
@@ -16,6 +17,7 @@ import type {
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { DraftInitializationOptions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { RowToast } from './contract/slots.ts'
import { pinOrderAccounts, pinOrderSource } from './pin-order.ts'
import type { WorkspaceViewStoreActions } from './stores.ts'
@@ -25,6 +27,9 @@ interface MainSelection {
readonly subagentAddress?: SubagentAddress
}
/** Optional content preparation for the resolved target Session. */
export type StartSessionOptions = DraftInitializationOptions
/** Workspace archive and directory operations consumed by Client UI domains. */
export interface UiWorkspace {
/**
@@ -59,8 +64,9 @@ export interface UiWorkspace {
* Start a New Session flow and navigate to its Session; a creation the Host
* refuses is shown through the Workspace notice and leaves the selection as it was.
* @param workspaceId - explicit target; absent inherits the current or most recent Workspace.
* @param options - initial content; existing text or attachments are preserved unless clearPreviousDraft is true.
*/
startSession(workspaceId?: WorkspaceId): void
startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void
/**
* Archive a Session and clear it when it is the current selection.
* @param sessionId - Session to archive.
@@ -220,7 +226,10 @@ class UiWorkspaceService extends Service implements UiWorkspace {
return this.sessions.fork({ sessionId, increaseTitle: true, ...onCreated === undefined ? {} : { onCreated } })
}
startSession(workspaceId?: WorkspaceId): void {
startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void {
const draftOptions = options === undefined ? undefined : { ...options }
const initializeDraft = draftOptions !== undefined
&& (draftOptions.prompt !== undefined || draftOptions.clearPreviousDraft === true)
const workspace = this.workspaces.list.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const current = this.mainReference?.sessionId
@@ -232,14 +241,34 @@ class UiWorkspaceService extends Service implements UiWorkspace {
: undefined
const target = workspaceId ?? currentWorkspaceId ?? recent
if (target === undefined) {
if (initializeDraft) {
this.notify({ kind: 'createFailed', message: this.ctx.locale.bind('workspace')('draft.workspaceRequired') })
return
}
this.clearMain()
return
}
void this.openWorkspace(target).catch(
void this.openWorkspace(target, initializeDraft ? (id) => {
const binding = this.sessions.binding(id)
if (binding === undefined) this.draftPreparationFailed()
this.prepareDraft(binding, draftOptions)
} : undefined).catch(
(reason: unknown) => { console.warn('new session failed:', reason) },
)
}
private prepareDraft(binding: SessionBinding, options: DraftInitializationOptions): void {
const conversation = this.ctx.get('conversation')
if (conversation === undefined) this.draftPreparationFailed()
if (conversation.input.requestDraftInitialization(binding, options) === 'blocked') this.draftPreparationFailed()
}
private draftPreparationFailed(): never {
const message = this.ctx.locale.bind('workspace')('draft.initializationFailed')
this.notify({ kind: 'createFailed', message })
throw new Error(message)
}
async archiveSession(sessionId: SessionId, options: { readonly stopActivity?: boolean } = {}): Promise<void> {
await this.workspaces.archiveSession(sessionId, options)
if (this.mainReference?.sessionId === sessionId) this.clearMain()
@@ -1,7 +1,7 @@
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type {
ISessions, SessionListState, SessionReference, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
@@ -16,8 +16,11 @@ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { DraftInitializationOptions, SessionInputResolver } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { RowToast } from '../src/client/contract/slots.ts'
import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
import { en, zh } from '../src/client/locales.ts'
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
@@ -153,6 +156,9 @@ class FakeSessions implements ISessions {
return reference
})
readonly subagentAddress = vi.fn<ISessions['subagentAddress']>()
readonly binding = vi.fn<ISessions['binding']>(sessionId =>
this.retained.findLast(item => item.reference.sessionId === sessionId
&& item.release.mock.calls.length === 0)?.reference.binding)
declare readonly using: ISessions['using']
declare readonly retainInfo: ISessions['retainInfo']
declare readonly searchResultLimit: ISessions['searchResultLimit']
@@ -161,7 +167,6 @@ class FakeSessions implements ISessions {
declare readonly scope: ISessions['scope']
declare readonly scopeOf: ISessions['scopeOf']
declare readonly sessionOf: ISessions['sessionOf']
declare readonly binding: ISessions['binding']
constructor(initial: SessionListState) {
this.list = new MutableSource(initial)
@@ -266,6 +271,7 @@ class FakeDirectoryPicker {
}
interface BenchOptions {
readonly conversation?: boolean
readonly configureWorkspaces?: (workspaces: FakeWorkspaces) => void
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
@@ -275,6 +281,12 @@ interface BenchOptions {
function bench(options: BenchOptions = {}) {
const ctx = new Context()
contexts.push(ctx)
const locale = new LocaleRuntime(ctx)
locale.setLocale('en')
ctx.effect(() => locale.register('workspace', { en, zh }))
ctx.provide('locale', locale)
const requestDraftInitialization = vi.fn<SessionInputResolver['requestDraftInitialization']>(() => 'applied')
if (options.conversation !== false) ctx.provide('conversation', { input: { requestDraftInitialization } })
const layout = new LayoutController({
selectPanel: vi.fn(), retainMainPanels: vi.fn(),
setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
@@ -298,7 +310,13 @@ function bench(options: BenchOptions = {}) {
view.actions,
notify,
)
return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view, notify }
return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel, view, notify, requestDraftInitialization }
}
function lastOpening(open: MockInstance<UiWorkspaceService['openWorkspace']>): Promise<void> {
const result = open.mock.results.at(-1)
if (result?.type !== 'return') throw new Error('startSession did not start Workspace navigation')
return result.value
}
describe('UiWorkspaceService', () => {
@@ -738,6 +756,197 @@ describe('UiWorkspaceService', () => {
expect(b.selectPanel).toHaveBeenCalledWith(null)
})
describe('startSession draft initialization', () => {
it.each<{ name: string; options: DraftInitializationOptions }>([
{ name: 'plain text', options: { prompt: '草稿 🧭\nsecond line', clearPreviousDraft: true } },
{ name: 'empty text', options: { prompt: '' } },
{ name: 'clear without prompt', options: { clearPreviousDraft: true } },
{ name: 'reference-like text', options: { prompt: '🧭 @notes.md' } },
])('prepares $name on the retained target before replacing the current selection', async ({ options }) => {
const backing = persistSelection({})
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
b.uiWorkspace.openSession(sid('previous'))
const previous = b.sessions.retained[0]!
b.selectPanel.mockClear()
b.sessions.create.mockResolvedValueOnce(sid('actual-target'))
b.requestDraftInitialization.mockImplementation((binding) => {
const target = b.sessions.retained[1]!
expect(binding).toBe(target.reference.binding)
expect(binding.sessionId).toBe(sid('actual-target'))
expect(target.release).not.toHaveBeenCalled()
expect(previous.release).not.toHaveBeenCalled()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('previous') })
expect(b.selectPanel).not.toHaveBeenCalled()
return 'applied'
})
b.uiWorkspace.startSession(wid('a'), options)
await lastOpening(opening)
expect(b.requestDraftInitialization).toHaveBeenCalledExactlyOnceWith(b.sessions.retained[1]!.reference.binding, options)
expect(b.sessions.binding).toHaveBeenCalledExactlyOnceWith(sid('actual-target'))
expect(previous.release).toHaveBeenCalledOnce()
expect(b.sessions.retained[1]!.release).not.toHaveBeenCalled()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('actual-target') })
expect(b.notify).not.toHaveBeenCalled()
})
it.each([undefined, {}, { clearPreviousDraft: false }])('does not invoke the draft API for default options %j', async (options) => {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
b.uiWorkspace.startSession(wid('a'), options)
await lastOpening(opening)
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-a'), { source: 'mainView' })
expect(b.sessions.binding).not.toHaveBeenCalled()
expect(b.requestDraftInitialization).not.toHaveBeenCalled()
expect(b.notify).not.toHaveBeenCalled()
})
it('opens the retained target when its draft is preserved', async () => {
const backing = persistSelection({})
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
b.uiWorkspace.openSession(sid('previous'))
b.requestDraftInitialization.mockReturnValue('preserved')
b.uiWorkspace.startSession(wid('a'), { prompt: 'keep the existing draft' })
await lastOpening(opening)
expect(b.requestDraftInitialization).toHaveBeenCalledExactlyOnceWith(
b.sessions.retained[1]!.reference.binding, { prompt: 'keep the existing draft' },
)
expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
expect(b.sessions.retained[1]!.release).not.toHaveBeenCalled()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('created-a') })
expect(b.notify).not.toHaveBeenCalled()
})
it('captures prompt text and clear intent before asynchronous Session creation', async () => {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValueOnce(created.promise)
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
const options = {
prompt: '🧭 @notes.md',
clearPreviousDraft: true,
}
const expected = { ...options }
b.uiWorkspace.startSession(wid('a'), options)
const completion = lastOpening(opening)
expect(b.sessions.create).toHaveBeenCalledOnce()
expect(b.requestDraftInitialization).not.toHaveBeenCalled()
options.clearPreviousDraft = false
options.prompt = 'caller replaced the prompt'
created.resolve(sid('captured'))
await completion
expect(b.requestDraftInitialization).toHaveBeenCalledExactlyOnceWith(
b.sessions.retained[0]!.reference.binding, expected,
)
expect(b.requestDraftInitialization.mock.calls[0]![1]).not.toBe(options)
})
it.each(['workspace', 'session', 'panel', 'disposal'] as const)(
'never initializes a pending target superseded by %s', async (kind) => {
const b = bench({ workspaces: workspaceState([workspace('a'), workspace('b')]) })
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValueOnce(created.promise)
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
b.uiWorkspace.startSession(wid('a'), { prompt: 'late draft' })
const completion = lastOpening(opening)
expect(b.sessions.create).toHaveBeenCalledOnce()
expect(b.requestDraftInitialization).not.toHaveBeenCalled()
if (kind === 'workspace') {
b.uiWorkspace.startSession(wid('b'), { prompt: 'chosen draft' })
await lastOpening(opening)
} else if (kind === 'session') b.uiWorkspace.openSession(sid('chosen'))
else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId)
else await b.ctx.fiber.dispose()
created.resolve(sid('late'))
await completion
expect(b.sessions.retained.some(item => item.reference.sessionId === sid('late'))).toBe(false)
expect(b.sessions.binding).not.toHaveBeenCalledWith(sid('late'))
if (kind === 'workspace') {
expect(b.requestDraftInitialization).toHaveBeenCalledExactlyOnceWith(
b.sessions.retained[0]!.reference.binding, { prompt: 'chosen draft' },
)
} else expect(b.requestDraftInitialization).not.toHaveBeenCalled()
expect(b.notify).not.toHaveBeenCalled()
},
)
it.each(['blocked', 'missing conversation', 'missing binding'] as const)(
'keeps the previous selection and releases the target after %s', async (failure) => {
const backing = persistSelection({})
const b = bench({ workspaces: workspaceState([workspace('a')]), conversation: failure !== 'missing conversation' })
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.uiWorkspace.openSession(sid('previous'))
b.selectPanel.mockClear()
b.requestDraftInitialization.mockReturnValue('blocked')
if (failure === 'missing binding') b.sessions.binding.mockReturnValueOnce(undefined)
b.uiWorkspace.startSession(wid('a'), { prompt: 'unsent' })
await expect(lastOpening(opening)).rejects.toThrow('Could not fill the draft. Please try again shortly.')
expect(b.notify).toHaveBeenCalledExactlyOnceWith({
kind: 'createFailed', message: 'Could not fill the draft. Please try again shortly.',
})
expect(warning).toHaveBeenCalledExactlyOnceWith('new session failed:', expect.any(Error))
expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
expect(b.sessions.retained[1]!.release).toHaveBeenCalledOnce()
expect(b.sessions.binding(sid('created-a'))).toBeUndefined()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('previous') })
expect(b.selectPanel).not.toHaveBeenCalled()
expect(b.requestDraftInitialization).toHaveBeenCalledTimes(failure === 'blocked' ? 1 : 0)
},
)
it('requests a Workspace without clearing the current selection when no Workspace is available', () => {
const backing = persistSelection({})
const b = bench()
b.uiWorkspace.openSession(sid('ungrouped'))
b.selectPanel.mockClear()
b.uiWorkspace.startSession(undefined, { prompt: 'unsent' })
expect(b.notify).toHaveBeenCalledExactlyOnceWith({ kind: 'createFailed', message: 'Choose a workspace first.' })
expect(b.sessions.create).not.toHaveBeenCalled()
expect(b.requestDraftInitialization).not.toHaveBeenCalled()
expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('ungrouped') })
expect(b.selectPanel).not.toHaveBeenCalled()
})
it('reports an unknown explicit Workspace without preparing a draft or changing selection', async () => {
const backing = persistSelection({})
const b = bench()
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.uiWorkspace.openSession(sid('previous'))
b.selectPanel.mockClear()
b.uiWorkspace.startSession(wid('missing'), { prompt: 'unsent' })
await expect(lastOpening(opening)).rejects.toThrow('unknown workspace missing')
expect(b.notify).toHaveBeenCalledExactlyOnceWith({
kind: 'createFailed', message: 'uiWorkspace.connectWorkspace: unknown workspace missing',
})
expect(warning).toHaveBeenCalledExactlyOnceWith('new session failed:', expect.any(Error))
expect(b.requestDraftInitialization).not.toHaveBeenCalled()
expect(b.sessions.retained).toHaveLength(1)
expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({ sessionId: sid('previous') })
expect(b.selectPanel).not.toHaveBeenCalled()
})
})
it('releases a prepared Workspace target when synchronous preparation supersedes it', async () => {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
@@ -22,7 +22,8 @@ function fixture(recording?: Recording) {
const capture = Object.assign(new Recording(() => {}), { start: vi.fn<Recording['start']>(async () => {}), stop: vi.fn(async () => new Uint8Array(48)),
amplitude: () => 0, dispose: vi.fn(async () => {}) })
const inputActions = { notify: vi.fn(), captureInsertion: vi.fn(() => ({ start: 3, end: 3, draftRev: 1 })), insertText: vi.fn(() => true),
setDraft: vi.fn(), addAttachments: vi.fn(() => true), removeAttachment: vi.fn(), pruneAttachments: vi.fn(), submit: vi.fn() }
setDraft: vi.fn(), persistDraft: vi.fn(), addAttachments: vi.fn(() => true), removeAttachment: vi.fn(),
pruneAttachments: vi.fn(), submit: vi.fn() }
const readiness = createSnapshotStore<SpeechReadiness>({ connected: true, error: null, catalog: {
providers: [{ id, name: 'SenseVoiceSmall', location: 'host-local', languages: ['auto', 'zh', 'en', 'ja'], preparation: { phase: 'ready' } }],
selection: { providerId: id, language: 'auto' }, maxAudioBytes: 100, maxDurationSeconds: 120,
@@ -360,9 +360,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'a Session already addressable through the Session Controller.',
},
{
signature: 'startSession(workspaceId?: WorkspaceId): void',
signature: 'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void',
description: 'Start a New Session flow and navigate to its Session; a creation the Host refuses is shown through the Workspace notice and leaves the selection as it was.',
parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }],
parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }, { name: 'options', description: 'initial content; existing text or attachments are preserved unless clearPreviousDraft is true.' }],
},
{
signature: 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise<void>',
@@ -579,6 +579,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ConnectionStateSource',
declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}',
},
{
name: 'DraftInitializationOptions',
declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: string;\n readonly clearPreviousDraft?: boolean;\n}',
},
{
name: 'EntryKeyOf',
declaration: 'export type EntryKeyOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;',
@@ -967,6 +971,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SnapshotSelectorHook',
declaration: 'export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S;',
},
{
name: 'StartSessionOptions',
declaration: 'export type StartSessionOptions = DraftInitializationOptions;',
},
{
name: 'StoreDecl',
declaration: 'export type StoreDecl = StoreHandle<any, any> | StoreFactory;',
@@ -26,7 +26,7 @@ describe('Client Cordis inspect catalog', () => {
'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void>',
'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise<SessionId>',
'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
'startSession(workspaceId?: WorkspaceId): void',
'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void',
'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise<void>',
'unarchiveSession(sessionId: SessionId): Promise<void>',
'pickDirectory(): Promise<string | null>',
@@ -40,6 +40,21 @@ describe('Client Cordis inspect catalog', () => {
expect(new Set(names).size).toBe(names.length)
})
it('describes text-only draft initialization in Workspace navigation', () => {
const result = queryServiceApi('uiWorkspace') as {
referencedTypes: readonly { name: string; declaration: string }[]
}
expect(result.referencedTypes.find(type => type.name === 'StartSessionOptions')?.declaration)
.toContain('DraftInitializationOptions')
expect(result.referencedTypes.find(type => type.name === 'DraftInitializationOptions')?.declaration)
.toContain('readonly prompt?: string')
expect(result.referencedTypes.find(type => type.name === 'DraftInitializationOptions')?.declaration)
.toContain('readonly clearPreviousDraft?: boolean')
expect(result.referencedTypes.map(type => type.name)).not.toEqual(expect.arrayContaining([
'DraftInput', 'DraftSnapshot', 'DraftReference',
]))
})
it('includes the current referenced type closure for the Sessions service', () => {
const result = queryServiceApi('sessions') as {
referencedTypes: readonly { name: string; declaration: string }[]
+1
View File
@@ -68,6 +68,7 @@
"apps/web/tests/plugin-install-registry.e2e.ts",
"apps/web/tests/plugin-install-github.e2e.ts",
"apps/web/tests/client-plugin-live.e2e.ts",
"apps/web/tests/draft-initialization.e2e.ts",
"apps/web/tests/settings-chrome.e2e.ts",
"apps/web/tests/session-log-upload.e2e.ts",
"apps/web/tests/caption-overlays.e2e.ts",