= {
+ completed: '✓', in_progress: '●', pending: '○',
+}
+
+export function TodoPanel({ useSession }: TodoPanelProps) {
+ const todos = useSession(s => (s as { todos: readonly TodoItem[] }).todos)
+ const [collapsed, setCollapsed] = useState(false)
+ if (todos.length === 0) return null
+
+ const done = todos.filter(t => t.status === 'completed').length
+ const active = todos.find(t => t.status === 'in_progress')
+
+ return (
+
+
+ {!collapsed && (
+
+ {todos.map(item => (
+ -
+ {STATUS_GLYPHS[item.status]}
+ {item.content}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
new file mode 100644
index 0000000000..ff4068d49c
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
@@ -0,0 +1,42 @@
+/* todo_write plan-update row: title + progress summary on one line. */
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 24px;
+ min-width: 0;
+ cursor: pointer;
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.row:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.badge {
+ flex: none;
+ color: var(--dsw-alias-state-business-primary);
+}
+
+.title {
+ flex: none;
+ font-weight: 510;
+ color: var(--dsw-alias-label-primary);
+}
+
+.summary {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.err {
+ flex: none;
+ color: var(--dsw-alias-state-error-primary);
+ font-size: 11px;
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
new file mode 100644
index 0000000000..390361d20b
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
@@ -0,0 +1,71 @@
+// todo_write toolview: plan-flavored summary row replacing the generic
+// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
+// hole like the bash sample (a product registration, not a sample). The row
+// summarizes the written list (counts + active item) from the call args; the
+// durable list itself renders in the TodoPanel above the composer, so the
+// row stays one line.
+
+import type { Context } from 'cordis'
+import type { ToolRowProps } from '../contract/slots.ts'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import css from './todo-row.module.css'
+
+/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
+interface TodoWriteItem { content?: unknown; status?: unknown }
+
+function isItem(value: unknown): value is TodoWriteItem {
+ return typeof value === 'object' && value !== null
+}
+
+function summarize(argsRaw: string): string | null {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(argsRaw)
+ } catch {
+ // Mid-stream truncation or malformed model JSON: fall back to the generic summary.
+ return null
+ }
+ // Valid JSON with an invalid shape (null root, non-array todos, null items —
+ // a rejected tool/call retains such args verbatim): same generic fallback.
+ if (typeof parsed !== 'object' || parsed === null) return null
+ const todos = (parsed as { todos?: unknown }).todos
+ if (!Array.isArray(todos) || !todos.every(isItem)) return null
+ const done = todos.filter(t => t.status === 'completed').length
+ const active = todos.find(t => t.status === 'in_progress')
+ const head = `${done}/${todos.length} 已完成`
+ return typeof active?.content === 'string' && active.content !== ''
+ ? `${head} · ${active.content}`
+ : head
+}
+
+/** One-line plan update row (click opens the raw args in details). */
+export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
+ const model = toolRowModel(toolName, block)
+ const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
+ const summary = summarize(argsRaw) ?? model.summary
+ return (
+
+ ☰
+ 更新任务清单
+ {summary}
+ {model.state === 'error' && failed}
+
+ )
+}
+
+/**
+ * The todo row as a plain registrant plugin, riding the same load-order seam
+ * as the bash sample: `inject: ['conversation']` guarantees the chat entry
+ * (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
+ */
+export const todoToolview = {
+ name: 'todo-toolview',
+ inject: ['slots', 'conversation'],
+ /**
+ * Register the todo row into the chat view's keyed toolview hole.
+ * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+ */
+ apply(ctx: Context): void {
+ ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
+ },
+}
diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx
index adb9271c71..bcd5cec2ed 100644
--- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx
@@ -103,13 +103,13 @@ describe('apply wiring', () => {
expect(empty?.store).toBeUndefined()
})
- it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
+ it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
- // The sample plugin's inject: ['slots', 'conversation'] resolved — the
+ // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
- expect(entries.map((e) => e.options.key)).toEqual(['bash'])
+ expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
index 4d3383b2d1..6efb63fa00 100644
--- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
@@ -28,7 +28,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
- hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
+ hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [],
}
}
diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
index 5d2b3408a2..73e9478431 100644
--- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
@@ -41,7 +41,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
- hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
+ hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [],
} as ConversationSnapshot
}
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 3f1db55199..8930f3171d 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -30,7 +30,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
- hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
+ hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [],
}
}
diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx
index a598803a25..a3ffd3cb11 100644
--- a/packages/client/ui-conversation/tests/skeleton.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx
@@ -59,11 +59,12 @@ interface FakeSnapshot {
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
pending: readonly PendingInteraction[]
+ todos: readonly { content: string; status: 'pending' | 'in_progress' | 'completed' }[]
}
function fakeSession(init: Partial = {}) {
const store = createSnapshotStore({
- nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
+ nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], todos: [], ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
}
diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx
new file mode 100644
index 0000000000..761cb4dfd3
--- /dev/null
+++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx
@@ -0,0 +1,128 @@
+// @vitest-environment jsdom
+/**
+ * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
+ * rows, collapse with active hint) and the todo_write toolview row (progress
+ * summary from args, generic fallback on malformed JSON, error badge).
+ */
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { hookOf } from './hook.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Export discipline: packages/client/AGENTS.md.
+import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
+import { TodoPanel } from '../src/client/skeleton/TodoPanel.tsx'
+
+afterEach(cleanup)
+
+function sessionWith(todos: readonly TodoItem[]) {
+ const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos })
+ return { store, useSession: hookOf(store) as unknown as UseSession }
+}
+
+const LIST: TodoItem[] = [
+ { content: '搭骨架', status: 'completed' },
+ { content: '写组件', status: 'in_progress' },
+ { content: '补测试', status: 'pending' },
+]
+
+describe('TodoPanel', () => {
+ it('renders nothing while the list is empty, appears when todos land', () => {
+ const { store, useSession } = sessionWith([])
+ render()
+ expect(screen.queryByTestId('todo-panel')).toBeNull()
+ act(() => { store.set({ todos: LIST }) })
+ expect(screen.getByTestId('todo-panel')).toBeTruthy()
+ })
+
+ it('shows progress, one row per item with its status, and strikes done items', () => {
+ const { useSession } = sessionWith(LIST)
+ render()
+ expect(screen.getByText('1/3')).toBeTruthy()
+ const items = screen.getAllByRole('listitem')
+ expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
+ expect(screen.getByText('搭骨架')).toBeTruthy()
+ expect(screen.getByText('写组件')).toBeTruthy()
+ })
+
+ it('collapse hides the list and surfaces the active item in the header; expand restores', () => {
+ const { useSession } = sessionWith(LIST)
+ render()
+ const header = screen.getByRole('button', { expanded: true })
+ fireEvent.click(header)
+ expect(screen.queryByRole('list')).toBeNull()
+ // Collapsed header carries the in-progress content as the one-line hint.
+ expect(screen.getByText('写组件')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
+ expect(screen.getAllByRole('listitem')).toHaveLength(3)
+ })
+
+ it('collapsed header omits the hint when nothing is in progress', () => {
+ const { useSession } = sessionWith([{ content: '都完了', status: 'completed' }])
+ render()
+ fireEvent.click(screen.getByRole('button', { expanded: true }))
+ expect(screen.queryByText('都完了')).toBeNull()
+ expect(screen.getByText('1/1')).toBeTruthy()
+ })
+})
+
+const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({
+ kind: 'tool-result', seq: 10, callId: 'c1',
+ call: { name: 'todo_write', argsRaw },
+ content: [], isError: false, callView: null, resultView: null, ...over,
+})
+
+function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
+ return {
+ callId: 'c1', toolName: 'todo_write', block,
+ openDetails,
+ sessionId: 's1',
+ useSessions: () => undefined,
+ } as unknown as ToolRowProps
+}
+
+describe('TodoRow', () => {
+ const ARGS = JSON.stringify({ todos: LIST })
+
+ it('summarizes counts and the active item from the call args', () => {
+ render()
+ expect(screen.getByText('更新任务清单')).toBeTruthy()
+ expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
+ })
+
+ it('omits the active clause when no item is in progress and reads running-call args', () => {
+ const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
+ render()
+ expect(screen.getByText('1/1 已完成')).toBeTruthy()
+ })
+
+ it('falls back to the generic summary on malformed args and flags errors', () => {
+ render()
+ expect(screen.getByText('failed')).toBeTruthy()
+ // Generic others summary: " · ".
+ expect(screen.getByText('todo_write · not json')).toBeTruthy()
+ })
+
+ it('falls back when parsed args carry no todos array, and click opens details', () => {
+ const openDetails = vi.fn()
+ render()
+ expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
+ fireEvent.click(screen.getByText('更新任务清单'))
+ expect(openDetails).toHaveBeenCalledTimes(1)
+ })
+
+ it('window-truncated result (call head lost) falls back to the callId summary', () => {
+ render()
+ expect(screen.getByText('todo_write · c1')).toBeTruthy()
+ })
+
+ it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
+ expect(todoToolview.name).toBe('todo-toolview')
+ expect(todoToolview.inject).toEqual(['slots', 'conversation'])
+ const register = vi.fn()
+ todoToolview.apply({ slots: { register } } as never)
+ expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
+ })
+})
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx
index 4818e67db5..ec37507443 100644
--- a/packages/client/ui-trajectory/tests/views.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.spec.tsx
@@ -109,6 +109,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
+ todos: [] as ConversationSnapshot['todos'],
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession
const chat = createChatStore().create()