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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user