diff --git a/apps/desktop/src/components/TodoDock.tsx b/apps/desktop/src/components/TodoDock.tsx index 084f2cad51..c439b13eeb 100644 --- a/apps/desktop/src/components/TodoDock.tsx +++ b/apps/desktop/src/components/TodoDock.tsx @@ -42,6 +42,7 @@ export function TodoDock({ sessionId }: { sessionId: string }) { : finished ? t("chat.todo.completed", { completed, total: denominator }) : t("chat.todo.progress", { completed, total: denominator }); + const currentShimmers = !expanded && current !== undefined; return (
@@ -51,7 +52,10 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
{progressLabel} {current ? ( - + {current.content} ) : null} @@ -81,7 +85,7 @@ export function TodoDock({ sessionId }: { sessionId: string }) { tabIndex={expanded ? 0 : -1} > {snapshot.todos.map((todo, index) => ( - + ))}
@@ -90,16 +94,22 @@ export function TodoDock({ sessionId }: { sessionId: string }) { ); } -function TodoRow({ todo }: { todo: SessionTodo }) { +/* `shimmer` is only true while the list is open: the collapsed header already + carries the working step's text, and the list stays mounted behind the + disclosure, so without this gate the same text would sweep twice. */ +function TodoRow({ todo, shimmer }: { todo: SessionTodo; shimmer: boolean }) { const { t } = useTranslation(); const label = t(`chat.todo.status.${todo.status}`); - const completed = todo.status === "completed"; + const working = shimmer && todo.status === "in_progress"; return (
- {completed ? : statusSymbol(todo.status)} + {todo.status === "completed" ? : statusSymbol(todo.status)} - + {todo.content}
diff --git a/apps/desktop/src/styles/composer.css b/apps/desktop/src/styles/composer.css index 8a47131d96..61d1054708 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -862,14 +862,14 @@ transition: color var(--motion-duration-fast) var(--motion-ease-out); } +/* The status marker is a glyph, not a box: the plain list reads as text and + the state is carried by ink plus the completed strike-through. */ .todo-dock-row-symbol { - display: grid; + display: inline-flex; flex: 0 0 auto; width: 16px; - height: 16px; - place-items: center; - border: 1px solid var(--ds-border-default); - border-radius: var(--radius-3xs); + align-items: center; + justify-content: center; color: var(--ds-text-muted); font-size: var(--text-xs); line-height: var(--leading-none); @@ -883,29 +883,16 @@ text-decoration: line-through; } -.todo-dock-row-completed .todo-dock-row-symbol { - border-color: var(--ds-text-muted); -} - .todo-dock-row-in_progress { color: var(--ds-text-primary); font-weight: var(--font-weight-medium); } -.todo-dock-row-in_progress .todo-dock-row-symbol { - border-color: var(--ds-accent); - color: var(--ds-accent); -} - .todo-dock-row-cancelled { color: var(--ds-text-muted); opacity: 0.72; } -.todo-dock-row-cancelled .todo-dock-row-symbol { - border-color: var(--ds-text-muted); -} - .todo-dock-row-content { min-width: 0; overflow: hidden; @@ -913,6 +900,44 @@ white-space: nowrap; } +/* The one moving part: a light band travelling across the text of the step + that is currently running. It is painted into the text itself + (`background-clip: text`), so nothing changes shape and no row gains a fill. + Geometry that makes it actually loop: + · `background-size: 200%` makes the image twice the text width + · `background-position` runs 100% → 0%, which keeps the visible window + inside the image at every frame (the band never leaves and stalls) and + slides the peak from the right edge to the left + · both outer stops are equal, so the restart is invisible + Both ink stops are `--ds-*` tokens, so light and dark each keep their own + contrast and a contributed theme can still move them. */ +.todo-dock-shimmer { + color: transparent; + background-image: linear-gradient( + 90deg, + var(--ds-text-secondary) 0%, + var(--ds-text-secondary) 38%, + var(--ds-text-primary) 50%, + var(--ds-text-secondary) 62%, + var(--ds-text-secondary) 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + -webkit-background-clip: text; + background-clip: text; + animation: todo-dock-shimmer-sweep 2.6s linear infinite; +} + +@keyframes todo-dock-shimmer-sweep { + from { + background-position: 100% 0; + } + + to { + background-position: 0% 0; + } +} + @media (prefers-reduced-motion: reduce) { .composer-mode-chip[data-planning="true"] svg { animation: none; @@ -924,6 +949,14 @@ .todo-dock-row { transition: none; } + + /* A frozen sweep would strand the text on one gradient stop, so the reduced + state drops the band entirely and paints flat primary ink instead. */ + .todo-dock-shimmer { + animation: none; + background-image: none; + color: var(--ds-text-primary); + } } /* Background tasks bar — chat-started commands detached from their turn. The diff --git a/apps/desktop/test/todo-dock-rendering.test.mjs b/apps/desktop/test/todo-dock-rendering.test.mjs index 72b9d50b25..81b7eb8328 100644 --- a/apps/desktop/test/todo-dock-rendering.test.mjs +++ b/apps/desktop/test/todo-dock-rendering.test.mjs @@ -55,7 +55,7 @@ test("TodoDock renders bounded session progress and cancelled state", async () = assert.match(html, /class="[^"]*todo-dock-toggle[^"]*"[^>]*aria-label="Expand checklist"/); assert.match(html, /aria-expanded="false"/); assert.match(html, /aria-controls="[^"]+"/); - assert.match(html, /class="todo-dock-header-current"[^>]*>second<\/span>/); + assert.match(html, /class="todo-dock-header-current todo-dock-shimmer"[^>]*>second<\/span>/); assert.match(html, /class="todo-dock-content" aria-hidden="true"/); assert.match(html, /class="todo-dock-content" aria-hidden="true">
/); assert.match(html, /extra-0/); @@ -66,6 +66,15 @@ test("TodoDock renders bounded session progress and cancelled state", async () = assert.match(html, /class="todo-dock-list" role="list" tabindex="-1"/); assert.match(html, /lucide-chevron-up/); assert.match(html, /lucide-check/); + // The sweep rides the text of the working step only, and only once: while + // collapsed it is the header's current-item line, because the list stays + // mounted behind the disclosure and would otherwise sweep the same text. + assert.equal((html.match(/todo-dock-shimmer/g) ?? []).length, 1, "one shimmer at a time"); + assert.match(html, /class="todo-dock-header-current todo-dock-shimmer" title="second"/); + assert.match(html, /class="todo-dock-row-content" title="second"/); + // The marker is a glyph, not a bordered box: the completed row keeps the + // check icon and the working row keeps the bullet, with no extra chrome. + assert.match(html, /todo-dock-row-in_progress" role="listitem">]*>•<\/span>/); Object.assign(useAppStore.getInitialState(), { sessionTodos: { @@ -95,10 +104,22 @@ test("TodoDock uses a quiet disclosure animation and state surfaces", async () = assert.match(css, /\.todo-dock-content\s*\{[\s\S]*?grid-template-rows:\s*0fr;[\s\S]*?opacity:\s*0;/); assert.match(css, /\.todo-dock\.is-expanded \.todo-dock-content\s*\{[\s\S]*?grid-template-rows:\s*1fr;/); assert.match(css, /\.todo-dock-row\s*\{[\s\S]*?min-height:\s*24px;[\s\S]*?font-size:/); - assert.match(css, /\.todo-dock-row-symbol\s*\{[\s\S]*?border:\s*1px solid var\(--ds-border-default\)/); + // The status marker is a glyph, not a bordered square: the plain list must + // not regain per-row chrome. + assert.match(css, /\.todo-dock-row-symbol\s*\{[^}]*display:\s*inline-flex;/); + assert.doesNotMatch(css, /\.todo-dock-row-symbol\s*\{[^}]*border:/); + assert.doesNotMatch(css, /\.todo-dock-row-symbol\s*\{[^}]*border-radius:/); assert.match(css, /\.todo-dock-row-completed\s*\{[\s\S]*?color:\s*var\(--ds-text-muted\);/); assert.match(css, /\.todo-dock-row-in_progress\s*\{[\s\S]*?font-weight:/); assert.doesNotMatch(css, /\.todo-dock-row(?:-completed|-in_progress)?\s*\{[^}]*background:/); + // The shimmer is the only motion and it rides the text itself. + assert.match(css, /\.todo-dock-shimmer\s*\{[\s\S]*?background-clip:\s*text;/); + assert.match(css, /\.todo-dock-shimmer\s*\{[\s\S]*?background-size:\s*200% 100%;/); + assert.match(css, /@keyframes todo-dock-shimmer-sweep\s*\{[\s\S]*?background-position:\s*100% 0;/); + assert.match( + css, + /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.todo-dock-shimmer\s*\{[\s\S]*?animation:\s*none;[\s\S]*?background-image:\s*none;/, + ); // The expanded list is the scrollport (#1319): bounded height, internal // scroll, no chaining into the transcript, and reachable from the keyboard. assert.match( diff --git a/docs/adr/0312-session-scoped-todo-checklist.md b/docs/adr/0312-session-scoped-todo-checklist.md index 5dfec9635e..ec710e75b1 100644 --- a/docs/adr/0312-session-scoped-todo-checklist.md +++ b/docs/adr/0312-session-scoped-todo-checklist.md @@ -29,10 +29,12 @@ keeps snapshots keyed by session id while rejecting stale revisions. The Composer TodoDock is a non-focusing, session-aware presentation surface. It shows bounded progress and the complete ordered list: the expanded dock scrolls inside its own box instead of truncating, so a long checklist stays -readable without growing the Composer stack. Remote RACP sessions remain -local-only for this vertical slice because RACP v1 has no Todo snapshot -operation; the renderer skips local recovery for those session ids rather than -reading the local database. +readable without growing the Composer stack. The list is plain text with a +glyph status mark per row, and the step that is currently running carries a +light sweep painted into its own text as the surface's only motion. Remote +RACP sessions remain local-only for this vertical slice because RACP v1 has no +Todo snapshot operation; the renderer skips local recovery for those session +ids rather than reading the local database. ## Consequences diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 89aa8089fe..6557501b45 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -2969,10 +2969,19 @@ reasoning-level control. collapsed content is `aria-hidden` and reduced-motion users receive an immediate state change. A collapsed dock reserves only its header row: the list's inset is clipped, never laid out below the header. - Expanded rows use a compact, flat checklist layout with square status marks. - Completed task text is struck through and muted; cancelled rows are muted; - the in-progress row remains primary and medium-weight. Each status symbol has - a localized accessible name, and each row renders plain text. + Expanded rows are a plain text list: a glyph status mark, then the task text. + The mark is a glyph rather than a bordered box, so no row regains per-row + chrome. Completed task text is struck through and muted; cancelled rows are + muted; the in-progress row remains primary and medium-weight. Each status + symbol has a localized accessible name, and each row renders plain text. +- The only motion is a light band sweeping across the text of the step that is + currently working. It is painted into the text itself + (`-webkit-background-clip: text`), so no row changes shape, gains a fill, or + reserves space for it. Exactly one string carries it at a time: the collapsed + header's current-item line while collapsed, and the in-progress row while + expanded. The list stays mounted behind the disclosure, so the collapsed + state must not sweep the same text twice. A `prefers-reduced-motion` user + gets flat primary ink instead of a frozen band. - Renderer snapshots are keyed by session id. A `todos.changed` event with an older or equal revision is ignored. Session activation and host recovery re-read the authoritative snapshot, including already cached checklists; a diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e9069f3c55..0c0ddbdee8 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10062,8 +10062,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. advances revision, including clear, and emits one committed `todos.changed` snapshot. Invalid or unauthorized writes do not mutate or emit. TodoDock renders plain text, separates collapsed progress from the current item, - uses compact square status marks in a flat expanded list, does not take focus, - strikes through completed task text, resets expansion on session changes, + uses glyph status marks in a flat expanded list, does not take focus, + strikes through completed task text, sweeps a light band across the working + step's text only (the header line while collapsed, its row while expanded), + resets expansion on session changes, lists every ordered row with the dock's own list as the scrollport, and keeps a collapsed dock to its header height, rejects stale events, and skips local recovery for `remote:` sessions because diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 5c18f2aaac..254078bb96 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -2021,6 +2021,8 @@ MainChat 底部的输入区域,用于撰写和发送提示。支持多行输 - 收起标题显示完成/活动进度和当前 `in_progress` 内容;全部取消时显示明确的取消状态,不显示误导性的 `0/0 completed`。 - 展开后列出全部条目,并在自身范围内滚动(`max-height: min(280px, 30dvh)`、`overflow-y: auto`、`overscroll-behavior: contain`):长清单因此保持可读,既不撑高 Composer 栈,滚到底也不会带动背后的转写;该滚动区仅在展开时可被键盘聚焦。 - 列表在收起时仍保持挂载,但收起状态只占标题行高度(列表内边距被裁剪,不会在标题下方留白),通过有界的高度/透明度过渡播放展开和收起动画。 +- 展开后是纯文本清单:状态字形标记 + 任务文本。标记是字形而不是带边框的方块,因此任何一行都不会重新获得逐行装饰外壳。已完成任务文本加删除线并降为次级色;已取消行降为次级色;进行中行保持主色与中等字重。每个状态符号都有本地化无障碍名称,每行只渲染纯文本。 +- 唯一的动效是一道光带扫过「正在进行」那一步的文字。它直接绘制在文字上(`-webkit-background-clip: text`),因此没有任何一行改变形状、获得填充或为它预留空间。任一时刻只有一处文字带扫光:收起时是标题行的当前任务文本,展开时是进行中的那一行。清单在收起时仍保持挂载,因此收起态不能让同一段文字扫两次。开启 `prefers-reduced-motion` 的用户改为主色纯色文字,而不是被冻结的光带。 - 渲染器按 session id 保存快照并忽略旧或相同 revision。激活会话和主机恢复时重新读取权威快照,包括已缓存清单;首次读取失败不能导致主机恢复后清单持续隐藏。`remote:` 和 `native-pi:` 会话不拥有本地 Desktop 清单,跳过本地 `todos.get` 恢复;远程 Todo 对等能力留待新增 RACP 契约。 ### 11.4 状态 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 1bc712010e..3fb4276b46 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5713,7 +5713,7 @@ eleven-tool-round desktop paths are verified by - **前提:** 隔离的本地 Electron 配置、确定性的 Agent/Host fixture、两个 Desktop 会话,不使用真实 Provider 或付费 API。 - **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。展开一个超过 dock 高度上限的清单,确认渲染全部条目、列表在 dock 内滚动而不撑高 Composer 栈;完成和取消条目,确认全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。 -- **预期:** 切换会话时收起、收起状态只占标题行高度、列表渲染全部条目并以自身为滚动容器、拒绝旧事件。 +- **预期:** 切换会话时收起、收起状态只占标题行高度、列表渲染全部条目并以自身为滚动容器、拒绝旧事件。展开后是纯文本清单,状态用字形标记而非带边框的方块;进行中的那一步文字带一道扫光,且任一时刻只有一处文字带扫光(收起时在标题行,展开时在对应行)。 - **链接规格:** `03-runtime/03-tools-and-permissions.md`、`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md`、`04-ux/08-component-spec.md`、ADR 0312。 - **验收:** C / E / F / Quality / Security。 - **里程碑:** M6+。