From dadb92302f957b7949ae65835f27467a544e8b27 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:09:42 +0800 Subject: [PATCH] adapt todo display to the slash/input/session architecture - TodoPanel mounts through a 'conversation.input.dock' list entry (todoDockEntry, QueueDock posture, order -1 above the queue rows) instead of a ConversationRoot hardcode; the inner component is unchanged and takes useSession from the dock entry's standard kit. - The verify-todo-display.mjs chromium probe is replaced by an assembled keyless snapshot (apps/web/tests/todo-display.snapshot.ts, the code-mode-fixture idiom) pinning the TodoRow summary/state, the dock panel content, and the collapse round-trip over built bundles. - Fake snapshots across specs gain the todos field; bilingual note/READMEs updated for the dock mount and the snapshot. --- .../2026-07-23-web-todo-display.i18n.yaml | 4 +- .../feature/2026-07-23-web-todo-display.md | 7 +- .../feature/2026-07-23-web-todo-display.zh.md | 7 +- apps/web/tests/todo-display.snapshot.ts | 186 ++++++++++++++++++ .../ui-conversation/src/client/apply.ts | 4 + .../src/client/skeleton/TodoPanel.tsx | 39 +++- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- packages/todo/tool-todo/README.i18n.yaml | 2 +- packages/todo/tool-todo/README.zh.md | 4 +- scripts/verify-todo-display.mjs | 107 ---------- 14 files changed, 242 insertions(+), 128 deletions(-) create mode 100644 apps/web/tests/todo-display.snapshot.ts delete mode 100644 scripts/verify-todo-display.mjs diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 86de69efbf..292c9b6200 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-todo-display.md: 15368ee44c3a3aee1f1854964b9f3575d1b2fa92 -2026-07-23-web-todo-display.zh.md: c9ac6cda23721296a9cae5431adac4182feab72c +2026-07-23-web-todo-display.md: b08c50b2f1a0aea03382b9e012ac364d697a21ee +2026-07-23-web-todo-display.zh.md: daff6d4a18a621f3fd336ff6feb4433f5cbf9b16 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 15368ee44c..b08c50b2f1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The skeleton pins the panel between the view area and the composer (the composer-card axis), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the framework `useSession` hook — no store, no service, no ctx. It lives inside `ConversationRoot` rather than the details column or its own slot: the details slot is single-occupant and selection-driven (a different lifetime than an always-on strip), and the slot table reserves no plan seat. The component is props-complete and framework-free, so a later relocation to a dedicated slot touches nothing inside it. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot @@ -27,9 +27,10 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Alternatives considered - **Fold todo writes into `nodes` as surface entries** — replayed windows would render every superseded list; the event is deliberately not a surface type. -- **Details column or a dedicated slot for the panel** — the details slot is single-occupant and selection-driven; a new slot key needs a slot-table seat that design has not assigned. The panel is framework-free, so the relocation stays cheap if one lands. +- **Hardcoding the panel inside `ConversationRoot`** — the original landing spot before the input-dock slot existed; the dock is the architecture's home for always-on strips above the composer, and a hardcode bypasses the slot registry's disposal and ordering. +- **Details column for the panel** — the details slot is single-occupant and selection-driven, a different lifetime than an always-on strip. - **Host-computed view (a todo `ToolEventView`)** — presentation belongs to the client; the wire already carries the whole snapshot in the event payload. ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. Cold-load reconstruction is host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; the seeded value is preserved across window rebuilds and overwritten by any later write. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. Cold-load reconstruction is host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; the seeded value is preserved across window rebuilds and overwritten by any later write. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index c9ac6cda23..daff6d4a18 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -18,7 +18,7 @@ Status: implemented ### TodoPanel:长驻列表作为一条常驻横条 -骨架把面板钉在视图区与 composer 之间(composer-card 轴),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经框架 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。它住在 `ConversationRoot` 之内,而非 details 列或自建 slot:details slot 单占用且由选中驱动(生命周期不同于一条常开横条),且 slot 表没有为 plan 预留席位。组件 props 完备且框架无关,因此日后迁往专用 slot 不触及其内部任何东西。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 @@ -27,9 +27,10 @@ Status: implemented ## Alternatives considered - **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 -- **面板放进 details 列或专用 slot**——details slot 单占用且由选中驱动;新增一个 slot 键需要一个 slot 表席位,而设计尚未分配。面板框架无关,所以真要迁移,代价依然很低。 +- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给"composer 上方常开横条"安排的家,硬编码绕开了 slot 注册表的 disposal 与定序。 +- **面板放进 details 列**——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。 - **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。 ## Consequences -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。冷加载重建由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;播种值跨窗口重建保留,之后的任何写入照常覆盖。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。冷加载重建由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;播种值跨窗口重建保留,之后的任何写入照常覆盖。 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts new file mode 100644 index 0000000000..8d0127cc54 --- /dev/null +++ b/apps/web/tests/todo-display.snapshot.ts @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +// Todo display snapshot over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session and pins the todo_write turn's two +// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary +// derived from the call args) and the TodoPanel plan strip riding the +// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded +// by the tail history page), including the collapse interaction. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + // Anchor on the Workspace title, not the session-count meta: the count + // shifts when a blank session joins the group. + const group = (await within(tree).findByText('fixture')).closest('[role="treeitem"]') + if (group === null) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull() + }, { timeout: 10_000 }) +} + +it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => { + boot() + await openFixtureSession() + + const row = document.querySelector('[data-sample="todo-row"]') + if (row === null) throw new Error('todo row missing') + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + + expect({ + row: visibleText(row), + rowState: row.getAttribute('data-state'), + panelHeader: visibleText(panel.querySelector('button') ?? panel), + panelItems: [...panel.querySelectorAll('li')].map(item => ({ + status: item.getAttribute('data-status'), + text: visibleText(item), + })), + }).toMatchInlineSnapshot(` + { + "panelHeader": "Plan1/3", + "panelItems": [ + { + "status": "completed", + "text": "✓梳理需求", + }, + { + "status": "in_progress", + "text": "●实现 fixture 样本", + }, + { + "status": "pending", + "text": "○浏览器验收", + }, + ], + "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "rowState": "ok", + } + `) +}) + +it('collapses the plan strip to the in-progress hint and restores it', async () => { + boot() + await openFixtureSession() + + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + const header = panel.querySelector('button') + if (header === null) throw new Error('todo panel header missing') + + fireEvent.click(header) + expect({ + collapsedHeader: visibleText(header), + listGone: panel.querySelector('ul') === null, + }).toMatchInlineSnapshot(` + { + "collapsedHeader": "Plan1/3实现 fixture 样本", + "listGone": true, + } + `) + + fireEvent.click(header) + expect(panel.querySelectorAll('li')).toHaveLength(3) +}) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3005c84aff..208d75c5d4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { todoToolview } from './toolviews/todo-row.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' @@ -186,6 +187,9 @@ export function apply(ctx: Context): void { // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) + // The plan strip rides the input dock above the queue rows (same posture). + ctx.plugin(todoDockEntry) + // The read-only queue dock entry (T9 file territory) rides the same // registration seam into the input dock declared above. ctx.plugin(queueDockEntry) diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index efaa2599b8..b7d8e4172c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -1,10 +1,13 @@ -// TodoPanel: persistent plan strip pinned above the composer (the web -// counterpart of the TUI plan panel; ACP maps the same event to its native -// plan). Renders the latest todo/write whole-list snapshot off the session -// snapshot — no data of its own, hidden while the list is empty. Zero -// framework imports: useSession arrives via props from ConversationRoot. +// TodoPanel: persistent plan strip above the composer (the web counterpart +// of the TUI plan panel; ACP maps the same event to its native plan). Renders +// the latest todo/write whole-list snapshot off the session snapshot — no +// data of its own, hidden while the list is empty. Mounted through the +// 'conversation.input.dock' slot (QueueDock posture): the standard session +// kit supplies useSession, so the inner component stays framework-free. import { useState } from 'react' +import type { Context } from 'cordis' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -57,3 +60,29 @@ export function TodoPanel({ useSession }: TodoPanelProps) { ) } + +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ +export type TodoDockProps = PropsRuntime<'conversation.input.dock'> + +/** Dock adapter: the standard kit's useSession feeds the strip. */ +export function TodoDock({ useSession }: TodoDockProps) { + return +} + +/** + * The plan strip as a plain registrant plugin (QueueDock posture). + * `inject: ['conversation']` is the ordering seam: the conversation service + * mounts after ui-conversation's slot registrations, so the + * 'conversation.input.dock' declaration is on the ledger by then. + */ +export const todoDockEntry = { + name: 'conversation-todo-dock', + inject: ['slots', 'conversation'], + /** + * Register the plan strip into the input dock (list entry, above the queue rows). + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 125e421772..f48f35695a 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -56,7 +56,7 @@ function snapshotWith( ): ConversationSnapshot { return { sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches, - pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, + pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, } as ConversationSnapshot diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 385a5d417e..06a8d266a0 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -21,7 +21,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, ...overrides, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 6b60f7ea17..caa9b85ad9 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -24,7 +24,7 @@ const SID = 's1' as SessionId function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', + pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9a99eccbf0..ca71a2f0f8 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -111,7 +111,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { const wiring = shell const sessionStore = createSnapshotStore({ sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, }) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index d9b9e951bf..fa0c871bdb 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -19,7 +19,7 @@ const SID = 's1' as SessionId function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, + pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, } } diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index dff7032381..66d516740c 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 3615f68953cfe6cdc0e6fc41bf75b8dfdf90a310 -README.zh.md: ee0b99d1b4a444dc0b92217686140f691abf1e2b +README.zh.md: c4a7d829cc1583b65d2c8afa1683d957f68722e9 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index ee0b99d1b4..c4a7d829cc 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -16,11 +16,11 @@ ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务)。顺序与保持列表最新的纪律由模型根据工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 -规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)将其显示为持久计划,[web 客户端](../../client/ui-conversation)则基于 `ConversationSnapshot.todos` 渲染计划条和专属工具行([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md))。 +规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)将其显示为持久计划,[web 客户端](../../client/ui-conversation)则基于 `ConversationSnapshot.todos` 渲染计划横条与专属工具行([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md))。 ## 导出形状 diff --git a/scripts/verify-todo-display.mjs b/scripts/verify-todo-display.mjs deleted file mode 100644 index 3e8f42e5fe..0000000000 --- a/scripts/verify-todo-display.mjs +++ /dev/null @@ -1,107 +0,0 @@ -// Manual acceptance probe: boot the real shell + 8 bundles in ?fixture mode, -// open fx-alpha, assert the TodoPanel strip and the todo_write row render. -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' -import { startWebServer } from '@deepseek-ai/dsh-host-webserver' - -// playwright is a dependency of apps/web (the browser test owner), not the root. -const { chromium } = createRequire(new URL('../apps/web/package.json', import.meta.url)).call(undefined, 'playwright') - -const root = fileURLToPath(new URL('..', import.meta.url)) -const bundle = (dir) => `${root}packages/client/${dir}/lib/client.js` -const PLUGINS = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] -for (const p of PLUGINS) if (!existsSync(bundle(p.dir))) throw new Error(`bundle missing: ${p.dir}`) - -const rows = PLUGINS.map(p => ({ - id: p.id, url: `/plugins/${p.id}/client.js?rev=verify`, rev: 'verify', - ...(p.inject.length > 0 ? { inject: p.inject } : {}), - ...(p.immediately ? { immediately: true } : {}), -})) -const graph = { rev: 'verify', entries: rows } -const byId = new Map(PLUGINS.map(p => [p.id, bundle(p.dir)])) -const port = 34567 -const errors = [] -const server = await startWebServer({ - host: '127.0.0.1', - port, - distIndex: `${root}apps/web/dist/index.html`, - apiHandler: { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }, - webPlugins: { graph: () => graph, clientPath: (id) => byId.get(id), onRebuilt: () => () => undefined }, -}, (err) => errors.push(`server: ${String(err)}`)) - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) -page.on('pageerror', e => errors.push(String(e))) -await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' }) -try { - await page.waitForSelector('[class*="frame"]', { timeout: 15000 }) -} catch (e) { - console.error('BODY:', (await page.evaluate(() => document.body.innerText)).slice(0, 400)) - console.error('STATUS:', await page.evaluate(() => JSON.stringify(globalThis - -.__DSH_LOADER_STATUS__ ?? 'n/a'))) - console.error('BOOT:', await page.evaluate(() => JSON.stringify(window.__DSH_BOOT__))) - const reqs = await page.evaluate(() => performance.getEntriesByType('resource').map(r => `${r.name.split('/').slice(-2).join('/')}=${r.responseStatus ?? '?'}`)) - console.error('RES:', reqs.join(' ')) - console.error('ERRORS:', errors.join(' ;; ')) - throw e -} - -// Open fx-alpha: expand the workspace group, then click the newest session row. -await page.locator('[role="treeitem"]').first().click() -// fx-alpha is the newest (running) session — the first option row. -const sessionRow = page.locator('[role="treeitem"][aria-selected]').first() -await sessionRow.waitFor({ timeout: 5000 }) -await sessionRow.click() -await page.waitForSelector('[data-testid="todo-panel"]', { timeout: 15000 }) -console.log('✓ TodoPanel visible') - -const panelText = await page.locator('[data-testid="todo-panel"]').innerText() -for (const expected of ['Plan', '1/3', '梳理需求', '实现 fixture 样本', '浏览器验收']) { - if (!panelText.includes(expected)) throw new Error(`TodoPanel missing "${expected}"; got: ${panelText}`) -} -console.log('✓ TodoPanel content: counts + all three items') - -await page.screenshot({ path: `${root}.artifacts/todo-01-panel.png` }) - -// The todo_write row in the flow (turn 63 sample, already at the bottom). -const row = page.locator('[data-sample="todo-row"]') -await row.waitFor({ timeout: 10000 }) -const rowText = await row.innerText() -if (!rowText.includes('更新任务清单') || !rowText.includes('1/3 已完成')) throw new Error(`TodoRow wrong: ${rowText}`) -console.log('✓ TodoRow renders plan summary:', rowText.replace(/\n/g, ' ')) -await page.screenshot({ path: `${root}.artifacts/todo-02-row.png` }) - -// Row click opens details with the raw args. -await row.click() -await page.waitForSelector('text=Input', { timeout: 5000 }) -console.log('✓ TodoRow click opens details') -await page.screenshot({ path: `${root}.artifacts/todo-03-details.png` }) - -// Collapse: list hides, active item hint appears in the header. -await page.locator('[data-testid="todo-panel"] button').first().click() -const collapsed = await page.locator('[data-testid="todo-panel"]').innerText() -if (collapsed.includes('梳理需求')) throw new Error('collapse failed: list still visible') -if (!collapsed.includes('实现 fixture 样本')) throw new Error('collapsed hint missing the active item') -console.log('✓ Collapse hides list, shows active hint') -await page.screenshot({ path: `${root}.artifacts/todo-04-collapsed.png` }) - -// Dark theme spot check. -await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')) -await page.screenshot({ path: `${root}.artifacts/todo-05-dark.png` }) -console.log('✓ Dark screenshot taken') - -if (errors.length > 0) throw new Error(`page errors: ${errors.join('; ')}`) -console.log('✓ No page errors — todo display acceptance PASSED') -await browser.close() -await server.close()