feat(gui): todo display — TodoPanel plan strip + todo_write toolview row

TodoPanel pins above the composer (776px card axis), hidden while empty,
collapsible with the active item as the collapsed hint; status glyphs
mirror the TUI plan panel. todo_write rows render a plan-flavored summary
(counts + active item) via the toolview registry, generic fallback on
malformed args. Existing fake snapshots gain the required todos field.
This commit is contained in:
Chinesezjc
2026-07-22 13:02:53 +08:00
parent a0c269b0fb
commit 63109dab66
13 changed files with 427 additions and 7 deletions
@@ -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 () => {
@@ -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: [],
}
}
@@ -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
}
@@ -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: [],
}
}
@@ -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<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
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<ConversationSnapshot> }
}
@@ -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(<TodoPanel useSession={useSession} />)
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(<TodoPanel useSession={useSession} />)
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(<TodoPanel useSession={useSession} />)
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(<TodoPanel useSession={useSession} />)
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByText('都完了')).toBeNull()
expect(screen.getByText('1/1')).toBeTruthy()
})
})
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): 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(<TodoRow {...rowProps(resultNode(ARGS))} />)
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(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, callView: null })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('falls back to the generic summary on malformed args and flags errors', () => {
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(screen.getByText('failed')).toBeTruthy()
// Generic others summary: "<tool> · <raw>".
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(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
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(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
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)
})
})