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:
@@ -21,6 +21,7 @@ import { createChatStore } from './stores.ts'
|
|||||||
import { ConversationService } from './service.ts'
|
import { ConversationService } from './service.ts'
|
||||||
import { ChatView } from './chat/ChatView.tsx'
|
import { ChatView } from './chat/ChatView.tsx'
|
||||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||||
|
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||||
import { EmptyState } from './skeleton/EmptyState.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.
|
// The bash sample rides that exact seam, in third-party posture.
|
||||||
ctx.plugin(bashToolviewSample)
|
ctx.plugin(bashToolviewSample)
|
||||||
|
|
||||||
|
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||||
|
ctx.plugin(todoToolview)
|
||||||
|
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'details',
|
name: 'details',
|
||||||
store: chatStore,
|
store: chatStore,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
|
|||||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||||
import { InputBar } from './InputBar.tsx'
|
import { InputBar } from './InputBar.tsx'
|
||||||
import type { InputBarError } from './InputBar.tsx'
|
import type { InputBarError } from './InputBar.tsx'
|
||||||
|
import { TodoPanel } from './TodoPanel.tsx'
|
||||||
import css from './ConversationRoot.module.css'
|
import css from './ConversationRoot.module.css'
|
||||||
|
|
||||||
/** Full props = the automatic shares & injected share — composed by reference
|
/** 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 })}
|
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TodoPanel useSession={useSession} />
|
||||||
|
|
||||||
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -103,13 +103,13 @@ describe('apply wiring', () => {
|
|||||||
expect(empty?.store).toBeUndefined()
|
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()
|
const b = await bench()
|
||||||
await b.fiber.await()
|
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.
|
// service being present implies the chat entry declared the hole first.
|
||||||
const entries = b.slots.entries('conversation.chat.toolview')
|
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 () => {
|
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function snapshotBase(): ConversationSnapshot {
|
|||||||
return {
|
return {
|
||||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
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 {
|
return {
|
||||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
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
|
} as ConversationSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function snapshotBase(): ConversationSnapshot {
|
|||||||
return {
|
return {
|
||||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
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
|
removed: boolean
|
||||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||||
pending: readonly PendingInteraction[]
|
pending: readonly PendingInteraction[]
|
||||||
|
todos: readonly { content: string; status: 'pending' | 'in_progress' | 'completed' }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||||
const store = createSnapshotStore<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> }
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||||
}
|
}
|
||||||
|
|||||||
128
packages/client/ui-conversation/tests/todo-panel.spec.tsx
Normal file
128
packages/client/ui-conversation/tests/todo-panel.spec.tsx
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -109,6 +109,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
const sessionSnapshot = createSnapshotStore({
|
const sessionSnapshot = createSnapshotStore({
|
||||||
running: false, removed: false, promptError: null, nodes,
|
running: false, removed: false, promptError: null, nodes,
|
||||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||||
|
todos: [] as ConversationSnapshot['todos'],
|
||||||
})
|
})
|
||||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||||
const chat = createChatStore().create()
|
const chat = createChatStore().create()
|
||||||
|
|||||||
Reference in New Issue
Block a user