fix(gui): the collapsed plan hint accounts for parallel active items

Lifting the single-in_progress cap makes a list shape reachable that the
web surfaces never received. Two sites derived their one-line summary with
todos.find(t => t.status === 'in_progress') — the collapsed TodoPanel header
and the todo_write row — which was total under the old cap and silently
dropped every active item but the first once several could match: a plan
with three running tasks collapsed to the name of one. The expanded list
was always correct, so neither PR's tests covered it.

Both sites now take planSummary in contract/todo-plan-model.ts, the
domain-shared face both the skeleton and toolviews domains may import; the
duplicated derivation was why one find could be fixed while the other
stayed wrong. The hint names the first active item and suffixes +<n> for
the rest, so the collapsed line reports how many tasks are running.

The web fixture's todo sample now runs two items in_progress, so the
assembled web transcript replays a parallel plan: the row reads
'1/4 已完成 · 实现 fixture 样本 +1' over the built bundles.
This commit is contained in:
Chinesezjc
2026-07-27 14:25:02 +08:00
parent b2a4341ceb
commit f8b0bd31d3
13 changed files with 167 additions and 38 deletions

View File

@@ -0,0 +1,48 @@
/**
* Pure plan derivation shared by the two todo surfaces: the plan strip header
* (skeleton domain) and the todo_write row (toolviews domain). Both need the
* same done/total counts and the same one-line active hint, and several items
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
* hint built from one active item would silently drop the rest.
* @module
*/
/**
* One list item as either surface sees it: the typed `TodoItem` off the session
* snapshot, or unvalidated model JSON parsed from a call's args (any field may
* be missing or mistyped).
*/
export interface PlanItemLike {
content?: unknown
status?: unknown
}
/** Counts plus the one-line hint; `activeHint` is null when there is none to show. */
export interface PlanSummary {
done: number
total: number
activeHint: string | null
}
/**
* Derive the counts and the active hint from a whole-list snapshot. The hint is
* the first `in_progress` content suffixed `+<n>` for the remaining active
* items, so a parallel plan reports how many tasks are running rather than
* naming one and hiding the others. It is null when nothing is in progress, or
* when the first active item carries no usable content — model JSON may, and
* the caller then falls back to its own summary.
* @param todos - the whole list, in model order.
* @returns the done/total counts and the active hint.
*/
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
const active = todos.filter(t => t.status === 'in_progress')
const first = active[0]?.content
const activeHint = typeof first !== 'string' || first === ''
? null
: active.length > 1 ? `${first} +${active.length - 1}` : first
return {
done: todos.filter(t => t.status === 'completed').length,
total: todos.length,
activeHint,
}
}

View File

@@ -3,12 +3,15 @@
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Several items may be in_progress at once; the collapsed header's one-line
// hint comes from the shared plan model, which reports the extra active count.
import { useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { planSummary } from '../contract/todo-plan-model.ts'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
@@ -25,8 +28,7 @@ export function TodoPanel({ todos }: TodoPanelProps) {
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')
const { done, activeHint } = planSummary(todos)
return (
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
@@ -38,8 +40,8 @@ export function TodoPanel({ todos }: TodoPanelProps) {
>
<span className={css.title}>Plan</span>
<span className={css.progress}>{done}/{todos.length}</span>
{collapsed && active !== undefined && (
<span className={css.activeHint}>{active.content}</span>
{collapsed && activeHint !== null && (
<span className={css.activeHint}>{activeHint}</span>
)}
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}

View File

@@ -1,7 +1,7 @@
// 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
// summarizes the written list (counts + active items) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
@@ -10,12 +10,11 @@ import type { Context } from 'cordis'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import type { PlanItemLike } from '../contract/todo-plan-model.ts'
import { planSummary } from '../contract/todo-plan-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 {
function isItem(value: unknown): value is PlanItemLike {
return typeof value === 'object' && value !== null
}
@@ -32,12 +31,9 @@ function summarize(argsRaw: string): string | null {
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
const { done, total, activeHint } = planSummary(todos)
const head = `${done}/${total} 已完成`
return activeHint === null ? head : `${head} · ${activeHint}`
}
/** One-line plan update row (click opens the raw args in details). Non-ok