diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 4bccfa396e..5c8530da70 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.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-23-web-todo-display.md -2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b -2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38 +2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d +2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 830f55c86c..5fe08cc40c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index e68928d7ed..c121ffc27e 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -18,7 +18,7 @@ Status: implemented ### TodoPanel:长驻列表作为一条常驻横条 -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 3116bf4242..f916b64b5b 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -143,19 +143,19 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn })), }).toMatchInlineSnapshot(` { - "panelHeader": "Plan1/3", + "panelHeader": "To-dos1/3 tasks · 1 in progress", "panelItems": [ { "status": "completed", - "text": "✓梳理需求", + "text": "梳理需求", }, { "status": "in_progress", - "text": "●实现 fixture 样本", + "text": "实现 fixture 样本", }, { "status": "pending", - "text": "○浏览器验收", + "text": "浏览器验收", }, ], "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", @@ -164,7 +164,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn `) }) -it('collapses the plan strip to the in-progress hint and restores it', async () => { +it('collapses the plan strip to the count summary and restores it', async () => { boot() await openFixtureSession() @@ -179,7 +179,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async () listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { - "collapsedHeader": "Plan1/3实现 fixture 样本", + "collapsedHeader": "To-dos1/3 tasks · 1 in progress", "listGone": true, } `) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2ffa02d313..2f67e9eac3 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: b242812411d513931ecd2767622f9e23fb0aaa34 -README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12 +README.md: f6b7326916122545fc87d289cb422e644c7bae6c +README.zh.md: 55d4a743709695ba04814b4529aebac235a202d9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b242812411..f6b7326916 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 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 (`/ 已完成 · ` 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 header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). 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 77f68e02d8..55d4a74370 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`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 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`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 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/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index b17027a153..63614e3c13 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -21,8 +21,9 @@ flex-direction: column; align-items: center; /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 8 hosts the error strip's breathing room. */ - padding: 8px 32px 12px; + the chat scroller. Top 6 is the gap under the dock todo strip (12px todo + margin + 6px here); error/status strips still carry their own margin. */ + padding: 6px 32px 12px; } .hero { 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..5ac38c1c67 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,55 +1,53 @@ -/* Plan strip pinned above the composer: bordered card on the composer card's - axis (776px column inside 32px side padding). Colors resolve through - --dsw-alias-* tokens only; the active row rides the business blue, done - rows fade to tertiary. */ +/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): + tip surface, 14px radius, status icons + secondary item labels. Column is + calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ .root { flex: none; overflow: hidden; - margin: 8px auto 0; - width: calc(100% - 64px); + margin: 0 auto; + width: calc(100% - 88px); max-width: 776px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-alias-bg-base); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 14px; + background: var(--dsw-specific-tip); +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 16px; } .header { display: flex; align-items: center; - gap: 8px; + gap: 10px; width: 100%; - padding: 8px 12px; + padding: 0; border: none; background: transparent; text-align: left; cursor: pointer; } -.header:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .title { - font-size: 13px; - line-height: 16px; - font-weight: 510; + flex: none; + font-size: 14px; + line-height: 24px; + font-weight: 500; color: var(--dsw-alias-label-primary); } .progress { - font-size: 12px; - line-height: 16px; - color: var(--dsw-alias-label-tertiary); -} - -.activeHint { - flex: 1; + flex: 1 1 auto; min-width: 0; overflow: hidden; - font-size: 12px; - line-height: 16px; - color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; } @@ -58,13 +56,15 @@ display: grid; flex: none; place-items: center; - margin-left: auto; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } .list { + display: flex; + flex-direction: column; + gap: 8px; margin: 0; - padding: 0 12px 8px; + padding: 0; list-style: none; max-height: 180px; overflow-y: auto; @@ -72,40 +72,44 @@ .item { display: flex; - align-items: baseline; - gap: 8px; - padding: 2px 0; + align-items: center; + gap: 10px; + min-width: 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); } .glyph { + display: grid; flex: none; - width: 14px; - text-align: center; - color: var(--dsw-alias-label-tertiary); + place-items: center; + width: 16px; + height: 16px; } -.item[data-status='completed'] .content { - color: var(--dsw-alias-label-tertiary); - text-decoration: line-through; -} - -.item[data-status='completed'] .glyph { +.glyphCompleted { color: var(--dsw-alias-state-success-primary); } -.item[data-status='in_progress'] .content { - font-weight: 510; - color: var(--dsw-alias-label-primary); +.glyphProgress { + color: var(--dsw-alias-state-business-primary); + animation: todo-progress-spin 1s linear infinite; } -.item[data-status='in_progress'] .glyph { - color: var(--dsw-alias-state-business-primary); +.glyphPending { + color: var(--dsw-alias-label-caption); +} + +@keyframes todo-progress-spin { + to { + transform: rotate(360deg); + } } .content { min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 283eeb3e5e..24764088ab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -3,8 +3,9 @@ // 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. +// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded). -import { useState } from 'react' +import { useId, 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' @@ -16,45 +17,89 @@ export interface TodoPanelProps { todos: readonly TodoItem[] } -/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */ -const STATUS_GLYPHS: Record = { - completed: '✓', in_progress: '●', pending: '○', +/** Completed: green check ring (figma ic_ds_check_16). */ +function CompletedGlyph() { + return ( + + ) +} + +/** In-progress: business-blue ring fading out; CSS spins the svg. */ +function ProgressGlyph() { + const gradientId = useId() + return ( + + ) +} + +/** Pending: dashed unstarted ring (figma 14px, dash 2.4 2.4). */ +function PendingGlyph() { + return ( + + ) +} + +function StatusGlyph({ status }: { status: TodoItem['status'] }) { + switch (status) { + case 'completed': return + case 'in_progress': return + case 'pending': return + } +} + +/** Header summary: "/ tasks · in progress". */ +function progressLabel(todos: readonly TodoItem[]): string { + const done = todos.filter(t => t.status === 'completed').length + const active = todos.filter(t => t.status === 'in_progress').length + return `${done}/${todos.length} tasks · ${active} in progress` } 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') - return ( -
- + {!collapsed && ( +
    + {todos.map(item => ( +
  • + + {item.content} +
  • + ))} +
)} - - {collapsed ? : } - - - {!collapsed && ( -
    - {todos.map(item => ( -
  • - {STATUS_GLYPHS[item.status]} - {item.content} -
  • - ))} -
- )} +
) } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 5bc3aa5c3d..8888fd5659 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -1,9 +1,9 @@ // @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, + * rows, collapse), 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' @@ -31,32 +31,36 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('shows progress, one row per item with its status, and strikes done items', () => { + it('shows progress, one row per item with its status glyph', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('To-dos')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() + // Each status row carries an SVG glyph (not a text bullet). + expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) }) - it('collapse hides the list and surfaces the active item in the header; expand restores', () => { + it('collapse hides the list; expand restores; header keeps the count summary', () => { render() const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() - // Collapsed header carries the in-progress content as the one-line hint. - expect(screen.getByText('写组件')).toBeTruthy() + // Collapsed header is title + progress only (no in-progress content hint). + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() + expect(screen.queryByText('写组件')).toBeNull() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) - it('collapsed header omits the hint when nothing is in progress', () => { + it('collapsed header still shows zero in-progress when nothing is active', () => { render() fireEvent.click(screen.getByRole('button', { expanded: true })) expect(screen.queryByText('都完了')).toBeNull() - expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy() }) }) @@ -71,7 +75,7 @@ describe('TodoDock', () => { render() expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ todos: LIST }) }) - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() // A rollback to the empty list retires the strip (the panel owns no data). act(() => { store.set({ todos: [] }) }) expect(screen.queryByTestId('todo-panel')).toBeNull()