fix(gui): the collapsed plan hint accounts for parallel active items
Lifting the single-in_progress cap makes a list shape reachable that the web surfaces never received. Two sites derived their one-line summary with todos.find(t => t.status === 'in_progress') — the collapsed TodoPanel header and the todo_write row — which was total under the old cap and silently dropped every active item but the first once several could match: a plan with three running tasks collapsed to the name of one. The expanded list was always correct, so neither PR's tests covered it. Both sites now take planSummary in contract/todo-plan-model.ts, the domain-shared face both the skeleton and toolviews domains may import; the duplicated derivation was why one find could be fixed while the other stayed wrong. The hint names the first active item and suffixes +<n> for the rest, so the collapsed line reports how many tasks are running. The web fixture's todo sample now runs two items in_progress, so the assembled web transcript replays a parallel plan: the row reads '1/4 已完成 · 实现 fixture 样本 +1' over the built bundles.
This commit is contained in:
@@ -168,14 +168,17 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: the tool permits several, so both surfaces must render a
|
||||
// parallel plan rather than the first active item alone.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
|
||||
@@ -84,6 +84,10 @@ describe('createFixtureApi', () => {
|
||||
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
|
||||
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
|
||||
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
|
||||
// The sample is a parallel plan: the tool permits several in_progress, so
|
||||
// the surfaces fed from here are exercised against more than one active item.
|
||||
const snapshot = events[todoAt] as { data: { todos: { status: string }[] } }
|
||||
expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
|
||||
@@ -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: b242812411d513931ecd2767622f9e23fb0aaa34
|
||||
README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12
|
||||
README.md: 5b12242ac3f477233bd7e897261a9a0c2478aa41
|
||||
README.zh.md: 6076e706b2e6e80775149ebcf7c55ab478b41f18
|
||||
|
||||
@@ -12,11 +12,11 @@ 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 item>` 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 in-progress item. 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 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.
|
||||
|
||||
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.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`, `todo-plan-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
工具行同样是 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: '<tool>', 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`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
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,包括这条计划条。
|
||||
|
||||
逐 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 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`、`todo-plan-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Pure plan derivation shared by the two todo surfaces: the plan strip header
|
||||
* (skeleton domain) and the todo_write row (toolviews domain). Both need the
|
||||
* same done/total counts and the same one-line active hint, and several items
|
||||
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
|
||||
* hint built from one active item would silently drop the rest.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* One list item as either surface sees it: the typed `TodoItem` off the session
|
||||
* snapshot, or unvalidated model JSON parsed from a call's args (any field may
|
||||
* be missing or mistyped).
|
||||
*/
|
||||
export interface PlanItemLike {
|
||||
content?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
|
||||
/** Counts plus the one-line hint; `activeHint` is null when there is none to show. */
|
||||
export interface PlanSummary {
|
||||
done: number
|
||||
total: number
|
||||
activeHint: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
return {
|
||||
done: todos.filter(t => t.status === 'completed').length,
|
||||
total: todos.length,
|
||||
activeHint,
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
// no data of its own, hidden while the list is empty. Mounted through the
|
||||
// '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.
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { planSummary } from '../contract/todo-plan-model.ts'
|
||||
import css from './TodoPanel.module.css'
|
||||
|
||||
export interface TodoPanelProps {
|
||||
@@ -25,8 +28,7 @@ export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
const { done, activeHint } = planSummary(todos)
|
||||
|
||||
return (
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
|
||||
@@ -38,8 +40,8 @@ export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
>
|
||||
<span className={css.title}>Plan</span>
|
||||
<span className={css.progress}>{done}/{todos.length}</span>
|
||||
{collapsed && active !== undefined && (
|
||||
<span className={css.activeHint}>{active.content}</span>
|
||||
{collapsed && activeHint !== null && (
|
||||
<span className={css.activeHint}>{activeHint}</span>
|
||||
)}
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 item) from the call args; the
|
||||
// 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.
|
||||
|
||||
@@ -10,12 +10,11 @@ import type { Context } from 'cordis'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import type { PlanItemLike } from '../contract/todo-plan-model.ts'
|
||||
import { planSummary } from '../contract/todo-plan-model.ts'
|
||||
import css from './todo-row.module.css'
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
|
||||
function isItem(value: unknown): value is TodoWriteItem {
|
||||
function isItem(value: unknown): value is PlanItemLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
@@ -32,12 +31,9 @@ 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 = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
const head = `${done}/${todos.length} 已完成`
|
||||
return typeof active?.content === 'string' && active.content !== ''
|
||||
? `${head} · ${active.content}`
|
||||
: head
|
||||
const { done, total, activeHint } = planSummary(todos)
|
||||
const head = `${done}/${total} 已完成`
|
||||
return activeHint === null ? head : `${head} · ${activeHint}`
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: 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).
|
||||
* 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
|
||||
* 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).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -16,6 +17,7 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/contract/todo-plan-model.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -25,6 +27,43 @@ const LIST: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
/** A parallel plan: three tasks running at once (concurrent subagents). */
|
||||
const PARALLEL: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '读源码', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item verbatim', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeHint: '写组件' })
|
||||
})
|
||||
|
||||
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('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeHint: null })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeHint: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} />)
|
||||
@@ -52,6 +91,19 @@ describe('TodoPanel', () => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows every parallel active item expanded, and counts the extra ones collapsed', () => {
|
||||
render(<TodoPanel todos={PARALLEL} />)
|
||||
// Expanded: one row per item, all three active ones carrying the ● glyph.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
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.
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
expect(screen.getByText('写组件 +2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapsed header omits the hint when nothing is in progress', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
@@ -110,6 +162,11 @@ 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('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
|
||||
Reference in New Issue
Block a user