From 07f9959d55ec5f147ad2f2735cea67cec2d3c990 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:07:43 +0800 Subject: [PATCH] fix(gui): keep the parallel-active count outside the ellipsized hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 4 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 4 +- apps/web/tests/todo-display.snapshot.ts | 9 ++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/todo-plan-model.ts | 30 +++++++---- .../src/client/skeleton/TodoPanel.module.css | 9 ++++ .../src/client/skeleton/TodoPanel.tsx | 12 +++-- .../src/client/toolviews/todo-row.module.css | 7 +++ .../src/client/toolviews/todo-row.tsx | 31 ++++++++--- .../ui-conversation/tests/todo-panel.spec.tsx | 52 ++++++++++++------- 13 files changed, 116 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index efa0dabcb6..7a6dcd2cce 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index eb8d78e2fe..71123e07f6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -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 已完成 · ` 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 `+` 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 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index e610165f01..5355ea9724 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -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` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并为其余活跃项追加 `+` 后缀,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。 +现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并计数其余活跃项,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。 + +`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为两处面都用 `overflow: hidden` / `text-overflow: ellipsis` 截断该提示。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此两处各自把计数渲染在自己的 `flex: none` span 中,与被省略号截断的任务名并列;共享一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。 ## 后果 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 8fef40deee..b795d03dc4 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -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, } `) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 878a0859a7..844922a67e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5b12242ac3..4df6712d3b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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: '', 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 (`/ 已完成 · ` 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 `+` 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 (`/ 已完成 · ` plus a `+` 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6076e706b2..7ff3d36867 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 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` 推导该提示:第一个活跃条目的内容,加上代表其余活跃项的 `+`;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃任务>`,并把 `+` 并行活跃计数放在自己的 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 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 diff --git a/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts index 6147810460..a1ac9ae420 100644 --- a/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts +++ b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts @@ -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 `+` 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, } } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 17c9c890a7..531166e1a8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -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; diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index eec04bc892..9b62568aca 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -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 (
@@ -40,8 +41,11 @@ export function TodoPanel({ todos }: TodoPanelProps) { > Plan {done}/{todos.length} - {collapsed && activeHint !== null && ( - {activeHint} + {collapsed && activeContent !== null && ( + <> + {activeContent} + {activeExtra > 0 && +{activeExtra}} + )} {collapsed ? : } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..f94579c482 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -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); diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a6e0e5af4b..3ffb50032c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -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