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+。