Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions apps/desktop/src/components/TodoDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={`todo-dock${expanded ? " is-expanded" : ""}`}>
<div className="todo-dock-header">
Expand All @@ -51,7 +52,10 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
<div className="todo-dock-header-copy">
<span className="todo-dock-header-text">{progressLabel}</span>
{current ? (
<span className="todo-dock-header-current" title={current.content}>
<span
className={`todo-dock-header-current${currentShimmers ? " todo-dock-shimmer" : ""}`}
title={current.content}
>
{current.content}
</span>
) : null}
Expand Down Expand Up @@ -81,7 +85,7 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
tabIndex={expanded ? 0 : -1}
>
{snapshot.todos.map((todo, index) => (
<TodoRow key={`${index}:${todo.content}`} todo={todo} />
<TodoRow key={`${index}:${todo.content}`} todo={todo} shimmer={expanded} />
))}
</div>
</div>
Expand All @@ -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 (
<div className={`todo-dock-row todo-dock-row-${todo.status}`} role="listitem">
<span className="todo-dock-row-symbol" role="img" aria-label={label} title={label}>
{completed ? <IconCheck size={13} aria-hidden /> : statusSymbol(todo.status)}
{todo.status === "completed" ? <IconCheck size={13} aria-hidden /> : statusSymbol(todo.status)}
</span>
<span className="todo-dock-row-content" title={todo.content}>
<span
className={`todo-dock-row-content${working ? " todo-dock-shimmer" : ""}`}
title={todo.content}
>
{todo.content}
</span>
</div>
Expand Down
69 changes: 51 additions & 18 deletions apps/desktop/src/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -883,36 +883,61 @@
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;
text-overflow: ellipsis;
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;
Expand All @@ -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
Expand Down
25 changes: 23 additions & 2 deletions apps/desktop/test/todo-dock-rendering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"><div class="todo-dock-clip">/);
assert.match(html, /extra-0/);
Expand All @@ -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 class="todo-dock-row-symbol" role="img" aria-label="In progress"[^>]*>•<\/span>/);

Object.assign(useAppStore.getInitialState(), {
sessionTodos: {
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions docs/adr/0312-session-scoped-todo-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 13 additions & 4 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/zh-CN/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 状态
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+。
Expand Down
Loading