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
@@ -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,
@@ -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>
)
@@ -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)
},
}