From 63109dab66ad93ec4e274e15624fa3ebf9b603f7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:02:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui):=20todo=20display=20=E2=80=94=20TodoP?= =?UTF-8?q?anel=20plan=20strip=20+=20todo=5Fwrite=20toolview=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ui-conversation/src/client/apply.ts | 4 + .../src/client/skeleton/ConversationRoot.tsx | 3 + .../src/client/skeleton/TodoPanel.module.css | 111 +++++++++++++++ .../src/client/skeleton/TodoPanel.tsx | 59 ++++++++ .../src/client/toolviews/todo-row.module.css | 42 ++++++ .../src/client/toolviews/todo-row.tsx | 71 ++++++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 3 +- .../ui-conversation/tests/todo-panel.spec.tsx | 128 ++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 1 + 13 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.tsx create mode 100644 packages/client/ui-conversation/tests/todo-panel.spec.tsx diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..70ec78722c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -21,6 +21,7 @@ import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' @@ -140,6 +141,9 @@ export function apply(ctx: Context): void { // The bash sample rides that exact seam, in third-party posture. ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + slots.register({ name: 'details', store: chatStore, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..ca7fc526e4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { TodoPanel } from './TodoPanel.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -123,6 +124,8 @@ export function ConversationRoot({ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} + + {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })} ) diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css new file mode 100644 index 0000000000..17c9c890a7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -0,0 +1,111 @@ +/* Plan strip pinned above the composer: bordered card on the composer card's + axis (776px column inside 32px side padding). Colors resolve through + --dsw-alias-* tokens only; the active row rides the business blue, done + rows fade to tertiary. */ + +.root { + flex: none; + overflow: hidden; + margin: 8px auto 0; + width: calc(100% - 64px); + max-width: 776px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-base); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.header:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.title { + font-size: 13px; + line-height: 16px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.progress { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + +.activeHint { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + margin-left: auto; + color: var(--dsw-alias-label-secondary); +} + +.list { + margin: 0; + padding: 0 12px 8px; + list-style: none; + max-height: 180px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.glyph { + flex: none; + width: 14px; + text-align: center; + color: var(--dsw-alias-label-tertiary); +} + +.item[data-status='completed'] .content { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.item[data-status='completed'] .glyph { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-status='in_progress'] .content { + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.item[data-status='in_progress'] .glyph { + color: var(--dsw-alias-state-business-primary); +} + +.content { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx new file mode 100644 index 0000000000..efaa2599b8 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -0,0 +1,59 @@ +// 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. + +import { useState } from 'react' +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' +import css from './TodoPanel.module.css' + +export interface TodoPanelProps { + useSession: UseSession +} + +/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */ +const STATUS_GLYPHS: Record = { + 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()