feat(web-shell): make Session Workflow dependencies navigable and quiet its chrome (#10938)

* fix(web-shell): keep the auto-sized dialog's gutter on a phone

The `auto` size floors the panel at `min-w-[min(100%,560px)]` so a small plan
graph does not collapse to a narrow column. tailwind-merge keeps that class
alongside the base ceiling `max-w-[calc(100%-2rem)]` — different modifier
groups — and below the `sm:` breakpoint min-width wins over max-width. On a
390px viewport the Plan & tasks dialog therefore rendered its rounded, ringed
panel flush to both screen edges, while every fixed size (sm/md/lg/xl) kept
the 1rem gutter at that same width.

Floor the panel at the gutter the ceiling already reserves.

* fix(web-shell): bind the DAG lane reservation to the constant that places lanes

Lane spacing was stated twice in two languages: `EDGE_LANE_HEIGHT` in TS,
which places each layer-spanning return lane at `lane * EDGE_LANE_HEIGHT`,
and a literal `9px` in `.dagCanvas`'s bottom padding, which reserves room for
them. The two are coupled only through the `--plan-edge-lanes` inline style,
so raising the constant alone leaves the reservation short; with enough
spanning edges the clamp pins the outer lanes together and layer-spanning
edges overlap again — the exact defect the lanes exist to fix. Nothing would
have caught it: the lane test mocks `offsetHeight` directly.

TS now publishes the pitch as `--plan-edge-lane-height` beside the lane count,
and the stylesheet reads it.

Also give the graph a phone: three 240px lanes plus two 64px gutters are
~850px, so the canvas scrolled in both axes at once and the horizontal scroll
hid the layer the vertical scroll was looking for. Below 720px the lane and
gutter narrow. Layer semantics and the rect-measured edge routing are
untouched, so this changes what fits, not what is drawn.

The new stylesheet guard pins both, in the shape the other `.css.test.ts`
files use, since jsdom does not compute the cascade.

One test-only addition on the same component: the spanning-edge test fixed
the edge's arrival column (`H 876`) but not its arrival row, and nothing else
in the file asserted a spanning edge's `endY`. Substituting the return lane's
`routeY` for `endY` in the path tail draws the edge ~170px below the target
node while every existing assertion still passes. Pinned corner-agnostically.

Refs #10866.

* test(sdk): pin the skills entry in the transcript compactor keep-set

`compactTaskExecutionOutput` keeps an allowlist of `task_execution` fields
when it drops the bulky ones, and every neighbouring entry — `executionSummary`,
`tokenCount` — is already pinned by the reducer tests. `skills` was not:
removing it from the keep-set left the whole suite green, and a session
restored from a persisted transcript would silently lose the skill list the
live run recorded.

Mutation-checked: deleting `'skills'` from the keep-set fails exactly this
assertion (1 failed | 327 passed); restored, 328/328.

* feat(web-shell): make workflow dependencies navigable, and let the surface state each thing once

Two related passes over the Session Workflow inspector and cockpit.

## Dependencies become navigation

A step's dependencies rendered as `todo.blockedBy!.join(', ')` — a run of raw
Todo ids, in both the graph node and the inspector detail. An id is an
address, not a label, so following an edge meant reading `step-3, step-7` and
then finding those rows by eye, in a panel whose entire subject is which step
waits on which.

Each reference is now the step it points at — the step number the list and
the graph both already show, plus its title — as a control that selects that
step. The dependency list becomes the graph's navigation.

Recent activity was sliced to 6 while the count beside it reported the true
total, so the panel contradicted itself and the remainder was unreachable.
The cap stays as a preview and gains a control that opens the rest.

## The surface states each thing once

The panel had accumulated chrome that competed with its content:

- Section labels were tracked-out uppercase at 10-11px, so every fixed label
  shouted over the step title and status it introduced. They are labels, not
  headlines: sentence case at a muted weight.
- The cockpit spent the accent colour on a fixed "Dependency graph" eyebrow
  above the session title — making the one blue thing on the page the one
  thing that never changes, while the graph below uses that same accent to
  mean "this node is selected". The label moves to the subtitle line and the
  accent is left to mean selection.
- Completed-over-total sat in a tinted pill directly above the progress bar
  that draws the same ratio, beside the status word that names the same
  state — three surfaces for one fact. The count keeps the tone as ink and
  drops the pill.
- Runtime, tool calls, tokens and artifact kind/status were glued into single
  strings with middle dots, which wrapped as a unit and read out as one run of
  text. They are separate facts, so they are separate elements.

The identity line keeps the mono face only on the short session id, which is
an address the user copies or matches against a daemon log.

No token or theme values change; this is the existing system applied more
sparingly.

Both behaviour changes are mutation-checked: reverting the dependency list to
the joined id string, and restoring the silent cap, each fail exactly their
new test.

Refs #10866.

* docs(core): say why the Todo dependency semantics are not behind the flag

`todo_write`'s omitted-`blockedBy` preservation, and the `blockedBy` schema
text that invites the omission, both reach sessions with Session Workflow
disabled. That was a deliberate choice when the behaviour landed, but nothing
in the file said so, so it reads as a missed gate — the `hasActivePlan`
derivation sat bare and the only nearby comment explains the dangling-edge
filter, not the gating.

Record the decision and its reasoning: these are plan data-model semantics
rather than presentation, and gating them on a visualization switch would
give the tool two dialects, storing different plans for the same call
depending on whether anyone is looking at the graph.

Record the blast radius in the same place, because "reaches every session"
overstates it. The merge changes an update only when the previous plan still
has an unfinished item, the same id carried a non-empty `blockedBy`, and this
call omits it. Plans that never used dependencies are untouched
(`undefined?.filter()` is `undefined`), callers that always send the full list
are unaffected, and `[]` stays the explicit clear. What does reach every
session is the schema text, which is why it is named as widening this
branch's exposure rather than as an independent change.

Comment-only; no behaviour, no signatures.

* feat(web-shell): lead the plan DAG with the step, not its status

Read against the three questions a plan graph is opened with — what is
running, what is blocked on what, what needs me — the node answered none of
them first.

**Reading order was inverted.** The node led with a status glyph, then the raw
Todo id, then the status word; `nodeContent`, the only element that says what
the step *is*, came third. At graph scale a reader scanned `● step-3 Blocked`
before "Compare findings". An id is an address and the least useful thing at
that scale, so it leaves the node face entirely — it stays in the detail panel,
where it is actually copied. Content leads, at 13px/500, behind the step
number the inspector list and dependency chips already use, so the three
surfaces address a step identically instead of three different ways.

**Status was stated three or four times per node** — glyph, status word,
border tint, plus an attention badge. It is now stated once, as a 3px left
rule, and only when it is worth saying: waiting is the resting state of most
nodes in a real plan and carries no rule. The stylesheet already made this
argument for `blocked`; extending it consistently is what stops the canvas
reading as a wall of tinted boxes and gives the one running node something to
stand out against. Attention folds into the same rule and outranks the status
tone, because it is the state the reader is being asked to act on.

Status is colour, which assistive tech cannot read, so it stays in the
accessibility tree as words.

**Time was invisible** in an execution trace. The node face now carries the
agent count and the summed agent runtime for that step — the "is this alive or
hung" signal, which is the first thing asked of a live run. Two facts with
real spacing, not one middle-dot-joined string that wraps as a unit and reads
out as a single run of text.

**Dependencies were stated twice**, as a drawn edge and again as
`Depends on step-3, step-7`. The row is now rendered only when no edge states
it — above `MAX_RENDERED_PLAN_EDGES` the graph draws none, and there the row
is the dependency's only statement, so deleting it outright would have lost
information in exactly the case that needs it most. It renders as the same
number-and-title chips the inspector uses.

The input port dot goes with them: the left edge now carries the status rule,
and an incoming edge already terminates in an arrowhead at the node — that
arrowhead is the input marker. The output port stays, since outgoing edges
leave their source unmarked. Both port dots also drop `--agent-blue-500`;
every node with an edge had one, so a permanently blue dot across most of the
graph left selection no colour of its own.

Edge routing, the layering algorithm, the return lanes and the measure pass
are untouched. This is the node face and the colour rule.

Three geometry tests identified a node by its first `<span>` — the Todo id.
That is now the accessible status word, so they bind to `data-plan-node-id`
instead, which is the stable hook and was always the better one.

Every behavioural change is mutation-checked: ungating the dependency row,
deleting the accessible status word, and dropping the elapsed each fail
exactly their own assertion.

Design plan, including the two revisions the review pass forced, is in
docs/plans/2026-09-04-session-workflow-dag-design.md.

Refs #10866.

* fix(web-shell): drop the unused dependencyIdsByTodo destructure

ESLint flags the graph destructure binding as never used; the memo
still computes and returns the map for other consumers.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtlr9x6ugb

* fix(web-shell): stop the activity grid from swallowing the show-all control

Round-1 review findings.

**Blocker.** `.showAllActivity` was a single class (0,1,0), but the button is
a direct child of `.activityList`, whose base rule `.activityList > button`
(0,1,1) declares the four-column activity grid and wins. The label — a bare
text run that Grid wraps into an anonymous item — auto-placed into the 52px
first column and wrapped to three or four lines with three empty columns
beside it, and the intended padding lost too. Hover only looked right by
coincidence, because the row's own hover set the same background.

This is the collision handled two rules earlier for `.metrics`, missed here.
Both are now pinned by the stylesheet guard so neither can regress to a bare
class, and the fix resets `display` and `grid-template-columns` explicitly
rather than relying on source order.

**The graph's step-details panel still joined raw ids.** The node face's
chips cannot be controls — that row lives inside the node's own `<button>`
and a nested button is invalid HTML — but this panel sits outside it, so its
references become real controls that select the step they name, which is what
makes a dependency list navigation rather than text. `Depends on` and
`Unblocks` both.

**The narrow-viewport claim was wider than the fix.** Three 168px lanes plus
two 32px gutters is ~588px, which removes horizontal scrolling between there
and the 720px breakpoint but still scrolls on a 390px phone. Added a second
step below 480px that gets a three-layer plan under a phone viewport, and
said plainly in the comment that deeper plans still scroll — reusing the flat
layout stays with #10866. The guard test is renamed to what it pins: jsdom
computes no layout, so it can assert the two steps exist and narrow, not an
outcome.

**The lane pitch had a second copy.** The `var()` fallback restated `9px`
next to the `.dagCanvas` default, so "TS owns the pitch" held only when the
graph has dependencies. The default is now `0px` — the zero-lane case
collapses the reservation regardless — and the guard rejects the fallback
copy too.

**Comment antecedent.** "the prompt change" in todoWrite named nothing a
later reader could find; it now quotes the schema description it means and
says why a tool schema reaches every session.

Refs #10866.

* fix(web-shell): keep the node's non-colour status channel, and stop clipping its port

Two defects from my own review of the DAG pass, both in the class the round-1
review already caught once: invisible to jsdom, invisible to a passing unit
run, and only findable by reading the stylesheet.

**The status glyph was an accessibility channel, not decoration.** Making the
left rule the single carrier of status dropped the glyph — but the constant
it came from says exactly why it exists: "Status is carried by a glyph as well
as a colour so the graph survives colour-blindness, high-contrast mode, and a
greyscale screenshot." Colour plus a screen-reader-only word leaves a sighted
colour-blind reader, high-contrast mode, and any greyscale capture unable to
tell running from completed from needs-attention. "One status, one place" was
the wrong rule; the right one is one carrier of the status *colour*, plus a
redundant non-colour channel. The glyph returns for every status, in the meta
line so the step's content still leads, and muted so the rule stays the only
thing carrying the tone. Pinned per status, and pinned as distinct from each
other, so the shape channel cannot silently collapse.

**The status rule clipped the outgoing port.** Drawing it as `.node::before`
needed `overflow: hidden` for the node's 8px radius to apply to it — which
also clips the port dot at `right: -4px`, which is deliberately outside the
box. It is a `border-left` instead: border-radius clips it natively, no
overflow, and a transparent rule paints the node's own background so a resting
node reads as 3px of extra left padding with no width difference between
states. The stylesheet guard now rejects the overflow, the pseudo-element and
a missing port offset together, since the three only fail as a set.

Also drops `.nodeGlyph`'s dead sibling state: the rule is back in use, so
nothing here is unused CSS.

The design plan records both as corrections rather than folding them in, so a
later reader sees which of its rules were wrong and why.

Refs #10866.

* fix(web-shell): put dependency truncation where it can actually fire

Round-2 review findings that survive at this head; the round's three blockers
(the node's clipped port, the show-all specificity collision, and the
step-details panel's raw ids) were already fixed in the two commits after the
head it reviewed.

**The chips' `text-overflow: ellipsis` was dead.** It was declared on the
`inline-flex` chip itself, but `text-overflow` applies to a block container's
own inline content — the title sat in an anonymous flex item, so a long
dependency title was clipped hard with no ellipsis. The title now has its own
span carrying `overflow`, `min-width: 0` and the ellipsis, in all three places
that render a reference (the node face's read-only chips and both directions
of the step-details panel's links), and the stylesheet guard pins that it
lives on the title rather than the box.

**The comment above `.node` still described the old status behaviour** — it
said status drives the accent that drives the border, which this branch
stopped doing two commits ago. Rewritten to describe the border/rule split,
keeping the hazard it originally recorded: with status off the border, a
selected completed or blocked node can no longer pick up a second border in a
clashing hue.

**The compaction test's comment overclaimed.** It said every other keep-set
entry is pinned by that test; it pins `skills`. Narrowed to what it does.

Two coverage gaps the same round named:

- The inspector test exercised only the upstream direction. Upstream and
  downstream render through one helper but read from different sources
  (`blockedBy` versus the projection's `dependentsByTodo`), so covering one
  did not cover the other; the downstream link is now rendered, asserted and
  clicked.
- `planExecution.agentCount`'s singular and plural branches are separate
  formatter paths, and only the singular one was exercised — `1 agents` would
  have shipped green. Both are now pinned.

Refs #10866.

* fix(web-shell): drop ghost dependency controls and mark them interactive

The step-details dependency buttons mapped raw selectedTodo.blockedBy,
so an unresolvable step id rendered as a clickable "? <id>" control;
clicking it selected a ghost id, emptied selectedTodo, and hid the
details panel mid-navigation. Render from the same filtered projection
the edges draw from (dependencyIdsByTodo: unknown ids, duplicates, and
self-references dropped) and hide the section when nothing resolvable
remains.

Also add the data-plan-interactive marker to both step-details
dependency button groups. The host keyboard handlers in ToolApproval
and TasksStatusMessage isolate plan controls through that marker, and
these were the only interactive controls in the view without it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtmourmvi4

* fix(web-shell): let dependency titles keep their colour, and attention its ring

The muted `> span` rules match every direct-child span of the chip and
link — now including the title span the truncation fix added — so every
dependency title rendered muted while the chip's own
`color: var(--foreground)` applied to no rendered text. The title now
states its own colour on the title rule, where two classes outspecific
the one-class-plus-element mute.

The attention rule sat before the blocked/ready rule at equal
specificity, so a blocked node that also needs attention wore the muted
ring instead of the attention tone. Attention is declared after the
status tones so it outranks them, as its comment says. Both shapes are
pinned at the source in PlanExecutionView.css.test.ts.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtn1pqbuir

* fix(web-shell): dedup repeated blockedBy ids before rendering references

The node-face chip row and the inspector's upstream list mapped raw
model-authored blockedBy, so a repeated id emitted duplicate React keys
and a duplicate chip or link. Every other blockedBy consumer already
dedups with new Set, and the step-details panel reads the deduped
topology projection; both surfaces now match. The dense-plan test feeds
a repeated id and pins the single chip, and a new inspector test pins
the single upstream link.

Also pin the styles.dependencyTitle wiring on the chip and link title
spans in the existing render tests: the truncation rule targets that
class, so dropping the className would re-clip titles without an
ellipsis while every text assertion stayed green.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtn1pqbuir

* fix(web-shell): state the 480px step's true plan width in the comment

The comment claimed the 480px step gets a three-layer plan under a phone
viewport, but its own pinned values sum to ~396px (3×116px lanes, 2×18px
gaps, 2×6px canvas padding) — still ~6px wider than the 390px phone the
sibling comment names as the reference. Restate the comment with the true
figure: fits phones wider than ~396px, a 390px phone still scrolls the last
~6px. Comment only; the pinned values and the stylesheet guard are
unchanged.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnaadh9j8

* docs(plans): mark the session-workflow DAG design implemented, resolve its open questions

The doc shipped in this PR declared "Status: proposal, not implemented" while
the same PR ships the implementation, and left two open questions the code
had already answered. Mark it implemented and record the decisions where
they were made: the elapsed count renders on the node face (meta line, when
greater than zero), and attention folds into the left rule via
data-attention, declared after every status tone so it wins the cascade.
The verification note stays open — it is about process, not a design
decision.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnaadh9j8

* test(web-shell): pin the declarations the plan guards actually rest on

The truncation guard now pins `overflow: hidden` beside
`text-overflow: ellipsis` — the former is what makes the latter take
effect, and `overflow` is not inherited from the chip. The two
phone-width steps pin the lane width on its owner selector and add the
paired gap/padding narrowing the fit rests on; dropping any of them
restored the clipped-title or two-axis-scroll defect while the suite
stayed green.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(web-shell): drop self-blocks in the inspector, keep status words beside attention

The inspector's upstream dependency filter kept self-references: a todo
listing its own id in blockedBy rendered a "Blocked by" chip pointing at
itself, while the projection's dependentsByTodo builder and the DAG both
drop self-blocks. Apply the same rule so the inspector agrees with every
sibling surface.

The node face's sr-only span also announced attention instead of the
status word; an attention node lost its status from the accessibility
tree while the aria-hidden glyph still carried it for sighted users.
Announce both: the status word first, attention appended.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnn5c4cjw

* test(web-shell): witness the downstream click, attention word and cascade order

- click the downstream 'Unblocks' reference in the step-details panel and
  assert the named node gains aria-pressed, so a dropped onClick in the
  selectedDependents block turns red
- render a node into attention and assert its sr-only span carries the
  status word beside "Needs attention"; pin the inspector's self-block
  exclusion beside the dedup test
- extend the cascade-order guard to all six status selectors, not just
  blocked, so regrouping any status tone below the attention rule fails

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnn5c4cjw

* test(web-shell): pin the plural agent count and the attention attribute

- link two root Agent executions to one node and assert the face renders
  '2 agents', so an EN template that always emits `${count} agent` turns
  red; the count-1 side stays pinned by the existing assertion pair
- assert the attention fixture's article carries data-attention='true',
  the hook the `.node[data-attention='true']` colour rule selects on;
  the 'Needs attention' word was pinned at 18f9fa07, the attribute was not

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* test(web-shell): clear every .node-subject rule of overflow clipping

The unclipped-port guard only inspected the bare `.node {}` rule, so an
`overflow: hidden` on any of the other `.node`-targeting rules — a status
variant, attention, selection, the `:has()` state, the media-scoped copies —
would still cut the `right: -4px` output port and ship green. Walk every
rule block (at-rule preludes unwrapped) and assert no selector whose
subject is `.node` carries overflow clipping; descendant-subject rules and
the legitimate chip/title clipping stay out of scope.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* docs(plans): state the shipped color mappings in the Color table

The table claimed `--border` for every dependency line, `--status-running-fg`
for running "and nothing else", and `--status-attention-fg` for needs
attention "and nothing else". The shipped stylesheet draws edges with
`--muted-foreground` and `--agent-blue-500` arrowheads, dresses in_progress
in the running token, and dresses paused plus the step-details chips in the
attention token. Record the mappings as shipped so a later palette retune
reads the exclusivity clauses as they actually hold.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* fix(web-shell): close the cockpit review's rendering and a11y blockers (#10938)

- Keep selection off the status rule: re-pin border-left-color to
  --node-rule in .node[data-selected='true'] so selecting a blocked/ready
  node no longer paints a 3px muted status bar.
- Carry data-plan-interactive on the inspector's dependency links and the
  show-all-activity button, so the transcript's window-level keydown
  handlers early-return while they hold focus.
- Let .dependencyList > li shrink (min-width: 0) so the dependency chip
  title's ellipsis actually engages instead of overflowing the column.
- State dependencies as text whenever neither the aria-hidden edges nor
  the step-details panel can: above the edge budget, in the cockpit
  (showStepDetails={false}) and in document mode; drop self-references
  from the node-face chips like the topology builder does.
- Give attention a shape channel: a muted visible sigil in the node meta
  row, since on paused nodes the data-attention rule re-declares the very
  token paused already wears and colour alone cannot tell them apart.
- Count agents, not bare executions, in the node-face tally (nested
  subagents included; non-agent todo_id tools hidden), matching the
  node's own execution rows and the inspector's Subagents list.
- Scope the visuals spec's runtime gate to the node face and the
  inspector detail separately; the page-wide locator matched both and
  threw a Playwright strict-mode violation.

* fix(web-shell): keep plan arrowheads forward and name blockers (#10938)

Two regressions wenshao measured in Chromium at this head, both introduced
by this diff.

R6-3: the 32px and 18px gutters are narrower than the edge router s fixed
24px shoulder, so the control point landed on the end point (32px tier, end
tangent 0,0) or past it (18px tier, end tangent -14,0). orient=auto then
flips the arrowhead back at its source, and the input port is gone, so the
last direction cue pointed the wrong way at phone widths. The shoulder is now
half the run: every gutter wide enough to afford the 24px floor already
resolved to run / 2, so wide lanes keep their exact curve while the end
tangent stays strictly positive at all three tiers.

R7-3: with edges drawn and the details panel on, the node s accessible name
stopped naming its blockers, because drawn edges are aria-hidden and the
visible chip row is off there by design. A screen reader user had to activate
every node to learn what blocks it. An sr-only summary on the same channel as
the status word restores the words, using the chips step-number-plus-title
labels, without bringing the visible row back.

Evidence is unit level, not a browser capture: the geometry test reproduces
wenshao s measured tangents exactly (0 at the 32px gutter, -14 at the 18px
gutter) and goes red when the floor is restored; the accessible-name test
goes red when the sr-only summary is dropped.
vitest run PlanExecutionView + workflow: 9 files, 84 tests (was 81).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtzb8sj51m

* fix(web-shell): count node-face agents from the same sources the rows render

R8-2: agentCount read only transcript subTools, while renderExecution also
renders live child tasks from the task index, so a live child with no
transcript entry rendered a row the tally never counted. Count the same
deduped union the rows come from.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): keep layer-skipping plan lanes out of the step they pass (#10938)

wenshao's round-2 verification names two merge conditions on 5f70a13.

At <=480px the router gave every layer-skipping edge a fixed 24px shoulder.
The 18px gutter leaves a 10px run, so the vertical segment landed inside the
intervening lane: a dependency from step 3 to step 5 read as one into step 4,
and the SVG paints under the nodes so the lane just disappeared into it.
Derive each shoulder from the gutter measured on that side, the way the
adjacent-layer edge already does, and halve the corner radius so the run into
the arrowhead keeps a positive length. The 64px desktop tier has a 56px run,
so min(24, 28) is still 24 and every desktop path stays byte-identical; the
new witness pins the exact desktop path string.

The node-face agent tally dedups by toolUseId exactly as the rows do, but
nothing exercised the overlap. Add a fixture where a live child task and a
transcript subTool share one toolUseId and assert it counts once (2 agents
beside 2 rows); dropping the filter reads 3 agents.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtzjtfna1y

* ci(web-shell): re-fire the head CI lanes that never scheduled

The Qwen Code CI, Web-shell Visuals and review-lane runs created for d91a0f7e at 2026-09-13T08:50:29Z never scheduled a single job: a repo-wide Actions queue jam in that window left 59 runs stuck at status=queued with zero jobs, and both cancel and rerun are refused on them (cancel reports 'completed', rerun reports 'already running'). Runs created after ~09:30Z schedule normally, so these are dead rather than slow.

No content change. This empty commit only re-fires pull_request:synchronize so the lanes actually run on the exact tree wenshao validated in round 3 ('merge once CI has actually run green on this head').

Co-authored-by: Qwen-Coder <[email protected]>

Patrol-Run: qwen-pr-closeout/jmtzq8wyy28

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-13 13:32:32 +00:00
1 parent 6a211b4652
commit 01aa4f267f
13 files changed
+2240 -111

No files matched your search

@@ -0,0 +1,216 @@
# Session Workflow DAG — design plan
Status: implemented by this change. Written before code, reviewed against the
brief once, revised. The revision — and the two corrections found during
implementation — are recorded at the bottom rather than silently folded in.
## Brief
An execution trace of a dependency-ordered plan, watched live while an agent
works through it. Reader: a developer. The three questions they arrive with,
in order: **what is running now**, **what is blocked on what**, **what needs
me**.
This is a product surface, not a greenfield page: it inherits the Web Shell
token system, light/dark, CSS modules, and a stylesheet-source test
convention. So the palette is not up for invention — the plan spends its
freedom on structure and on the _rule_ governing existing tokens.
## What is wrong today
Read against the three questions, not as taste:
1. **Reading order is inverted.** The node leads with a status glyph, then the
raw Todo id, then the status word; `nodeContent` — the only element that
says what the step _is_ — is third. At graph scale the reader scans
`● step-3 Blocked` before "Compare findings". An id is an address, and it
is the least useful thing at the largest scale.
2. **Status is stated three or four times per node**: the glyph, the status
word, the border tint, and an "attention" badge when it applies. On a 240px
node, most of the visible elements are about status.
3. **Dependencies are stated twice** — as a drawn edge, and again as
"Depends on step-3, step-7" in raw ids.
4. **Every node looks equally important.** In a real trace at most one node is
running and a few need attention; the rest are done or waiting. Same border
weight, same fill, same radius on all of them — the graph does not answer
"where do I look".
5. **Time is invisible.** This is an execution trace, and the layout encodes
dependency order but never elapsed time.
6. **The port dots are always `--agent-blue-500`** — the selection colour —
on every node that has edges. The accent is spent on something that never
changes, so selection has to shout over it.
## Color
No new values; a rule for the existing tokens.
| role | token | used for |
| ----- | ----------------------- | ------------------------------------------------------------------------------------------ |
| ink | `--foreground` | step content — the only full-contrast text |
| quiet | `--muted-foreground` | counts, elapsed, ids |
| rule | `--border` | node borders; dependency lines are `--muted-foreground` with `--agent-blue-500` arrowheads |
| live | `--status-running-fg` | running and in_progress |
| done | `--status-done-fg` | completed, and nothing else |
| wait | `--status-attention-fg` | needs attention, and paused (plus the step-details chips) |
| pick | `--agent-blue-500` | selection and focus only, never decoration |
**The rule: a node carries at most one status colour, in exactly one place,
and only when the status is worth saying.** Waiting/blocked is the resting
state of most nodes in any real plan, so it carries none — the stylesheet
already makes this argument for `blocked`; this extends it consistently
instead of applying it to one status.
## Type
One family (the app's). Three sizes inside a node:
- **content** 13px / 500 — promoted to the first line
- **meta** 11px / 400 quiet — one line beneath
- **number** 11px tabular — the step's index
No status word on the node face in the default case; the left rule says it.
No mono on the node face — the id is an address, so it belongs where it is
copied (the detail panel), not on every card at graph scale.
## Layout
Two candidates, then the pick.
**A — content-first, status as a left rule**
```
┌──────────────────────────────┐
│▌ 3 Compare findings │ ▌ 3px status rule, absent when waiting
│ 2 agents 1m14s │
└──────────────────────────────┘
```
**B — status as a filled number column**
```
┌───┬──────────────────────────┐
│ 3 │ Compare findings │ number cell filled with the status tone
│ │ 2 agents 1m14s │
└───┴──────────────────────────┘
```
**Pick: A, carrying B's step number.** The number has to appear on the node
because the inspector list and the new dependency chips both address steps by
number — three surfaces, one identity, or they disagree. But B spends ~24px
of a 240px lane on a cell whose only other job is a tone the rule already
carries.
Alignment is left throughout. The lanes are already a strong vertical rhythm;
centring anything fights it.
The node's border stays `rule` for every status. Removing the per-status
border tint is what stops the canvas reading as a wall of tinted boxes and
lets the one running node actually stand out.
## Where the boldness goes
One place: **the running node.** Usually exactly one node is running, and
"is this thing alive or hung" is the first question a developer asks of a live
trace. It gets the only saturated treatment on the canvas — a filled left rule
and a live elapsed count — and everything around it stays hairline and quiet.
Motion: none added. The elapsed count is data changing, not an animation; no
transition on it. The existing `prefers-reduced-motion` block stands.
## Principles
1. The step's content is the node; everything else is annotation.
2. One status, one place, and only when it is worth saying.
3. The edge is the dependency statement — do not restate it as text.
4. The step number is one identity across graph, list, and dependency chip.
5. Exactly one thing on the canvas is loud: what is running now.
## Explicitly not changing
Edge routing, the layering algorithm, the return-lane mechanics, and the
measure pass. They work, they are heavily tested, and touching them would turn
a node-face redesign into a second oversized PR. This proposal is the node
face plus the colour rule.
## Review against the brief
Worked through the generic-default calibration; two findings, one of which
changed the plan.
- Cream/serif/terracotta, or near-black with an acid accent: not applicable,
the tokens are the product's.
- Hairline rules everywhere: I _am_ moving toward hairlines, which is itself a
default look. Kept, but for a subject-specific reason — the hairline
replaces a per-status border tint, and the argument is the one the codebase
already makes for `blocked`: most nodes in a healthy plan are resting, and
tinting them all makes a healthy graph read as a wall of warnings. The 8px
radius stays, so this is not a broadsheet.
- The SaaS-card kit — identical rounded cards, one border treatment, a status
wash on each: this is what the node is today. Dropping the status border and
two of the three status tokens moves away from it.
- **Numbered markers (01 / 02 / 03):** the calibration warns against these
unless the content is genuinely a sequence. Here it is — a topologically
ordered plan — and the number is already how the inspector addresses steps.
Kept.
- **`·`-joined meta strings — caught, and the plan changed.** The first draft
of the node meta line read `step-3 · 2 agents · 1m14s`. That is on the tell
list, and it is the exact pattern just removed from the inspector. Revised
to two facts with real spacing and no id on the node face, matching the
`.metrics` flex row already built. This also buys back horizontal room in a
240px lane, which is what makes the narrow-viewport case work honestly
rather than by shrinking type.
## Caveat found while planning
The "Depends on …" text row cannot simply be deleted. There are three layout
states, not two:
- `hasDependencies === false` → flat layout, no `blockedBy` exists anywhere,
so nothing to state. Safe.
- `drawsDependencyEdges === true` → edges are drawn, the text is redundant.
Remove.
- `hasDependencies && !drawsDependencyEdges` → dependencies exist but exceed
`MAX_RENDERED_PLAN_EDGES` (500), so **no edges are drawn**. Here the text is
the only statement of the dependency and must stay.
So the row is kept and gated on `!drawsDependencyEdges`, rendered as the same
number-and-title chips the inspector uses rather than as raw ids.
## Corrections found after the plan, during implementation
Recorded rather than folded in, because both are places the plan's own rule
was wrong and a later reader should see why.
1. **"One status, one place" was too strong — it deleted an accessibility
channel.** The plan replaced the status glyph with a colour-only left rule.
But the glyph carried a documented purpose the plan never checked
(`PlanExecutionView.tsx`: "Status is carried by a glyph as well as a colour
so the graph survives colour-blindness, high-contrast mode, and a greyscale
screenshot"). Colour plus a screen-reader-only word leaves a sighted
colour-blind reader, high-contrast mode, and any greyscale capture with no
way to tell running from completed. The rule is corrected to: one carrier
of the status _colour_, plus a redundant non-colour channel. The glyph
returns for every status, in the meta line so the content still leads, and
muted so the rule stays the only thing carrying the tone.
2. **A pseudo-element rule needed clipping, and clipping cut the port.**
Drawing the rule as `.node::before` required `overflow: hidden` for the 8px
radius to apply — which also clips the outgoing port dot, deliberately
positioned at `right: -4px` outside the box. The rule is a `border-left`
instead, which border-radius clips natively and needs no overflow. Both
halves are now pinned by the stylesheet guard, since neither is visible to
jsdom or to a passing unit run.
## Open questions
The first two were answered by the implementation; the answers are recorded
here so the decisions stay visible.
1. Does the elapsed count on a running node belong on the node face, or only
in the inspector? — On the face: the meta line renders it whenever it is
greater than zero.
2. Should `attention` keep a badge, or fold into the left rule as the `wait`
tone? — Folded into the rule: `data-attention` carries it, declared after
every status tone so it wins the cascade.
3. Verification: this box cannot run a browser, so the visual pass would ship
read from CSS. Worth a real render before merge.
@@ -0,0 +1,205 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// jsdom does not compute the CSS cascade, so pin the stylesheet's source
// shape instead. The DAG reserves bottom padding for its layer-spanning
// return lanes, and that reservation has to use the same pitch that places
// the lanes: TS owns `EDGE_LANE_HEIGHT` and the canvas placed lanes at
// `lane * EDGE_LANE_HEIGHT`, while the stylesheet re-stated the pitch as a
// literal `9px`. Raising the constant alone left the reservation short, the
// clamp pinned the outer lanes together, and layer-spanning edges overlapped
// again — the exact defect the lanes exist to fix — with no test signal,
// because the lane test mocks `offsetHeight` directly. Strip comments so the
// guards match declarations only, not prose about them.
const planCss = readFileSync(
fileURLToPath(new URL('./PlanExecutionView.module.css', import.meta.url)),
'utf8',
).replace(/\/\*[\s\S]*?\*\//g, '');
const planSource = readFileSync(
fileURLToPath(new URL('./PlanExecutionView.tsx', import.meta.url)),
'utf8',
);
describe('PlanExecutionView stylesheet', () => {
it('takes the edge-lane pitch from TS instead of restating it', () => {
const canvas = planCss.match(/\.dagCanvas\s*\{[^}]*\}/)?.[0];
expect(canvas).toBeTruthy();
expect(canvas).toMatch(/var\(--plan-edge-lane-height/);
// The literal that used to stand in for the constant must not return
// anywhere in the reservation.
expect(canvas).not.toMatch(/--plan-edge-lanes\)\s*\*\s*9px/);
// No second copy of the constant as a var() fallback either: the pitch
// has one source, and the default only covers the zero-lane case.
expect(canvas).not.toMatch(/--plan-edge-lane-height,\s*9px/);
// TS has to actually publish it, or the fallback silently takes over.
expect(planSource).toMatch(
/'--plan-edge-lane-height':\s*`\$\{EDGE_LANE_HEIGHT\}px`/,
);
});
// The output port is positioned at `right: -4px`, deliberately outside the
// node's box, so the node must not clip its overflow — and the status rule
// must therefore be a border, which border-radius clips natively, rather
// than a pseudo-element that would need `overflow: hidden` to be rounded.
// Neither half of that is visible to jsdom or to a green unit run.
it('keeps the node unclipped so its outgoing port survives', () => {
const node = planCss.match(/(^|\n)\.node\s*\{[^}]*\}/)?.[0];
expect(node).toBeTruthy();
expect(node).not.toMatch(/overflow:\s*hidden/);
expect(node).toMatch(/border-left:\s*3px solid var\(--node-rule\)/);
expect(planCss).not.toMatch(/\.node::before\s*\{/);
expect(planCss).toMatch(
/\.dagCanvas\s+\.node\[data-plan-output='true'\]::after\s*\{[^}]*right:\s*-4px/,
);
// Clipping on ANY rule whose subject is `.node` — a status variant,
// attention, selection, a `:has()` state, the media-scoped copies — cuts
// the port just as surely as clipping on the base rule, and jsdom never
// sees it. Walk every rule block and clear every `.node` subject of
// overflow clipping; rules that merely descend from `.node` (such as
// `.node[...] .nodeGlyph`) keep their own overflow business, as do the
// chip and title rules that clip legitimately.
const clipped: string[] = [];
// At-rules wrap rules without adding a selector subject of their own, so
// unwrap their preludes and scan the inner rules like top-level ones
// (the leftover closing braces are lone text no block regex matches).
const unwrapped = planCss.replace(/@[a-z-]+[^{]*\{/gi, '');
for (const [, selectorList, body] of unwrapped.matchAll(
/([^{}]+)\{([^}]*)\}/g,
)) {
if (!/overflow:\s*(hidden|clip)/.test(body)) continue;
for (const selector of selectorList.split(',')) {
const subject =
selector
.trim()
.split(/\s*[>+~\s]\s*/)
.pop() ?? '';
if (/\.node(?![A-Za-z0-9-])/.test(subject)) {
clipped.push(selector.trim());
}
}
}
expect(clipped).toEqual([]);
});
// The selected rule's `border-color` shorthand expands to
// border-left-color and outranks the base rule's
// `border-left: 3px solid var(--node-rule)` longhand (0,2,0 beats 0,1,0)
// while the 3px width survives — so a selected blocked/ready node, whose
// rule is deliberately transparent, grew a 3px muted left bar and
// selection read as a status change. jsdom computes no cascade, so the
// re-pin is asserted at the source.
it('keeps selection off the status rule', () => {
const selected = planCss.match(
/(^|\n)\.node\[data-selected='true'\]\s*\{[^}]*\}/,
)?.[0];
expect(selected).toBeTruthy();
expect(selected).toMatch(/border-left-color:\s*var\(--node-rule\)/);
});
// `text-overflow` applies to a block container's own inline content, so
// declaring it on the inline-flex chip did nothing: the title sat in an
// anonymous flex item and was clipped with no ellipsis. jsdom computes no
// layout, so this is pinned at the source instead.
it('puts dependency truncation on the title, not the flex box', () => {
for (const name of ['dependencyChip', 'dependencyLink']) {
const rule = planCss.match(
new RegExp(`(^|\\n)\\.${name}\\s*\\{[^}]*\\}`),
)?.[0];
expect(rule).toBeTruthy();
expect(rule).toMatch(/display:\s*inline-flex/);
expect(rule).not.toMatch(/text-overflow/);
}
const title = planCss.match(
/\.dependencyChip\s+\.dependencyTitle,\s*\n\s*\.dependencyLink\s+\.dependencyTitle\s*\{[^}]*\}/,
)?.[0];
expect(title).toMatch(/text-overflow:\s*ellipsis/);
// `text-overflow` only takes effect on a box whose own `overflow` is
// hidden; overflow is not inherited, and the chip's own clipping does
// not reach the title span, so the declaration must live on this rule.
expect(title).toMatch(/overflow:\s*hidden/);
expect(title).toMatch(/min-width:\s*0/);
// The `> span` rules mute every direct-child span, the title included;
// without its own colour the title would render muted while the chip's
// `color: var(--foreground)` applies to no rendered text.
expect(title).toMatch(/color:\s*var\(--foreground\)/);
});
it('declares the attention tone after every status tone', () => {
// All these selectors carry the same specificity, so the cascade lets
// the later rule win. Attention co-occurs with every status (a linked
// agent task can fail under any of them), so the attention rule must
// stay declared after every status tone, or a node that needs attention
// would wear its status ring instead of the attention tone its comment
// promises — whichever status block a later edit regroups.
const attentionAt = planCss.indexOf(".node[data-attention='true']");
expect(attentionAt).toBeGreaterThan(-1);
for (const status of [
'completed',
'running',
'in_progress',
'paused',
'blocked',
'ready',
]) {
const statusAt = planCss.indexOf(`.node[data-status='${status}']`);
expect(statusAt).toBeGreaterThan(-1);
expect(attentionAt).toBeGreaterThan(statusAt);
}
});
it('narrows the DAG lane at two viewport steps', () => {
// Three 240px lanes plus two 64px gutters exceed a phone viewport, so the
// canvas scrolled in both axes at once and the horizontal scroll hid the
// layer the vertical scroll was looking for.
expect(planCss).toMatch(
/\.dagCanvas\s+\.layer\s*\{[^}]*flex:\s*0\s+0\s+var\(--plan-layer-width/,
);
// Pinned as the source shape, not as an outcome: jsdom computes no
// cascade and no layout, so this cannot assert that a plan of a given
// depth stops scrolling — only that both steps exist and narrow.
const narrow = planCss.match(
/@media\s*\(max-width:\s*720px\)\s*\{[\s\S]*?\n\}/,
)?.[0];
// The width pin names the block's owner selector too: a declaration
// moved onto a selector `.layer` never reads from must fail the guard
// even though the value still appears inside the media block.
expect(narrow).toMatch(/\.dagCanvas\s*\{[^}]*--plan-layer-width:\s*168px/);
// The fit also rests on the paired gutter narrowing: dropping it falls
// back to the base 64px gap, putting a three-layer plan past the
// viewport this step exists to fit.
expect(narrow).toMatch(/gap:\s*32px/);
const narrower = planCss.match(
/@media\s*\(max-width:\s*480px\)\s*\{[\s\S]*?\n\}/,
)?.[0];
expect(narrower).toMatch(
/\.dagCanvas\s*\{[^}]*--plan-layer-width:\s*116px/,
);
// Same pairing at the phone step: 3×116px lanes only fit because the
// gap narrows to 18px and the canvas padding to 6px a side
// (3×116 + 2×18 + 2×6 = 396px). Dropping any of the three restores
// the two-axis scroll the step was added to remove.
expect(narrower).toMatch(/gap:\s*18px/);
expect(narrower).toMatch(/padding-right:\s*6px/);
expect(narrower).toMatch(/padding-left:\s*6px/);
});
it('keeps the sr-only dependency summary off screen', () => {
// The interactive graph states its dependencies to assistive tech only,
// because the visible chip row is deliberately off there and the drawn
// edges are aria-hidden. That only holds while the summary shares the
// status word's screen-reader clipping: dropping it from this selector
// list turns the summary into a visible row — the very thing the diff
// removed — with no test signal, since jsdom computes no cascade and the
// component test can only see the class name.
const srOnly = planCss.match(
/(^|\n)\.nodeStatusText,\s*\n\s*\.nodeDependencyText\s*\{[^}]*\}/,
)?.[0];
expect(srOnly).toBeTruthy();
expect(srOnly).toMatch(/position:\s*absolute/);
expect(srOnly).toMatch(/clip-path:\s*inset\(50%\)/);
expect(srOnly).toMatch(/width:\s*1px/);
expect(srOnly).toMatch(/height:\s*1px/);
expect(srOnly).toMatch(/overflow:\s*hidden/);
});
});
@@ -136,7 +136,11 @@
}
.dagCanvas {
/* Both are overwritten from TS whenever the graph has dependencies; these
defaults only cover the no-dependency case, where --plan-edge-lanes is 0
and the reservation collapses regardless of the pitch. */
--plan-edge-lanes: 0;
--plan-edge-lane-height: 0px;
position: relative;
display: flex;
align-items: center;
@@ -144,7 +148,11 @@
gap: 64px;
width: max-content;
min-width: 100%;
padding: 8px 10px calc(28px + var(--plan-edge-lanes) * 9px);
/* Both operands come from TS (PlanExecutionView sets --plan-edge-lanes and
--plan-edge-lane-height from EDGE_LANE_HEIGHT) so the reservation cannot
drift from the lane placement it is reserving for. */
padding: 8px 10px
calc(28px + var(--plan-edge-lanes) * var(--plan-edge-lane-height));
}
.dagEdges {
@@ -218,25 +226,67 @@
.dagCanvas .layer {
position: relative;
z-index: 1;
flex: 0 0 240px;
flex: 0 0 var(--plan-layer-width, 240px);
min-width: 0;
}
/* Status drives one accent, and the accent drives every edge treatment on the
node: the border, and the selection ring. Before this, status set
`border-color` while selection stacked an always-blue ring on top, so a
completed (green) or blocked (amber) node picked up a second border in a
clashing hue the moment it was selected. */
/* Three 240px lanes plus two 64px gutters are ~850px, so on a phone the graph
became a canvas that scrolls in both axes at once — the horizontal scroll
hiding the layer the vertical scroll is looking for. Narrowing the lane and
the gutter keeps the whole topology on the horizontal axis the graph
actually needs, and leaves the layer semantics (and the measured edge
routing, which reads real rects) untouched. */
@media (max-width: 720px) {
.dagCanvas {
--plan-layer-width: 168px;
gap: 32px;
}
}
/* 168px lanes still put a three-layer plan at ~588px, which scrolls on a
390px phone and grows by a lane with every extra layer. This step brings
a three-layer plan to ~396px (3×116px lanes, 2×18px gaps, 2×6px canvas
padding): it fits phones wider than ~396px, and a 390px phone still
scrolls the last ~6px. Deeper plans scroll horizontally at any phone
width, which is the honest limit of narrowing alone — reusing the flat
layout is tracked in #10866. */
@media (max-width: 480px) {
.dagCanvas {
--plan-layer-width: 116px;
gap: 18px;
padding-right: 6px;
padding-left: 6px;
}
}
/* Status now drives the left rule; the border stays neutral for every status
and the accent is left to mean selection. Tinting every border made a
healthy plan read as a wall of coloured boxes and left the one running node
with nothing to stand out against — the first thing a reader of an
execution trace looks for. The older hazard this replaces is still worth
knowing: status used to set `border-color` while selection stacked an
always-blue ring on top, so a completed or blocked node picked up a second
border in a clashing hue the moment it was selected. With status off the
border, the two can no longer collide. */
.node {
--node-accent: var(--border);
--node-ring: var(--agent-blue-500);
--node-rule: transparent;
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
gap: 5px;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--node-accent);
/* Status, stated once. A border rather than a pseudo-element so the node's
own border-radius clips it natively: clipping a ::before would have
needed `overflow: hidden`, which also cuts the output port dot in half —
it sits at `right: -4px`, deliberately outside the box. Transparent when
resting, and the node's background paints under it (background-clip is
border-box), so a resting node reads as 3px of extra left padding with
no width difference between states. */
border-left: 3px solid var(--node-rule);
border-radius: 8px;
background: var(--background);
transition:
@@ -244,25 +294,25 @@
box-shadow 160ms ease;
}
.dagCanvas .node::before,
/* Only the outgoing port is drawn; an incoming edge already terminates in an
arrowhead, which is the input marker. It also drops the accent: every node
with an edge had one, so a permanently blue dot across most of the graph
left selection no colour of its own. It is structure, so it takes the
hairline. Note this sits outside the node's box, so the node must not clip
its overflow. */
.dagCanvas .node::after {
display: none;
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border: 1px solid var(--agent-blue-500);
border: 1px solid var(--border);
border-radius: 999px;
background: var(--background);
content: '';
transform: translateY(-50%);
}
.dagCanvas .node[data-plan-input='true']::before {
left: -4px;
display: block;
}
.dagCanvas .node[data-plan-output='true']::after {
right: -4px;
display: block;
@@ -274,26 +324,18 @@
real plan, so it stays neutral; painting it amber made a perfectly healthy
graph read as a wall of warnings. */
.node[data-status='completed'] {
--node-accent: color-mix(in srgb, var(--status-done-fg) 55%, var(--border));
--node-rule: var(--status-done-fg);
--node-ring: var(--status-done-fg);
}
.node[data-status='running'],
.node[data-status='in_progress'] {
--node-accent: color-mix(
in srgb,
var(--status-running-fg) 55%,
var(--border)
);
--node-rule: var(--status-running-fg);
--node-ring: var(--status-running-fg);
}
.node[data-status='paused'] {
--node-accent: color-mix(
in srgb,
var(--status-attention-fg) 55%,
var(--border)
);
--node-rule: var(--status-attention-fg);
--node-ring: var(--status-attention-fg);
}
@@ -303,10 +345,25 @@
--node-ring: var(--muted-foreground);
}
/* Attention folds into the same rule instead of adding a fourth status
token to the node face: one status, one place. It outranks the status
tone because it is the state the reader is being asked to act on — so
it is declared after every status tone: at equal specificity the cascade
lets the later rule win, and a blocked/ready node that also needs
attention would otherwise wear the muted ring. */
.node[data-attention='true'] {
--node-rule: var(--status-attention-fg);
--node-ring: var(--status-attention-fg);
}
/* Selection reads as "the same edge, turned up": the border goes to full
strength and the ring echoes it, rather than introducing a second colour. */
.node[data-selected='true'] {
border-color: var(--node-ring);
/* The shorthand above expands to border-left-color too: on blocked/ready,
whose --node-rule stays transparent, it would repaint the 3px status
rule in the ring tone and selection would read as a status change. */
border-left-color: var(--node-rule);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--node-ring) 32%, transparent);
}
@@ -333,10 +390,50 @@
outline: none;
}
/* Content first. The node led with a glyph, the raw Todo id and the status
word, so the only element that says what the step *is* came third — and an
id is an address, the least useful thing at graph scale. */
.nodeTop {
display: flex;
align-items: center;
gap: 6px;
align-items: baseline;
gap: 7px;
}
/* The same number the inspector list and the dependency chips use, so the
three surfaces address a step identically. */
.nodeNumber {
flex: none;
color: var(--muted-foreground);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* The left rule is colour, which assistive tech cannot read, so the status
stays in the accessibility tree as words. The dependency summary shares the
channel: the interactive graph draws the dependency as an aria-hidden edge
rather than stating it, so the node's accessible name has to carry it. */
.nodeStatusText,
.nodeDependencyText {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
clip-path: inset(50%);
white-space: nowrap;
}
/* Two facts, spaced, rather than one middle-dot-joined string: they wrap as
a unit and read out as one run of text when glued. */
.nodeMeta {
display: flex;
flex-wrap: wrap;
gap: 1px 12px;
color: var(--muted-foreground);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.nodeId {
@@ -373,15 +470,89 @@
}
.nodeContent {
min-width: 0;
color: var(--foreground);
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
/* Rendered only when no edge states the dependency — above
MAX_RENDERED_PLAN_EDGES the graph draws none, and then this row is the
only statement of it. Chips carry the step number and title, matching the
inspector, instead of a comma-joined run of raw ids. */
.dependencies {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 3px 6px;
color: var(--muted-foreground);
font-size: 11px;
}
/* `text-overflow` applies to a block container's own inline content, so
declaring it on the flex box did nothing — the title sat in an anonymous
flex item and was clipped without an ellipsis. The truncation belongs on
the title span, which is a real box. */
.dependencyChip {
display: inline-flex;
max-width: 100%;
align-items: baseline;
overflow: hidden;
gap: 5px;
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
color: var(--foreground);
white-space: nowrap;
}
/* The `> span` rules below mute every direct-child span — the title
included — and a direct declaration beats the chip's inherited colour,
so the title states its own full-weight colour here. */
.dependencyChip .dependencyTitle,
.dependencyLink .dependencyTitle {
overflow: hidden;
min-width: 0;
color: var(--foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.dependencyChip > span {
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
/* The step-details panel's references, which sit outside the node's button
and so can be real controls. Same face as the node's read-only chips. */
.dependencyLink {
display: inline-flex;
max-width: 100%;
align-items: baseline;
overflow: hidden;
gap: 5px;
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
background: var(--background);
color: var(--foreground);
cursor: pointer;
font-size: 11px;
text-align: left;
white-space: nowrap;
}
.dependencyLink:hover {
border-color: color-mix(in srgb, var(--agent-blue-500) 45%, var(--border));
background: var(--accent);
}
.dependencyLink > span {
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.executions {
display: flex;
flex-direction: column;
@@ -584,10 +755,12 @@ button.nestedExecution:not(:disabled):focus-visible {
/* The status glyph. Sized and aligned to the mono ID beside it so the two read
as one identifier block rather than an icon bolted onto a label. */
/* Muted, not `--node-ring`: the left rule is the single carrier of the
status colour, and this is the redundant *shape* channel beside it. */
.nodeGlyph {
flex: 0 0 auto;
width: 13px;
color: var(--node-ring);
color: var(--muted-foreground);
font-family: var(--font-mono, monospace);
font-size: 12px;
font-style: normal;
@@ -595,6 +768,23 @@ button.nestedExecution:not(:disabled):focus-visible {
text-align: center;
}
/* The attention sigil, muted like the status glyph: the left rule stays the
only carrier of tone, and this is the shape channel that survives forced
colours — the data-attention rule re-declares the very token paused
already wears, so without a mark a paused node needing attention paints
pixel-identical to a healthy one. */
.nodeAttentionMark {
flex: 0 0 auto;
width: 13px;
color: var(--muted-foreground);
font-family: var(--font-mono, monospace);
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: 1;
text-align: center;
}
/* Work in flight is the only thing on the page that moves, so the eye finds it
without a legend. */
.node[data-status='running'] .nodeGlyph {
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { DaemonSessionAgentTaskStatus } from '@qwen-code/sdk/daemon';
import type { ACPToolCall, TodoItem } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
import styles from './PlanExecutionView.module.css';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import {
getActiveAgents,
@@ -14,6 +15,7 @@ import {
layerPlanTodos,
nestedAgentToolsForTool,
nestedTasksForTool,
PLAN_STATUS_GLYPH,
PlanExecutionView,
} from './PlanExecutionView';
@@ -571,7 +573,64 @@ describe('PlanExecutionView', () => {
);
});
expect(container.textContent).toContain('Depends on: research');
// The drawn edge is the dependency statement, so the node face does not
// restate it *visibly* — the chip row stays off. The step-details panel
// still states it, asserted below, and the sr-only summary carries it to
// assistive tech because the drawn edges are aria-hidden (asserted in
// 'names the blockers in the node accessible name…').
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.querySelector(`.${styles.dependencies}`)).toBeNull();
// Content leads the node; the step number matches the inspector list and
// the dependency chips, and the agent count and elapsed carry the "is
// this alive" signal onto the face.
expect(buildNode?.textContent).toContain('Build');
// Root agent plus the live child task below (parentAgentId set, no
// transcript entry): the node renders two agent rows, so the face tally
// must read 2 — counting only transcript subTools is the R8-2 divergence.
expect(buildNode?.textContent).toContain('2 agents');
expect(buildNode?.textContent).toContain('1m 5s');
// The plural branch is pinned above; `1 agents` would ship green without
// the negative assertion here. The singular branch is pinned in
// 'the node-face agent tally matches the rows it renders'.
const researchNode = container
.querySelector('[data-plan-node-id="research"]')
?.closest('article');
expect(buildNode?.textContent).not.toContain('1 agents');
expect(researchNode?.textContent ?? '').not.toContain('1 agents');
// Status is colour on the left rule, so it stays in the accessibility
// tree as words rather than being dropped. Asserted on a node with no
// linked agent, so the word can only come from the node's own status
// element and not from an execution row inside it.
const verifyNode = container
.querySelector('[data-plan-node-id="verify"]')
?.closest('article');
expect(
verifyNode?.querySelector(`.${styles.nodeStatusText}`)?.textContent,
).toBe('Blocked');
// `verify` has no linked tool call in this fixture, so its visible face
// is still just the number, the content and the status glyph; the two
// sr-only spans (status word, dependency summary) are the only other text
// the node carries.
expect(verifyNode?.querySelector(`.${styles.nodeTop}`)?.textContent).toBe(
'3Verify',
);
expect(verifyNode?.querySelector(`.${styles.nodeMeta}`)?.textContent).toBe(
PLAN_STATUS_GLYPH.blocked,
);
// The glyph is the non-colour status channel: the left rule that carries
// status visually is colour only, so without a shape beside it the graph
// would lose status entirely under colour-blindness, high-contrast mode
// or a greyscale screenshot. Pinned per status, not just as "a glyph".
expect(buildNode?.textContent).toContain(PLAN_STATUS_GLYPH.running);
expect(
container
.querySelector('[data-plan-node-id="research"]')
?.closest('article')?.textContent,
).toContain(PLAN_STATUS_GLYPH.completed);
expect(PLAN_STATUS_GLYPH.blocked).not.toBe(PLAN_STATUS_GLYPH.running);
expect(PLAN_STATUS_GLYPH.running).not.toBe(PLAN_STATUS_GLYPH.completed);
expect(container.textContent).toContain('33%');
expect(container.textContent).toContain('1 / 3');
// 3, not 2: the strip now derives from the same source as the node
@@ -590,8 +649,26 @@ describe('PlanExecutionView', () => {
const details = container.querySelector('[data-plan-step-details]');
expect(details?.textContent).toContain('Step details');
expect(details?.textContent).toContain('Build');
expect(details?.textContent).toContain('Depends on: research');
expect(details?.textContent).toContain('Unblocks: verify');
// Outside the node's own button, so these references are real controls:
// each names the step by number and title, and selects it on click.
const upstreamLink = details?.querySelector<HTMLButtonElement>(
'[data-plan-dependency="research"]',
);
expect(upstreamLink?.tagName).toBe('BUTTON');
// The host keyboard handlers isolate plan controls through this marker
// (ToolApproval/TasksStatusMessage early-return on it), so these buttons
// must carry it like every other control in the view.
expect(upstreamLink?.hasAttribute('data-plan-interactive')).toBe(true);
expect(upstreamLink?.textContent).toBe('1Research');
expect(
details?.querySelector('[data-plan-dependency="verify"]')?.textContent,
).toBe('3Verify');
expect(
details
?.querySelector('[data-plan-dependency="verify"]')
?.hasAttribute('data-plan-interactive'),
).toBe(true);
expect(details?.textContent).not.toContain('Depends on: research');
expect(details?.textContent).toContain('Subagents');
expect(details?.textContent).toContain(
'Current activity:Inspecting the implementation',
@@ -606,6 +683,193 @@ describe('PlanExecutionView', () => {
act(() => button?.click());
expect(onOpen).toHaveBeenCalledWith(agentTool('build'));
// The downstream block wires its own onClick; witness it before the
// upstream click below moves the selection off `build`. Dropping that
// handler must turn this red.
act(() =>
details
?.querySelector<HTMLButtonElement>('[data-plan-dependency="verify"]')
?.click(),
);
expect(
container
.querySelector('[data-plan-node-id="verify"]')
?.getAttribute('aria-pressed'),
).toBe('true');
// That click selected `verify` and re-rendered the panel around it, so
// bring the selection back to `build` before exercising the upstream
// reference.
act(() => {
container
.querySelector<HTMLButtonElement>('[data-plan-node-id="build"]')
?.click();
});
expect(
container
.querySelector('[data-plan-node-id="build"]')
?.getAttribute('aria-pressed'),
).toBe('true');
// Last, because it moves the selection: following an upstream reference
// selects the step it names, which is what makes the dependency list the
// graph's navigation rather than a run of text.
act(() =>
details
?.querySelector<HTMLButtonElement>('[data-plan-dependency="research"]')
?.click(),
);
expect(
container
.querySelector('[data-plan-node-id="research"]')
?.getAttribute('aria-pressed'),
).toBe('true');
act(() => root.unmount());
container.remove();
});
it('drops unresolvable dependency ids from the step-details controls', () => {
// blockedBy is model-authored todo_write output parsed without
// normalization, so it can name a step that does not exist. Such an id
// must not render as a control: clicking it would select a ghost id,
// empty selectedTodo, and hide the panel mid-navigation.
const ghostTodos = todos.map((todo) =>
todo.id === 'build'
? { ...todo, blockedBy: ['research', 'ghost-step'] }
: todo,
);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={ghostTodos} tools={[]} tasks={[]} />
</I18nProvider>,
);
});
act(() => {
container
.querySelector<HTMLButtonElement>('[data-plan-node-id="build"]')
?.click();
});
const details = container.querySelector('[data-plan-step-details]');
expect(details).not.toBeNull();
// The resolvable dependency stays a working control…
expect(
details?.querySelector('[data-plan-dependency="research"]'),
).not.toBeNull();
// …while the ghost id renders none at all.
expect(
details?.querySelector('[data-plan-dependency="ghost-step"]'),
).toBeNull();
expect(details?.textContent).not.toContain('ghost-step');
// The ellipsis lives on `.dependencyTitle`; pin the class wiring so a
// dropped className cannot re-clip titles while these text assertions
// stay green.
expect(
Array.from(
details
?.querySelector('[data-plan-dependency="research"]')
?.querySelectorAll('span') ?? [],
).some((span) => span.classList.contains(styles.dependencyTitle)),
).toBe(true);
act(() => root.unmount());
container.remove();
});
it('announces attention beside the status word, not instead of it', () => {
// A failed descendant puts the node into attention while its own status
// stays running. The sr-only span must keep announcing the status word
// too — attention is additive for assistive tech, never a replacement.
const runningRoot = task('running');
const failedChild = task('failed', {
id: 'agent-child',
toolUseId: 'call-child',
parentAgentId: runningRoot.id,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[agentTool('build')]}
tasks={[runningRoot, failedChild]}
/>
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.textContent).toContain('Running');
expect(buildNode?.textContent).toContain('Needs attention');
expect(
buildNode
?.querySelector(`.${styles.nodeStatusText}`)
?.textContent?.trim(),
).toBe('Running, Needs attention');
// The attribute is the hook the `.node[data-attention='true']` colour
// rule selects on; dropping it keeps the word but silently loses the
// attention tone the stylesheet derives from this state.
expect(buildNode?.getAttribute('data-attention')).toBe('true');
// Colour alone cannot carry attention: on a paused node the
// data-attention rule re-declares the token paused already wears, so
// without a shape channel the two paint pixel-identical. The visible
// sigil lives in the meta row beside the status glyph; the words stay
// in the sr-only span above.
const attentionMark = buildNode?.querySelector(
`.${styles.nodeMeta} .${styles.nodeAttentionMark}`,
);
expect(attentionMark?.textContent).toBe('!');
expect(attentionMark?.getAttribute('aria-hidden')).toBe('true');
act(() => root.unmount());
container.remove();
});
it('pluralizes the agent count when two agents share one node', () => {
// Every other fixture links a single agent per node, so the EN
// template's plural branch ships unobserved: a template that always
// emits `${count} agent` renders "2 agent" and nothing turns red.
// Link two root agents to one step and pin the plural rendering.
const secondBuildTool: ACPToolCall = {
...agentTool('build'),
callId: 'call-build-2',
title: 'Agent build 2',
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[agentTool('build'), secondBuildTool]}
tasks={[
task('running'),
task('running', {
id: 'agent-second',
label: 'Second agent',
toolUseId: 'call-build-2',
}),
]}
/>
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.textContent).toContain('2 agents');
act(() => root.unmount());
container.remove();
});
@@ -868,7 +1132,11 @@ describe('PlanExecutionView', () => {
}
if (this.tagName === 'ARTICLE') {
const [left, top] =
positions[this.querySelector('span')!.textContent!]!;
positions[
this.querySelector('[data-plan-node-id]')!.getAttribute(
'data-plan-node-id',
)!
]!;
return scaledRect(100 + left * 0.72, 50 + top * 0.72, 144, 57.6);
}
return scaledRect(0, 0, 0, 0);
@@ -951,7 +1219,11 @@ describe('PlanExecutionView', () => {
}
if (this.tagName === 'ARTICLE') {
const [left, top] =
positions[this.querySelector('span')!.textContent!]!;
positions[
this.querySelector('[data-plan-node-id]')!.getAttribute(
'data-plan-node-id',
)!
]!;
return rect(100 + left, 50 + top, 200, 80);
}
return rect(0, 0, 0, 0);
@@ -988,6 +1260,14 @@ describe('PlanExecutionView', () => {
(match) => Number(match[1]),
);
expect(Math.max(...routedYs)).toBeGreaterThan(200);
// ...and arrives at the target's own row, not merely at its column. The
// `release` node sits at y 10 with height 80 inside a viewport whose
// origin is (100, 50), so its vertical centre is 50. Without this, a tail
// drawn from the return lane's `routeY` instead of the edge's `endY`
// lands ~170px below the node while the column and clearance assertions
// above both still pass.
expect(spanningEdge).toMatch(/ 50 H 876$/);
expect(routedYs).toContain(50);
act(() => root.unmount());
container.remove();
@@ -1086,7 +1366,11 @@ describe('PlanExecutionView', () => {
}
if (this.tagName === 'ARTICLE') {
const [left, top] =
positions[this.querySelector('span')!.textContent!]!;
positions[
this.querySelector('[data-plan-node-id]')!.getAttribute(
'data-plan-node-id',
)!
]!;
return rect(100 + left, 50 + top, 200, 80);
}
return rect(0, 0, 0, 0);
@@ -1137,6 +1421,211 @@ describe('PlanExecutionView', () => {
}
});
it('keeps the arrowhead pointing at the target on every gutter tier', () => {
// The narrow gutters this PR adds are tighter than the router's 24px
// shoulder, so the control point landed on (32px tier) or past (18px
// tier) the end point. The curve then arrives with a zero or negative x
// tangent and `orient="auto"` flips the arrowhead back at its source —
// measured in the browser as (0, 0) at 700px and (-14, 0) at 430/390px
// against (28, 0) at 1440px. The input port is gone, so that arrowhead
// is the last direction cue the graph has. Restoring the fixed
// `Math.max(24, …)` shoulder turns the two narrow tiers red.
const chain: TodoItem[] = [
{ id: 'source', content: 'Source', status: 'completed' },
{
id: 'target',
content: 'Target',
status: 'pending',
blockedBy: ['source'],
},
];
// The lane width the 700px tier narrows to; only the gutter varies.
const lane = 168;
// gutter → 64px (≥721px), 32px (≤720px), 18px (≤480px).
for (const gap of [64, 32, 18]) {
const rect = (left: number, top: number, width: number, height: number) =>
({
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
}) as DOMRect;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function () {
if (this.parentElement?.hasAttribute('data-plan-workflow')) {
return rect(100, 50, 1100, 300);
}
if (this.tagName === 'ARTICLE') {
const id = this.querySelector('[data-plan-node-id]')!.getAttribute(
'data-plan-node-id',
)!;
return rect(100 + (id === 'target' ? lane + gap : 0), 60, lane, 80);
}
return rect(0, 0, 0, 0);
});
const widthSpy = vi
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(1100);
const heightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockReturnValue(300);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={chain} tools={[]} tasks={[]} />
</I18nProvider>,
);
});
const d = container
.querySelector('[data-from="source"][data-to="target"]')
?.getAttribute('d');
expect(d, `${gap}px gutter drew no edge`).toBeTruthy();
// `M startX startY C c1x c1y, c2x c2y, endX endY`.
const n = [...d!.matchAll(/-?[\d.]+/g)].map((m) => Number(m[0]));
expect(n).toHaveLength(8);
// Prove the fixture really reproduced the tier: the router insets
// both ends by 4px, so the run is the gutter minus 8 (56 / 24 / 10).
expect(n[6] - n[0], `${gap}px gutter run`).toBe(gap - 8);
// The end tangent is the end point minus the last control point, and
// it is what the arrowhead orients on. Its x must be strictly
// positive at every tier, or the head points back at the source.
expect(n[6] - n[4], `${gap}px gutter end tangent x`).toBeGreaterThan(0);
} finally {
act(() => root.unmount());
container.remove();
rectSpy.mockRestore();
widthSpy.mockRestore();
heightSpy.mockRestore();
}
}
});
it('keeps a layer-skipping lane out of the step it passes', () => {
// The other half of R6-3. The router gave every layer-skipping edge a
// fixed 24px shoulder, which is wider than the ≤480px gutter (18px, a
// 10px run). At 390/430px the rise sat 10px inside the intervening step,
// so a dependency from step 1 to step 3 read as one into step 2 — and the
// SVG paints under the nodes, so the lane simply disappeared into it.
// Restoring `startX + 24` / `endX - 24` turns the 18px tier red.
const skip: TodoItem[] = [
{ id: 'one', content: 'One', status: 'completed' },
{ id: 'two', content: 'Two', status: 'pending', blockedBy: ['one'] },
{
id: 'three',
content: 'Three',
status: 'pending',
// The layer-skipping edge: `one` is layer 0, `three` is layer 2.
blockedBy: ['two', 'one'],
},
];
// The lane width the 700px tier narrows to; only the gutter varies.
const lane = 168;
// gutter → 64px (≥721px), 32px (≤720px), 18px (≤480px).
for (const gap of [64, 32, 18]) {
const rect = (left: number, top: number, width: number, height: number) =>
({
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
}) as DOMRect;
// One node per layer here, so the step the lane passes is all of layer 1.
const leftOf = (layer: number) => layer * (lane + gap);
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function () {
if (this.parentElement?.hasAttribute('data-plan-workflow')) {
return rect(100, 50, 1100, 300);
}
if (this.tagName === 'ARTICLE') {
const id = this.querySelector('[data-plan-node-id]')!.getAttribute(
'data-plan-node-id',
)!;
const layer = id === 'one' ? 0 : id === 'two' ? 1 : 2;
return rect(100 + leftOf(layer), 60, lane, 80);
}
return rect(0, 0, 0, 0);
});
const widthSpy = vi
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(1100);
const heightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockReturnValue(300);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={skip} tools={[]} tasks={[]} />
</I18nProvider>,
);
});
const d = container
.querySelector('[data-from="one"][data-to="three"]')
?.getAttribute('d');
expect(d, `${gap}px gutter drew no lane`).toBeTruthy();
// The route's four turns, as [control x, end x]: Q1/Q2 carry dropX,
// Q3/Q4 carry riseX. Those are the two vertical segments' columns.
const turns = [
...d!.matchAll(/Q ([-\d.]+) [-\d.]+ ([-\d.]+) [-\d.]+/g),
].map((match) => [Number(match[1]), Number(match[2])]);
expect(turns).toHaveLength(4);
const dropX = turns[0][0];
const riseX = turns[2][0];
const startX = lane + 4;
const endX = leftOf(2) - 4;
// The lane may only turn inside its own gutter, never inside layer 1.
expect(dropX, `${gap}px drop column`).toBeGreaterThan(startX);
expect(dropX, `${gap}px drop column`).toBeLessThan(leftOf(1));
expect(riseX, `${gap}px rise column`).toBeGreaterThan(leftOf(1) + lane);
expect(riseX, `${gap}px rise column`).toBeLessThan(endX);
// The corner is halved at 18px so the run into the arrowhead keeps a
// positive length; at zero the head flips back at its source.
expect(Number(/H ([-\d.]+)$/.exec(d!)![1])).toBe(endX);
expect(
endX - turns[3][1],
`${gap}px gutter end tangent x`,
).toBeGreaterThan(0);
if (gap === 64) {
// A 64px gutter affords the full 24px shoulder on both sides, so
// desktop geometry is untouched. Pinned as a golden path because
// the requirement is byte-identical, not merely non-crossing.
expect(d).toBe(
'M 172 50 H 190 Q 196 50 196 56 V 98 Q 196 104 202 104 ' +
'H 430 Q 436 104 436 98 V 56 Q 436 50 442 50 H 460',
);
}
} finally {
act(() => root.unmount());
container.remove();
rectSpy.mockRestore();
widthSpy.mockRestore();
heightSpy.mockRestore();
}
}
});
it('does not synchronously remeasure unchanged topology on task polling', () => {
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect');
const container = document.createElement('div');
@@ -1342,6 +1831,275 @@ describe('PlanExecutionView', () => {
container.remove();
});
it('states the dependency on the node when no panel can state it', () => {
// The drawn edge is aria-hidden and the cockpit passes
// showStepDetails={false}, so there the chip row is the dependency's
// only statement anywhere — restoring the bare !drawsDependencyEdges
// gate turns this red.
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[]}
tasks={[]}
showStepDetails={false}
/>
</I18nProvider>,
);
});
expect(container.querySelector('[data-plan-step-details]')).toBeNull();
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.textContent).toContain('Depends on');
expect(buildNode?.textContent).toContain('Research');
act(() => root.unmount());
container.remove();
});
it('names the blockers in the node accessible name when the graph draws them', () => {
// The other arm of the same gate. In the interactive graph the edges are
// drawn and the visible chip row is off by design, but the edge layer is
// aria-hidden — so the node's accessible name was the only place left to
// state the dependency, and it stopped stating it: base read
// `Blocked … Depends on: survey-api, read-tests`, head read
// `Blocked 4 Compare findings and draft the migration plan`. A
// screen-reader user had to activate every node to learn what blocks it.
// Dropping the sr-only summary turns this red.
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
// showStepDetails defaults to true: the Plan & Review card and the
// Plan & tasks dialog both render the graph that way.
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={todos} tools={[]} tasks={[]} />
</I18nProvider>,
);
});
// Nothing selected, so no details panel states the dependency either.
expect(container.querySelector('[data-plan-step-details]')).toBeNull();
expect(
container.querySelector('[data-from="research"][data-to="build"]'),
).not.toBeNull();
const buildButton = container.querySelector<HTMLButtonElement>(
'[data-plan-node-id="build"]',
);
const summary = buildButton?.querySelector(`.${styles.nodeDependencyText}`);
// A button's accessible name is its descendant text in DOM order, so this
// span is what a screen reader appends after the step's own title.
expect(summary?.textContent).toContain('Depends on:');
// Human labels, matching the chips: step number plus title, not the id.
expect(summary?.textContent).toContain('1 Research');
expect(summary?.textContent).not.toContain('research');
// It restores the words only — the visible row stays off, which is the
// point of the diff.
expect(buildButton?.querySelector(`.${styles.dependencies}`)).toBeNull();
// And it reads after the step it belongs to, like the base name did.
const announced = buildButton?.textContent ?? '';
expect(announced.indexOf('Build')).toBeLessThan(
announced.indexOf('Depends on:'),
);
// Sibling nodes with no blockers announce nothing extra.
expect(
container
.querySelector('[data-plan-node-id="research"]')
?.querySelector(`.${styles.nodeDependencyText}`),
).toBeNull();
act(() => root.unmount());
container.remove();
});
it('counts nested subagents in the node-face agent tally', () => {
// The chip counts agents, like the execution rows beneath it and the
// inspector's Subagents list — not just root executions. Reverting the
// count to executions.length reads "1 agent" here and goes red.
const rootTool: ACPToolCall = {
...agentTool('build'),
subTools: [
{
callId: 'call-nested',
toolName: 'Agent',
title: 'Nested agent',
status: 'in_progress',
},
],
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={todos} tools={[rootTool]} tasks={[]} />
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.textContent).toContain('2 agents');
// The same node renders both agent rows the tally names.
expect(buildNode?.textContent).toContain('Agent build');
expect(buildNode?.textContent).toContain('Nested agent');
act(() => root.unmount());
container.remove();
});
it('the node-face agent tally matches the rows it renders', () => {
// R8-2 witness: a live child task whose toolUseId matches no subTools
// callId renders a row (via toolForNestedTask) while the transcript-only
// tally never saw it — the node read "1 agent" beside two agent rows.
const rootTool: ACPToolCall = {
...agentTool('build'),
subTools: [
{
callId: 'call-nested',
toolName: 'Agent',
title: 'Nested agent',
status: 'in_progress',
},
],
};
const rootTask = task('running');
const liveChild = task('running', {
id: 'agent-live-child',
label: 'Live child',
toolUseId: 'call-live-child',
parentAgentId: rootTask.id,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[rootTool, agentTool('research')]}
tasks={[rootTask, liveChild]}
/>
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
// Three rows render: root, the transcript nested agent, and the live
// child — and the tally counts the same three.
expect(buildNode?.textContent).toContain('3 agents');
expect(buildNode?.textContent).toContain('Nested agent');
expect(buildNode?.textContent).toContain('Live child');
// The lone root on the research node keeps the singular path pinned.
const researchNode = container
.querySelector('[data-plan-node-id="research"]')
?.closest('article');
expect(researchNode?.textContent).toContain('1 agent');
expect(researchNode?.textContent).not.toContain('1 agents');
act(() => root.unmount());
container.remove();
});
it('counts an agent seen as both a live task and a transcript sub-tool once', () => {
// The tally counts the same deduped union the rows render from, so an
// agent observed through BOTH a live child task and a persisted subTools
// entry is one agent, not two. Dropping the `!liveCallIds.has(…)` filter
// reads "3 agents" beside two rows and turns this red (mutant M20).
const rootTool: ACPToolCall = {
...agentTool('build'),
subTools: [
{
callId: 'call-nested',
toolName: 'Agent',
title: 'Nested agent',
status: 'in_progress',
},
],
};
const rootTask = task('running');
// The live child carries the same toolUseId as the sub-tool above: one
// agent, two observations of it.
const liveChild = task('running', {
id: 'agent-live-child',
label: 'Nested agent',
toolUseId: 'call-nested',
parentAgentId: rootTask.id,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[rootTool]}
tasks={[rootTask, liveChild]}
/>
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
expect(buildNode?.textContent).toContain('2 agents');
// Root row plus one nested row: the tally and the rows agree because they
// dedup the same way.
expect(buildNode?.textContent).toContain('Nested agent');
expect(
buildNode?.querySelectorAll(`.${styles.nestedExecution}`),
).toHaveLength(1);
act(() => root.unmount());
container.remove();
});
it('renders no agent tally for a non-agent tool tagged to the step', () => {
// Declaring todo_id does not make a tool an agent: the tally hides
// rather than printing "0 agents" beside the tool's row.
const editTool: ACPToolCall = {
callId: 'call-edit',
toolName: 'edit',
title: 'Edit package.json',
status: 'completed',
args: { todo_id: 'verify' },
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView todos={todos} tools={[editTool]} tasks={[]} />
</I18nProvider>,
);
});
const verifyNode = container
.querySelector('[data-plan-node-id="verify"]')
?.closest('article');
expect(verifyNode?.textContent).toContain('Edit package.json');
expect(verifyNode?.textContent).not.toContain('agent');
act(() => root.unmount());
container.remove();
});
it('skips SVG edge materialization for an excessively dense plan', () => {
const denseTodos = Array.from(
{ length: 33 },
@@ -1358,6 +2116,13 @@ describe('PlanExecutionView', () => {
),
}),
}),
).map((todo) =>
// blockedBy is model-authored and can repeat an id — or name the todo
// itself; the chip row must dedup repeats and drop self-references
// like the topology builder does.
todo.id === 'dense-1'
? { ...todo, blockedBy: ['dense-0', 'dense-0', 'dense-1'] }
: todo,
);
const container = document.createElement('div');
document.body.appendChild(container);
@@ -1372,13 +2137,33 @@ describe('PlanExecutionView', () => {
expect(container.querySelector('[data-plan-workflow]')).not.toBeNull();
expect(container.querySelectorAll('[data-plan-edge]')).toHaveLength(0);
expect(
container.querySelectorAll('[data-plan-input], [data-plan-output]'),
).toHaveLength(0);
expect(container.querySelectorAll('[data-plan-output]')).toHaveLength(0);
expect(container.textContent).toContain('Dense 32');
// Lines disappearing with no explanation reads as a broken render, so the
// skip is stated rather than silent.
expect(container.textContent).toContain('Too many dependencies to draw');
// With no edges drawn, the node's dependency row is the only statement of
// the dependency, so it must survive here even though a node whose edges
// ARE drawn drops it. Each reference reads as the step it names — number
// and title, matching the inspector — not as a raw id.
const denseNode = container
.querySelector('[data-plan-node-id="dense-1"]')
?.closest('article');
expect(denseNode?.textContent).toContain('Depends on');
expect(denseNode?.textContent).toContain('Dense 0');
// The repeated id renders one chip, not two.
expect((denseNode?.textContent ?? '').split('Dense 0').length - 1).toBe(1);
// A self-reference never renders the step as its own blocker either: the
// node's own title is the only 'Dense 1' in its text.
expect((denseNode?.textContent ?? '').split('Dense 1').length - 1).toBe(1);
// The truncation rule targets `.dependencyTitle`; pin the class wiring
// so a dropped className cannot re-clip titles while text assertions
// stay green.
expect(
Array.from(denseNode?.querySelectorAll('span') ?? []).some((span) =>
span.classList.contains(styles.dependencyTitle),
),
).toBe(true);
act(() => root.unmount());
container.remove();
@@ -62,6 +62,12 @@ const EMPTY_GRAPH_LAYOUT: PlanGraphLayout = {
const EDGE_LANE_HEIGHT = 9;
/** Corner radius where an orthogonal edge turns. */
const EDGE_CORNER = 6;
/**
* Widest horizontal shoulder a layer-spanning edge runs before turning down
* into its return lane. It is a ceiling, never a floor: a narrow gutter takes
* half of its own run instead. See the router for why.
*/
const EDGE_SHOULDER = 24;
const MAX_RENDERED_PLAN_EDGES = 500;
@@ -560,6 +566,7 @@ export function PlanExecutionView({
// rather than defeating itself on fresh array identities.
const {
todosById,
stepNumberByTodo,
toolsByTodo,
unassigned,
statesByTodo,
@@ -579,6 +586,12 @@ export function PlanExecutionView({
} = useMemo(() => {
const knownIds = new Set(todos.map((todo) => todo.id));
const todosById = new Map(todos.map((todo) => [todo.id, todo]));
// The step number addresses a step in the inspector list and in the
// dependency chips, so the graph shows the same number or the three
// surfaces name the same step differently.
const stepNumberByTodo = new Map(
todos.map((todo, index) => [todo.id, index + 1]),
);
const toolsByTodo = new Map<string, ACPToolCall[]>();
const unassigned: ACPToolCall[] = [];
for (const tool of tools) {
@@ -655,6 +668,7 @@ export function PlanExecutionView({
}
return {
todosById,
stepNumberByTodo,
toolsByTodo,
unassigned,
statesByTodo,
@@ -783,6 +797,9 @@ export function PlanExecutionView({
? graphRect.height / graphElement.offsetHeight
: 1;
const measuredNodes = new Map<string, DOMRect>();
// Horizontal extent of every topological layer, so an edge that skips
// a layer can size its shoulders from the gutter it really crosses.
const layerBounds = new Map<number, { left: number; right: number }>();
let maxNodeBottom = 0;
for (const [todoId, node] of nodeRefs.current) {
const rect = node.getBoundingClientRect();
@@ -797,6 +814,17 @@ export function PlanExecutionView({
} as DOMRect;
measuredNodes.set(todoId, normalizedRect);
maxNodeBottom = Math.max(maxNodeBottom, normalizedRect.bottom);
const layer = layerByTodoRef.current.get(todoId) ?? 0;
const bounds = layerBounds.get(layer);
if (bounds) {
bounds.left = Math.min(bounds.left, normalizedRect.left);
bounds.right = Math.max(bounds.right, normalizedRect.right);
} else {
layerBounds.set(layer, {
left: normalizedRect.left,
right: normalizedRect.right,
});
}
}
const edges: PlanEdgePath[] = [];
const spanning: Array<{
@@ -833,7 +861,16 @@ export function PlanExecutionView({
});
continue;
}
const controlX = startX + Math.max(24, (endX - startX) / 2);
// Half the run, not the old fixed 24px floor. The ≤720px gutter
// leaves a 24px run and the ≤480px gutter a 10px one, so a 24px
// shoulder put the control point exactly on the end point (zero
// tangent) or past it (negative tangent) — and `orient="auto"` then
// flips the arrowhead back at its source, which is the only
// direction cue left now that the input port is gone. Every gutter
// wide enough to afford the floor already resolved to run / 2, so
// wide lanes keep their exact curve while the end tangent's x stays
// strictly positive at every tier.
const controlX = startX + (endX - startX) / 2;
edges.push({
from: dependencyId,
to: todoId,
@@ -851,8 +888,40 @@ export function PlanExecutionView({
maxNodeBottom + 14 + lane * EDGE_LANE_HEIGHT,
Math.max(graphHeight - 6, maxNodeBottom + 14),
);
const dropX = edge.startX + 24;
const riseX = edge.endX - 24;
// Shoulders derived from the gutter each side actually has, the same
// way the adjacent-layer edge already does it — not a fixed 24px. The
// ≤480px gutter is 18px, so a 24px shoulder left a 10px run and put
// the vertical segment inside the intervening lane: a dependency from
// step 3 to step 5 looked like it entered step 4, and the SVG paints
// under the nodes so the lane just vanished into it. Halving the run
// centres the segment in its own gutter. EDGE_SHOULDER still wins
// wherever the gutter can afford it (the 64px desktop tier has a 56px
// run), so wide lanes keep the exact curve they had.
const sourceLayer = layerByTodoRef.current.get(edge.from) ?? 0;
const targetLayer = layerByTodoRef.current.get(edge.to) ?? 0;
// An unmeasured neighbouring layer falls back to the full shoulder.
const nextLayerLeft =
layerBounds.get(sourceLayer + 1)?.left ?? Number.POSITIVE_INFINITY;
const prevLayerRight =
layerBounds.get(targetLayer - 1)?.right ?? Number.NEGATIVE_INFINITY;
const dropShoulder = Math.min(
EDGE_SHOULDER,
(nextLayerLeft - 4 - edge.startX) / 2,
);
const riseShoulder = Math.min(
EDGE_SHOULDER,
(edge.endX - prevLayerRight - 4) / 2,
);
// The corner has to fit inside both shoulders. At 18px the shoulder
// is 5, so the unhalved 6px radius left a zero-length final segment
// and the arrowhead lost its direction again.
const corner = Math.min(
EDGE_CORNER,
dropShoulder / 2,
riseShoulder / 2,
);
const dropX = edge.startX + dropShoulder;
const riseX = edge.endX - riseShoulder;
const down = routeY > edge.startY ? 1 : -1;
const up = edge.endY > routeY ? 1 : -1;
edges.push({
@@ -860,14 +929,14 @@ export function PlanExecutionView({
to: edge.to,
d:
`M ${edge.startX} ${edge.startY} ` +
`H ${dropX - EDGE_CORNER} ` +
`Q ${dropX} ${edge.startY} ${dropX} ${edge.startY + EDGE_CORNER * down} ` +
`V ${routeY - EDGE_CORNER * down} ` +
`Q ${dropX} ${routeY} ${dropX + EDGE_CORNER} ${routeY} ` +
`H ${riseX - EDGE_CORNER} ` +
`Q ${riseX} ${routeY} ${riseX} ${routeY + EDGE_CORNER * up} ` +
`V ${edge.endY - EDGE_CORNER * up} ` +
`Q ${riseX} ${edge.endY} ${riseX + EDGE_CORNER} ${edge.endY} ` +
`H ${dropX - corner} ` +
`Q ${dropX} ${edge.startY} ${dropX} ${edge.startY + corner * down} ` +
`V ${routeY - corner * down} ` +
`Q ${dropX} ${routeY} ${dropX + corner} ${routeY} ` +
`H ${riseX - corner} ` +
`Q ${riseX} ${routeY} ${riseX} ${routeY + corner * up} ` +
`V ${edge.endY - corner * up} ` +
`Q ${riseX} ${edge.endY} ${riseX + corner} ${edge.endY} ` +
`H ${edge.endX}`,
});
});
@@ -936,6 +1005,12 @@ export function PlanExecutionView({
const selectedDependents = selectedTodo
? (dependentsByTodo.get(selectedTodo.id) ?? [])
: [];
// The same filtered projection the edges draw from: the topology builder
// drops ids that name no step (and self-references), so no control here
// can select a ghost id and hide this panel mid-navigation.
const selectedDependencies = selectedTodo
? (dependencyIdsByTodo.get(selectedTodo.id) ?? [])
: [];
const detailsId = `plan-step-details-${graphId}`;
const overallProgressId = `plan-overall-progress-${graphId}`;
@@ -1183,7 +1258,12 @@ export function PlanExecutionView({
ref={hasDependencies ? graphRef : undefined}
style={
hasDependencies
? ({ '--plan-edge-lanes': graph.lanes } as CSSProperties)
? ({
'--plan-edge-lanes': graph.lanes,
// Publish the lane pitch so .dagCanvas reserves bottom
// padding from the same constant that places the lanes.
'--plan-edge-lane-height': `${EDGE_LANE_HEIGHT}px`,
} as CSSProperties)
: undefined
}
>
@@ -1254,10 +1334,63 @@ export function PlanExecutionView({
{layer.map((todo) => {
const executions = toolsByTodo.get(todo.id) ?? [];
const state = statesByTodo.get(todo.id)!;
// Agent time this step has taken, summed across its root
// agent tasks. It is the node's "is this alive" signal, so it
// is on the face rather than only in the inspector.
const nodeRuntimeMs = executions.reduce(
(total, tool) =>
total + (taskForTool(tool, taskIndex)?.runtimeMs ?? 0),
0,
);
// Agents, not bare executions: a nested subagent counts too,
// matching the rows this node renders and the inspector's
// Subagents list for the same step. The runtime above stays
// on roots, so nested time is not summed twice. Count from
// the same two sources renderExecution draws the rows from —
// live child tasks from the task index plus transcript
// subTools, deduped by toolUseId exactly as the rows are —
// otherwise a live child task with no transcript entry
// renders a row the count never sees.
const agentCount = executions.reduce((count, tool) => {
const liveNested = nestedTasksFromIndex(tool, taskIndex);
const liveCallIds = new Set(
liveNested.flatMap(({ task }) =>
task.toolUseId ? [task.toolUseId] : [],
),
);
const transcriptOnly = nestedAgentToolsForTool(tool).filter(
({ tool: nested }) => !liveCallIds.has(nested.callId),
);
return (
count +
(isSubAgentToolCall(tool) ? 1 : 0) +
liveNested.length +
transcriptOnly.length
);
}, 0);
// blockedBy is model-authored and can repeat an id — or name
// the todo itself. Dedup and drop self-references like the
// topology builder; ghost ids stay, because above the edge
// budget this row is the dependency's only statement.
const faceDependencies = [
...new Set(todo.blockedBy ?? []),
].filter((id) => id !== todo.id);
// Whether the visible chip row carries the dependency. It is
// rendered whenever nothing else can: drawn edges are
// aria-hidden, so they state it only visually; above
// MAX_RENDERED_PLAN_EDGES no edges draw at all; and with the
// details panel off (the cockpit) or selection disabled
// (document mode) the panel never states it either. When this
// is false the sr-only summary below carries the same fact to
// assistive tech instead, so the dependency is stated exactly
// once either way.
const statesDependenciesVisibly =
!drawsDependencyEdges || !showStepDetails || documentMode;
return (
<article
className={styles.node}
data-status={state.status}
data-attention={state.attention || undefined}
onPointerEnter={() => setHoveredTodoId(todo.id)}
onPointerLeave={() =>
setHoveredTodoId((current) =>
@@ -1272,11 +1405,11 @@ export function PlanExecutionView({
current === todo.id ? undefined : current,
)
}
data-plan-input={
(drawsDependencyEdges &&
(dependencyIdsByTodo.get(todo.id)?.length ?? 0) > 0) ||
undefined
}
// No input port: the left edge now carries the status
// rule, and an incoming edge already terminates in an
// arrowhead at the node — that arrowhead is the input
// marker. Outgoing edges leave their source unmarked, so
// the output port stays.
data-plan-output={
(drawsDependencyEdges &&
(dependentsByTodo.get(todo.id)?.length ?? 0) > 0) ||
@@ -1317,29 +1450,96 @@ export function PlanExecutionView({
}
disabled={documentMode}
>
{/* Status reaches assistive tech as words; the left
rule that carries it visually is colour only.
Attention is announced beside the status word,
never instead of it. */}
<span className={styles.nodeStatusText}>
{t(statusKey(state.status))}
{state.attention
? `, ${t('planExecution.attention')}`
: ''}
</span>
<div className={styles.nodeTop}>
<span className={styles.nodeNumber}>
{(stepNumberByTodo.get(todo.id) ?? 0) || ''}
</span>
<span className={styles.nodeContent}>
{todo.content}
</span>
</div>
<div className={styles.nodeMeta}>
{/* The glyph is the non-colour status channel, kept
for every status so the graph still survives
colour-blindness, high-contrast mode and a
greyscale screenshot. It moved off the first line
so the step's content leads, and it is muted so
the left rule remains the only carrier of the
status *colour*. */}
<i aria-hidden="true" className={styles.nodeGlyph}>
{PLAN_STATUS_GLYPH[state.status]}
</i>
<span className={styles.nodeId}>{todo.id}</span>
<span
className={`${styles.nodeStatus} ${styles[state.status]}`}
>
{t(statusKey(state.status))}
</span>
{/* Attention's shape channel: on a paused node the
data-attention rule re-declares the token paused
already wears, so colour alone cannot tell it
apart from healthy. */}
{state.attention && (
<span className={styles.attention}>
{t('planExecution.attention')}
<i
aria-hidden="true"
className={styles.nodeAttentionMark}
>
!
</i>
)}
{agentCount > 0 && (
<span>
{t('planExecution.agentCount', {
count: agentCount,
})}
</span>
)}
{nodeRuntimeMs > 0 && (
<span>{formatRuntime(nodeRuntimeMs)}</span>
)}
</div>
<div className={styles.nodeContent}>{todo.content}</div>
{(todo.blockedBy?.length ?? 0) > 0 && (
<div className={styles.dependencies}>
{t('planExecution.dependsOn')}{' '}
{todo.blockedBy!.join(', ')}
</div>
)}
{statesDependenciesVisibly &&
faceDependencies.length > 0 && (
<div className={styles.dependencies}>
<span>{t('planExecution.dependsOn')}</span>
{faceDependencies.map((id) => (
<span className={styles.dependencyChip} key={id}>
<span>
{(stepNumberByTodo.get(id) ?? 0) || '?'}
</span>
<span className={styles.dependencyTitle}>
{todosById.get(id)?.content ?? id}
</span>
</span>
))}
</div>
)}
{/* The interactive graph draws the dependency instead
of stating it, and drawn edges are aria-hidden — so
without this the node's accessible name stops
naming its blockers and a screen-reader user has to
activate every node to find out what blocks it.
Same sr-only channel as the status word, same
step-number-plus-title labels as the chips, and it
never brings the visible row back. */}
{!statesDependenciesVisibly &&
faceDependencies.length > 0 && (
<span className={styles.nodeDependencyText}>
{t('planExecution.dependsOn')}{' '}
{faceDependencies
.map(
(id) =>
`${(stepNumberByTodo.get(id) ?? 0) || '?'} ${
todosById.get(id)?.content ?? id
}`,
)
.join(', ')}
</span>
)}
</button>
{executions.length > 0 && (
<div className={styles.executions}>
@@ -1375,15 +1575,50 @@ export function PlanExecutionView({
)}
</div>
<div className={styles.nodeContent}>{selectedTodo.content}</div>
{(selectedTodo.blockedBy?.length ?? 0) > 0 && (
{/* This panel is outside the node's own button, so unlike the
chips on the node face these references can be controls: each
one selects the step it names, which is what makes the
dependency list the graph's navigation. */}
{selectedDependencies.length > 0 && (
<div className={styles.dependencies}>
{t('planExecution.dependsOn')}{' '}
{selectedTodo.blockedBy!.join(', ')}
<span>{t('planExecution.dependsOn')}</span>
{selectedDependencies.map((id) => (
<button
className={styles.dependencyLink}
data-plan-interactive
data-plan-dependency={id}
key={id}
onClick={() => updateSelectedTodoId(id)}
title={todosById.get(id)?.content}
type="button"
>
<span>{(stepNumberByTodo.get(id) ?? 0) || '?'}</span>
<span className={styles.dependencyTitle}>
{todosById.get(id)?.content ?? id}
</span>
</button>
))}
</div>
)}
{selectedDependents.length > 0 && (
<div className={styles.dependencies}>
{t('planExecution.unblocks')} {selectedDependents.join(', ')}
<span>{t('planExecution.unblocks')}</span>
{selectedDependents.map((id) => (
<button
className={styles.dependencyLink}
data-plan-interactive
data-plan-dependency={id}
key={id}
onClick={() => updateSelectedTodoId(id)}
title={todosById.get(id)?.content}
type="button"
>
<span>{(stepNumberByTodo.get(id) ?? 0) || '?'}</span>
<span className={styles.dependencyTitle}>
{todosById.get(id)?.content ?? id}
</span>
</button>
))}
</div>
)}
{selectedExecutions.length > 0 && (
@@ -47,12 +47,31 @@
min-width: 0;
}
.identity span {
color: var(--agent-blue-500);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
/* The accent is the selection colour the graph below uses to say "this node".
Spending it on a fixed label above the title made the one blue thing on the
page the one thing that never changes, so the label drops to the subtitle
line and the accent is left to mean selection. */
.identity small {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 10px;
}
.identity small > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspaceName {
max-width: 28ch;
}
/* The short session id is an address the user copies or matches against a
daemon log, so it keeps the mono face the rest of the line does not need. */
.sessionRef {
font-family: var(--font-mono, monospace);
}
/* preflight.css resets headings to `font: inherit`, so size and weight both
@@ -60,7 +79,7 @@
`min-width: 0` already caps the width; a fixed max-width only stranded space
on wide viewports. */
.identity h1 {
margin: 4px 0 3px;
margin: 0 0 3px;
overflow: hidden;
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 600;
@@ -71,7 +90,6 @@
.identity small {
color: var(--muted-foreground);
font-family: var(--font-mono, monospace);
font-size: 11px;
}
@@ -88,14 +88,19 @@ export function SessionWorkflowCockpit({
{t('workflow.chatTitle')}
</button>
<div>
<span>{t('workflow.inspector.graphCanvas')}</span>
<h1 title={sessionName}>
{sessionName || t('workflow.session.defaultTitle')}
</h1>
<small>
{sessionId.slice(0, 8)} ·{' '}
{workspaceCwd?.split('/').at(-1) ||
t('workflow.session.workspace')}
<span>{t('workflow.inspector.graphCanvas')}</span>
<span
className={styles.workspaceName}
title={workspaceCwd || undefined}
>
{workspaceCwd?.split('/').at(-1) ||
t('workflow.session.workspace')}
</span>
<span className={styles.sessionRef}>{sessionId.slice(0, 8)}</span>
</small>
</div>
</div>
@@ -29,4 +29,44 @@ describe('SessionWorkflowInspector stylesheet', () => {
expect(inspectorCss).not.toMatch(/\.summaryHeading\s+span/);
expect(inspectorCss).toMatch(/\.summaryHeading\s*>\s*div\s+span/);
});
// `.activityList > button` (0,1,1) declares the four-column activity grid
// for every direct-child button. A control styled with a bare class (0,1,0)
// loses to it and has its label auto-placed into the 52px first column,
// wrapping to three or four lines with three empty columns beside it —
// invisible to jsdom, to the visuals suite and to a green unit run, so it
// is pinned here at the source.
it('lets the show-all control out-specify the activity grid', () => {
expect(inspectorCss).toMatch(
/\.activityList\s*>\s*button\.showAllActivity\s*\{/,
);
expect(inspectorCss).not.toMatch(/(^|\n)\.showAllActivity\s*\{/);
const rule = inspectorCss.match(
/\.activityList\s*>\s*button\.showAllActivity\s*\{[^}]*\}/,
)?.[0];
// It must actually undo the inherited grid, not merely win the cascade.
expect(rule).toMatch(/display:\s*block/);
expect(rule).toMatch(/grid-template-columns:\s*none/);
});
// Same collision, already fixed for the wrapping metrics row: keep both
// pinned so neither regresses to a bare class.
it('lets the metrics row out-specify its single-line row styles', () => {
expect(inspectorCss).toMatch(/\.linkedAgents\s+button\s+small\.metrics/);
expect(inspectorCss).toMatch(/\.deliverables\s+button\s+small\.metrics/);
});
// The dependency chip's parent <li> is the flex item of .dependencyList;
// its default min-width: auto floored it at the title's full nowrap
// width, so the chip grew out of its column and the title's ellipsis
// never engaged. jsdom computes no layout, so the shrink floor is pinned
// at the source.
it('lets the dependency list item shrink so the chip ellipsis engages', () => {
const rule = inspectorCss.match(
/\.dependencyList\s*>\s*li\s*\{[^}]*\}/,
)?.[0];
expect(rule).toBeTruthy();
expect(rule).toMatch(/min-width:\s*0/);
expect(rule).toMatch(/max-width:\s*100%/);
});
});
@@ -49,35 +49,39 @@
gap: 3px;
}
/* Section labels name what follows; they are not decoration. Tracked-out
uppercase made every one of them shout over the content it introduces, so
they read as chrome. Sentence case at a muted weight puts the emphasis back
on the step title and the status beside it. Kept scoped to the heading's
inner div so the count beside it keeps its own tone. */
.summaryHeading > div span,
.sectionHeading > div > span {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 500;
}
.summaryHeading strong {
font-size: 16px;
}
/* Completed-over-total is a reading of the progress bar directly beneath it,
and the status word sits to its left, so a third tinted surface said the
same thing a third time. The count keeps the tone as ink and drops the
pill. */
.summaryCount {
padding: 5px 8px;
border-radius: 999px;
color: var(--status-running-fg);
background: var(--status-running-bg);
font-size: 12px;
font-size: 13px;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.summaryCount[data-status='attention'] {
color: var(--status-attention-fg);
background: var(--status-attention-bg);
}
.summaryCount[data-status='completed'] {
color: var(--status-done-fg);
background: var(--status-done-bg);
}
.progress {
@@ -281,6 +285,92 @@
overflow-wrap: anywhere;
}
/* A dependency is an edge in the graph beside it, so it is rendered as the
step it points at — number and title — and selects that step on click. The
previous comma-joined id list made the reader find the row by eye. */
.dependencyList {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
/* The chip's parent <li> is the flex item, and its default min-width: auto
floors it at the title's full nowrap width — the chip grew out of its
column and the title's ellipsis never engaged. The number badge is kept
out of the shrink by its own flex: none. */
.dependencyList > li {
min-width: 0;
max-width: 100%;
}
.dependencyLink {
display: flex;
max-width: 100%;
align-items: center;
gap: 6px;
padding: 3px 7px 3px 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 11px;
text-align: left;
}
.dependencyLink > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dependencyLink:hover {
border-color: color-mix(in srgb, var(--agent-blue-500) 45%, var(--border));
background: var(--accent);
}
.dependencyNumber {
display: grid;
width: 16px;
height: 16px;
flex: none;
place-items: center;
border-radius: var(--radius-sm, 4px);
color: var(--muted-foreground);
background: var(--muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
/* The summary beside the list reports the true total, so the preview cap
needs a way out of it rather than a silent truncation.
Specificity note, same class of collision as `.metrics` above: this button
is a DIRECT CHILD of `.activityList`, whose base rule
`.activityList > button` (0,1,1) declares the four-column activity grid
(`52px 26px minmax(0, 1fr) auto`) and `padding: 7px 4px`. A single-class
rule loses to it, and the label — a bare text run that Grid wraps into an
anonymous item — would auto-place into the 52px first column and wrap to
three or four lines with three empty columns beside it. So this rule
carries the same `.activityList >` prefix, and resets `display` and
`grid-template-columns` explicitly rather than relying on order. */
.activityList > button.showAllActivity {
display: block;
grid-template-columns: none;
padding: 7px 8px;
border-radius: var(--radius-md);
color: var(--muted-foreground);
cursor: pointer;
font-size: 11px;
text-align: left;
}
.activityList > button.showAllActivity:hover {
color: var(--foreground);
background: var(--accent);
}
.linkedAgents {
margin-top: 14px;
padding-top: 12px;
@@ -436,6 +526,22 @@
font-size: 11px;
}
/* Runtime, tool calls and tokens are separate facts, so they are separate
elements with real spacing rather than one string glued with middle dots —
which also let them wrap as a unit and be read out as one run of text. */
/* Specificity note: `.linkedAgents button small` (0,1,2) and
`.deliverables small` (0,1,1) both declare the single-line ellipsis shape
these rows use, so the wrapping metrics row has to out-specify them rather
than rely on source order. */
.linkedAgents button small.metrics,
.deliverables button small.metrics {
display: flex;
overflow: visible;
flex-wrap: wrap;
gap: 2px 10px;
white-space: normal;
}
.stateLabel {
color: var(--muted-foreground);
font-size: 11px;
@@ -223,4 +223,242 @@ describe('SessionWorkflowInspector', () => {
act(() => root.unmount());
container.remove();
});
it('selects the referenced step from a dependency, by number and title', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const onSelectedTodoIdChange = vi.fn();
const todos: TodoItem[] = [
{ id: 'prepare', content: 'Prepare inputs', status: 'completed' },
{
id: 'ship',
content: 'Ship result',
status: 'in_progress',
blockedBy: ['prepare'],
},
];
act(() => {
root.render(
<I18nProvider language="en">
<SessionWorkflowInspector
todos={todos}
tools={[]}
tasks={[]}
artifacts={[]}
selectedTodoId="ship"
onSelectedTodoIdChange={onSelectedTodoIdChange}
onExpandGraph={vi.fn()}
onOpenSubagent={vi.fn()}
/>
</I18nProvider>,
);
});
const upstream = container.querySelector<HTMLButtonElement>(
'[data-testid="workflow-dependency-prepare"]',
);
// The reference reads as the step it points at — the step number the list
// and graph both show, plus its title — not the bare `prepare` id.
expect(upstream).toBeTruthy();
expect(upstream?.textContent).toContain('1');
expect(upstream?.textContent).toContain('Prepare inputs');
// The host keyboard handlers isolate plan controls through this marker
// (TasksStatusMessage/ToolApproval early-return on it), so these
// controls must carry it like the graph's own buttons do.
expect(upstream?.hasAttribute('data-plan-interactive')).toBe(true);
onSelectedTodoIdChange.mockClear();
act(() => upstream?.click());
expect(onSelectedTodoIdChange).toHaveBeenCalledWith('prepare');
// Downstream too: the two directions render through the same helper but
// read from different sources (`blockedBy` vs the projection's
// `dependentsByTodo`), so covering one does not cover the other.
act(() => {
root.render(
<I18nProvider language="en">
<SessionWorkflowInspector
todos={todos}
tools={[]}
tasks={[]}
artifacts={[]}
selectedTodoId="prepare"
onSelectedTodoIdChange={onSelectedTodoIdChange}
onExpandGraph={vi.fn()}
onOpenSubagent={vi.fn()}
/>
</I18nProvider>,
);
});
const downstream = container.querySelector<HTMLButtonElement>(
'[data-testid="workflow-dependency-ship"]',
);
expect(downstream?.textContent).toContain('2');
expect(downstream?.textContent).toContain('Ship result');
expect(downstream?.hasAttribute('data-plan-interactive')).toBe(true);
onSelectedTodoIdChange.mockClear();
act(() => downstream?.click());
expect(onSelectedTodoIdChange).toHaveBeenCalledWith('ship');
act(() => root.unmount());
container.remove();
});
it('dedups a repeated dependency id in the upstream list', () => {
// blockedBy is model-authored and can repeat an id; without the dedup
// the list rendered two links sharing one key.
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const todos: TodoItem[] = [
{ id: 'prepare', content: 'Prepare inputs', status: 'completed' },
{
id: 'ship',
content: 'Ship result',
status: 'in_progress',
blockedBy: ['prepare', 'prepare'],
},
];
act(() => {
root.render(
<I18nProvider language="en">
<SessionWorkflowInspector
todos={todos}
tools={[]}
tasks={[]}
artifacts={[]}
selectedTodoId="ship"
onSelectedTodoIdChange={vi.fn()}
onExpandGraph={vi.fn()}
onOpenSubagent={vi.fn()}
/>
</I18nProvider>,
);
});
expect(
container.querySelectorAll('[data-testid="workflow-dependency-prepare"]'),
).toHaveLength(1);
act(() => root.unmount());
container.remove();
});
it('drops a self-reference from the upstream list', () => {
// The projection's dependentsByTodo builder skips self-blocks
// (session-workflow-model.ts), and the DAG draws no edge for them; the
// inspector's upstream filter must apply the same rule, or a todo
// naming itself in blockedBy renders a chip that only re-selects the
// already-selected step.
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const todos: TodoItem[] = [
{ id: 'prepare', content: 'Prepare inputs', status: 'completed' },
{
id: 'ship',
content: 'Ship result',
status: 'in_progress',
blockedBy: ['ship', 'prepare'],
},
];
act(() => {
root.render(
<I18nProvider language="en">
<SessionWorkflowInspector
todos={todos}
tools={[]}
tasks={[]}
artifacts={[]}
selectedTodoId="ship"
onSelectedTodoIdChange={vi.fn()}
onExpandGraph={vi.fn()}
onOpenSubagent={vi.fn()}
/>
</I18nProvider>,
);
});
expect(
container.querySelector('[data-testid="workflow-dependency-ship"]'),
).toBeNull();
expect(
container.querySelectorAll('[data-testid="workflow-dependency-prepare"]'),
).toHaveLength(1);
act(() => root.unmount());
container.remove();
});
it('keeps every activity row reachable past the preview cap', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const todos: TodoItem[] = [
{ id: 'build', content: 'Build', status: 'in_progress' },
];
const tools: ACPToolCall[] = Array.from({ length: 8 }, (_, index) => ({
callId: `agent-${index}`,
toolName: 'Agent',
title: `Agent ${index}`,
status: 'completed' as const,
args: { todo_id: 'build' },
}));
const tasks = tools.map((tool, index) => ({
kind: 'agent' as const,
id: `task-${index}`,
label: `Agent ${index}`,
description: `Ran step ${index}`,
status: 'completed' as const,
startTime: 1_000 + index,
endTime: 2_000 + index,
runtimeMs: 1_000,
isBackgrounded: true,
toolUseId: tool.callId,
}));
act(() => {
root.render(
<I18nProvider language="en">
<SessionWorkflowInspector
todos={todos}
tools={tools}
tasks={tasks}
artifacts={[]}
onSelectedTodoIdChange={vi.fn()}
onExpandGraph={vi.fn()}
onOpenSubagent={vi.fn()}
/>
</I18nProvider>,
);
});
const activityList = () =>
Array.from(container.querySelectorAll('button')).filter((button) =>
/^\d{1,2}:\d{2}/.test(button.textContent?.trim() ?? ''),
);
const beforeExpand = activityList().length;
const showAll = container.querySelector<HTMLButtonElement>(
'[data-testid="workflow-activity-show-all"]',
);
// The count beside the list reports the true total, so the cap has to be
// an invitation rather than a silent truncation.
expect(beforeExpand).toBe(6);
expect(showAll?.textContent).toContain('8');
// Same host-keyboard isolation as the dependency links above.
expect(showAll?.hasAttribute('data-plan-interactive')).toBe(true);
act(() => showAll?.click());
expect(activityList().length).toBe(8);
expect(
container.querySelector('[data-testid="workflow-activity-show-all"]'),
).toBeNull();
act(() => root.unmount());
container.remove();
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type {
DaemonSessionArtifact,
DaemonSessionTaskStatus,
@@ -22,6 +22,12 @@ import {
} from './session-workflow-model';
import styles from './SessionWorkflowInspector.module.css';
/**
* Activity rows shown before the list asks to be expanded. The summary beside
* it reports the true total, so the remainder must stay reachable.
*/
const ACTIVITY_PREVIEW_COUNT = 6;
export interface SessionWorkflowInspectorProps {
todos: readonly TodoItem[];
tools: readonly ACPToolCall[];
@@ -53,6 +59,15 @@ export function SessionWorkflowInspector({
[tasks, todos, tools],
);
const defaultTodoId = getDefaultWorkflowTodoId(todos, projection);
// Dependencies are stated as Todo ids, which are addresses, not labels. The
// step number is what the list and the graph both show, so carry it into
// every dependency reference.
const stepNumberById = useMemo(() => {
const numbers = new Map<string, number>();
todos.forEach((todo, index) => numbers.set(todo.id, index + 1));
return numbers;
}, [todos]);
const [showAllActivity, setShowAllActivity] = useState(false);
const effectiveSelectedTodoId = projection.todosById.has(selectedTodoId ?? '')
? selectedTodoId
: defaultTodoId;
@@ -84,9 +99,16 @@ export function SessionWorkflowInspector({
const selectedTools = selectedTodo
? (projection.agentToolsByTodo.get(selectedTodo.id) ?? [])
: [];
const upstream = selectedTodo?.blockedBy?.filter((id) =>
projection.todosById.has(id),
);
// blockedBy is model-authored and can repeat an id — or name the todo
// itself. Dedup before mapping so a repeated reference cannot emit a
// duplicate key or a second link, and drop self-references with the same
// rule the projection's dependentsByTodo builder applies, so the
// inspector never shows a step blocked by itself.
const upstream = selectedTodo
? [...new Set(selectedTodo.blockedBy ?? [])].filter(
(id) => id !== selectedTodo.id && projection.todosById.has(id),
)
: undefined;
// The projection already derives this for the graph's edges; recomputing it
// here rescanned every todo's `blockedBy` for the same answer. It also drops
// a todo that lists itself in `blockedBy`, which the previous filter kept as
@@ -95,6 +117,30 @@ export function SessionWorkflowInspector({
? (projection.dependentsByTodo.get(selectedTodo.id) ?? [])
: [];
// A dependency reference selects the step it names: the list of ids was
// read-only text, so following an edge meant finding the row by eye.
const dependencyLink = (todoId: string) => {
const target = projection.todosById.get(todoId);
const number = stepNumberById.get(todoId);
return (
<li key={todoId}>
<button
className={styles.dependencyLink}
data-plan-interactive
data-testid={`workflow-dependency-${todoId}`}
onClick={() => onSelectedTodoIdChange(todoId)}
title={target?.content}
type="button"
>
{number !== undefined && (
<span className={styles.dependencyNumber}>{number}</span>
)}
<span>{target?.content ?? todoId}</span>
</button>
</li>
);
};
const detail = selectedTodo && selectedState && (
<section className={styles.detail} data-testid="workflow-step-detail">
<div className={styles.sectionHeading}>
@@ -111,17 +157,25 @@ export function SessionWorkflowInspector({
<div>
<dt>{t('workflow.dependencies.upstream')}</dt>
<dd>
{upstream?.length
? upstream.join(', ')
: t('workflow.dependencies.none')}
{upstream?.length ? (
<ul className={styles.dependencyList}>
{upstream.map((id) => dependencyLink(id))}
</ul>
) : (
t('workflow.dependencies.none')
)}
</dd>
</div>
<div>
<dt>{t('workflow.dependencies.unblocks')}</dt>
<dd>
{downstream.length
? downstream.map((todo) => todo.id).join(', ')
: t('workflow.dependencies.noDownstream')}
{downstream.length ? (
<ul className={styles.dependencyList}>
{downstream.map((todo) => dependencyLink(todo.id))}
</ul>
) : (
t('workflow.dependencies.noDownstream')
)}
</dd>
</div>
</dl>
@@ -168,7 +222,13 @@ export function SessionWorkflowInspector({
task.description}
</small>
)}
{metrics.length > 0 && <small>{metrics.join(' · ')}</small>}
{metrics.length > 0 && (
<small className={styles.metrics}>
{metrics.map((metric) => (
<span key={metric}>{metric}</span>
))}
</small>
)}
</span>
{task && (
<span className={styles.stateLabel} data-status={task.status}>
@@ -314,7 +374,10 @@ export function SessionWorkflowInspector({
<small>{projection.activity.length}</small>
</summary>
<div className={styles.activityList}>
{projection.activity.slice(0, 6).map((task) => {
{(showAllActivity
? projection.activity
: projection.activity.slice(0, ACTIVITY_PREVIEW_COUNT)
).map((task) => {
const tool = projection.toolsByTaskId.get(task.id);
const at = task.endTime ?? task.startTime;
const content = (
@@ -359,6 +422,20 @@ export function SessionWorkflowInspector({
{projection.activity.length === 0 && (
<p>{t('workflow.activity.empty')}</p>
)}
{!showAllActivity &&
projection.activity.length > ACTIVITY_PREVIEW_COUNT && (
<button
className={styles.showAllActivity}
data-plan-interactive
data-testid="workflow-activity-show-all"
onClick={() => setShowAllActivity(true)}
type="button"
>
{t('workflow.activity.showAll', {
count: projection.activity.length,
})}
</button>
)}
</div>
</details>
@@ -377,8 +454,9 @@ export function SessionWorkflowInspector({
>
<span className={styles.itemText}>
<strong>{artifact.title}</strong>
<small>
{artifact.kind} · {artifact.status}
<small className={styles.metrics}>
<span>{artifact.kind}</span>
<span>{artifact.status}</span>
</small>
</span>
<ArrowUpRightIcon aria-hidden="true" />
@@ -179,8 +179,16 @@ for (const theme of [
// the todo graph. A broken tool-call ↔ task linkage (e.g. a `toolUseId`
// drift) still renders the canvas but silently drops the runtime metric
// and the inspector's agent row. `1m 14s` is `formatRuntime(runtimeMs)`
// for the fixture's running task — locale-independent.
await expect(page.getByText('1m 14s')).toBeVisible();
// for the fixture's running task — locale-independent. It prints on
// both the node face and the inspector's agent row, so a page-wide
// locator would match both and throw a strict-mode violation; gate each
// surface separately, keeping both halves load-bearing.
await expect(
page.locator('[data-plan-node-id="inspect-package"]').getByText('1m 14s'),
).toBeVisible();
await expect(
page.locator('[data-testid="workflow-step-detail"]').getByText('1m 14s'),
).toBeVisible();
await captureScreenshot(page, `session-workflow-cockpit-${theme}`);
});
}
+5
View File
@@ -2904,6 +2904,8 @@ const EN: Messages = {
'planExecution.unblocks': 'Unblocks:',
'planExecution.unassigned': 'Unassigned executions',
'planExecution.attention': 'Needs attention',
'planExecution.agentCount': (v) =>
`${v?.count ?? 0} ${Number(v?.count ?? 0) === 1 ? 'agent' : 'agents'}`,
'planExecution.status.running': 'Running',
'planExecution.status.paused': 'Paused',
'planExecution.status.completed': 'Completed',
@@ -2943,6 +2945,7 @@ const EN: Messages = {
'workflow.dependencies.unblocks': 'Unblocks',
'workflow.dependencies.noDownstream': 'No downstream steps',
'workflow.activity.empty': 'No Agent runs are linked to a Todo yet.',
'workflow.activity.showAll': (v) => `Show all ${v?.count ?? 0} runs`,
'workflow.deliverables.title': 'Session deliverables',
'workflow.deliverables.none': 'No artifacts have been published yet',
'workflow.status.running': 'Running',
@@ -6493,6 +6496,7 @@ const ZH: Messages = {
'planExecution.unblocks': '解锁:',
'planExecution.unassigned': '未关联的执行',
'planExecution.attention': '需要关注',
'planExecution.agentCount': (v) => `${v?.count ?? 0} 个 Agent`,
'planExecution.status.running': '运行中',
'planExecution.status.paused': '已暂停',
'planExecution.status.completed': '已完成',
@@ -6531,6 +6535,7 @@ const ZH: Messages = {
'workflow.dependencies.unblocks': '解除阻塞',
'workflow.dependencies.noDownstream': '没有下游步骤',
'workflow.activity.empty': '还没有关联到待办的 Agent 执行。',
'workflow.activity.showAll': (v) => `查看全部 ${v?.count ?? 0} 条执行`,
'workflow.deliverables.title': '会话交付物',
'workflow.deliverables.none': '尚未发布产物',
'workflow.status.running': '运行中',