From e400349e3a469b07352154938f59ceda0b9827c5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:11:02 +0800 Subject: [PATCH 01/12] feat(client): initialize session prompts and preserve structured drafts --- ...9-30-structured-draft-initialization.zh.md | 86 ++++ apps/web/tests/draft-initialization.e2e.ts | 318 ++++++++++++ .../fixture-draft-initialization/client.js | 71 +++ .../cordis.patch.yml | 3 + .../fixture-draft-initialization/index.js | 2 + .../fixture-draft-initialization/package.json | 12 + apps/web/tsconfig.json | 1 + .../tests/message-image.client.spec.tsx | 1 + .../ui-chat/tests/chat-view.client.spec.tsx | 1 + .../ui-conversation/src/client/apply.ts | 6 +- .../src/client/contract/draft-editor.ts | 15 + .../src/client/contract/input.ts | 25 +- .../src/client/contract/slots.ts | 4 +- .../src/client/contract/views.ts | 3 +- .../ui-conversation/src/client/draft.ts | 82 ++++ .../ui-conversation/src/client/index.ts | 5 +- .../src/client/input/decorations.ts | 24 +- .../src/client/input/editor/runtime.ts | 34 +- .../src/client/input/editor/text-ref.ts | 4 +- .../src/client/input/facade.ts | 83 +++- .../ui-conversation/src/client/input/hub.ts | 17 +- .../skeleton/DefaultConversationViews.tsx | 17 +- .../ui-conversation/src/client/stores.ts | 26 +- .../tests/apply-inject.client.spec.tsx | 23 +- .../tests/draft-document.client.spec.ts | 459 ++++++++++++++++++ .../tests/draft-hub.client.spec.ts | 211 ++++++++ .../input-reference-submit.client.spec.ts | 2 +- .../tests/input-scenarios.client.spec.tsx | 14 +- .../tests/skeleton.client.spec.tsx | 9 +- .../tests/submit-machine.client.spec.ts | 5 +- .../tests/expand-button.client.spec.tsx | 2 +- .../tests/guide-body.client.spec.tsx | 2 +- .../tests/terminal-guide.client.spec.tsx | 2 +- .../tests/conversation-ui.client.spec.tsx | 1 + .../ui-trajectory/tests/views.client.spec.tsx | 4 +- .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + .../tests/workflow-run.client.spec.tsx | 1 + .../client/ui-workspace/src/client/index.ts | 2 +- .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/navigation.ts | 35 +- .../tests/workspaces-service.client.spec.ts | 235 ++++++++- .../tests/voice-input.client.spec.tsx | 3 +- .../src/client/api-catalog.ts | 32 +- tsconfig.host.json | 1 + 45 files changed, 1788 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md create mode 100644 apps/web/tests/draft-initialization.e2e.ts create mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml create mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json create mode 100644 packages/client/ui-conversation/src/client/draft.ts create mode 100644 packages/client/ui-conversation/tests/draft-document.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/draft-hub.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md new file mode 100644 index 0000000000..5025eb64be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md @@ -0,0 +1,86 @@ +# Agent Note: 草稿初始内容与结构化引用恢复 + +Status: implemented + +## 问题 + +新任务入口需要把一段尚未发送的内容放入输入框。草稿不仅包含文字,也包含用户已经选定的文件、目录和 Session 引用;只保存引用的文字投影,会在输入对象销毁后丢失胶囊的来源、目标和展示字段。页面挂载后的填字也不能保证第一次读取输入模型时已经得到完整内容。 + +## 决策 + +保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。 + +`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 接受字符串或 `DraftSnapshot`;无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。 + +### 数据模型 + +`DraftSnapshot` 由 `text` 和 `references` 组成。每个引用保留 `offset`、`length`、`source`、`ref`、`label`、`appearance`、`clipboardText` 及已有的无效标记;位置使用 JavaScript 字符串坐标。引用位置必须有序、不重叠,且对应的正文片段与 `clipboardText` 一致。不保存 Lexical NodeKey、DOM、运行时 occurrenceId、函数或临时高亮。 + +字符串等价于不含引用结构的输入;不会据模糊的 `@` 查询擅自选中某个文件。结构化输入直接复用编辑器已有的引用恢复能力,无需查询文件目录或读取文件内容。恢复普通文字、文件胶囊、目录胶囊和 Session 胶囊都经过已注册的编辑器变换和投影流程。 + +现有 `dsh.conversation.` 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,不覆盖旧文本 | +| `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)`、`setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`;进入异步创建前复制 options,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。 + +### 数据流 + +```text +保存记录/初始化参数 + → 字符串或 DraftSnapshot + → 输入 owner 导入文字和引用 + → Lexical 变换、引用投影、InputState + → 第一次对外读取即得到可用文档 + → React 挂载已有 editor + +用户编辑/模型 API 编辑 + → 同一 editor 与投影 + → 导出 DraftSnapshot + → 原 Conversation store 保存 +``` + +### 异步 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)使用真实 Web 组合及测试插件调用公开 `startSession`,三个用例通过。用例验证文字与结构化初始化、创建与复用、两个 Session 多轮切换和编辑、浏览器重载、清空后不复活及旧字符串兼容;同时检查胶囊数量、来源、文字、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。延迟 skill 匹配由模型用例控制词典到达时机,验证其只处理最新内容。 + +## 后果与边界 + +旧字符串草稿缺失的引用信息不能无损推断,按普通文字恢复;不为兼容旧数据自动查询并猜测文件。附件文件对象、上传状态、跨浏览器同步、多个独立编辑器和完整 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)继续各自约束来源路由和代次清理,不被本次决策取代。 diff --git a/apps/web/tests/draft-initialization.e2e.ts b/apps/web/tests/draft-initialization.e2e.ts new file mode 100644 index 0000000000..33c31bcc87 --- /dev/null +++ b/apps/web/tests/draft-initialization.e2e.ts @@ -0,0 +1,318 @@ +/** Unsubmitted drafts initialized through a real client plugin, persisted and restored by the shipped 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 { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-draft-initialization', import.meta.url)) +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: [] } + +function composer(page: Page) { + return page.locator('[data-composer-input][contenteditable="true"]').first() +} + +function selectedSession(page: Page): Promise { + 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 { + 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 assertDraft(page: Page, sessionId: SessionId, expected: DraftSnapshot): Promise { + await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId) + await composer(page).waitFor() + await expect.poll(() => storedDraft(page, sessionId), SETTLE).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 | DraftSnapshot, workspaceId = '', clearPreviousDraft = false) { + const panel = page.locator('[data-draft-initialization]') + await panel.getByRole('textbox', { name: 'Target workspace ID', exact: true }).fill(workspaceId) + await panel.getByRole('textbox', { name: 'Initial draft JSON', exact: true }).fill(JSON.stringify(prompt)) + await panel.getByRole('checkbox', { name: 'Clear previous draft', exact: true }).setChecked(clearPreviousDraft) + await panel.getByRole('button', { name: 'Initialize draft', exact: true }).click() +} + +async function openSession(page: Page, sessionId: SessionId) { + const panel = page.locator('[data-draft-initialization]') + await panel.getByRole('textbox', { name: 'Target session ID', exact: true }).fill(sessionId) + await panel.getByRole('button', { name: 'Open session', exact: true }).click() + await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId) +} + +async function assertUnsubmitted(scaffold: WebScaffold, ids: readonly SessionId[]): Promise { + 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() { + const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) + onTestFinished(() => scaffold.close()) + const first = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'Draft origin') + await scaffold.ctx.loader.create({ name: '@fixture/draft-initialization' }) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const console = watchConsole(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 page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-draft-initialization]').waitFor() + 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('initializes a new blank Session, reuses its draft, replaces it explicitly and keeps a prompt-free clear empty', async () => { + const { scaffold, page, console, 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 openSession(page, firstId) + await initialize(page, '已有草稿不能被覆盖', second.id) + await assertDraft(page, secondId, initial) + expect(second.sessionIds).toEqual([secondId]) + const replacement = { text: '显式替换 🧪', references: [] } + await initialize(page, replacement.text, '', true) + await assertDraft(page, secondId, replacement) + expect(second.sessionIds).toEqual([secondId]) + + await page.getByRole('button', { name: 'Clear draft without prompt', exact: true }).click() + await assertDraft(page, secondId, EMPTY) + for (let round = 0; round < 2; round++) { + await openSession(page, firstId) + await openSession(page, 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) + } + const secondDraft = structuredDraft(firstId, '工作区乙') + await initialize(page, secondDraft, second.id) + await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1) + const secondId = second.sessionIds[0]! + 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\",末尾 🦉", + ], + } + `) + const firstDraft = structuredDraft(secondId, '工作区甲') + await initialize(page, firstDraft, first.id) + await assertDraft(page, firstId, firstDraft) + + const drafts = [{ id: firstId, draft: firstDraft }, { id: secondId, draft: secondDraft }] + for (let round = 0; round < 3; round++) { + for (const item of drafts) { + await openSession(page, 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) + await page.locator('[data-draft-initialization]').waitFor() + for (const item of drafts) { + await openSession(page, 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 page.getByRole('button', { name: 'Clear draft without prompt', exact: true }).click() + await assertDraft(page, secondId, EMPTY) + for (let round = 0; round < 2; round++) { + await openSession(page, firstId) + await assertDraft(page, firstId, drafts[0]!.draft) + await openSession(page, 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,不是胶囊' + // Seed the old storage representation before the reloaded client creates its stores. + await page.addInitScript(({ id, text }) => { + localStorage.setItem(`dsh.conversation.${id}`, JSON.stringify({ draft: text, view: null, viewRequest: null })) + }, { id: firstId, text: legacy }) + await page.reload({ waitUntil: 'load' }) + 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([]) +}) diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js new file mode 100644 index 0000000000..7d03aa0c6c --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js @@ -0,0 +1,71 @@ +/** Browser controls exercising the shipped Workspace draft-initialization API. */ +window.__ModuleLoader__.load({ + id: '@fixture/draft-initialization', + factory(require) { + const React = require('react') + return { + inject: ['slots', 'locale', 'uiWorkspace'], + apply(ctx) { + ctx.effect(() => ctx.locale.register('fixtureDraftInitialization', { + en: { + panel: 'Draft initialization fixture', workspace: 'Target workspace ID', + prompt: 'Initial draft JSON', replace: 'Clear previous draft', + initialize: 'Initialize draft', clear: 'Clear draft without prompt', + session: 'Target session ID', open: 'Open session', + }, + zh: { + panel: '草稿初始化测试', workspace: '目标工作区 ID', + prompt: '初始草稿 JSON', replace: '清除已有草稿', + initialize: '初始化草稿', clear: '无提示词清空草稿', + session: '目标会话 ID', open: '打开会话', + }, + })) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'fixture-draft-initialization', locale: 'fixtureDraftInitialization', + }, function DraftInitializationControls({ t }) { + const [workspaceId, setWorkspaceId] = React.useState('') + const [sessionId, setSessionId] = React.useState('') + const [prompt, setPrompt] = React.useState('""') + const [clearPreviousDraft, setClearPreviousDraft] = React.useState(false) + return React.createElement('section', { + 'data-draft-initialization': '', 'aria-label': t('panel'), + style: { + position: 'fixed', top: 64, right: 16, zIndex: 9999, + display: 'grid', gap: 4, width: 260, + }, + }, + React.createElement('input', { + 'aria-label': t('workspace'), value: workspaceId, + onChange: event => setWorkspaceId(event.target.value), + }), + React.createElement('textarea', { + 'aria-label': t('prompt'), value: prompt, + onChange: event => setPrompt(event.target.value), + }), + React.createElement('label', null, + React.createElement('input', { + type: 'checkbox', checked: clearPreviousDraft, + onChange: event => setClearPreviousDraft(event.target.checked), + }), t('replace')), + React.createElement('button', { + type: 'button', + onClick: () => ctx.uiWorkspace.startSession(workspaceId || undefined, { + prompt: JSON.parse(prompt), clearPreviousDraft, + }), + }, t('initialize')), + React.createElement('button', { + type: 'button', + onClick: () => ctx.uiWorkspace.startSession(workspaceId || undefined, { clearPreviousDraft: true }), + }, t('clear')), + React.createElement('input', { + 'aria-label': t('session'), value: sessionId, + onChange: event => setSessionId(event.target.value), + }), + React.createElement('button', { + type: 'button', onClick: () => ctx.uiWorkspace.openSession(sessionId), + }, t('open'))) + })) + }, + } + }, +}) diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml new file mode 100644 index 0000000000..670c87eda4 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: fixture-draft-initialization + name: '@fixture/draft-initialization' diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js new file mode 100644 index 0000000000..6733883191 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js @@ -0,0 +1,2 @@ +/** Host companion for the browser's unsent-draft initialization fixture. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json new file mode 100644 index 0000000000..5f16e1c44f --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/draft-initialization", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.js", + "exports": { ".": "./index.js", "./client": "./client.js" }, + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { "platform": "web" } + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e213a2ab14..44666ba54d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 21db2ee64e..7fa2c0a980 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -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(), diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ba6d2e0b47..78b0da0cbe 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -431,6 +431,7 @@ function makeHarness( captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), insertText: () => false, setDraft: () => {}, + persistDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, pruneAttachments: () => {}, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0fc9b2560c..9994c4e9ae 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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, } }, diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts index 4aaae89bc6..c2426e7a36 100644 --- a/packages/client/ui-conversation/src/client/contract/draft-editor.ts +++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts @@ -19,6 +19,21 @@ export interface ReferenceInsert { readonly clipboardText: string } +/** Persisted reference data without the current editor's occurrence identity. */ +export type DraftReference = Omit + +/** + * 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' diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 7b792d2760..99a9eb336e 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -7,10 +7,11 @@ * 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' -import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts' +import type { ArbitrateKey, ArbitrateOutcome, DraftInput, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts' import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts' /** Attachment payload passed to a claimed command submission. */ @@ -175,7 +176,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). */ - setDraft(text: string): void + setDraft(text: DraftInput): 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. @returns whether the id was removed. */ @@ -211,8 +212,24 @@ 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. + */ + requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions): DraftInitializationResult } +/** Initial content for a target Session; attachments remain under their existing owner. */ +export interface DraftInitializationOptions { + readonly prompt?: DraftInput + 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 @@ -230,7 +247,9 @@ export interface InputActions { */ insertText(text: string, span: TokenSpan): boolean /** Replace the whole draft (persisted-draft seed and programmatic writes). */ - setDraft(text: string): void + setDraft(text: DraftInput): 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. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 61f7d8ff8b..5564aea9b6 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 } /** 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 } diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 979680fe81..e7281e2deb 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -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. */ diff --git a/packages/client/ui-conversation/src/client/draft.ts b/packages/client/ui-conversation/src/client/draft.ts new file mode 100644 index 0000000000..90efabe5e7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/draft.ts @@ -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 }), + } +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index e5825f8c70..b97aa3ed44 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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' { diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index d68bfee8e3..a0b232e3f4 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -34,10 +34,10 @@ const SLASH_TOKEN_END_RE = /^(?:\s|$)/ * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member; a `/name` token must end at whitespace or the draft + * exact lexicon member when its catalog is available; 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 slash catalogs allow syntax-only matches. * @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 ((trigger === '/' && !lexicon.has('/')) || 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 diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index cf83316fea..7cbac8676b 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -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 | 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)) diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index 9a0a0479c0..dc0dccc74b 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -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 }) } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79aa620f34..0434c726d5 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -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' @@ -95,7 +96,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection const EMPTY_QUEUE: InboxState['next-turn'] = [] -/** No-pipeline lexicon: zero text-ref decorations. */ +/** Unavailable catalogs leave syntax-only text decoration enabled. */ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() /** Editor and attachment snapshot owned by one detached default send. */ @@ -128,6 +129,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 +144,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[0] | undefined /** Default sends retained until admission settles or scope disposal releases their attachments. */ @@ -177,6 +180,7 @@ export class SessionInputShell implements SessionInput { this.unregister = this.draftEditor.register() this.state = createSnapshotStore(this.compose()) this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() }) + this.draftEditor.refreshLexiconSubscription() } // ---- editor plumbing ---- @@ -210,10 +214,51 @@ 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 whether text changed, was preserved, or submission prevented editing. + */ + 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 +574,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 +894,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) } } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 8a908bb7b0..756bc0dc55 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -20,7 +20,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 +28,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,6 +82,13 @@ 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 @@ -157,6 +165,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 } diff --git a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx index 4e91d709f1..ea3fd4ce85 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx @@ -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 diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 244c6399a2..34e4261a26 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -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 ({ 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 { 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 +364,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 +544,25 @@ 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 }], + } + b.inputApi(ROOT).actions.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) diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts new file mode 100644 index 0000000000..5ae32bf1aa --- /dev/null +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -0,0 +1,459 @@ +// @vitest-environment jsdom +/** Structured draft import, persistence, and reference restoration through the real input shell. */ +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 { 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 + +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 = {}): SessionInputShell { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const shell = new SessionInputShell({ + actx: ctx, + defaultSink: () => Promise.resolve({ kind: 'success' }), + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: token => token, + }, + ...overrides, + }) + onTestFinished(() => { shell.dispose() }) + 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(() => $nodesOfType(TextRefNode).map(node => node.getTextContent())) +} + +function triggerProvider(lexicon: InputTriggerController['lexicon']): InputTriggerController { + return { + lexicon, + launcher: createSnapshotStore(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: documentOf('replacement ', folder), 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) + }) + + 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('applies initial structured prompts without waiting for a view', () => { + const shell = makeShell() + const prompt = documentOf('read ', file) + expect(shell.requestDraftInitialization({ prompt })).toBe('applied') + expect(shell.draftSnapshot).toEqual(prompt) + expect(chips(shell)).toHaveLength(1) + }) + + 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() + const started = Promise.withResolvers() + const lexicon = createSnapshotStore>(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() + const started = Promise.withResolvers() + 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() + const started = Promise.withResolvers() + const sink = vi.fn(() => { 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>(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(['/current']) + 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) }) + expect(textReferences(shell)).toEqual(['/older']) + 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' }]) + }) +}) diff --git a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts new file mode 100644 index 0000000000..c571287d90 --- /dev/null +++ b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts @@ -0,0 +1,211 @@ +// @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 { 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 type { 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 fixture(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, {})) + const shell = hub.shellFor(binding) + return { ctx, sessions, id, key, raw, reference, binding, hub, shell } +} + +function textReferences(shell: SessionInputShell): string[] { + return shell.editor.getEditorState().read(() => $nodesOfType(TextRefNode).map(node => node.getTextContent())) +} + +function catalog(initial: readonly string[]) { + const store = createSnapshotStore>(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(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 }) { + super(ctx, 'inputTriggers') + } + + sessionOf(): InputTriggerController { + return this.config.controller + } +} + +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(['/saved']) + 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(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(['/current']) + 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() + expect(first.subscribers).toBe(0) + 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() + expect(second.subscribers).toBe(0) + 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 + 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 + source.set([]) + 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') +}) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index e25e3e6754..b14dadbc35 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -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', diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index c4df0217e8..2ab3c6c025 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -354,8 +354,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('keeps provisional slash decoration for a matching catalog and removes it for an empty catalog', async () => { let roll: readonly string[] | undefined let notify: (() => void) | undefined const b = await scopedBench((inputTriggers) => { @@ -370,9 +370,8 @@ describe('scenario: reference decoration lights up when the lexicon settles', () }, } as never) }) - // Typed before the catalog settled: a plain token, no decoration. b.type('/deploy now') - expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() + expect(b.view.container.querySelector('[data-composer-text-ref]')?.textContent).toBe('/deploy') // The catalog settles (ui-skill's settle path fires the same notification). act(() => { roll = ['deploy'] @@ -381,6 +380,13 @@ describe('scenario: reference decoration lights up when the lexicon settles', () 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') + act(() => { + roll = [] + notify?.() + }) + act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) + expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() + expect(b.shell.snapshot.draft).toBe('/deploy now') }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 2d6bd58003..7f0225ff33 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -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() diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 575c568c97..d5e3a8cc9b 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -362,8 +362,9 @@ describe('decorations: scanTextRefs', () => { ]) }) - it('a cold (empty) lexicon scans nothing lexicon-based', () => { - expect(scanTextRefs('/goal x', new Map())).toEqual([]) + it('matches slash syntax while the catalog is unavailable and narrows when it arrives', () => { + expect(scanTextRefs('/goal x', new Map())).toEqual([{ start: 0, end: 5, trigger: '/' }]) + expect(scanTextRefs('/goal x', new Map([['/', []]]))).toEqual([]) }) it('recognizes directory paths independently of the dynamic lexicon', () => { diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 8a5a4a7bac..9061f68145 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -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 }, } diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index d640d4356e..a4015b079e 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -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 }, } diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index 4d0ef3d022..a50f7ca835 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -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 = { diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 5e0401e33e..0c36c8a1c4 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -99,6 +99,7 @@ function props( captureInsertion: unused, insertText: unused, setDraft: unused, + persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 04d4040a6e..e3158ee3e3 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -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>) { 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>) { useStore={bindSnapshotSelector(conversation)} actions={conversation.actions} renderSlot={renderSlot} - bindDraftMirror={() => () => {}} + bindDraftPersistence={() => () => {}} openView={conversation.actions.openView} useInspectCall={selector => selector(undefined)} /> diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 7d9ff6da60..a8ee31eea9 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -131,6 +131,7 @@ const kit: Omit = { 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') }, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 6e0fa4f418..d440341178 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -130,6 +130,7 @@ const kitBase: Omit ({ 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') }, diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 728efbf906..f5c428b842 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -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: () => {}, diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 2ecaf7e264..cdf3214954 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -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, diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 1ce42fab28..60e5e30326 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 2ce1c187cf..0998520f98 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -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 : structuredClone(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 { await this.workspaces.archiveSession(sessionId, options) if (this.mainReference?.sessionId === sessionId) this.clearMain() diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index aae7d728ac..640992d008 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -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() + readonly binding = vi.fn(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(() => '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): Promise { + 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,217 @@ 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: 'structured references', + options: { + prompt: { + text: '🧭 @notes.md', + references: [{ + offset: 3, length: 9, source: 'reference', ref: '@notes.md', label: 'notes.md', + appearance: 'file', clipboardText: '@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 options and nested references before asynchronous Session creation', async () => { + const b = bench({ workspaces: workspaceState([workspace('a')]) }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValueOnce(created.promise) + const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace') + const options = { + prompt: { + text: '🧭 @notes.md', + references: [{ + offset: 3, length: 9, source: 'reference', ref: '@notes.md', label: 'notes.md', + appearance: 'file' as const, clipboardText: '@notes.md', + }], + }, + clearPreviousDraft: true, + } + const expected = structuredClone(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.text = 'caller changed the text' + options.prompt.references[0]!.label = 'caller changed the reference' + options.prompt.references.length = 0 + options.prompt = { text: 'caller replaced the prompt', references: [] } + 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() + 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')]) }) diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index cc27020f71..aafc039f75 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -22,7 +22,8 @@ function fixture(recording?: Recording) { const capture = Object.assign(new Recording(() => {}), { start: vi.fn(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({ 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, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 42d03757cd..1f474a2f24 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -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', @@ -579,6 +579,22 @@ 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?: DraftInput;\n readonly clearPreviousDraft?: boolean;\n}', + }, + { + name: 'DraftInput', + declaration: 'export type DraftInput = string | DraftSnapshot;', + }, + { + name: 'DraftReference', + declaration: 'export type DraftReference = Omit;', + }, + { + name: 'DraftSnapshot', + declaration: 'export interface DraftSnapshot {\n readonly text: string;\n readonly references: readonly DraftReference[];\n}', + }, { name: 'EntryKeyOf', declaration: 'export type EntryKeyOf = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;', @@ -707,6 +723,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObservableSnapshot', declaration: 'export interface ObservableSnapshot {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', }, + { + name: 'Occurrence', + declaration: 'export interface Occurrence {\n readonly occurrenceId: number;\n readonly source: string;\n readonly ref: string;\n readonly offset: number;\n readonly length: number;\n readonly label: string;\n readonly appearance?: ReferenceInsert[\'appearance\'];\n readonly clipboardText: string;\n readonly invalid?: boolean;\n}', + }, { name: 'OpenState', declaration: 'export type OpenState = \'cold\' | \'loading\' | \'open\' | \'error\';', @@ -791,6 +811,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'QueueAction', declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly TextBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', }, + { + name: 'ReferenceInsert', + declaration: 'export interface ReferenceInsert {\n readonly source: string;\n readonly ref: string;\n readonly label: string;\n readonly appearance?: \'session\' | \'file\' | \'folder\';\n readonly clipboardText: string;\n}', + }, { name: 'RegisterFactory', declaration: 'export interface RegisterFactory {\n (options: RegisterFactoryOptions, component: SlotComponent>): () => void;\n}', @@ -967,6 +991,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SnapshotSelectorHook', declaration: 'export type SnapshotSelectorHook = (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 | StoreFactory;', diff --git a/tsconfig.host.json b/tsconfig.host.json index 5c5c81b7e0..3de3b5fcfc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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", From 236ab7acbd3a73cdb3fde91e3f2ecd3e7d092222 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:28:35 +0800 Subject: [PATCH 02/12] docs(client): document structured draft initialization in both languages --- ...-structured-draft-initialization.i18n.yaml | 34 +++++++ ...6-09-30-structured-draft-initialization.md | 88 +++++++++++++++++++ ...9-30-structured-draft-initialization.zh.md | 22 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +- packages/client/ui-conversation/README.zh.md | 6 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + 9 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml new file mode 100644 index 0000000000..ce8ab89c81 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml @@ -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: 28c7f9a2f2c6bbfd + zh: 2586b5e5c96eb0fe +/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model: + en: e55a4326cc40f5f7 + zh: 089d8243a3ab9d8c +/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods: + en: 6f895b21233fae0e + zh: 4a1e92a97edcd519 +/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: afbbf0faf308c575 + zh: b0fda2d70a4e186b +/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: 284eee6cc40f7304 + zh: 3c550e38df3e1c2c +/agent-note-initial-draft-content-and-structured-reference-restoration/consequences: + en: 2ffae2dca902341d + zh: fb33a64aaabfd89c diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md new file mode 100644 index 0000000000..b89eb332e2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md @@ -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 a string or `DraftSnapshot`; 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. Structured input 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.` 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` | Accept a string or `DraftSnapshot` and import one document through the editor, without simulating keyboard events or submitting | +| `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()` | Subscribe to an available lexicon during construction; `InputHub` uses Session-scope `inject` to reconnect when the service arrives or is replaced and unsubscribes on disposal. Updates rescan the current editor without writing old text back | +| `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 `setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`. Options are copied 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. Its existing subscription rescans the current document on arrival instead of saving and restoring the text present when the request began. Generic syntax may highlight provisionally; an available catalog narrows the match and can remove unmatched highlighting. 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) uses the real Web composition and a test plugin calling public `startSession`; all three cases passed. It checks text and structured initialization, creation and reuse, repeated Session switching and editing, reload, clearing without resurrection, and legacy strings, together with chip counts, sources, text, file previews, and persisted snapshots. Before sending, Host logs contain neither `user/message` nor `turn/start`. Model cases control delayed skill-catalog arrival to verify that matching reads only current content. + +## 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. diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md index 5025eb64be..4b40c6f82c 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md @@ -2,6 +2,8 @@ Status: implemented +[English](2026-09-30-structured-draft-initialization.md) | 中文 + ## 问题 新任务入口需要把一段尚未发送的内容放入输入框。草稿不仅包含文字,也包含用户已经选定的文件、目录和 Session 引用;只保存引用的文字投影,会在输入对象销毁后丢失胶囊的来源、目标和展示字段。页面挂载后的填字也不能保证第一次读取输入模型时已经得到完整内容。 @@ -44,17 +46,17 @@ Status: implemented ### 数据流 ```text -保存记录/初始化参数 - → 字符串或 DraftSnapshot - → 输入 owner 导入文字和引用 - → Lexical 变换、引用投影、InputState - → 第一次对外读取即得到可用文档 - → React 挂载已有 editor +stored draft / startSession options + → DraftInput + → SessionInputShell.setDraft + → DraftEditorRuntime → Lexical → InputState + → InputHub.shellFor returns + → React mounts the editor -用户编辑/模型 API 编辑 - → 同一 editor 与投影 - → 导出 DraftSnapshot - → 原 Conversation store 保存 +user edits / input actions + → editor → projection + → DraftSnapshot + → Conversation store ``` ### 异步 skill 匹配 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 95bf8db2bf..3fdacc9557 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13ae68865fa315d7 zh: c152f020abf20f35 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 2f44e26f8ff84268 - zh: 879534cee58aaed5 + en: 7ba912ea173fbf46 + zh: 97bf287488883e9e /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b53f2c15eb..930d1a04a5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. 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; 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index dc18970069..7430c95c51 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1d71954157..22e9edbcb1 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -36,8 +36,8 @@ en: dd0a7799c76c0f94 zh: 48d1cdacc9168aa2 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: - en: 21590da1f7488361 - zh: 95916475cced409e + en: 51493d1e280e99e3 + zh: 67bfc0df920c6952 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole: en: 978415ec6b2e0cc0 zh: 0d5c705ff34fb1da diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 7a5f94d110..fb71459968 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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 or a structured draft with explicit reference metadata. Without clearing, existing text or attachments are preserved; explicit clearing replaces only the target Session's text and inline references, leaving attachments intact. Options are copied 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 634d293535..fea41ead64 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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。 From 7d3420126ed1404a658ab6ae10d0c1d4f946ea31 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:28:36 +0800 Subject: [PATCH 03/12] test(client): update workspace initialization API expectations --- .../tests/api-catalog.client.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index c9039d726f..30cb487823 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -26,7 +26,7 @@ describe('Client Cordis inspect catalog', () => { 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', - 'startSession(workspaceId?: WorkspaceId): void', + 'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void', 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', 'unarchiveSession(sessionId: SessionId): Promise', 'pickDirectory(): Promise', @@ -40,6 +40,17 @@ describe('Client Cordis inspect catalog', () => { expect(new Set(names).size).toBe(names.length) }) + it('describes initial content and structured references in Workspace navigation', () => { + const result = queryServiceApi('uiWorkspace') as { + referencedTypes: readonly { name: string; declaration: string }[] + } + expect(result.referencedTypes).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'StartSessionOptions', declaration: expect.stringContaining('DraftInitializationOptions') }), + expect.objectContaining({ name: 'DraftInitializationOptions', declaration: expect.stringContaining('readonly prompt?: DraftInput') }), + expect.objectContaining({ name: 'DraftSnapshot', declaration: expect.stringContaining('readonly references: readonly DraftReference[]') }), + ])) + }) + it('includes the current referenced type closure for the Sessions service', () => { const result = queryServiceApi('sessions') as { referencedTypes: readonly { name: string; declaration: string }[] From ec6b275c6407e4b612c796d3bf5318909255ba5c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:55:00 +0800 Subject: [PATCH 04/12] test(web): cover draft option and navigation cases without a test plugin --- ...-structured-draft-initialization.i18n.yaml | 4 +- ...6-09-30-structured-draft-initialization.md | 2 +- ...9-30-structured-draft-initialization.zh.md | 2 +- apps/web/tests/draft-initialization.e2e.ts | 442 ++++++++++++++++-- .../fixture-draft-initialization/client.js | 71 --- .../cordis.patch.yml | 3 - .../fixture-draft-initialization/index.js | 2 - .../fixture-draft-initialization/package.json | 12 - .../tests/conversation-store.client.spec.ts | 100 +++- .../tests/draft-document.client.spec.ts | 161 ++++++- .../tests/draft-hub.client.spec.ts | 8 + 11 files changed, 680 insertions(+), 127 deletions(-) delete mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js delete mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml delete mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js delete mode 100644 apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml index ce8ab89c81..fd07ff5ec3 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml @@ -27,8 +27,8 @@ en: eddaec471425fafc zh: b206ecdb12e8c33d /agent-note-initial-draft-content-and-structured-reference-restoration/verification: - en: 284eee6cc40f7304 - zh: 3c550e38df3e1c2c + en: b8a2894d1ab69437 + zh: 781aa785a67958c3 /agent-note-initial-draft-content-and-structured-reference-restoration/consequences: en: 2ffae2dca902341d zh: fb33a64aaabfd89c diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md index b89eb332e2..5455c1b938 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md @@ -79,7 +79,7 @@ The core model invariants are semantic equivalence after import, export, and JSO [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) uses the real Web composition and a test plugin calling public `startSession`; all three cases passed. It checks text and structured initialization, creation and reuse, repeated Session switching and editing, reload, clearing without resurrection, and legacy strings, together with chip counts, sources, text, file previews, and persisted snapshots. Before sending, Host logs contain neither `user/message` nor `turn/start`. Model cases control delayed skill-catalog arrival to verify that matching reads only current content. +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`, without an additional test plugin. All eight cases passed. They cover absent, empty, plain, and structured prompt options; 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 diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md index 4b40c6f82c..09b9020506 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md @@ -79,7 +79,7 @@ skill 是可编辑文字,不转为文件胶囊。词典尚未返回时不锁 [导航用例](../../../../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)使用真实 Web 组合及测试插件调用公开 `startSession`,三个用例通过。用例验证文字与结构化初始化、创建与复用、两个 Session 多轮切换和编辑、浏览器重载、清空后不复活及旧字符串兼容;同时检查胶囊数量、来源、文字、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。延迟 skill 匹配由模型用例控制词典到达时机,验证其只处理最新内容。 +[浏览器 e2e](../../../../apps/web/tests/draft-initialization.e2e.ts)通过已有模块加载观测方式取得实际 Workspace API,调用公开 `startSession`,不新增测试插件。完整八个用例通过,覆盖缺省、空字符串、普通文字和结构化提示词参数,明确传入及沿用当前工作区,保留与清空、创建与复用、真实界面的 Session 多轮切换和编辑、刷新、旧字符串及工作区选择器携带。延迟创建响应的两种返回顺序都保持较新的目标选择,并且不改动另一份草稿。延迟返回的真实 skill 目录只重匹配当前文字,不替换胶囊、选区或 Undo 历史。断言检查胶囊字段、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。 ## 后果与边界 diff --git a/apps/web/tests/draft-initialization.e2e.ts b/apps/web/tests/draft-initialization.e2e.ts index 33c31bcc87..17ffc7818d 100644 --- a/apps/web/tests/draft-initialization.e2e.ts +++ b/apps/web/tests/draft-initialization.e2e.ts @@ -1,15 +1,15 @@ -/** Unsubmitted drafts initialized through a real client plugin, persisted and restored by the shipped Web profile. */ +/** 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 FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-draft-initialization', import.meta.url)) 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' @@ -32,6 +32,76 @@ interface DraftSnapshot { const EMPTY: DraftSnapshot = { text: '', references: [] } +interface DraftOptions { + prompt?: string | DraftSnapshot + clearPreviousDraft?: boolean +} + +interface DraftApiObservation { + startSession?: (workspaceId: string | undefined, options: DraftOptions) => void +} + +interface WorkspaceApplyContext { + get(name: 'uiWorkspace'): { startSession: NonNullable } | undefined +} + +async function observeDraftApi(page: Page): Promise { + // 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 { + 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() } @@ -53,10 +123,31 @@ function storedDraft(page: Page, sessionId: SessionId): Promise { }, sessionId) } -async function assertDraft(page: Page, sessionId: SessionId, expected: DraftSnapshot): Promise { +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 { await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId) await composer(page).waitFor() - await expect.poll(() => storedDraft(page, sessionId), SETTLE).toEqual(expected) + 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 => ({ @@ -71,18 +162,15 @@ async function assertDraft(page: Page, sessionId: SessionId, expected: DraftSnap [...element.children].map(paragraph => paragraph.textContent).join('\n')), SETTLE).toBe(displayed) } -async function initialize(page: Page, prompt: string | DraftSnapshot, workspaceId = '', clearPreviousDraft = false) { - const panel = page.locator('[data-draft-initialization]') - await panel.getByRole('textbox', { name: 'Target workspace ID', exact: true }).fill(workspaceId) - await panel.getByRole('textbox', { name: 'Initial draft JSON', exact: true }).fill(JSON.stringify(prompt)) - await panel.getByRole('checkbox', { name: 'Clear previous draft', exact: true }).setChecked(clearPreviousDraft) - await panel.getByRole('button', { name: 'Initialize draft', exact: true }).click() +async function initialize(page: Page, prompt: string | DraftSnapshot, workspaceId?: string, clearPreviousDraft = false) { + await startSession(page, workspaceId, { prompt, clearPreviousDraft }) } -async function openSession(page: Page, sessionId: SessionId) { - const panel = page.locator('[data-draft-initialization]') - await panel.getByRole('textbox', { name: 'Target session ID', exact: true }).fill(sessionId) - await panel.getByRole('button', { name: 'Open session', exact: true }).click() +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) } @@ -100,15 +188,16 @@ async function assertUnsubmitted(scaffold: WebScaffold, ids: readonly SessionId[ } } -async function launchDraftFixture() { - const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) +async function launchDraftFixture(beforeBrowserOpen?: (page: Page, scaffold: WebScaffold) => Promise) { + const scaffold = await launchWebScaffold() onTestFinished(() => scaffold.close()) const first = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'Draft origin') - await scaffold.ctx.loader.create({ name: '@fixture/draft-initialization' }) 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 }) @@ -120,8 +209,12 @@ async function launchDraftFixture() { globalThis.console.warn('Draft initialization failure screenshot unavailable:', error) } }) + await beforeBrowserOpen?.(page, scaffold) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.locator('[data-draft-initialization]').waitFor() + 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]! @@ -158,8 +251,8 @@ function structuredDraft(sessionId: SessionId, title: string): DraftSnapshot { return { text, references } } -it('initializes a new blank Session, reuses its draft, replaces it explicitly and keeps a prompt-free clear empty', async () => { - const { scaffold, page, console, firstId, second } = await launchDraftFixture() +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) @@ -167,20 +260,31 @@ it('initializes a new blank Session, reuses its draft, replaces it explicitly an expect(secondId).not.toBe(firstId) await assertDraft(page, secondId, initial) - await openSession(page, firstId) + 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, '', true) + await initialize(page, replacement.text, undefined, true) await assertDraft(page, secondId, replacement) expect(second.sessionIds).toEqual([secondId]) - await page.getByRole('button', { name: 'Clear draft without prompt', exact: true }).click() + await startSession(page, undefined, { clearPreviousDraft: true }) await assertDraft(page, secondId, EMPTY) for (let round = 0; round < 2; round++) { - await openSession(page, firstId) - await openSession(page, secondId) + await openWorkspaceSession(page, first, firstId) + await openWorkspaceSession(page, second, secondId) await assertDraft(page, secondId, EMPTY) } await page.reload({ waitUntil: 'load' }) @@ -254,10 +358,13 @@ it('restores repeated file, folder and Session capsules across edits, Workspace await initialize(page, firstDraft, first.id) await assertDraft(page, firstId, firstDraft) - const drafts = [{ id: firstId, draft: firstDraft }, { id: secondId, draft: secondDraft }] + 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 openSession(page, item.id) + await openWorkspaceSession(page, item.workspace, item.id) await assertDraft(page, item.id, item.draft) await composer(page).click() await page.keyboard.press('ControlOrMeta+End') @@ -269,21 +376,20 @@ it('restores repeated file, folder and Session capsules across edits, Workspace } await page.reload({ waitUntil: 'load' }) await assertDraft(page, secondId, drafts[1]!.draft) - await page.locator('[data-draft-initialization]').waitFor() for (const item of drafts) { - await openSession(page, item.id) + 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 page.getByRole('button', { name: 'Clear draft without prompt', exact: true }).click() + await startSession(page, undefined, { clearPreviousDraft: true }) await assertDraft(page, secondId, EMPTY) for (let round = 0; round < 2; round++) { - await openSession(page, firstId) + await openWorkspaceSession(page, first, firstId) await assertDraft(page, firstId, drafts[0]!.draft) - await openSession(page, secondId) + await openWorkspaceSession(page, second, secondId) await assertDraft(page, secondId, EMPTY) } await page.reload({ waitUntil: 'load' }) @@ -316,3 +422,275 @@ it('reads a legacy string from the existing conversation key and saves ordinary 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() + let captured: { status: number; body: string; request: string | null } | undefined + let catalogRequests = 0 + const { scaffold, page, console, first, 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) + 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' }) + }) + }) + 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 initialize(page, initial, first.id) + await assertDraft(page, firstId, initial) + 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(['/draft-late-current', '/draft-late-missing']) + + 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 initialize(page, draft, first.id) + 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 replacement = structuredDraft(firstId, '目标替换草稿') + const plain = { text: '已有目标文字 🧭', references: [] } + await initialize(page, source, first.id) + await initialize(page, structured, second.id, true) + await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1) + const secondId = second.sessionIds[0]! + await assertDraft(page, secondId, structured) + + const transitions: { + name: string + target: 'explicit' | 'current' + options: DraftOptions + expected: 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: 'structured input preserves existing text', target: 'explicit', options: { prompt: structured, clearPreviousDraft: false }, expected: plain }, + { name: 'empty text explicitly clears existing text', target: 'current', options: { prompt: '', clearPreviousDraft: true }, expected: EMPTY }, + { name: 'structured input fills a reused empty target', target: 'explicit', options: { prompt: structured, clearPreviousDraft: false }, expected: structured }, + { name: 'structured input explicitly replaces capsules', target: 'current', options: { prompt: replacement, clearPreviousDraft: true }, expected: replacement, reload: true }, + { name: 'absent prompt and false clear preserve capsules', target: 'explicit', options: { clearPreviousDraft: false }, expected: replacement }, + { name: 'empty text explicitly clears capsules', target: 'current', options: { prompt: '', clearPreviousDraft: true }, expected: EMPTY, reload: true }, + ] + for (const transition of transitions) { + 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 = structuredDraft(firstId, '只有最后目标收到草稿') + await initialize(page, source, first.id) + await assertDraft(page, firstId, source) + + interface HeldCreation { + workspaceId: string + release: ReturnType> + response?: { status: number; body: string } + } + const older: HeldCreation = { workspaceId: second.id, release: Promise.withResolvers() } + const newer: HeldCreation = { workspaceId: third.id, release: Promise.withResolvers() } + 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 => { + 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(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, 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([]) + }, +) diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js deleted file mode 100644 index 7d03aa0c6c..0000000000 --- a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/client.js +++ /dev/null @@ -1,71 +0,0 @@ -/** Browser controls exercising the shipped Workspace draft-initialization API. */ -window.__ModuleLoader__.load({ - id: '@fixture/draft-initialization', - factory(require) { - const React = require('react') - return { - inject: ['slots', 'locale', 'uiWorkspace'], - apply(ctx) { - ctx.effect(() => ctx.locale.register('fixtureDraftInitialization', { - en: { - panel: 'Draft initialization fixture', workspace: 'Target workspace ID', - prompt: 'Initial draft JSON', replace: 'Clear previous draft', - initialize: 'Initialize draft', clear: 'Clear draft without prompt', - session: 'Target session ID', open: 'Open session', - }, - zh: { - panel: '草稿初始化测试', workspace: '目标工作区 ID', - prompt: '初始草稿 JSON', replace: '清除已有草稿', - initialize: '初始化草稿', clear: '无提示词清空草稿', - session: '目标会话 ID', open: '打开会话', - }, - })) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'fixture-draft-initialization', locale: 'fixtureDraftInitialization', - }, function DraftInitializationControls({ t }) { - const [workspaceId, setWorkspaceId] = React.useState('') - const [sessionId, setSessionId] = React.useState('') - const [prompt, setPrompt] = React.useState('""') - const [clearPreviousDraft, setClearPreviousDraft] = React.useState(false) - return React.createElement('section', { - 'data-draft-initialization': '', 'aria-label': t('panel'), - style: { - position: 'fixed', top: 64, right: 16, zIndex: 9999, - display: 'grid', gap: 4, width: 260, - }, - }, - React.createElement('input', { - 'aria-label': t('workspace'), value: workspaceId, - onChange: event => setWorkspaceId(event.target.value), - }), - React.createElement('textarea', { - 'aria-label': t('prompt'), value: prompt, - onChange: event => setPrompt(event.target.value), - }), - React.createElement('label', null, - React.createElement('input', { - type: 'checkbox', checked: clearPreviousDraft, - onChange: event => setClearPreviousDraft(event.target.checked), - }), t('replace')), - React.createElement('button', { - type: 'button', - onClick: () => ctx.uiWorkspace.startSession(workspaceId || undefined, { - prompt: JSON.parse(prompt), clearPreviousDraft, - }), - }, t('initialize')), - React.createElement('button', { - type: 'button', - onClick: () => ctx.uiWorkspace.startSession(workspaceId || undefined, { clearPreviousDraft: true }), - }, t('clear')), - React.createElement('input', { - 'aria-label': t('session'), value: sessionId, - onChange: event => setSessionId(event.target.value), - }), - React.createElement('button', { - type: 'button', onClick: () => ctx.uiWorkspace.openSession(sessionId), - }, t('open'))) - })) - }, - } - }, -}) diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml deleted file mode 100644 index 670c87eda4..0000000000 --- a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/cordis.patch.yml +++ /dev/null @@ -1,3 +0,0 @@ -- insert: - - id: fixture-draft-initialization - name: '@fixture/draft-initialization' diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js deleted file mode 100644 index 6733883191..0000000000 --- a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/index.js +++ /dev/null @@ -1,2 +0,0 @@ -/** Host companion for the browser's unsent-draft initialization fixture. */ -export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json b/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json deleted file mode 100644 index 5f16e1c44f..0000000000 --- a/apps/web/tests/fixtures/plugins/fixture-draft-initialization/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@fixture/draft-initialization", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "index.js", - "exports": { ".": "./index.js", "./client": "./client.js" }, - "dsh": { - "bundle": { "patch": "./cordis.patch.yml" }, - "client": { "platform": "web" } - } -} diff --git a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts index b9e7857721..6b01857c86 100644 --- a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts @@ -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() } + }) }) diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index 5ae32bf1aa..99fd92b3f9 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -1,5 +1,6 @@ // @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' @@ -7,6 +8,7 @@ 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' @@ -62,7 +64,8 @@ function chips(shell: SessionInputShell) { } function textReferences(shell: SessionInputShell): string[] { - return shell.editor.getEditorState().read(() => $nodesOfType(TextRefNode).map(node => node.getTextContent())) + return shell.editor.getEditorState().read(() => $getRoot().getAllTextNodes() + .filter(node => node instanceof TextRefNode).map(node => node.getTextContent())) } function triggerProvider(lexicon: InputTriggerController['lexicon']): InputTriggerController { @@ -141,6 +144,20 @@ describe('draft document values', () => { 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 }>([ @@ -271,6 +288,26 @@ describe('draft documents in the input shell', () => { 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 structured prompts without waiting for a view', () => { const shell = makeShell() const prompt = documentOf('read ', file) @@ -456,4 +493,126 @@ describe('draft documents in the input shell', () => { 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() + const second = Promise.withResolvers() + const provider = triggerProvider(createSnapshotStore>(new Map())) + const sink = vi.fn() + .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() + const started = Promise.withResolvers() + const provider: InputTriggerController = { + ...triggerProvider(createSnapshotStore>(new Map())), + serializeReference: (_source, _ref, signal) => { + started.resolve(signal) + return serialization.promise + }, + } + const sink = vi.fn(() => 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('recognizes slash syntax only while its catalog is unavailable', () => { + const unavailable = new Map<'/' | '@', readonly string[]>() + expect(scanTextRefs('', unavailable)).toEqual([]) + expect(scanTextRefs('/plan /plan.md /plan/path /plan。 x/plan @person', unavailable)).toEqual([ + { start: 0, end: 5, trigger: '/' }, + ]) + expect(scanTextRefs('/plan @person', new Map([['/', []]]))).toEqual([]) + expect(scanTextRefs('/plan @person', new Map([['@', ['person']]]))).toEqual([ + { start: 0, end: 5, trigger: '/' }, + { start: 6, end: 13, trigger: '@' }, + ]) + }) + + it('orders folder and catalog references without overlapping ranges', () => { + const text = '@src/ /plan @other/ @src/' + expect(scanTextRefs(text, new Map([['@', ['src']]]))).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: '@' }, + ]) + }) }) diff --git a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts index c571287d90..ee1aae3bd4 100644 --- a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts @@ -179,6 +179,11 @@ it('reconnects a replacement lexicon source on an explicit subscription refresh' 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(() => { @@ -204,7 +209,10 @@ it('unsubscribes the lexicon and refuses the old binding after Session disposal' 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') From 59b29ed6bdc6f0adeb03ec06a694e3e15cd790de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:56:13 +0800 Subject: [PATCH 05/12] style(test): format the browser error observer --- apps/web/tests/draft-initialization.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/draft-initialization.e2e.ts b/apps/web/tests/draft-initialization.e2e.ts index 17ffc7818d..d6bd18bbe0 100644 --- a/apps/web/tests/draft-initialization.e2e.ts +++ b/apps/web/tests/draft-initialization.e2e.ts @@ -196,7 +196,7 @@ async function launchDraftFixture(beforeBrowserOpen?: (page: Page, scaffold: Web onTestFinished(() => browser.close()) const page = await newEnglishPage(browser) const console = watchConsole(page) - page.on('pageerror', error => { globalThis.console.error(error.stack ?? error.message) }) + page.on('pageerror', (error) => { globalThis.console.error(error.stack ?? error.message) }) await observeDraftApi(page) onTestFinished(async ({ task }) => { if (task.result?.state !== 'fail') return From a4560150825c488e98e90658b6ab4a354a4d7185 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:23:54 +0800 Subject: [PATCH 06/12] docs(client): clarify draft lexicon initialization timing --- .../2026-09-30-structured-draft-initialization.i18n.yaml | 4 ++-- .../2026-09-30-structured-draft-initialization.md | 2 +- .../2026-09-30-structured-draft-initialization.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml index fd07ff5ec3..96262061e0 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml @@ -15,8 +15,8 @@ en: e55a4326cc40f5f7 zh: 089d8243a3ab9d8c /agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods: - en: 6f895b21233fae0e - zh: 4a1e92a97edcd519 + en: 1b701550258577c0 + zh: fad6da8f412688cb /agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow: en: dd57e639e9328f1d zh: 5e16cb2e3a73597c diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md index 5455c1b938..b4d4e0d5ab 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md @@ -34,7 +34,7 @@ The existing `dsh.conversation.` key remains. Readers accept both leg | `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()` | Subscribe to an available lexicon during construction; `InputHub` uses Session-scope `inject` to reconnect when the service arrives or is replaced and unsubscribes on disposal. Updates rescan the current editor without writing old text back | +| `SessionInputShell.refreshLexiconSubscription()` / `DraftEditorRuntime.refreshLexiconSubscription()` | Draft import connects an available lexicon after `InputHub` has cached the shell; construction does not resolve catalogs because their initialization can synchronously reenter the input resolver. Session-scope `inject` reconnects when the service arrives or is replaced and unsubscribes on disposal. Updates rescan the current editor without writing old text back | | `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 | diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md index 09b9020506..2bd66f7c83 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md @@ -34,7 +34,7 @@ Status: implemented | `SessionInputShell.requestDraftInitialization` | 在已经恢复的模型上执行清空/保留/填入规则;提交中拒绝修改,失败前不先清稿 | | `SessionInputShell.draftSnapshot`/`persistCurrentDraft()` | 按内容修订号缓存不含运行时 ID 的快照;`InputActions.persistDraft()` 委托显式保存方法;`bindDraftPersistence(write)` 仍只绑定回调 | | `DraftEditorRuntime.restoreDraft` | 接受引用的语义字段,重建 ReferenceChipNode;既有发送失败恢复继续使用同一实现 | -| `SessionInputShell.refreshLexiconSubscription()`/`DraftEditorRuntime.refreshLexiconSubscription()` | 构造时接上可用词典;`InputHub` 用 Session scope 的 `inject` 在服务到达或替换时重接,销毁时退订;词典更新重扫当前 editor,不覆盖旧文本 | +| `SessionInputShell.refreshLexiconSubscription()`/`DraftEditorRuntime.refreshLexiconSubscription()` | `InputHub` 缓存输入对象后,由草稿导入流程接上可用词典;构造时不解析词典,避免其初始化同步重入输入对象解析。Session scope 的 `inject` 在服务到达或替换时重接,销毁时退订;词典更新重扫当前 editor,不覆盖旧文本 | | `ConversationStoreState`/`createConversationStore` | draft 支持兼容旧字符串的结构化输入,View 状态及原 key 保持 | | `DefaultConversationViews` | 删除恢复/预填 effect;保留持久化回调的绑定和解绑。初始保存只能读取 owner 的当前快照,不能把 render 时的旧文字写回 | | `apply.ts` 中现有 `selectWorkspace` 回调 | 沿用原有跨工作区携带规则,但传递 `draftSnapshot` 而不是纯文本,防止携带时再次丢失引用 | From e6783c20fc66e79c8a12174e054c27545f73babb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:24:01 +0800 Subject: [PATCH 07/12] fix(client): initialize draft lexicon after caching input shell --- .../src/client/input/facade.ts | 3 +- .../tests/draft-hub.client.spec.ts | 85 +++++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0434c726d5..5dcb925ac9 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -180,7 +180,6 @@ export class SessionInputShell implements SessionInput { this.unregister = this.draftEditor.register() this.state = createSnapshotStore(this.compose()) this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() }) - this.draftEditor.refreshLexiconSubscription() } // ---- editor plumbing ---- @@ -217,6 +216,8 @@ export class SessionInputShell implements SessionInput { * @param text - plain text or the complete semantic document. */ setDraft(text: DraftInput): void { + // Catalog resolution can reenter InputHub, which caches this shell before importing its draft. + this.refreshLexiconSubscription() const draft = resolveDraftInput(text) if (draft.references.length === 0) this.draftEditor.setDraft(draft.text) else this.draftEditor.restoreDraft(draft.text, draft.references) diff --git a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts index ee1aae3bd4..46e62c7607 100644 --- a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts @@ -2,6 +2,7 @@ /** 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' @@ -11,7 +12,7 @@ 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 type { SessionInputShell } from '../src/client/input/facade.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' import { InputHub } from '../src/client/input/hub.ts' import { zh } from '../src/client/locales.ts' @@ -24,7 +25,7 @@ const saved: DraftSnapshot = { }], } -async function fixture(draft: unknown) { +async function storedSession(draft: unknown) { const id = SessionId(`draft-hub-${randomUUID()}`) const key = `dsh.conversation.${id}` const previous = localStorage.getItem(key) @@ -47,8 +48,12 @@ async function fixture(draft: unknown) { await reference.ready const binding = reference.binding const hub = new InputHub(ctx, makeTranslate(zh, {})) - const shell = hub.shellFor(binding) - return { ctx, sessions, id, key, raw, reference, binding, hub, shell } + 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[] { @@ -97,15 +102,85 @@ function controller(lexicon: () => InputTriggerController['lexicon']): InputTrig } class TriggerProvider extends Service { - constructor(ctx: Context, private readonly config: { readonly controller: InputTriggerController }) { + 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('does not resolve input triggers until the first draft initialization', 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(resolve).toHaveBeenCalled() + 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) + expect(observed).toBe(true) + 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).toEqual(saved) + 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: [] }) From ae08c57c21e1d9aafaad4f9875ad40573f07d9a8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:33:38 +0800 Subject: [PATCH 08/12] test(client): avoid unsafe draft catalog matchers --- .../tests/api-catalog.client.spec.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 30cb487823..ae3c1ed243 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -44,11 +44,12 @@ describe('Client Cordis inspect catalog', () => { const result = queryServiceApi('uiWorkspace') as { referencedTypes: readonly { name: string; declaration: string }[] } - expect(result.referencedTypes).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'StartSessionOptions', declaration: expect.stringContaining('DraftInitializationOptions') }), - expect.objectContaining({ name: 'DraftInitializationOptions', declaration: expect.stringContaining('readonly prompt?: DraftInput') }), - expect.objectContaining({ name: 'DraftSnapshot', declaration: expect.stringContaining('readonly references: readonly DraftReference[]') }), - ])) + expect(result.referencedTypes.find(type => type.name === 'StartSessionOptions')?.declaration) + .toContain('DraftInitializationOptions') + expect(result.referencedTypes.find(type => type.name === 'DraftInitializationOptions')?.declaration) + .toContain('readonly prompt?: DraftInput') + expect(result.referencedTypes.find(type => type.name === 'DraftSnapshot')?.declaration) + .toContain('readonly references: readonly DraftReference[]') }) it('includes the current referenced type closure for the Sessions service', () => { From 32e5555699a361b5352cb376a60031be50c1b5e1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:13:37 +0800 Subject: [PATCH 09/12] test(web): await source draft before target initialization --- apps/web/tests/draft-initialization.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/draft-initialization.e2e.ts b/apps/web/tests/draft-initialization.e2e.ts index d6bd18bbe0..8968e5aeec 100644 --- a/apps/web/tests/draft-initialization.e2e.ts +++ b/apps/web/tests/draft-initialization.e2e.ts @@ -556,6 +556,7 @@ it('applies a parameter transition matrix to one reusable target without changin const replacement = structuredDraft(firstId, '目标替换草稿') const plain = { text: '已有目标文字 🧭', references: [] } await initialize(page, source, first.id) + await assertDraft(page, firstId, source) await initialize(page, structured, second.id, true) await expect.poll(() => second.sessionIds.length, SETTLE).toBe(1) const secondId = second.sessionIds[0]! From d7099f9e2e8394240ab566e2bad8f897e582c9cc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:42 +0800 Subject: [PATCH 10/12] docs(client): define text-only prompt initialization --- ...-structured-draft-initialization.i18n.yaml | 20 +++++++++---------- ...6-09-30-structured-draft-initialization.md | 14 ++++++------- ...9-30-structured-draft-initialization.zh.md | 14 ++++++------- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml index 96262061e0..35787428a1 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.i18n.yaml @@ -9,26 +9,26 @@ en: 6aa396e12264ee48 zh: 56849a18acaf830a /agent-note-initial-draft-content-and-structured-reference-restoration/decision: - en: 28c7f9a2f2c6bbfd - zh: 2586b5e5c96eb0fe + en: 9cf2afd9d5e663e9 + zh: ad59b1bec6ec4666 /agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model: - en: e55a4326cc40f5f7 - zh: 089d8243a3ab9d8c + en: 7f33895364a9f5cc + zh: f1c79ea15f5c151e /agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods: - en: 1b701550258577c0 - zh: fad6da8f412688cb + 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: afbbf0faf308c575 - zh: b0fda2d70a4e186b + 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: b8a2894d1ab69437 - zh: 781aa785a67958c3 + en: 341f4f36936f686a + zh: 479fbe04e810f640 /agent-note-initial-draft-content-and-structured-reference-restoration/consequences: en: 2ffae2dca902341d zh: fb33a64aaabfd89c diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md index b4d4e0d5ab..2c332fc18c 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md @@ -12,13 +12,13 @@ New-task entry points need to place unsent content in the composer. A draft cont 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 a string or `DraftSnapshot`; 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`. +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. Structured input 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. +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.` 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. @@ -30,18 +30,18 @@ The existing `dsh.conversation.` key remains. Readers accept both leg | `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` | Accept a string or `DraftSnapshot` and import one document through the editor, without simulating keyboard events or submitting | +| `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()` | Draft import connects an available lexicon after `InputHub` has cached the shell; construction does not resolve catalogs because their initialization can synchronously reenter the input resolver. Session-scope `inject` reconnects when the service arrives or is replaced and unsubscribes on disposal. Updates rescan the current editor without writing old text back | +| `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 `setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`. Options are copied 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. +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 @@ -61,7 +61,7 @@ user edits / input actions ### Asynchronous skill matching -Skills remain editable text, not file chips. A pending lexicon never locks the draft. Its existing subscription rescans the current document on arrival instead of saving and restoring the text present when the request began. Generic syntax may highlight provisionally; an available catalog narrows the match and can remove unmatched highlighting. 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. +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 @@ -79,7 +79,7 @@ The core model invariants are semantic equivalence after import, export, and JSO [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`, without an additional test plugin. All eight cases passed. They cover absent, empty, plain, and structured prompt options; 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`. +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 diff --git a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md index 2bd66f7c83..e4d78615e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md @@ -12,13 +12,13 @@ Status: implemented 保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。 -`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 接受字符串或 `DraftSnapshot`;无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。 +`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、函数或临时高亮。 -字符串等价于不含引用结构的输入;不会据模糊的 `@` 查询擅自选中某个文件。结构化输入直接复用编辑器已有的引用恢复能力,无需查询文件目录或读取文件内容。恢复普通文字、文件胶囊、目录胶囊和 Session 胶囊都经过已注册的编辑器变换和投影流程。 +字符串等价于不含引用结构的输入;不会据模糊的 `@` 查询擅自选中某个文件。公开的 `SessionInput.setDraft` 与 `InputActions.setDraft` 仅接受文本。内部结构化恢复直接复用编辑器已有的引用恢复能力,无需查询文件目录或读取文件内容。恢复普通文字、文件胶囊、目录胶囊和 Session 胶囊都经过已注册的编辑器变换和投影流程。 现有 `dsh.conversation.` key 保留,`draft` 可读取旧字符串,也可读取新的结构化值;新写入保存结构化值。仍由原 Conversation store 写整个记录,不增加第二个同 key 写入者。引用变化即使没有改变显示文字,也必须进入持久化快照。 @@ -30,18 +30,18 @@ Status: implemented | `SessionInputResolver`/`InputHub.requestDraftInitialization` | 接收 binding 和 options,交给该 binding 的输入对象,不重新查找全局当前 Session | | `InputHub.shellFor` | 新对象登记及清理接线后,读取该 Session 保存的草稿,通过输入模型导入,然后返回;复用对象不再恢复 | | `stores.ts` 的 `readConversationDraft(sessionId)`/`draft.ts` 的 `parseStoredDraft` | 只读原存储 key;兼容字符串并检查结构化引用的位置和字段,不创建 store 或增加写入者 | -| `SessionInputShell.setDraft` | 接受字符串或 `DraftSnapshot`,向编辑器发送一次文档导入,不模拟键盘事件、不提交内容 | +| `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,不覆盖旧文本 | +| `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)`、`setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`;进入异步创建前复制 options,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。 +调用参数依次为 `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。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。 ### 数据流 @@ -61,7 +61,7 @@ user edits / input actions ### 异步 skill 匹配 -skill 是可编辑文字,不转为文件胶囊。词典尚未返回时不锁草稿;数据到达后通过现有订阅机制重匹配当前文档,而不是保存请求发出时的文字并回写。高亮可先按通用语法展示,已知目录返回后再收窄;没有匹配时可撤销高亮。此过程不修改正文和结构化引用,不抢光标、不增加用户 Undo 步骤。命令仍只在用户提交时走既有校验和执行路径。 +skill 是可编辑文字,不转为文件胶囊。词典尚未返回时不锁草稿;斜杠名称先保持普通文字,词典提供匹配名称后才高亮。提供方缺席、词典不可用或名称不匹配时不显示 skill 高亮。数据到达后通过现有订阅机制重匹配当前文档,而不是保存请求发出时的文字并回写。此过程不修改正文和结构化引用,不抢光标、不增加用户 Undo 步骤。命令仍只在用户提交时走既有校验和执行路径。 ## 考虑过的替代方案 @@ -79,7 +79,7 @@ skill 是可编辑文字,不转为文件胶囊。词典尚未返回时不锁 [导航用例](../../../../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`,不新增测试插件。完整八个用例通过,覆盖缺省、空字符串、普通文字和结构化提示词参数,明确传入及沿用当前工作区,保留与清空、创建与复用、真实界面的 Session 多轮切换和编辑、刷新、旧字符串及工作区选择器携带。延迟创建响应的两种返回顺序都保持较新的目标选择,并且不改动另一份草稿。延迟返回的真实 skill 目录只重匹配当前文字,不替换胶囊、选区或 Undo 历史。断言检查胶囊字段、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。 +[浏览器 e2e](../../../../apps/web/tests/draft-initialization.e2e.ts)通过已有模块加载观测方式取得实际 Workspace API,调用公开 `startSession` 传入文本选项,不新增测试插件。结构化 fixture 经既有已存草稿路径恢复。用例覆盖缺省、空字符串和非空提示词,明确传入及沿用当前工作区,保留与清空、创建与复用、真实界面的 Session 多轮切换和编辑、刷新、旧字符串及工作区选择器携带。延迟创建响应的两种返回顺序都保持较新的目标选择,并且不改动另一份草稿。延迟返回的真实 skill 目录只重匹配当前文字,不替换胶囊、选区或 Undo 历史。断言检查胶囊字段、文件预览和持久化快照。未点击发送前,Host 日志没有 `user/message` 或 `turn/start`。 ## 后果与边界 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3fdacc9557..a4822e73a4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13ae68865fa315d7 zh: c152f020abf20f35 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 7ba912ea173fbf46 - zh: 97bf287488883e9e + en: dfccf6560c8022d4 + zh: 42e07e8c57f85a82 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 930d1a04a5..4ad3bd853b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -56,7 +56,7 @@ 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. 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; 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. +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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7430c95c51..b7e0be6157 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -56,7 +56,7 @@ 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)规定所有权与恢复流程。 +草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 22e9edbcb1..731148ce81 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -36,8 +36,8 @@ en: dd0a7799c76c0f94 zh: 48d1cdacc9168aa2 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: - en: 51493d1e280e99e3 - zh: 67bfc0df920c6952 + en: 0cc718002a249cc9 + zh: 969b2a034331ab56 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole: en: 978415ec6b2e0cc0 zh: 0d5c705ff34fb1da diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index fb71459968..e5fbda2eea 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -89,7 +89,7 @@ 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 or a structured draft with explicit reference metadata. Without clearing, existing text or attachments are preserved; explicit clearing replaces only the target Session's text and inline references, leaving attachments intact. Options are copied 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. +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 diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index fea41ead64..c38b395ba6 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -89,7 +89,7 @@ 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 的数据流。 +客户端调用方可以在 `uiWorkspace.startSession` 的第二参数中传入 `prompt` 与 `clearPreviousDraft`。提示词仅接受普通文字;结构化草稿由内部恢复和工作区携带流程处理。不清空时保留已有文字或附件;显式清空只替换目标 Session 的文字和内联引用,不删除附件。在异步创建前捕获选项值,在导航提交前应用到准确保留的 binding。已被后续操作取代的导航不写内容;输入处于提交阻塞状态时拒绝准备并保留原选择。不传选项时保持正常新会话流程。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)说明输入 owner 的数据流。 ### 目录流子 slot From 2c93df5620b85f45fc2ed9e28d67af246d08f78d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:56 +0800 Subject: [PATCH 11/12] fix(client): narrow prompt input and defer skill highlighting --- apps/web/tests/draft-initialization.e2e.ts | 128 +++++++++++------- .../src/client/contract/input.ts | 14 +- .../src/client/input/decorations.ts | 6 +- .../src/client/input/facade.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 4 +- .../tests/draft-document.client.spec.ts | 31 +++-- .../tests/draft-hub.client.spec.ts | 31 +++-- .../tests/input-scenarios.client.spec.tsx | 31 +++-- .../tests/submit-machine.client.spec.ts | 5 +- .../ui-workspace/src/client/navigation.ts | 2 +- .../tests/workspaces-service.client.spec.ts | 30 +--- .../src/client/api-catalog.ts | 22 +-- .../tests/api-catalog.client.spec.ts | 11 +- 13 files changed, 175 insertions(+), 144 deletions(-) diff --git a/apps/web/tests/draft-initialization.e2e.ts b/apps/web/tests/draft-initialization.e2e.ts index 8968e5aeec..9883a7db41 100644 --- a/apps/web/tests/draft-initialization.e2e.ts +++ b/apps/web/tests/draft-initialization.e2e.ts @@ -33,7 +33,7 @@ interface DraftSnapshot { const EMPTY: DraftSnapshot = { text: '', references: [] } interface DraftOptions { - prompt?: string | DraftSnapshot + prompt?: string clearPreviousDraft?: boolean } @@ -123,6 +123,25 @@ function storedDraft(page: Page, sessionId: SessionId): Promise { }, sessionId) } +async function writeStoredDrafts(page: Page, drafts: readonly { sessionId: SessionId; draft: string | DraftSnapshot }[]): Promise { + 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 { + await writeStoredDrafts(page, drafts) + await page.reload({ waitUntil: 'load' }) +} + function editorSelection(page: Page) { return composer(page).evaluate((element) => { const selection = element.ownerDocument.getSelection() @@ -162,7 +181,7 @@ async function assertDraft(page: Page, sessionId: SessionId, expected: DraftSnap [...element.children].map(paragraph => paragraph.textContent).join('\n')), SETTLE).toBe(displayed) } -async function initialize(page: Page, prompt: string | DraftSnapshot, workspaceId?: string, clearPreviousDraft = false) { +async function initialize(page: Page, prompt: string, workspaceId?: string, clearPreviousDraft = false) { await startSession(page, workspaceId, { prompt, clearPreviousDraft }) } @@ -300,10 +319,16 @@ it('restores repeated file, folder and Session capsules across edits, Workspace await mkdir(join(workspace.path, '目录')) await writeFile(join(workspace.path, FILE_NAME), FILE_BODY) } - const secondDraft = structuredDraft(firstId, '工作区乙') - await initialize(page, secondDraft, second.id) + 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), @@ -354,8 +379,7 @@ it('restores repeated file, folder and Session capsules across edits, Workspace ], } `) - const firstDraft = structuredDraft(secondId, '工作区甲') - await initialize(page, firstDraft, first.id) + await openWorkspaceSession(page, first, firstId) await assertDraft(page, firstId, firstDraft) const drafts = [ @@ -404,11 +428,7 @@ it('restores repeated file, folder and Session capsules across edits, Workspace 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,不是胶囊' - // Seed the old storage representation before the reloaded client creates its stores. - await page.addInitScript(({ id, text }) => { - localStorage.setItem(`dsh.conversation.${id}`, JSON.stringify({ draft: text, view: null, viewRequest: null })) - }, { id: firstId, text: legacy }) - await page.reload({ waitUntil: 'load' }) + 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 => @@ -427,7 +447,7 @@ it('rematches the current draft after a delayed real skills catalog without repl const releaseCatalog = Promise.withResolvers() let captured: { status: number; body: string; request: string | null } | undefined let catalogRequests = 0 - const { scaffold, page, console, first, firstId } = await launchDraftFixture(async (page, scaffold) => { + 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 }) @@ -437,27 +457,6 @@ it('rematches the current draft after a delayed real skills catalog without repl ].join('\n')) } await writeFile(join(scaffold.workspaceCwd, FILE_NAME), FILE_BODY) - 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' }) - }) }) const file = `@"${FILE_NAME}"` const originalPrefix = '/draft-late-original\n' @@ -469,8 +468,33 @@ it('rematches the current draft after a delayed real skills catalog without repl label: FILE_NAME, appearance: 'file', clipboardText: file, }], } - await initialize(page, initial, first.id) + 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') @@ -499,7 +523,7 @@ it('rematches the current draft after a delayed real skills catalog without repl expect(selection.text).toBe('cd') expect(catalogRequests).toBe(1) await expect.poll(() => composer(page).locator('[data-composer-text-ref]').allTextContents(), SETTLE) - .toEqual(['/draft-late-current', '/draft-late-missing']) + .toEqual([]) const delivered = page.waitForResponse(response => new URL(response.url()).pathname === '/api/skills/list') releaseCatalog.resolve(undefined) @@ -526,7 +550,7 @@ it('carries all reference capsules through the real Workspace picker and leaves await writeFile(join(workspace.path, FILE_NAME), FILE_BODY) } const draft = structuredDraft(firstId, '切换工作区携带') - await initialize(page, draft, first.id) + 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() @@ -553,13 +577,18 @@ it('applies a parameter transition matrix to one reusable target without changin const { scaffold, page, console, first, firstId, second } = await launchDraftFixture() const source = structuredDraft(firstId, '源草稿保持不变') const structured = structuredDraft(firstId, '目标结构草稿') - const replacement = structuredDraft(firstId, '目标替换草稿') + const restored = structuredDraft(firstId, '目标恢复草稿') + const replacement = { text: '目标替换文字 🧭', references: [] } const plain = { text: '已有目标文字 🧭', references: [] } - await initialize(page, source, first.id) - await assertDraft(page, firstId, source) - await initialize(page, structured, second.id, true) + 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: { @@ -567,19 +596,24 @@ it('applies a parameter transition matrix to one reusable target without changin 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: 'structured input preserves existing text', target: 'explicit', options: { prompt: structured, clearPreviousDraft: false }, expected: plain }, + { 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: 'structured input fills a reused empty target', target: 'explicit', options: { prompt: structured, clearPreviousDraft: false }, expected: structured }, - { name: 'structured input explicitly replaces capsules', target: 'current', options: { prompt: replacement, clearPreviousDraft: true }, expected: replacement, reload: true }, - { name: 'absent prompt and false clear preserve capsules', target: 'explicit', options: { clearPreviousDraft: false }, expected: replacement }, + { 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) @@ -609,8 +643,8 @@ it.each(['older-first', 'newer-first'] as const)( 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 = structuredDraft(firstId, '只有最后目标收到草稿') - await initialize(page, source, first.id) + const latest = { text: '只有最后目标收到文字草稿 🧭', references: [] } + await restoreStoredDrafts(page, [{ sessionId: firstId, draft: source }]) await assertDraft(page, firstId, source) interface HeldCreation { @@ -658,7 +692,7 @@ it.each(['older-first', 'newer-first'] as const)( const secondId = second.sessionIds[0]! expect(older.response?.body).toContain(secondId) await assertDraft(page, firstId, source) - await startSession(page, third.id, { prompt: latest, clearPreviousDraft: true }) + 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]! diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 99a9eb336e..e199f72fe0 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -11,7 +11,7 @@ import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/cli 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' -import type { ArbitrateKey, ArbitrateOutcome, DraftInput, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts' +import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts' import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts' /** Attachment payload passed to a claimed command submission. */ @@ -175,8 +175,8 @@ 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). */ - setDraft(text: DraftInput): void + /** 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 /** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */ @@ -221,9 +221,9 @@ export interface SessionInputResolver { requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions): DraftInitializationResult } -/** Initial content for a target Session; attachments remain under their existing owner. */ +/** Initial text for a target Session; attachments remain under their existing owner. */ export interface DraftInitializationOptions { - readonly prompt?: DraftInput + readonly prompt?: string readonly clearPreviousDraft?: boolean } @@ -246,8 +246,8 @@ 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). */ - setDraft(text: DraftInput): void + /** 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. */ diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index a0b232e3f4..d71c4fb252 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -34,10 +34,10 @@ const SLASH_TOKEN_END_RE = /^(?:\s|$)/ * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member when its catalog is available; a `/name` token must end at whitespace or the draft + * 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; missing slash catalogs allow syntax-only matches. + * @param lexicon - per-trigger name lists; missing catalogs match no named tokens. * @returns matched ranges in draft order. */ export function scanTextRefs( @@ -51,7 +51,7 @@ export function scanTextRefs( 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 ((trigger === '/' && !lexicon.has('/')) || lexicon.get(trigger)?.includes(name)) { + if (lexicon.get(trigger)?.includes(name)) { const start = m.index + (m[1]?.length ?? 0) out.push({ start, end: start + 1 + name.length, trigger }) } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 5dcb925ac9..43648924f1 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -96,7 +96,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection const EMPTY_QUEUE: InboxState['next-turn'] = [] -/** Unavailable catalogs leave syntax-only text decoration enabled. */ +/** 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. */ @@ -216,8 +216,6 @@ export class SessionInputShell implements SessionInput { * @param text - plain text or the complete semantic document. */ setDraft(text: DraftInput): void { - // Catalog resolution can reenter InputHub, which caches this shell before importing its draft. - this.refreshLexiconSubscription() const draft = resolveDraftInput(text) if (draft.references.length === 0) this.draftEditor.setDraft(draft.text) else this.draftEditor.restoreDraft(draft.text, draft.references) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index ae4b5f7cb6..ce7386dde8 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -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') @@ -551,7 +552,8 @@ describe('Conversation inject API', () => { references: [{ source: 'reference', ref: '@one.ts', label: 'one.ts', appearance: 'file' as const, clipboardText: '@one.ts', offset: 6, length: 7 }], } - b.inputApi(ROOT).actions.setDraft(draft) + 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) diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index 99fd92b3f9..d545273e8f 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -53,6 +53,7 @@ function makeShell(overrides: Partial = {}): SessionInputShell ...overrides, }) onTestFinished(() => { shell.dispose() }) + shell.refreshLexiconSubscription() return shell } @@ -90,7 +91,7 @@ function expectInitializationBlocked(shell: SessionInputShell): void { const unbind = shell.bindDraftPersistence(write) try { expect(shell.requestDraftInitialization({ clearPreviousDraft: true })).toBe('blocked') - expect(shell.requestDraftInitialization({ prompt: documentOf('replacement ', folder), 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) @@ -308,12 +309,12 @@ describe('draft documents in the input shell', () => { expect(second).toHaveBeenCalledTimes(2) }) - it('applies initial structured prompts without waiting for a view', () => { + it('applies initial text without waiting for a view or inferring reference chips', () => { const shell = makeShell() - const prompt = documentOf('read ', file) + const prompt = `read ${file.clipboardText}` expect(shell.requestDraftInitialization({ prompt })).toBe('applied') - expect(shell.draftSnapshot).toEqual(prompt) - expect(chips(shell)).toHaveLength(1) + expect(shell.draftSnapshot).toEqual({ text: prompt, references: [] }) + expect(chips(shell)).toEqual([]) }) it('preserves existing text or attachments unless target text is explicitly cleared', () => { @@ -468,7 +469,7 @@ describe('draft documents in the input shell', () => { start: 0, end: '/older'.length, draftRev: shell.snapshot.draftRev, })).toBe(true) const draft = shell.draftSnapshot - expect(textReferences(shell)).toEqual(['/current']) + expect(textReferences(shell)).toEqual([]) lexicon.set(new Map([['/', ['older']]])) await vi.waitFor(() => { expect(textReferences(shell)).toEqual([]) }) expect(shell.draftSnapshot).toBe(draft) @@ -479,7 +480,11 @@ describe('draft documents in the input shell', () => { expect(chips(shell)).toHaveLength(1) shell.editor.dispatchCommand(UNDO_COMMAND, undefined) await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(previous) }) - expect(textReferences(shell)).toEqual(['/older']) + // 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']) @@ -590,22 +595,22 @@ describe('draft documents in the input shell', () => { }) describe('plain-text reference catalogs', () => { - it('recognizes slash syntax only while its catalog is unavailable', () => { + 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([ - { start: 0, end: 5, trigger: '/' }, - ]) + 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: 0, end: 5, trigger: '/' }, { 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']]]))).toEqual([ + expect(scanTextRefs(text, new Map([['@', ['src']], ['/', ['plan']]]))).toEqual([ { start: 0, end: 4, trigger: '@' }, { start: 6, end: 11, trigger: '/' }, { start: 12, end: 19, trigger: '@' }, diff --git a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts index 46e62c7607..0800ae35ce 100644 --- a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts @@ -115,7 +115,7 @@ class TriggerProvider extends Service { } } -it('does not resolve input triggers until the first draft initialization', async () => { +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) @@ -134,7 +134,9 @@ it('does not resolve input triggers until the first draft initialization', async expect(resolve).not.toHaveBeenCalled() shell.setDraft(saved) - expect(resolve).toHaveBeenCalled() + expect(source.subscribers).toBe(0) + expect(shell.draftSnapshot).toEqual(saved) + shell.refreshLexiconSubscription() expect(source.subscribers).toBe(1) expect(shell.draftSnapshot).toEqual(saved) }) @@ -169,13 +171,19 @@ it('reuses one shell during synchronous catalog retention and restores its initi await provider.await() const first = b.hub.shellFor(b.binding) - expect(observed).toBe(true) + 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).toEqual(saved) + 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) @@ -184,7 +192,7 @@ it('reuses one shell during synchronous catalog retention and restores its initi 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(['/saved']) + 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) @@ -196,6 +204,7 @@ it('restores legacy text before a view and reuses the live draft on later shell 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) @@ -209,7 +218,7 @@ it('subscribes when a provider arrives and disconnects each withdrawn provider', start: 0, end: '/saved'.length, draftRev: b.shell.snapshot.draftRev, })).toBe(true) const current = b.shell.draftSnapshot - expect(textReferences(b.shell)).toEqual(['/current']) + expect(textReferences(b.shell)).toEqual([]) const first = catalog([]) const firstProvider = b.ctx.plugin(TriggerProvider, { controller: controller(() => first.source) }) await firstProvider.await() @@ -221,7 +230,10 @@ it('subscribes when a provider arrives and disconnects each withdrawn provider', first.set(['current']) await vi.waitFor(() => { expect(textReferences(b.shell)).toEqual(['/current']) }) await firstProvider.dispose() - expect(first.subscribers).toBe(0) + 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) @@ -238,7 +250,10 @@ it('subscribes when a provider arrives and disconnects each withdrawn provider', second.set(['current']) await vi.waitFor(() => { expect(textReferences(b.shell)).toEqual(['/current']) }) await secondProvider.dispose() - expect(second.subscribers).toBe(0) + 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) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2ab3c6c025..d3993c70df 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -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) @@ -355,7 +359,7 @@ describe('scenario H: backspace breaks the token', () => { }) describe('scenario: reference decoration follows the current lexicon', () => { - it('keeps provisional slash decoration for a matching catalog and removes it for an empty catalog', async () => { + 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,22 +374,31 @@ describe('scenario: reference decoration follows the current lexicon', () => { }, } as never) }) + b.type('/older now') + expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() b.type('/deploy now') - expect(b.view.container.querySelector('[data-composer-text-ref]')?.textContent).toBe('/deploy') - // The catalog settles (ui-skill's settle path fires the same notification). + const draft = b.shell.draftSnapshot + expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() 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?.() }) - act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) - expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull() + 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') }) }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index d5e3a8cc9b..9bda0536aa 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -362,9 +362,10 @@ describe('decorations: scanTextRefs', () => { ]) }) - it('matches slash syntax while the catalog is unavailable and narrows when it arrives', () => { - expect(scanTextRefs('/goal x', new Map())).toEqual([{ start: 0, end: 5, trigger: '/' }]) + 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', () => { diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 0998520f98..61084b7f79 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -227,7 +227,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { } startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void { - const draftOptions = options === undefined ? undefined : structuredClone(options) + const draftOptions = options === undefined ? undefined : { ...options } const initializeDraft = draftOptions !== undefined && (draftOptions.prompt !== undefined || draftOptions.clearPreviousDraft === true) const workspace = this.workspaces.list.getSnapshot() diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 640992d008..ef041c51b2 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -761,18 +761,7 @@ describe('UiWorkspaceService', () => { { name: 'plain text', options: { prompt: '草稿 🧭\nsecond line', clearPreviousDraft: true } }, { name: 'empty text', options: { prompt: '' } }, { name: 'clear without prompt', options: { clearPreviousDraft: true } }, - { - name: 'structured references', - options: { - prompt: { - text: '🧭 @notes.md', - references: [{ - offset: 3, length: 9, source: 'reference', ref: '@notes.md', label: 'notes.md', - appearance: 'file', clipboardText: '@notes.md', - }], - }, - }, - }, + { 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')]) }) @@ -835,32 +824,23 @@ describe('UiWorkspaceService', () => { expect(b.notify).not.toHaveBeenCalled() }) - it('captures options and nested references before asynchronous Session creation', async () => { + it('captures prompt text and clear intent before asynchronous Session creation', async () => { const b = bench({ workspaces: workspaceState([workspace('a')]) }) const created = Promise.withResolvers() b.sessions.create.mockReturnValueOnce(created.promise) const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace') const options = { - prompt: { - text: '🧭 @notes.md', - references: [{ - offset: 3, length: 9, source: 'reference', ref: '@notes.md', label: 'notes.md', - appearance: 'file' as const, clipboardText: '@notes.md', - }], - }, + prompt: '🧭 @notes.md', clearPreviousDraft: true, } - const expected = structuredClone(options) + 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.text = 'caller changed the text' - options.prompt.references[0]!.label = 'caller changed the reference' - options.prompt.references.length = 0 - options.prompt = { text: 'caller replaced the prompt', references: [] } + options.prompt = 'caller replaced the prompt' created.resolve(sid('captured')) await completion diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1f474a2f24..13adbfde3e 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -581,19 +581,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DraftInitializationOptions', - declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: DraftInput;\n readonly clearPreviousDraft?: boolean;\n}', - }, - { - name: 'DraftInput', - declaration: 'export type DraftInput = string | DraftSnapshot;', - }, - { - name: 'DraftReference', - declaration: 'export type DraftReference = Omit;', - }, - { - name: 'DraftSnapshot', - declaration: 'export interface DraftSnapshot {\n readonly text: string;\n readonly references: readonly DraftReference[];\n}', + declaration: 'export interface DraftInitializationOptions {\n readonly prompt?: string;\n readonly clearPreviousDraft?: boolean;\n}', }, { name: 'EntryKeyOf', @@ -723,10 +711,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObservableSnapshot', declaration: 'export interface ObservableSnapshot {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', }, - { - name: 'Occurrence', - declaration: 'export interface Occurrence {\n readonly occurrenceId: number;\n readonly source: string;\n readonly ref: string;\n readonly offset: number;\n readonly length: number;\n readonly label: string;\n readonly appearance?: ReferenceInsert[\'appearance\'];\n readonly clipboardText: string;\n readonly invalid?: boolean;\n}', - }, { name: 'OpenState', declaration: 'export type OpenState = \'cold\' | \'loading\' | \'open\' | \'error\';', @@ -811,10 +795,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'QueueAction', declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly TextBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', }, - { - name: 'ReferenceInsert', - declaration: 'export interface ReferenceInsert {\n readonly source: string;\n readonly ref: string;\n readonly label: string;\n readonly appearance?: \'session\' | \'file\' | \'folder\';\n readonly clipboardText: string;\n}', - }, { name: 'RegisterFactory', declaration: 'export interface RegisterFactory {\n (options: RegisterFactoryOptions, component: SlotComponent>): () => void;\n}', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index ae3c1ed243..9a63d8886f 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -40,16 +40,19 @@ describe('Client Cordis inspect catalog', () => { expect(new Set(names).size).toBe(names.length) }) - it('describes initial content and structured references in Workspace navigation', () => { + 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?: DraftInput') - expect(result.referencedTypes.find(type => type.name === 'DraftSnapshot')?.declaration) - .toContain('readonly references: readonly DraftReference[]') + .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', () => { From 9aa8e788baa4811232c57dc66484191cad9fd874 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:54:35 +0800 Subject: [PATCH 12/12] docs(client): clarify draft initialization lifecycle --- .../src/client/contract/input.ts | 1 + .../ui-conversation/src/client/input/facade.ts | 10 +++++----- .../ui-conversation/src/client/input/hub.ts | 18 +++++++----------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index e199f72fe0..52ec80eda9 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -217,6 +217,7 @@ export interface SessionInputResolver { * @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 } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 43648924f1..9f4a264e46 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -34,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. */ @@ -242,7 +241,8 @@ export class SessionInputShell implements SessionInput { /** * Apply one new-task request to this already-initialized input model. * @param options - replacement content and explicit permission to clear existing text. - * @returns whether text changed, was preserved, or submission prevented editing. + * @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' diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 756bc0dc55..fb8d8a736f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -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' @@ -90,10 +87,9 @@ export class InputHub implements SessionInputResolver { } /** - * 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. */