fix(gui): keep the parallel-active count outside the ellipsized hint

Both todo one-line surfaces truncate the active hint with overflow: hidden
and text-overflow: ellipsis. A "+N" appended to the first active task's name
therefore sat at the far end of the truncatable text, so a long task name or a
narrow viewport clipped exactly the part that reports the other running tasks,
leaving a parallel plan indistinguishable from a sequential one.

planSummary now returns activeContent and activeExtra as separate fields
instead of one joined activeHint, and each surface renders the count in its own
flex: none span beside the ellipsized name: .activeExtra in the collapsed plan
strip header, .extra in the todo_write row. Putting the count in front of the
name was rejected — the task name is what the reader looks for first.

The parallel-plan cases in todo-panel.spec.tsx now assert the count is a
separate element from the name, and both fail if the two are rejoined. The
assembled web snapshot re-records: the flex gap supplies the visual space, so
the transcript reads "实现 fixture 样本+1" with no space in the text nodes.
This commit is contained in:
Chinesezjc
2026-07-27 15:07:43 +08:00
parent f8b0bd31d3
commit 07f9959d55
13 changed files with 116 additions and 54 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md
2026-07-26-todo-parallel-in-progress.md: eb8d78e2fe2895d952a355226ac518b9ccd40f98
2026-07-26-todo-parallel-in-progress.zh.md: e610165f0170d92535a3709cc23dab8c77d2767f
2026-07-26-todo-parallel-in-progress.md: 71123e07f6141346520114bff7029a4dca78ad0c
2026-07-26-todo-parallel-in-progress.zh.md: 5355ea97247115ac0b2290b447d5ffab441e1a4d

View File

@@ -31,7 +31,9 @@ A coded invariant can only see the list, not the runtime: whether two `in_progre
Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information.
Both sites now take `planSummary` in `contract/todo-plan-model.ts`, the domain-shared face the skeleton and toolviews domains may both import. Duplicated derivation was the reason one `find` could be fixed while the other stayed wrong, and the counts were already computed twice. The hint names the first active item and suffixes `+<n>` for the rest, so the collapsed line reports how many tasks are running instead of implying one. Naming every active item was rejected: the hint is a single line next to the composer, and an unbounded join would overflow it — the count degrades predictably where a list does not.
Both sites now take `planSummary` in `contract/todo-plan-model.ts`, the domain-shared face the skeleton and toolviews domains may both import. Duplicated derivation was the reason one `find` could be fixed while the other stayed wrong, and the counts were already computed twice. The hint names the first active item and counts the rest, so the collapsed line reports how many tasks are running instead of implying one. Naming every active item was rejected: the hint is a single line next to the composer, and an unbounded join would overflow it — the count degrades predictably where a list does not.
`planSummary` returns the name and the count as separate fields rather than one joined string, because both surfaces truncate the hint with `overflow: hidden` / `text-overflow: ellipsis`. A count appended to the task name sits at the far end of the truncatable text, so exactly the narrow viewports and long task names that make the count informative are the ones that clip it away, leaving a parallel plan indistinguishable from a sequential one. Each surface therefore renders the count in its own `flex: none` span beside the ellipsized name; a shared pre-joined string could not express that split, and pushing the count in front of the name was rejected because the task name is what the reader is looking for first.
## Consequences

View File

@@ -31,7 +31,9 @@ Status: implemented
解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack栈叠在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。
现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并其余活跃项追加 `+<n>` 后缀,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。
现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并计数其余活跃项,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。
`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为两处面都用 `overflow: hidden` / `text-overflow: ellipsis` 截断该提示。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此两处各自把计数渲染在自己的 `flex: none` span 中,与被省略号截断的任务名并列;共享一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。
## 后果

View File

@@ -8,7 +8,10 @@
// by the tail history page), including the collapse interaction. The sample
// plan runs two items in_progress at once, so both surfaces are pinned against
// a parallel plan — the collapsed one-line hint must account for the second
// active item instead of naming the first and dropping it.
// active item instead of naming the first and dropping it. The `+1` reads
// against the task name with no space because it is a separate non-shrinking
// span (spaced by the flex `gap`), kept outside the ellipsized text so a narrow
// viewport clips the task name rather than the count.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -165,7 +168,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
"text": "○浏览器验收",
},
],
"row": "☰更新任务清单1/4 已完成 · 实现 fixture 样本 +1",
"row": "☰更新任务清单1/4 已完成 · 实现 fixture 样本+1",
"rowState": "ok",
}
`)
@@ -186,7 +189,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async ()
listGone: panel.querySelector('ul') === null,
}).toMatchInlineSnapshot(`
{
"collapsedHeader": "Plan1/4实现 fixture 样本 +1",
"collapsedHeader": "Plan1/4实现 fixture 样本+1",
"listGone": true,
}
`)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 5b12242ac3f477233bd7e897261a9a0c2478aa41
README.zh.md: 6076e706b2e6e80775149ebcf7c55ab478b41f18
README.md: 4df6712d3beb980f564650ca39caa1e77d8fdb4e
README.zh.md: 7ff3d36867296ee1d870e189bc5103e8cea12731

View File

@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active hint>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the same active hint. Several items may be `in_progress` at once (the tool permits parallel work), so both one-line surfaces derive that hint through `contract/todo-plan-model.ts` `planSummary`: the first active item's content plus `+<n>` for the remaining active ones, and no hint at all when nothing is active or the first active content is unusable. The expanded list needs no such rule — it renders every item with its own status glyph. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active task>` plus a `+<n>` parallel-active count in its own span, parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the same active hint. Several items may be `in_progress` at once (the tool permits parallel work), so both one-line surfaces derive that hint through `contract/todo-plan-model.ts` `planSummary`: the first active item's content plus a separate count of the remaining active ones, and no hint at all when nothing is active or the first active content is unusable. `planSummary` deliberately does not join the two — both surfaces ellipsize the task name, so a count concatenated onto its end would be the first thing a narrow viewport clips; each renders the count in its own non-shrinking span. The expanded list needs no such rule — it renders every item with its own status glyph. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.

View File

@@ -12,7 +12,7 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃提示>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带同一活跃提示的单行表头。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此两处单行面都通过 `contract/todo-plan-model.ts``planSummary` 推导该提示:第一个活跃条目的内容,加上代表其余活跃项的 `+<n>`;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃任务>`,并把 `+<n>` 并行活跃计数放在自己的 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带同一活跃提示的单行表头。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此两处单行面都通过 `contract/todo-plan-model.ts``planSummary` 推导该提示:第一个活跃条目的内容,加上单独一项「其余活跃项的数量」;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。`planSummary` 刻意不把两者拼成一个字符串:两处面都会对任务名做省略号截断,把数量接在其末尾时,窄视口最先裁掉的正是这个数量;两处各自把数量渲染在自己的不收缩 span 里。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store`stores.ts` `createChatStore`apply 构造一个 handle并将其传给会话、聊天视图和详情注册因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹框架标准工具包Session scope 下的 `useSession``sessionId`,以及全局 `useSessions``useWorkspaces`)和 store 表层(`useStore``actions`会从注册声明自动到达inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。

View File

@@ -17,32 +17,40 @@ export interface PlanItemLike {
status?: unknown
}
/** Counts plus the one-line hint; `activeHint` is null when there is none to show. */
/**
* Counts plus the two halves of the one-line hint, deliberately NOT pre-joined:
* both surfaces ellipsize the hint, and a count concatenated onto the end of
* the task name is the first thing a narrow viewport clips — exactly when it
* carries information. Each surface renders `activeExtra` in its own
* non-shrinking span beside the truncatable `activeContent`.
*/
export interface PlanSummary {
done: number
total: number
activeHint: string | null
/** First `in_progress` content, or null when there is no usable one to name. */
activeContent: string | null
/** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */
activeExtra: number
}
/**
* 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
* Derive the counts and the active hint from a whole-list snapshot. The hint
* names the first `in_progress` item and counts the remaining active ones, so a
* parallel plan reports how many tasks are running rather than naming one and
* hiding the others. `activeContent` 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.
* @returns the done/total counts and the two hint halves.
*/
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
const named = typeof first === 'string' && first !== ''
return {
done: todos.filter(t => t.status === 'completed').length,
total: todos.length,
activeHint,
activeContent: named ? first : null,
activeExtra: named ? active.length - 1 : 0,
}
}

View File

@@ -54,6 +54,15 @@
white-space: nowrap;
}
/* The parallel-active count sits outside .activeHint's ellipsis: a count
appended to a long task name would be the first thing clipped. */
.activeExtra {
flex: none;
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
display: grid;
flex: none;

View File

@@ -4,7 +4,8 @@
// '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.
// hint comes from the shared plan model, which reports the extra active count
// in its own non-shrinking span so ellipsizing the task name cannot clip it.
import { useState } from 'react'
import type { Context } from 'cordis'
@@ -28,7 +29,7 @@ export function TodoPanel({ todos }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(false)
if (todos.length === 0) return null
const { done, activeHint } = planSummary(todos)
const { done, activeContent, activeExtra } = planSummary(todos)
return (
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
@@ -40,8 +41,11 @@ export function TodoPanel({ todos }: TodoPanelProps) {
>
<span className={css.title}>Plan</span>
<span className={css.progress}>{done}/{todos.length}</span>
{collapsed && activeHint !== null && (
<span className={css.activeHint}>{activeHint}</span>
{collapsed && activeContent !== null && (
<>
<span className={css.activeHint}>{activeContent}</span>
{activeExtra > 0 && <span className={css.activeExtra}>+{activeExtra}</span>}
</>
)}
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}

View File

@@ -35,6 +35,13 @@
color: var(--dsw-alias-label-secondary);
}
/* Parallel-active count, kept out of .summary's ellipsis so a long task name
clips before the count that reports the other running tasks. */
.extra {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);

View File

@@ -1,9 +1,10 @@
// 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 items) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
// summarizes the written list (counts + active items) from the call args, with
// the parallel-active count in its own non-shrinking span outside the
// ellipsized text; the durable list itself renders in the TodoPanel above the
// composer, so the row stays one line.
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
@@ -18,7 +19,17 @@ function isItem(value: unknown): value is PlanItemLike {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
/**
* The row's summary split at the ellipsis boundary: `text` truncates, `extra`
* is the parallel-active count that must not, so a narrow row never clips the
* one part that says several tasks are running.
*/
interface RowSummary {
text: string
extra: number
}
function summarize(argsRaw: string): RowSummary | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
@@ -31,9 +42,12 @@ 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, total, activeHint } = planSummary(todos)
const { done, total, activeContent, activeExtra } = planSummary(todos)
const head = `${done}/${total} 已完成`
return activeHint === null ? head : `${head} · ${activeHint}`
return {
text: activeContent === null ? head : `${head} · ${activeContent}`,
extra: activeExtra,
}
}
/** One-line plan update row (click opens the raw args in details). Non-ok
@@ -42,7 +56,7 @@ function summarize(argsRaw: string): string | null {
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
const summary = summarize(argsRaw) ?? { text: model.summary, extra: 0 }
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
@@ -64,7 +78,8 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
? <span className={css.badge} aria-hidden></span>
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
<span className={css.title}></span>
<span className={css.summary}>{summary}</span>
<span className={css.summary}>{summary.text}</span>
{summary.extra > 0 && <span className={css.extra}>+{summary.extra}</span>}
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}
</div>

View File

@@ -1,8 +1,9 @@
// @vitest-environment jsdom
/**
* Todo display acceptance: the shared plan model (counts + the one-line active
* hint, which carries `+N` once parallel work marks several items in_progress),
* the TodoPanel plan strip (empty-hidden, status rows, collapse with active
* Todo display acceptance: the shared plan model (counts plus the two halves of
* the one-line active hint — the named task and the `+N` count that parallel
* work adds, kept apart so neither surface ellipsizes the count away), the
* TodoPanel plan strip (empty-hidden, status rows, collapse with active
* hint), its TodoDock adapter (selects the plan off the session snapshot and
* follows changes), and the todo_write toolview row (progress summary from
* args, generic fallback on malformed JSON, error badge, keyboard activation).
@@ -37,30 +38,32 @@ const PARALLEL: TodoItem[] = [
]
describe('planSummary', () => {
it('counts done/total and names the single active item verbatim', () => {
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeHint: '写组件' })
it('counts done/total and names the single active item with no extra count', () => {
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
})
it('suffixes the extra active count when several items are in progress', () => {
// Parallel work marks several: naming one and hiding the rest would lose them.
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeHint: '写组件 +2' })
it('reports the extra active count separately when several items are in progress', () => {
// Parallel work marks several: naming one and hiding the rest would lose
// them, and the count stays unjoined so neither surface can ellipsize it.
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
})
it('has no hint when nothing is in progress', () => {
expect(planSummary([{ content: '都完了', status: 'completed' }]))
.toEqual({ done: 1, total: 1, activeHint: null })
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
})
it('has no hint when the first active item carries no usable content (model JSON)', () => {
// Unvalidated args: a missing, mistyped, or empty content yields no hint,
// even with a second active item that would otherwise supply the count.
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]).activeHint).toBeNull()
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeHint).toBeNull()
expect(planSummary([{ content: '', status: 'in_progress' }]).activeHint).toBeNull()
// Unvalidated args: a missing, mistyped, or empty content yields no hint
// and no orphan count, even with a second active item to count.
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
})
it('is empty-safe', () => {
expect(planSummary([])).toEqual({ done: 0, total: 0, activeHint: null })
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
})
})
@@ -98,10 +101,15 @@ describe('TodoPanel', () => {
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
expect(screen.getByText('跑后台构建')).toBeTruthy()
expect(screen.getByText('读源码')).toBeTruthy()
// Collapsed: the hint reports the other two rather than dropping them.
// Collapsed: the hint reports the other two rather than dropping them, and
// the count lives in its own element — the task-name span is the one that
// ellipsizes, so a joined "写组件 +2" would lose the count on a narrow view.
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByRole('list')).toBeNull()
expect(screen.getByText('写组件 +2')).toBeTruthy()
const name = screen.getByText('写组件')
const extra = screen.getByText('+2')
expect(extra).not.toBe(name)
expect(name.contains(extra)).toBe(false)
})
it('collapsed header omits the hint when nothing is in progress', () => {
@@ -162,9 +170,13 @@ describe('TodoRow', () => {
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
})
it('reports the extra active count when the written list runs several tasks', () => {
render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
expect(screen.getByText('1/5 已完成 · 写组件 +2')).toBeTruthy()
it('reports the extra active count outside the ellipsized summary text', () => {
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
const text = screen.getByText('1/5 已完成 · 写组件')
const extra = screen.getByText('+2')
// Separate spans: .summary truncates, the count must not travel inside it.
expect(text.contains(extra)).toBe(false)
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
})
it('omits the active clause when no item is in progress and reads running-call args', () => {