瀏覽代碼

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.
Chinesezjc 3 月之前
父節點
當前提交
63109dab66

+ 4 - 0
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,

+ 3 - 0
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 })}
       </div>
 
+      <TodoPanel useSession={useSession} />
+
       {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
     </div>
   )

+ 111 - 0
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;
+}

+ 59 - 0
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<TodoItem['status'], string> = {
+  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 (
+    <section className={css.root} data-testid="todo-panel" aria-label="任务清单">
+      <button
+        type="button"
+        className={css.header}
+        aria-expanded={!collapsed}
+        onClick={() => { setCollapsed(v => !v) }}
+      >
+        <span className={css.title}>Plan</span>
+        <span className={css.progress}>{done}/{todos.length}</span>
+        {collapsed && active !== undefined && (
+          <span className={css.activeHint}>{active.content}</span>
+        )}
+        <span className={css.chevron} aria-hidden>
+          {collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
+        </span>
+      </button>
+      {!collapsed && (
+        <ul className={css.list}>
+          {todos.map(item => (
+            <li key={item.content} className={css.item} data-status={item.status}>
+              <span className={css.glyph} aria-hidden>{STATUS_GLYPHS[item.status]}</span>
+              <span className={css.content}>{item.content}</span>
+            </li>
+          ))}
+        </ul>
+      )}
+    </section>
+  )
+}

+ 42 - 0
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;
+}

+ 71 - 0
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 (
+    <div className={css.row} data-sample="todo-row" onClick={openDetails}>
+      <span className={css.badge} aria-hidden>☰</span>
+      <span className={css.title}>更新任务清单</span>
+      <span className={css.summary}>{summary}</span>
+      {model.state === 'error' && <span className={css.err}>failed</span>}
+    </div>
+  )
+}
+
+/**
+ * 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)
+  },
+}

+ 3 - 3
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 () => {

+ 1 - 1
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: [],
   }
 }
 

+ 1 - 1
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
 }
 

+ 1 - 1
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: [],
   }
 }
 

+ 2 - 1
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<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> }
 }

+ 128 - 0
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(<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)
+  })
+})

+ 1 - 0
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<ConversationSnapshot>
   const chat = createChatStore().create()