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

@@ -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>