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 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', () => {