Skip to content

feat(harness): project rows carry a live-session indicator [SAP-3200] - #841

Open
gwitwer wants to merge 3 commits into
mainfrom
feat/SAP-3200
Open

feat(harness): project rows carry a live-session indicator [SAP-3200]#841
gwitwer wants to merge 3 commits into
mainfrom
feat/SAP-3200

Conversation

@gwitwer

@gwitwer gwitwer commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The harness half of SAP-3200: a project row in the rail shows an indicator when the project has one or more live sessions, so which projects are active reads at a glance.

"projects with active sessions to have an indicator showing that" (the maintainer, 2026-09-05)

Gate: design-eng #185 is still OPEN

The ticket sequences this after sapiom/design-eng#185 merges. As of this push it has not: git ls-tree origin/main finds none of its files, and the frames this cites live only on agent-studio-v2-project-live-indicator. Do not merge this before #185. The implementation matches that mock frame by frame; if #185 changes on the way in, this changes with it.

Frames cited: agent-studio-v2/docs/evidence/rail-live-indicator/rail-live-indicator-light.png and -dark.png (on that branch, not yet on origin/main).

The mark

  • Where: the project header row's trailing edge, first in the trailing cluster. The map glyph, the + and the ... are .workspace-row-action (opacity 0 at rest), so they fade in beside the mark rather than moving it.
  • What: the session bar's own dot recipe in its running state (.session-dot[data-status="running"]). No new colour, no new token, no second dot recipe. The one new CSS rule is a margin-right: var(--sp1).
  • When: present when at least one of the project's agents has a live session, absent otherwise. No grey dot for "nothing running".
  • Words: the count in both the tooltip and the accessible name, "1 live session" / "N live sessions", never a bare dot.
  • Both axes: the Group axis header carries the same mark for its members.
  • Agent rows are unchanged. The rail still lists no sessions; this is a derived fact about a project, the way the deploy glyph is a derived fact about an agent.

Derivation

A project's live sessions are liveSessionsForProject (session-scope.ts), the same function the session tab strip renders from, so the dot and the tabs cannot disagree about which project a session is in. The first draft of this PR defined its own project rule; review round 1 caught that as a second answer to a question that module already owns, and it is gone. See the round-1 reply below for the failure it would have produced.

web/src/lib/project-live.ts now holds only what session-scope has no answer for: a group is a label over agents with no directory behind it, so it cannot be asked the containment question a project is asked. Membership there is the rule liveSessionsForFocus already applies to one agent, over several: bound to a member, or unbound in a member's own folder. Its docblock records the two places the project and group rules read differently, why each is right on its own terms, and why neither is changed to match the other.

Before and after, against a real server

A real agent tree (polsia/sapiom-workflows, 6 agents), two live sessions running on the server in both columns. main says nothing about them; this branch shows the dot.

Light Dark
Before (origin/main, 2 sessions running) before light before dark
After (this branch, same 2 sessions) after light after dark
After, nothing running quiet light quiet dark

Recording of the mark appearing on a real server, counting down as sessions end, and going when the last one exits: after-live-mark.mp4

Specs

web/src/lib/project-live.test.ts (11 tests) pins the group rule both directions, including the exit walk 2 -> 1 -> none, a starting session counting as live, and the trailing-separator case. The project rule is liveSessionsForProject, already pinned by 8 assertions in session-scope.test.ts.

web/e2e/rail-live-indicator.spec.ts (8 tests) drives it on screen: present on acme-app with the running state and a counted accessible name; standing rather than hover-revealed (opacity multiplied up the ancestor chain, and not inside a .workspace-row-action); absent on rfq-agent (only an exited session) and on onboarding-flow (no sessions); the count walking down and the mark leaving as both sessions end; agent rows carrying no dot; no group header claiming a mark where nothing under it is live; and the gateway group header carrying the mark alone when a session is bound to one of its members.

That last one needed a session BOUND to a group member, which no fixture had: the default fixture's live sessions belong to acme-app, which has one agent and so renders no group sections, while deep has the groups and no sessions. ?mockBoundSession=1 seeds exactly one, behind its own query parameter so nothing already running against mockFixtures=deep moves.

No existing spec needed narrowing. The two that guard "no sessions in the rail" assert on workflow-session-dot-* and rail-session-* testids, which this does not add.

Mutation test of the new specs

7 mutations, 7 killed, 0 survivors. Each was applied to a file while the dev server was already running, so HMR invalidated the transform rather than serving a cached one.

Mutation Result
the mark never renders 4 failed
the mark renders regardless of liveness 4 failed
exited sessions count as live 4 failed
the accessible name drops the count 3 failed
every group header claims a live session 1 failed
the group rule ignores the binding and matches cwd only 1 failed
the mark is hover-revealed (opacity: 0 at rest) 1 failed

Checks

Check Result
tsc --noEmit (server and web) pass
eslint src pass (note: it does not cover web/src)
vitest: project-live, session-scope, css-brand-surface, api 115 pass
playwright rail-live-indicator (includes both mockBoundSession and deep group-axis specs) 8 pass
playwright smoke, group-axis, project-axis, rail-grammar, session-scope, session-tabs, agent-map-planning, project-map-groups 96 pass

The full suite was not run locally; CI covers it.

did_not_work

  • The count cannot be driven to zero through the UI on a real server. With a project selected, ending its last session makes the app start a replacement, so the recording ends the final session through DELETE /api/sessions/:id. The mock-mode spec covers the same transition through the UI alone.
  • The project and group rules read membership differently, and this ships that way. A session bound to an agent under a project but rooted outside it lights the group header while the project row stays dark. Both halves are right on their own terms, and every alternative was worse: see the liveSessionsOnAgents docblock. POST /api/agents/move is the way to produce it.
  • The first evidence runs reported a mark on a "quiet" start because sessions persisted in a reused --state-root. Every screenshot above is from a run against a fresh state root.
  • The first draft shipped a second project-membership rule, which review round 1 caught. It is recorded here rather than quietly rewritten, because the near-identical name was the durable half of that mistake.

🤖 Generated with Claude Code

https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM

A project row in the rail now shows a dot when one or more of its agents
has a running session, so which projects are active reads at a glance.
Before this the rail could hold a dozen projects with something running in
one of them and say nothing about which.

The mark is the session bar's own dot recipe in its running state
(`.session-dot[data-status="running"]`), reused rather than redrawn: one
dot means one thing across the app, and it introduces no colour and no
token of its own. It stands at the trailing edge of the row, first in the
trailing cluster, so the actions that fade in on hover appear beside it
instead of pushing it along. The count lives in the tooltip and the
accessible name ("1 live session" / "N live sessions") because a bare dot
is mute.

Membership is derived, never stored, and reuses `rootContains`, the app's
one containment answer: a session belongs to a project when it runs inside
the root or is bound to an agent under it, and to a group when it is bound
to a member or sits unbound in a member's own folder. The rail still lists
no sessions; this is a fact about a project, the way the deploy glyph is a
fact about an agent, and agent rows are untouched.

Design of record: design-eng PR #185 (agent-studio-v2, DECISIONS D37),
frames at agent-studio-v2/docs/evidence/rail-live-indicator/. That PR is
still OPEN, so this must not merge before it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Review — PR #841 (round 1)

🔒 Confidentiality: nothing new in the diff. polsia is pre-existing repo-wide fixture
vocabulary (web/e2e/polsia-workspace.spec.ts, mock-data.ts:1146, session-scope.ts),
not introduced here. No changeset text at all — see the first finding.


1. No changeset for a user-facing change to a published package

@sapiom/harness is published (packages/harness/package.json, v0.14.0) and
@sapiom/harness-desktop ships its web assets. This PR adds a visible rail affordance
and the diff contains no .changeset/*.md, and the body does not mark one N/A.
CONTRIBUTING.md:193–204 requires one "when a change affects a published package's
behavior", and the sibling UI PRs did exactly that
(.changeset/project-session-shortcut.md, .changeset/one-studio-dialog-shell.md).

What breaks: @sapiom/harness releases at the wrong level (or not at all), and the
changelog a consumer reads has no record that project rows grew an indicator. Add a
minor for @sapiom/harness (+ patch for @sapiom/harness-desktop, matching the
sibling), phrased for an anonymous npm reader — no ticket ids, no design-eng links.

2. liveSessionsInProject is a second answer to a question session-scope.ts already answers

web/src/lib/project-live.ts:34 adds containment or boundWorkflowPath containment.
liveSessionsForProject (session-scope.ts:164) is containment alone, and it is what
App.tsx:887 and App.tsx:1433 render the session tab strip from. The two disagree on a
live session whose cwd is outside the project root but whose binding points inside it.

Failure: an agent moved between projects (POST /api/agents/move) while a session bound
to it is running. The session's cwd stays in project A, its binding now resolves under
project B. A's row says "1 live session" (containment) and B's row says "1 live
session" (binding) — the same session marks two projects — and selecting B opens a strip
with no tabs, because liveSessionsForProject never counted it. The docblock at
session-scope.ts:148–152 names this exact hazard ("Two functions answering 'is this
session in this project' differently is how a strip and a handover come to disagree").

The near-identical names make it durable: liveSessionsForProject vs
liveSessionsInProject differ by one preposition and by their answer, so the next caller
picks whichever autocomplete offers. Either extend liveSessionsForProject with the
binding clause and have both surfaces read it, or drop the binding clause here and call
the existing function — the PR's own justification ("since SAP-2927 every session boots at
its project root, so in practice the two agree") is an argument for dropping it.

3. The Group-axis mark stays dark for the app's own default session flow

liveSessionsOnAgents counts a session only when it is bound to a member, or unbound with
cwd exactly equal to a member's folder. New sessions are created with
boundWorkflowPath: null (src/core/session-manager.ts:689, :767) at the project root,
and binding happens later (:1781). So between "start a session" and "focus an agent" the
project row carries the mark and every group header under it does not. The unit test at
project-live.test.ts pins this as intended, and the PR states there is no positive e2e
for the group mark — which means the one path that lights a group header
(boundWorkflowPath set) is unverified on screen in either direction. If it is intended,
say so in the GroupRow liveCount JSDoc so the next reader does not file it as a bug; a
unit test asserting the intended positive already exists, so a fixture with one bound
session would close the e2e half cheaply.

Nits

  • liveSessionsInProject/liveSessionsOnAgents allocate a filtered array per project and
    per group on every rail render, and both call sites only read .length.
  • rail-live-indicator.spec.ts:47 reads getComputedStyle(el).opacity, which is the
    element's own opacity — it would still be 1 under an opacity: 0 ancestor. The claim
    the test names ("it is on screen without hovering") would be pinned by asserting the
    mark's own opacity and that it is not inside a .workspace-row-action.

Verdict: the derivation is clean, tested, and reuses rootContains/samePath and the
existing .session-dot recipe rather than redrawing them. Blocking on the missing
changeset and on finding 2 — a second membership rule beside the one whose own docblock
forbids it. (The PR's own merge gate on design-eng#185 still stands.)

…P-3200]

Review round 1, findings 1 to 3 and both nits.

The mark's project count was a SECOND answer to a question `session-scope.ts`
already answers, and that module's own docblock names the hazard: a session
whose cwd is in project A while its binding resolves under project B, which
`POST /api/agents/move` produces, would have marked both rows live while B's
session tab strip stayed empty, because the strip renders from
`liveSessionsForProject` and the mark did not. The rail now calls that same
function, so the dot and the tabs cannot disagree by construction, and the
near-identical `liveSessionsInProject` is gone rather than left for the next
caller to pick by autocomplete. `project-live.ts` keeps only what
session-scope has no answer for: a group has no directory, so it cannot be
asked the containment question.

The group mark now has positive coverage on screen. No fixture had a session
BOUND to a group member, so `?mockBoundSession=1` seeds exactly one, bound to
`gateway`, behind its own parameter so no spec that counts sessions on the
deep fixture moves. The spec asserts the mark on that header, its absence on
the neighbours, and the same session counted once by the project row above.

That a fresh session marks the project and no group under it is intended, not
a gap: a session is unbound until its agent is known, and an unbound session
at the root is working on no member yet. Said so in both `liveSessionsOnAgents`
and the `liveCount` JSDoc so it is not filed as a bug.

Also: a changeset (minor for `@sapiom/harness`, patch for the desktop app),
and the standing-mark spec now multiplies opacity up the ancestor chain and
checks the mark is not inside a `.workspace-row-action`, since an element's
own opacity reads 1 inside a faded parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM
@gwitwer

gwitwer commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Round 1 addressed in 4bcfb7e. Findings 1, 2 and 3 taken; one nit taken, one declined with reasoning.

1. Changeset: added

.changeset/project-live-indicator.md, minor for @sapiom/harness and patch for @sapiom/harness-desktop, matching project-session-shortcut.md. Written for an anonymous npm reader: no ticket ids, no design-eng links.

2. Second membership answer: you were right, and it is gone

I confirmed the failure rather than taking it on faith. liveSessionsForProject (session-scope.ts:164) is what renders the tab strip at App.tsx:887 and App.tsx:1433, and its own docblock names POST /api/agents/move as the reason membership is derived one way. The move is reachable in this repo (agent-move.ts, drag-to-move on the Project axis), so the divergence was not hypothetical: A's row and B's row would both have said "1 live session" for one session, and selecting B would have opened an empty strip.

I took your second option, which is also what my own justification argued for: the binding clause is gone and the rail now calls liveSessionsForProject(sessions, project.root).length. liveSessionsInProject is deleted rather than kept alongside, so there is no near-identical name for the next caller to pick by autocomplete. project-live.ts now holds only liveSessionsOnAgents and liveSessionsLabel, which is the part session-scope has no answer for: a group has no directory behind it, so it cannot be asked the containment question. Its module docblock records the rejected draft and why, so the clause is not re-added later as an improvement.

Coverage did not shrink: liveSessionsForProject is already pinned by 8 assertions in session-scope.test.ts (exited sessions, nested roots, null and empty root, ordering), so the deleted tests were duplicating it. project-live.test.ts now covers the group rule only, including the exit walk 2 -> 1 -> none.

3. Group mark dark for the default session flow: documented, and now covered on screen

The behaviour is intended, and you are right that it reads like a bug. It is now stated in two places: liveSessionsOnAgents's docblock and the liveCount JSDoc on GroupRowProps. The reasoning is that a session is unbound until its agent is known, and an unbound session at a project root is working on no member yet, so crediting a group would print a guess as a fact.

I also took the fixture suggestion, since it closed a hole I had flagged myself. ?mockBoundSession=1 seeds exactly one session bound to gateway, the head of the deep fixture's biggest derived group, behind its own query parameter so that nothing already running against mockFixtures=deep moves. The new spec asserts the mark on the gateway header with a counted name, its absence on mailer and Ungrouped, exactly one group mark in the rail, and the same session counted once by the project row above it.

Nits

Opacity assertion: taken. Correct, the element's own opacity reads 1 inside a faded ancestor. The spec now walks up to .workspace-row, multiplies opacity along the chain, and asserts the mark is not inside a .workspace-row-action, then checks both facts at once. Mutation 7 below confirms it.

Per-render allocation: declining. Taking this would mean adding a counting variant beside liveSessionsForProject, which is the second answer finding 2 asked me to remove; the two findings pull opposite ways and correctness wins. The measured cost is a filter and a sort over the session list once per project row: a rail with a dozen projects and a handful of sessions, inside a render that is already building the whole project tree. If it ever shows up in a profile, the fix is to hoist one grouping pass above the map rather than to give the mark private membership logic.

Mutation test, re-run after the changes

7 mutations, 7 killed, 0 survivors. Each applied to a file while the dev server was already running, so HMR invalidated the transform rather than serving a cached one.

Mutation Result
the mark never renders 4 failed
the mark renders regardless of liveness 4 failed
exited sessions count as live 4 failed
the accessible name drops the count 3 failed
every group header claims a live session 1 failed
the group rule ignores the binding and matches cwd only 1 failed
the mark is hover-revealed (opacity: 0 at rest) 1 failed

Checks

tsc --noEmit (server and web), eslint src, vitest on the changed area (project-live, session-scope, css-brand-surface, api: 115 pass), and playwright over rail-live-indicator, group-axis, project-axis, session-scope, session-tabs, rail-grammar, project-map-groups, agent-map-planning (96 pass, plus the 8 in rail-live-indicator). Full suite left to CI.

The merge gate on design-eng#185 still stands: it is still OPEN, and this must not merge first.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Review — PR #841 (round 2, delta since 21381a1)

🔒 Confidentiality: the new .changeset/project-live-indicator.md is clean — generic
prose, no company name, no ticket id, no design-eng link. Nothing else in the delta adds
prose.

Round-1 findings: all three addressed

  1. Changeset — added, @sapiom/harness minor + @sapiom/harness-desktop patch,
    matching the sibling PRs. Body reads as npm-consumer copy. Resolved.
  2. Second membership ruleliveSessionsInProject is deleted; WorkflowsRail.tsx:1353
    now calls liveSessionsForProject from session-scope.ts, the same function the tab
    strip renders from. Resolved, and the module docblock records why.
  3. Group mark — the intended-behaviour note landed in the GroupRow.liveCount JSDoc
    and the positive e2e now exists (?mockBoundSession=1 seeds one session bound to
    gateway, asserted alone). Resolved beyond what was asked.

Round-1 nit on getComputedStyle().opacity is fixed (walks the ancestor chain and asserts
insideHoverAction: false). Nothing round 1 got wrong.

New

  • The project/group rules still disagree in the direction the fix didn't cover. Project
    rows are containment-only; liveSessionsOnAgents is binding-or-exact-cwd. A session bound
    to an agent under project A with a cwd outside A (the POST /api/agents/move case the
    new docblock cites) lights the group header under A while A's own project row stays
    dark — a child marked live inside a parent that isn't. One line in the
    liveSessionsOnAgents docblock naming this asymmetry would be enough; it is narrower than
    the round-1 bug and not worth another rule.
  • The PR body is now stale. did_not_work still says "The Group axis mark has no
    positive e2e", Specs still says 7 tests, and the Checks table lists no run covering
    mockBoundSession. A reader comparing the body to the branch will file the closed gap as
    open.

Nit

  • The per-render allocation nit got slightly heavier: liveSessionsForProject also sorts,
    and both call sites read only .length.

Verdict: the push resolves every round-1 finding, including the blocking one. Remaining
items are a docblock line and a PR-body refresh. The merge gate on design-eng#185 still
stands.

Review round 2. A project row counts by containment and a group by binding,
so a session bound to an agent under a project but rooted outside it lights
the group header while the project row stays dark: a child marked live inside
a parent that is not. `POST /api/agents/move` produces it.

Left standing rather than fixed, and the docblock now says why. A group has
nothing on disk behind it, so binding is the only membership it has, and it is
the rule `liveSessionsForFocus` already applies to one agent whose own tab
strip lists that same session. Intersecting the group rule with the project's
containment would make a group mean something different from the agent rows
inside it; adding a binding clause to the project rule is the second
membership answer round 1 removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM
@gwitwer

gwitwer commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 addressed in 4411f4d. Both new items taken; the nit declined again, with the reason sharpened.

The project/group asymmetry: documented, not fixed

You are right, and it is the mirror of the round-1 bug: a session bound to an agent under project A but rooted outside A lights the group header and leaves A's row dark. POST /api/agents/move is the way to produce it.

I took the docblock line, and wrote down why it is left standing rather than closed, since "known and deliberate" is only useful if the next reader can see the reasoning:

  • A group is a label over agents with nothing on disk behind it, so binding is the only membership it has. It is the same rule liveSessionsForFocus applies to one agent, and that agent's own tab strip lists the very same session, so the group header agrees with the rows inside it.
  • Intersecting the group rule with the project's containment would make a group mean something different from its own agent rows.
  • Adding a binding clause to the project rule is exactly the second membership answer round 1 had me remove.

Every alternative is worse than the odd mark, so the mark stays odd and the rules stay singular. That is now in liveSessionsOnAgents under a heading, and in the PR body's did_not_work.

PR body: refreshed

Correct, it was stale in three places. Updated: did_not_work no longer claims the group mark has no positive e2e (it names the two real remaining limits instead), the spec counts read 11 unit and 8 e2e, and the Checks table lists the rail-live-indicator run that covers mockBoundSession. The Derivation section now leads with liveSessionsForProject rather than describing the rule I deleted, and did_not_work records that the first draft shipped a second membership rule and that round 1 caught it, rather than quietly rewriting history: the near-identical name was the durable half of that mistake and is worth leaving visible.

Nit, declined again

Correct that liveSessionsForProject sorts as well as filters, and that both call sites read only .length. Declining for the same reason as round 1, which the sort does not change: the only way to avoid the work is a counting variant that duplicates membership, and a second function answering "is this session in this project" is the round-1 blocking bug. A per-render sort of a session list that is single-digit in every real install, inside a render already building the whole project tree, is not worth reopening that door. If it ever appears in a profile, the fix is one grouping pass hoisted above the project map, feeding both marks, and that keeps a single membership rule.

Checks after the change

tsc --noEmit (server and web) pass, prettier --check clean, project-live.test.ts 11 pass, rail-live-indicator 8 pass. The edit is comment-only, so the mutation results from round 1 stand unchanged.

The merge gate on design-eng#185 still stands: it is still OPEN, and this must not merge first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant