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.
This commit is contained in:
imccyu
2026-07-27 10:09:42 +08:00
parent 9d63d75a6f
commit dadb92302f
14 changed files with 242 additions and 128 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -18,7 +18,7 @@ Status: implemented
### TodoPanel长驻列表作为一条常驻横条
骨架把面板钉在视图区与 composer 之间composer-card 轴),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经框架 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。它住在 `ConversationRoot` 之内,而非 details 列或自建 slotdetails 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 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;播种值跨窗口重建保留,之后的任何写入照常覆盖。

View File

@@ -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<string, unknown>).__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<void> {
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<HTMLElement>('[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)
})

View File

@@ -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)

View File

@@ -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) {
</section>
)
}
/** 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 <TodoPanel useSession={useSession} />
}
/**
* 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)
},
}

View File

@@ -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

View File

@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): 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,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
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,
})

View File

@@ -111,7 +111,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
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,
})

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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))。
## 导出形状

View File

@@ -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()