From 5bc69a20c60b76282f5df22898b71566421c0929 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 16:44:50 -0700 Subject: [PATCH 1/3] feat(harness): project rows carry a live-session indicator [SAP-3200] 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) Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM --- .../web/e2e/rail-live-indicator.spec.ts | 141 ++++++++++++++++ .../harness/web/src/components/GroupRow.tsx | 28 +++- .../web/src/components/ProjectTreeRows.tsx | 40 +++++ .../web/src/components/WorkflowsRail.tsx | 12 ++ .../harness/web/src/lib/project-live.test.ts | 157 ++++++++++++++++++ packages/harness/web/src/lib/project-live.ts | 88 ++++++++++ packages/harness/web/src/styles.css | 8 + 7 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 packages/harness/web/e2e/rail-live-indicator.spec.ts create mode 100644 packages/harness/web/src/lib/project-live.test.ts create mode 100644 packages/harness/web/src/lib/project-live.ts diff --git a/packages/harness/web/e2e/rail-live-indicator.spec.ts b/packages/harness/web/e2e/rail-live-indicator.spec.ts new file mode 100644 index 000000000..028bc02a5 --- /dev/null +++ b/packages/harness/web/e2e/rail-live-indicator.spec.ts @@ -0,0 +1,141 @@ +/** + * SAP-3200: a project row carries a live-session mark. + * + * `project-live.test.ts` pins the derivation; what a unit test cannot see is + * whether the mark reaches the row, stands there without being hovered, and + * LEAVES when the last session ends. The disappearing direction is the half + * that has to be driven through the browser: it depends on the rail + * re-deriving from the session list rather than remembering what it drew. + * + * Mock fixtures this leans on (web/src/lib/mock-data.ts), at `?seed=0`: + * - `sess-boot` and `sess-leasing-2` are both running in /Users/demo/acme-app + * - `sess-rfq` in /Users/demo/rfq-agent has EXITED + * - `onboarding-flow` is a project in recentDirs with no sessions at all + */ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +/** End the active session through the tab menu's confirm dialog. Clicks + * straight through, the way `session-scope.spec.ts` does: asserting the + * dialog visible first catches it mid pop-in and the confirm is then + * unstable. */ +const endActiveSession = async (page: Page): Promise => { + await page.getByTestId("session-menu").click(); + await page.getByTestId("session-end-btn").click(); + await page.getByTestId("end-session-confirm-btn").click(); +}; + +test.beforeEach(async ({ page }) => { + await page.goto("/?seed=0"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); +}); + +test("a project holding live sessions carries the mark, counted and named", async ({ + page, +}) => { + const mark = page.getByTestId("project-live-acme-app"); + await expect(mark).toBeVisible(); + // The dot recipe in its running state, not a second one. + await expect(mark).toHaveClass(/session-dot/); + await expect(mark).toHaveAttribute("data-status", "running"); + // Never a bare dot: both live sessions are counted, in both the accessible + // name and the tooltip. + await expect(mark).toHaveAttribute("aria-label", "2 live sessions"); + await expect(mark).toHaveAttribute("data-tooltip", "2 live sessions"); +}); + +test("the mark STANDS: it is on screen without hovering the row", async ({ + page, +}) => { + // The `+` and the ⋮ beside it are hover-revealed (`.workspace-row-action`, + // opacity 0 at rest). The mark answers a question asked at a glance, so it + // must not be. + const opacity = await page + .getByTestId("project-live-acme-app") + .evaluate((element) => getComputedStyle(element).opacity); + expect(Number(opacity)).toBe(1); +}); + +test("a project whose only session has exited carries no mark", async ({ + page, +}) => { + await expect(page.getByTestId("workspace-group-rfq-agent")).toBeVisible(); + await expect(page.getByTestId("project-live-rfq-agent")).toHaveCount(0); +}); + +test("a project with no sessions at all carries no mark", async ({ page }) => { + await expect( + page.getByTestId("workspace-group-onboarding-flow"), + ).toBeVisible(); + await expect(page.getByTestId("project-live-onboarding-flow")).toHaveCount(0); +}); + +test("the mark counts down as sessions end, and goes when the last one does", async ({ + page, +}) => { + const mark = page.getByTestId("project-live-acme-app"); + await expect(mark).toHaveAttribute("aria-label", "2 live sessions"); + + // Both live sessions in the fixtures are acme-app's, so ending them one at a + // time walks the mark down and then off. + await endActiveSession(page); + await expect(mark).toHaveAttribute("aria-label", "1 live session"); + await expect(mark).toBeVisible(); + + await endActiveSession(page); + await expect(page.getByTestId("project-live-acme-app")).toHaveCount(0); +}); + +test("agent rows are untouched: the mark is a fact about a project", async ({ + page, +}) => { + const leasing = page.getByTestId("workflow-leasing"); + await expect(leasing).toBeVisible(); + // The rail still lists no sessions and an agent row still carries only its + // deploy glyph. + await expect(leasing.locator(".session-dot")).toHaveCount(0); + await expect( + page.locator("[data-testid^='workflow-session-dot-']"), + ).toHaveCount(0); + await expect(page.locator("[data-testid^='rail-session-']")).toHaveCount(0); +}); + +/** + * The Group axis carries the SAME mark on its headers, by the membership rule + * `liveSessionsOnAgents` pins: bound to a member, or unbound in a member's own + * folder. + * + * Only the negative is reachable from the fixtures. Every live mock session is + * acme-app's, and acme-app has one agent, so it renders no group sections at + * all; the deep fixture's projects have the groups and none of the sessions. + * What this guards is the failure a positive could not catch anyway (a mark + * that renders unconditionally), and the membership rule itself is pinned in + * `project-live.test.ts`, both directions. + */ +test.describe("the Group axis", () => { + test("group headers carry no mark when nothing under them is live", async ({ + page, + }) => { + await page.goto("/?mockFixtures=deep"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + // The create row appears only once the stored arrangement AND the launch + // edges have loaded, so it is the honest "the groups are drawn" signal. + await expect(page.getByTestId("group-create-polsia")).toBeVisible(); + + await expect( + page.locator('[data-testid^="group-row-"]').first(), + ).toBeVisible(); + await expect(page.locator('[data-testid^="group-live-"]')).toHaveCount(0); + + // The project row above them still reports its own sessions, so the axis + // has not simply stopped deriving. + await expect(page.getByTestId("project-live-acme-app")).toHaveAttribute( + "aria-label", + "2 live sessions", + ); + }); +}); diff --git a/packages/harness/web/src/components/GroupRow.tsx b/packages/harness/web/src/components/GroupRow.tsx index 9964b6eb9..0a759c9c9 100644 --- a/packages/harness/web/src/components/GroupRow.tsx +++ b/packages/harness/web/src/components/GroupRow.tsx @@ -3,10 +3,12 @@ import type { CSSProperties, DOMAttributes, JSX } from "react"; import type { WorkflowInfo } from "@shared/types"; import { Icon } from "./Icon"; -import { RowDisclosure } from "./ProjectTreeRows"; +import { LiveMark, RowDisclosure } from "./ProjectTreeRows"; import { WorkflowRow } from "./WorkflowRow"; import type { GroupNode } from "../lib/agent-groups"; import { trackingAttrs } from "../lib/analytics/tracking-attrs"; +import { liveSessionsOnAgents } from "../lib/project-live"; +import type { ScopedSession } from "../lib/session-scope"; /** * The drag payload rides in `dataTransfer`, NOT in component state. @@ -35,6 +37,12 @@ export interface GroupRowProps { * greyed-out control still says "this is a thing you could do here". */ isUngrouped?: boolean; + /** How many live sessions this group's members hold, for the standing live + * mark (SAP-3200). A group header is a header like the project row is, and it + * answers the same at-a-glance question about the agents under it. Counted by + * the SECTION rather than here: the row has no sessions of its own, and the + * membership rule belongs with the model. */ + liveCount?: number; /** True while this row is the drop target. Owned by the section, not the row: * rows that each track their own hover disagree mid-drag. */ isDropTarget?: boolean; @@ -77,6 +85,7 @@ export function GroupRow({ collapsed, onToggleCollapsed, isUngrouped = false, + liveCount = 0, isDropTarget = false, onRename, onDelete, @@ -214,6 +223,12 @@ export function GroupRow({ )} + {/* LIVE, at a glance (SAP-3200, D37): a member has a running session. + Ahead of the hover actions, and outside the editing guard below, so the + fact stays true while the row is being renamed; it is not an action a + stray click could fire. */} + + {/* Hidden while editing: the input owns the row's width, and clicking an action would blur-commit and act in one gesture. */} {canRename && !editing && ( @@ -345,6 +360,7 @@ export function GroupSections({ onToggleCollapsed, focusedAgentPath, onFocusAgent, + sessions, onCreate, onRename, onDelete, @@ -375,6 +391,10 @@ export function GroupSections({ onToggleCollapsed: (key: string) => void; focusedAgentPath: string | null; onFocusAgent: (path: string) => void; + /** Every session the rail knows about, so each header can count its own live + * ones. Structurally typed (`ScopedSession`), like the rules in + * `session-scope.ts`, so a test can pin a header with an object literal. */ + sessions: readonly ScopedSession[]; onCreate: () => void; onRename: (groupId: string, label: string) => void; onDelete: (groupId: string) => void; @@ -443,6 +463,12 @@ export function GroupSections({ collapsed={collapsed} onToggleCollapsed={() => onToggleCollapsed(group.id)} isUngrouped={group.isUngrouped} + liveCount={ + liveSessionsOnAgents( + sessions, + group.agents.map((agent) => agent.workflow.path), + ).length + } isDropTarget={dropTarget === group.id} startRenaming={fresh} /* The launch claim is only true of a DETECTED group. Once the diff --git a/packages/harness/web/src/components/ProjectTreeRows.tsx b/packages/harness/web/src/components/ProjectTreeRows.tsx index e7d18ad68..e6ad65e7e 100644 --- a/packages/harness/web/src/components/ProjectTreeRows.tsx +++ b/packages/harness/web/src/components/ProjectTreeRows.tsx @@ -9,6 +9,7 @@ import type { WorkspaceKey } from "@shared/system-graph"; import { Icon } from "./Icon"; import { WorkflowRow } from "./WorkflowRow"; +import { liveSessionsLabel } from "../lib/project-live"; import { projectInitial } from "../lib/project-tree"; import type { AgentNode, DirNode } from "../lib/project-tree"; import { DRAG_MOVE_TYPE } from "../lib/agent-move"; @@ -184,6 +185,45 @@ function ProjectMark({ root }: { root: string }): JSX.Element { ); } +/** + * A project's (or a group's) LIVE mark (SAP-3200, design-eng DECISIONS D37): + * at least one of its agents has a running session. + * + * The session bar's own dot recipe in its running state, reused rather than + * redrawn: one dot means one thing across the app, and a second recipe here + * would be a second thing that looks the same. It carries no colour of its + * own: `.session-dot[data-status="running"]` already reads `var(--green)`. + * + * STANDING, not hover-revealed, because "is anything running here" is the + * question the rail is being asked at a glance; and FIRST in the trailing + * cluster, so the actions that fade in on hover appear beside it rather than + * pushing it along the row. + * + * Present when at least one session is live, absent otherwise. There is no + * grey dot for "nothing running": a row that is quiet says so by carrying + * nothing, the way an undeployed agent does. + */ +export function LiveMark({ + count, + testId, +}: { + count: number; + testId: string; +}): JSX.Element | null { + if (count <= 0) return null; + const label = liveSessionsLabel(count); + return ( + + ); +} + /** * The nested rows inside a project: directories that actually branch, and the * agents under them. diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index fcce99b6f..9dc9dceb2 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -37,6 +37,7 @@ import { UpdateCard } from "./UpdateCard"; import { SettingsPopover } from "./SettingsPopover"; import { describeUpdateOutcome, getDesktopBridge } from "../lib/desktop"; import { + LiveMark, ProjectRow, ProjectTreeRows, dirKey, @@ -82,6 +83,7 @@ import { } from "../lib/project-membership"; import type { RailAxis, RailSort } from "../lib/project-tree"; import { samePath } from "../lib/paths"; +import { liveSessionsInProject } from "../lib/project-live"; import type { PendingWorkspace } from "../lib/use-harness-state"; import { SAPIOM_AGENTS_URL } from "../lib/urls"; import { getTheme, subscribeTheme, toggleTheme } from "../lib/theme"; @@ -1379,6 +1381,15 @@ export function WorkflowsRail({ } trailing={ <> + {/* LIVE, at a glance (SAP-3200, D37): something is + running inside this project. Derived from the + sessions the rail already holds, never a row. */} + {creating && ( { const label = nextGroupLabel(groupNodes); railGroups.edit(project.root, groupAgents, (state) => diff --git a/packages/harness/web/src/lib/project-live.test.ts b/packages/harness/web/src/lib/project-live.test.ts new file mode 100644 index 000000000..cca5ecc65 --- /dev/null +++ b/packages/harness/web/src/lib/project-live.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { + liveSessionsInProject, + liveSessionsLabel, + liveSessionsOnAgents, +} from "./project-live"; +import type { ScopedSession } from "./session-scope"; + +const ACME = "/Users/demo/acme-app"; +const LEASING = `${ACME}/leasing`; +const PRICING = `${ACME}/pricing`; +const RFQ = "/Users/demo/rfq-agent"; + +/** A session with only the fields these rules read. `createdAt` is required by + * `ScopedSession` and is never consulted here: the mark counts, it does not + * order. */ +function session(over: Partial & { id: string }): ScopedSession { + return { + cwd: ACME, + status: "running", + boundWorkflowPath: null, + createdAt: "2026-09-05T00:00:00.000Z", + ...over, + }; +} + +describe("liveSessionsInProject", () => { + it("counts a session rooted at the project root", () => { + const sessions = [session({ id: "a", cwd: ACME })]; + expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); + }); + + it("counts a session rooted in a subdirectory of the project", () => { + const sessions = [session({ id: "a", cwd: LEASING })]; + expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); + }); + + it("counts a session bound to an agent in the project from outside it", () => { + const sessions = [ + session({ + id: "a", + cwd: "/Users/demo/elsewhere", + boundWorkflowPath: LEASING, + }), + ]; + expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); + }); + + it("counts every live session, so the mark can say how many", () => { + const sessions = [ + session({ id: "a", cwd: ACME }), + session({ id: "b", cwd: ACME, status: "starting" }), + session({ id: "c", cwd: LEASING }), + ]; + expect(liveSessionsInProject(sessions, ACME)).toHaveLength(3); + }); + + it("does not count an exited session, so the mark goes with the last one", () => { + const sessions = [session({ id: "a", cwd: ACME, status: "exited" })]; + expect(liveSessionsInProject(sessions, ACME)).toEqual([]); + }); + + it("drops the mark once the last live session exits, and not before", () => { + const both = [ + session({ id: "a", cwd: ACME }), + session({ id: "b", cwd: LEASING }), + ]; + expect(liveSessionsInProject(both, ACME)).toHaveLength(2); + + const one = [ + both[0]!, + session({ id: "b", cwd: LEASING, status: "exited" }), + ]; + expect(liveSessionsInProject(one, ACME)).toHaveLength(1); + + const none = one.map((entry) => ({ ...entry, status: "exited" as const })); + expect(liveSessionsInProject(none, ACME)).toEqual([]); + }); + + it("does not count another project's session", () => { + const sessions = [session({ id: "a", cwd: RFQ })]; + expect(liveSessionsInProject(sessions, ACME)).toEqual([]); + }); + + it("refuses a bare string prefix: `acme-app-old` is not inside `acme-app`", () => { + const sessions = [session({ id: "a", cwd: `${ACME}-old` })]; + expect(liveSessionsInProject(sessions, ACME)).toEqual([]); + }); + + it("counts nothing for an empty root, which prefixes every path", () => { + const sessions = [session({ id: "a", cwd: ACME })]; + expect(liveSessionsInProject(sessions, "")).toEqual([]); + }); +}); + +describe("liveSessionsOnAgents", () => { + it("counts a session bound to a member", () => { + const sessions = [ + session({ id: "a", cwd: ACME, boundWorkflowPath: LEASING }), + ]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toHaveLength(1); + }); + + it("counts an unbound session sitting in a member's own folder", () => { + const sessions = [session({ id: "a", cwd: LEASING })]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toHaveLength(1); + }); + + it("does not count an unbound session at the project root above the members", () => { + // The group is a label over agents, not a directory: a session at the + // project root is in no member's folder and belongs to no group. + const sessions = [session({ id: "a", cwd: ACME })]; + expect(liveSessionsOnAgents(sessions, [LEASING, PRICING])).toEqual([]); + }); + + it("does not count a session bound to an agent outside the group", () => { + const sessions = [ + session({ id: "a", cwd: ACME, boundWorkflowPath: PRICING }), + ]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toEqual([]); + }); + + it("does not count an exited session on a member", () => { + const sessions = [ + session({ + id: "a", + cwd: ACME, + boundWorkflowPath: LEASING, + status: "exited", + }), + ]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toEqual([]); + }); + + it("matches paths, not strings: a trailing separator is the same folder", () => { + const sessions = [ + session({ id: "a", cwd: ACME, boundWorkflowPath: `${LEASING}/` }), + ]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toHaveLength(1); + }); + + it("counts nothing for a group with no members", () => { + const sessions = [session({ id: "a", cwd: LEASING })]; + expect(liveSessionsOnAgents(sessions, [])).toEqual([]); + }); +}); + +describe("liveSessionsLabel", () => { + it("names one session in the singular", () => { + expect(liveSessionsLabel(1)).toBe("1 live session"); + }); + + it("names several in the plural", () => { + expect(liveSessionsLabel(4)).toBe("4 live sessions"); + }); +}); diff --git a/packages/harness/web/src/lib/project-live.ts b/packages/harness/web/src/lib/project-live.ts new file mode 100644 index 000000000..606a1127c --- /dev/null +++ b/packages/harness/web/src/lib/project-live.ts @@ -0,0 +1,88 @@ +/** + * Whether a project (or a group) is LIVE: one of its agents has a running + * session (SAP-3200, design-eng DECISIONS D37). + * + * The rail lists no sessions, and this does not change that. It is a DERIVED + * fact about a project, the same kind of fact the deploy glyph is about an + * agent, so that "is anything running in here" can be answered at a glance + * without the rail growing session rows it deliberately does not have. + * + * Pure, and free of React and of fixtures, for the reason `session-scope.ts` + * gives: a rule you can call with two arguments is a rule a test can pin, and + * the mark and its specs then read ONE definition rather than two that drift. + * + * Containment is `rootContains`, the app's one containment answer, applied to + * the two fields a session can be attached by. Nothing here re-implements it. + */ +import { samePath } from "./paths"; +import { rootContains, type ScopedSession } from "./session-scope"; + +/** Live is anything that has not exited: a session still starting is about to + * be running, and a mark that waits for the transition would blink off during + * exactly the moment the user just asked for. */ +const isLive = (session: ScopedSession): boolean => session.status !== "exited"; + +/** + * The live sessions a PROJECT holds: rooted inside it, or bound to an agent + * inside it. + * + * `liveSessionsForProject` (session-scope.ts) answers the tab strip's question + * with the containment clause alone, and since SAP-2927 every session boots at + * its project root, so in practice the two agree on every session the app + * creates. The binding clause is here because the mark answers a slightly + * different question ("does this project have anything running"), and a + * session bound to an agent in the project is running in the project whatever + * its cwd says. It can only ever ADD a session that genuinely belongs here, so + * the mark cannot claim a project is live on the strength of someone else's + * session. + */ +export function liveSessionsInProject( + sessions: readonly S[], + root: string, +): S[] { + return sessions.filter( + (session) => + isLive(session) && + (rootContains(root, session.cwd) || + (session.boundWorkflowPath != null && + rootContains(root, session.boundWorkflowPath))), + ); +} + +/** + * The live sessions on any of a set of agents: a GROUP's members. + * + * A group is a label over agents and has no directory behind it, so it cannot + * be asked the containment question a project is asked. Membership is the same + * rule `liveSessionsForFocus` applies to one agent, over several: bound to a + * member, or unbound and sitting in a member's own folder. + * + * `samePath`, not `===`, for the reason that function gives: the server + * `path.resolve()`s what it stores while the rail holds whatever the registry + * reported, so a trailing separator or a `C:/…` spelling would hide a session + * that is plainly running. + */ +export function liveSessionsOnAgents( + sessions: readonly S[], + agentPaths: readonly string[], +): S[] { + return sessions.filter((session) => { + if (!isLive(session)) return false; + const bound = session.boundWorkflowPath ?? null; + return bound != null + ? agentPaths.some((path) => samePath(path, bound)) + : agentPaths.some((path) => samePath(path, session.cwd)); + }); +} + +/** + * The mark's words. + * + * A bare dot is mute: it is the only thing on the row with no label, and a + * screen reader reaching it would say nothing at all. The count goes in both + * the tooltip and the accessible name, so the mark says what it means to + * everyone who meets it. + */ +export function liveSessionsLabel(count: number): string { + return count === 1 ? "1 live session" : `${count} live sessions`; +} diff --git a/packages/harness/web/src/styles.css b/packages/harness/web/src/styles.css index 0657aa7f4..28f0da936 100644 --- a/packages/harness/web/src/styles.css +++ b/packages/harness/web/src/styles.css @@ -2758,6 +2758,14 @@ button.rail-footer-card:hover { background: var(--text-faint); } +/* A project or group header's live mark (SAP-3200): the dot recipe above in + its running state, standing at the trailing edge of the row ahead of the + actions that fade in on hover, so a hovered row never shifts it. Geometry + only; the colour is the running state's and is not restated here. */ +.project-live-dot { + margin-right: var(--sp1); +} + .session-dot[data-status="running"] { background: var(--green); } From 329ba9ffaf662b8777b30bd059f7c9e25ce1887d Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 16:58:06 -0700 Subject: [PATCH 2/3] fix(harness): the live mark reads the one project-membership rule [SAP-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) Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM --- .changeset/project-live-indicator.md | 6 + .../web/e2e/rail-live-indicator.spec.ts | 76 ++++++++++--- .../harness/web/src/components/GroupRow.tsx | 8 +- .../web/src/components/WorkflowsRail.tsx | 10 +- packages/harness/web/src/lib/api.ts | 7 +- packages/harness/web/src/lib/mock-data.ts | 41 +++++++ .../harness/web/src/lib/project-live.test.ts | 105 +++++------------- packages/harness/web/src/lib/project-live.ts | 72 ++++++------ 8 files changed, 189 insertions(+), 136 deletions(-) create mode 100644 .changeset/project-live-indicator.md diff --git a/.changeset/project-live-indicator.md b/.changeset/project-live-indicator.md new file mode 100644 index 000000000..68cb5f239 --- /dev/null +++ b/.changeset/project-live-indicator.md @@ -0,0 +1,6 @@ +--- +"@sapiom/harness": minor +"@sapiom/harness-desktop": patch +--- + +A project row in the rail now shows a green dot when one or more of its agents has a running session, so which projects are active reads at a glance without opening them. The dot names its own count, "1 live session" or "3 live sessions", in its tooltip and to a screen reader, and it disappears when the last of those sessions ends. Group headers carry the same dot for the agents filed under them. Agent rows are unchanged, and the rail still lists no sessions. diff --git a/packages/harness/web/e2e/rail-live-indicator.spec.ts b/packages/harness/web/e2e/rail-live-indicator.spec.ts index 028bc02a5..482edec2e 100644 --- a/packages/harness/web/e2e/rail-live-indicator.spec.ts +++ b/packages/harness/web/e2e/rail-live-indicator.spec.ts @@ -51,10 +51,27 @@ test("the mark STANDS: it is on screen without hovering the row", async ({ // The `+` and the ⋮ beside it are hover-revealed (`.workspace-row-action`, // opacity 0 at rest). The mark answers a question asked at a glance, so it // must not be. - const opacity = await page + // + // The element's own opacity is not enough on its own: it would still read 1 + // inside a faded ancestor. So walk the chain to the row and multiply, which + // catches the mark being nested into a hover-revealed cluster as well as the + // mark being given `opacity: 0` directly. + const effective = await page .getByTestId("project-live-acme-app") - .evaluate((element) => getComputedStyle(element).opacity); - expect(Number(opacity)).toBe(1); + .evaluate((element) => { + let node: HTMLElement | null = element as HTMLElement; + let opacity = 1; + let insideHoverAction = false; + while (node && !node.classList.contains("workspace-row")) { + opacity *= Number(getComputedStyle(node).opacity); + if (node.classList.contains("workspace-row-action")) { + insideHoverAction = true; + } + node = node.parentElement; + } + return { opacity, insideHoverAction }; + }); + expect(effective).toEqual({ opacity: 1, insideHoverAction: false }); }); test("a project whose only session has exited carries no mark", async ({ @@ -106,25 +123,28 @@ test("agent rows are untouched: the mark is a fact about a project", async ({ * `liveSessionsOnAgents` pins: bound to a member, or unbound in a member's own * folder. * - * Only the negative is reachable from the fixtures. Every live mock session is - * acme-app's, and acme-app has one agent, so it renders no group sections at - * all; the deep fixture's projects have the groups and none of the sessions. - * What this guards is the failure a positive could not catch anyway (a mark - * that renders unconditionally), and the membership rule itself is pinned in - * `project-live.test.ts`, both directions. + * The positive needs a session BOUND to a group member, which no fixture had: + * every live mock session belongs to `acme-app`, which has one agent and so + * renders no group sections, while `deep` has the groups and no sessions. So + * `?mockBoundSession=1` seeds exactly one, bound to `gateway`, behind its own + * parameter, invisible to every other spec that counts sessions on `deep`. */ +const openGroupAxis = async (page: Page): Promise => { + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + // The create row appears only once the stored arrangement AND the launch + // edges have loaded, so it is the honest "the groups are drawn" signal. + await expect(page.getByTestId("group-create-polsia")).toBeVisible(); +}; + test.describe("the Group axis", () => { test("group headers carry no mark when nothing under them is live", async ({ page, }) => { await page.goto("/?mockFixtures=deep"); await expect(page.locator(".rail-workflows")).toBeVisible(); - await page.getByTestId("history-trigger").click(); - await page.getByTestId("filing-group-by").selectOption("group"); - await page.keyboard.press("Escape"); - // The create row appears only once the stored arrangement AND the launch - // edges have loaded, so it is the honest "the groups are drawn" signal. - await expect(page.getByTestId("group-create-polsia")).toBeVisible(); + await openGroupAxis(page); await expect( page.locator('[data-testid^="group-row-"]').first(), @@ -138,4 +158,30 @@ test.describe("the Group axis", () => { "2 live sessions", ); }); + + test("the header of the group holding the bound session carries the mark, alone", async ({ + page, + }) => { + await page.goto("/?mockFixtures=deep&mockBoundSession=1"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openGroupAxis(page); + + const gateway = page.getByTestId("group-live-gateway"); + await expect(gateway).toBeVisible(); + await expect(gateway).toHaveAttribute("data-status", "running"); + await expect(gateway).toHaveAttribute("aria-label", "1 live session"); + + // Its neighbours in the same project are unaffected: one session belongs to + // one group, and `mailer` holds none of it. + await expect(page.getByTestId("group-live-mailer")).toHaveCount(0); + await expect(page.getByTestId("group-live-Ungrouped")).toHaveCount(0); + await expect(page.locator('[data-testid^="group-live-"]')).toHaveCount(1); + + // And the project row it sits under counts the same session, by + // containment: the session is rooted at the polsia root. + await expect(page.getByTestId("project-live-polsia")).toHaveAttribute( + "aria-label", + "1 live session", + ); + }); }); diff --git a/packages/harness/web/src/components/GroupRow.tsx b/packages/harness/web/src/components/GroupRow.tsx index 0a759c9c9..93c81daa7 100644 --- a/packages/harness/web/src/components/GroupRow.tsx +++ b/packages/harness/web/src/components/GroupRow.tsx @@ -41,7 +41,13 @@ export interface GroupRowProps { * mark (SAP-3200). A group header is a header like the project row is, and it * answers the same at-a-glance question about the agents under it. Counted by * the SECTION rather than here: the row has no sessions of its own, and the - * membership rule belongs with the model. */ + * membership rule belongs with the model. + * + * INTENDED, and it reads like a bug at first: a session started at a project + * root is unbound until its agent is known, so for that window the PROJECT + * row carries the mark and no group under it does. A group is a label over + * agents, and an unbound session at the root is working on none of them yet; + * crediting one would print a guess as a fact. See `liveSessionsOnAgents`. */ liveCount?: number; /** True while this row is the drop target. Owned by the section, not the row: * rows that each track their own hover disagree mid-drag. */ diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index 9dc9dceb2..498181128 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -83,7 +83,7 @@ import { } from "../lib/project-membership"; import type { RailAxis, RailSort } from "../lib/project-tree"; import { samePath } from "../lib/paths"; -import { liveSessionsInProject } from "../lib/project-live"; +import { liveSessionsForProject } from "../lib/session-scope"; import type { PendingWorkspace } from "../lib/use-harness-state"; import { SAPIOM_AGENTS_URL } from "../lib/urls"; import { getTheme, subscribeTheme, toggleTheme } from "../lib/theme"; @@ -1382,11 +1382,13 @@ export function WorkflowsRail({ trailing={ <> {/* LIVE, at a glance (SAP-3200, D37): something is - running inside this project. Derived from the - sessions the rail already holds, never a row. */} + running inside this project. Derived, never a row, and + derived by the SAME function the session tab strip + renders from, so the dot and the tabs cannot disagree + about which project a session is in. */} diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index cdb06d1a4..65673d4f7 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -74,7 +74,9 @@ import { basenameOf, isWithinDir, parentOf, samePath } from "./paths"; import type { CanvasGraph, CanvasGraphNode } from "./canvas-graph"; import { + isBoundSessionFixture, MOCK_ACCOUNT_PLAN, + MOCK_BOUND_SESSION, MOCK_FS_TREE, MOCK_HARNESSES, MOCK_HISTORY, @@ -2041,7 +2043,10 @@ export class MockApi implements HarnessApi { private sessionsStore: HarnessSession[] = this.fresh || this.noLiveSessions ? [] - : MOCK_SESSIONS.map((session) => ({ + : [ + ...MOCK_SESSIONS, + ...(isBoundSessionFixture() ? [MOCK_BOUND_SESSION] : []), + ].map((session) => ({ ...session, ...(this.restoredSessions ? { status: "exited" as const, ready: false } diff --git a/packages/harness/web/src/lib/mock-data.ts b/packages/harness/web/src/lib/mock-data.ts index 05b938a45..73de56e91 100644 --- a/packages/harness/web/src/lib/mock-data.ts +++ b/packages/harness/web/src/lib/mock-data.ts @@ -1246,6 +1246,27 @@ export const MOCK_FLOOD_WORKFLOWS: WorkflowInfo[] = [ ), ]; +/** + * One live session BOUND to `gateway`, the head of the deep fixture's biggest + * derived group. Seeded only behind `?mockBoundSession=1` (see + * `isBoundSessionFixture`), so the `gateway` group header carries a live mark + * and its neighbours do not. + */ +export const MOCK_BOUND_SESSION: HarnessSession = { + id: "sess-gateway", + agentSessionId: null, + boundWorkflowPath: `${DEEP_ROOT}/services/gateway`, + harness: "claude-code", + // At the PROJECT root, like every session since SAP-2927. The binding, not + // the cwd, is what puts it in a group. + cwd: DEEP_ROOT, + title: "polsia", + status: "running", + createdAt: minutesAgo(2), + lastActiveAt: minutesAgo(2), + ready: true, +}; + /** Roots the deep fixture opens, appended to `recentDirs`. */ export const MOCK_DEEP_ROOTS: string[] = [ DEEP_ROOT, @@ -1264,6 +1285,26 @@ export function isDeepRailFixture(): boolean { ); } +/** + * Whether the BOUND-SESSION fixture is seeded (`?mockFixtures=deep&mockBoundSession=1`). + * + * Opt-in, and separate from `deep`, for one reason: every spec that already + * runs against `deep` counts sessions, tabs and rows, and a session added to + * that fixture unconditionally would move numbers in files this has nothing to + * do with. Behind its own parameter it is invisible until a spec asks for it. + * + * It exists because the group-axis live mark (SAP-3200) needs a session BOUND + * to a group member, and no other fixture has one: the default fixture's live + * sessions belong to `acme-app`, which has a single agent and so renders no + * group sections at all, while `deep` has the groups and no live sessions. + */ +export function isBoundSessionFixture(): boolean { + if (typeof window === "undefined") return false; + return ( + new URLSearchParams(window.location.search).get("mockBoundSession") === "1" + ); +} + export const MOCK_WORKFLOWS: WorkflowInfo[] = [ { name: "leasing", diff --git a/packages/harness/web/src/lib/project-live.test.ts b/packages/harness/web/src/lib/project-live.test.ts index cca5ecc65..bddc32674 100644 --- a/packages/harness/web/src/lib/project-live.test.ts +++ b/packages/harness/web/src/lib/project-live.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it } from "vitest"; -import { - liveSessionsInProject, - liveSessionsLabel, - liveSessionsOnAgents, -} from "./project-live"; +import { liveSessionsLabel, liveSessionsOnAgents } from "./project-live"; import type { ScopedSession } from "./session-scope"; const ACME = "/Users/demo/acme-app"; const LEASING = `${ACME}/leasing`; const PRICING = `${ACME}/pricing`; -const RFQ = "/Users/demo/rfq-agent"; /** A session with only the fields these rules read. `createdAt` is required by * `ScopedSession` and is never consulted here: the mark counts, it does not @@ -25,75 +20,6 @@ function session(over: Partial & { id: string }): ScopedSession { }; } -describe("liveSessionsInProject", () => { - it("counts a session rooted at the project root", () => { - const sessions = [session({ id: "a", cwd: ACME })]; - expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); - }); - - it("counts a session rooted in a subdirectory of the project", () => { - const sessions = [session({ id: "a", cwd: LEASING })]; - expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); - }); - - it("counts a session bound to an agent in the project from outside it", () => { - const sessions = [ - session({ - id: "a", - cwd: "/Users/demo/elsewhere", - boundWorkflowPath: LEASING, - }), - ]; - expect(liveSessionsInProject(sessions, ACME)).toHaveLength(1); - }); - - it("counts every live session, so the mark can say how many", () => { - const sessions = [ - session({ id: "a", cwd: ACME }), - session({ id: "b", cwd: ACME, status: "starting" }), - session({ id: "c", cwd: LEASING }), - ]; - expect(liveSessionsInProject(sessions, ACME)).toHaveLength(3); - }); - - it("does not count an exited session, so the mark goes with the last one", () => { - const sessions = [session({ id: "a", cwd: ACME, status: "exited" })]; - expect(liveSessionsInProject(sessions, ACME)).toEqual([]); - }); - - it("drops the mark once the last live session exits, and not before", () => { - const both = [ - session({ id: "a", cwd: ACME }), - session({ id: "b", cwd: LEASING }), - ]; - expect(liveSessionsInProject(both, ACME)).toHaveLength(2); - - const one = [ - both[0]!, - session({ id: "b", cwd: LEASING, status: "exited" }), - ]; - expect(liveSessionsInProject(one, ACME)).toHaveLength(1); - - const none = one.map((entry) => ({ ...entry, status: "exited" as const })); - expect(liveSessionsInProject(none, ACME)).toEqual([]); - }); - - it("does not count another project's session", () => { - const sessions = [session({ id: "a", cwd: RFQ })]; - expect(liveSessionsInProject(sessions, ACME)).toEqual([]); - }); - - it("refuses a bare string prefix: `acme-app-old` is not inside `acme-app`", () => { - const sessions = [session({ id: "a", cwd: `${ACME}-old` })]; - expect(liveSessionsInProject(sessions, ACME)).toEqual([]); - }); - - it("counts nothing for an empty root, which prefixes every path", () => { - const sessions = [session({ id: "a", cwd: ACME })]; - expect(liveSessionsInProject(sessions, "")).toEqual([]); - }); -}); - describe("liveSessionsOnAgents", () => { it("counts a session bound to a member", () => { const sessions = [ @@ -140,6 +66,35 @@ describe("liveSessionsOnAgents", () => { expect(liveSessionsOnAgents(sessions, [LEASING])).toHaveLength(1); }); + it("drops the mark once the last live member session exits, and not before", () => { + const both = [ + session({ id: "a", cwd: ACME, boundWorkflowPath: LEASING }), + session({ id: "b", cwd: PRICING }), + ]; + expect(liveSessionsOnAgents(both, [LEASING, PRICING])).toHaveLength(2); + + const one = [ + both[0]!, + session({ id: "b", cwd: PRICING, status: "exited" }), + ]; + expect(liveSessionsOnAgents(one, [LEASING, PRICING])).toHaveLength(1); + + const none = one.map((entry) => ({ ...entry, status: "exited" as const })); + expect(liveSessionsOnAgents(none, [LEASING, PRICING])).toEqual([]); + }); + + it("counts a starting session, which is about to be running", () => { + const sessions = [ + session({ + id: "a", + cwd: ACME, + boundWorkflowPath: LEASING, + status: "starting", + }), + ]; + expect(liveSessionsOnAgents(sessions, [LEASING])).toHaveLength(1); + }); + it("counts nothing for a group with no members", () => { const sessions = [session({ id: "a", cwd: LEASING })]; expect(liveSessionsOnAgents(sessions, [])).toEqual([]); diff --git a/packages/harness/web/src/lib/project-live.ts b/packages/harness/web/src/lib/project-live.ts index 606a1127c..e411c90c0 100644 --- a/packages/harness/web/src/lib/project-live.ts +++ b/packages/harness/web/src/lib/project-live.ts @@ -1,54 +1,38 @@ /** - * Whether a project (or a group) is LIVE: one of its agents has a running - * session (SAP-3200, design-eng DECISIONS D37). + * Whether a GROUP is live: one of its member agents has a running session + * (SAP-3200, design-eng DECISIONS D37). * - * The rail lists no sessions, and this does not change that. It is a DERIVED - * fact about a project, the same kind of fact the deploy glyph is about an - * agent, so that "is anything running in here" can be answered at a glance - * without the rail growing session rows it deliberately does not have. + * The rail lists no sessions, and this does not change that. A header's mark is + * a DERIVED fact about the agents under it, the same kind of fact the deploy + * glyph is about one agent, so that "is anything running in here" can be + * answered at a glance without the rail growing session rows it deliberately + * does not have. * - * Pure, and free of React and of fixtures, for the reason `session-scope.ts` - * gives: a rule you can call with two arguments is a rule a test can pin, and - * the mark and its specs then read ONE definition rather than two that drift. + * THE PROJECT SIDE OF THE MARK IS NOT HERE. A project's live sessions are + * `liveSessionsForProject` (session-scope.ts), the same function the session tab + * strip renders from, and the mark calls it rather than defining membership a + * second time. An earlier draft of this module added a binding clause on top of + * that containment, which would have let one session mark two projects at once + * after `POST /api/agents/move` moved an agent out from under a running session, + * while the strip on the second project stayed empty. That is precisely the + * disagreement `session-scope.ts` says it exists to prevent, and one function + * answering the question is the only way to keep it prevented. + * + * What remains here is the part 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. * - * Containment is `rootContains`, the app's one containment answer, applied to - * the two fields a session can be attached by. Nothing here re-implements it. + * Pure, and free of React and of fixtures, for the reason `session-scope.ts` + * gives: a rule you can call with two arguments is a rule a test can pin. */ import { samePath } from "./paths"; -import { rootContains, type ScopedSession } from "./session-scope"; +import type { ScopedSession } from "./session-scope"; /** Live is anything that has not exited: a session still starting is about to * be running, and a mark that waits for the transition would blink off during - * exactly the moment the user just asked for. */ + * exactly the moment the user just asked about. */ const isLive = (session: ScopedSession): boolean => session.status !== "exited"; -/** - * The live sessions a PROJECT holds: rooted inside it, or bound to an agent - * inside it. - * - * `liveSessionsForProject` (session-scope.ts) answers the tab strip's question - * with the containment clause alone, and since SAP-2927 every session boots at - * its project root, so in practice the two agree on every session the app - * creates. The binding clause is here because the mark answers a slightly - * different question ("does this project have anything running"), and a - * session bound to an agent in the project is running in the project whatever - * its cwd says. It can only ever ADD a session that genuinely belongs here, so - * the mark cannot claim a project is live on the strength of someone else's - * session. - */ -export function liveSessionsInProject( - sessions: readonly S[], - root: string, -): S[] { - return sessions.filter( - (session) => - isLive(session) && - (rootContains(root, session.cwd) || - (session.boundWorkflowPath != null && - rootContains(root, session.boundWorkflowPath))), - ); -} - /** * The live sessions on any of a set of agents: a GROUP's members. * @@ -57,6 +41,14 @@ export function liveSessionsInProject( * rule `liveSessionsForFocus` applies to one agent, over several: bound to a * member, or unbound and sitting in a member's own folder. * + * A CONSEQUENCE WORTH STATING, because it looks like a bug and is not: a + * session created at a project root is unbound until the agent it works on is + * known (`session-manager.ts` binds later), and a project root is nobody's + * member folder. So a fresh session marks the PROJECT row and no group header + * under it, and the group headers light as binding arrives. That is the honest + * reading: until a session is bound, no group can claim it, and picking one + * would be a guess printed as a fact. + * * `samePath`, not `===`, for the reason that function gives: the server * `path.resolve()`s what it stores while the rail holds whatever the registry * reported, so a trailing separator or a `C:/…` spelling would hide a session From fe263736b2a51822bb0d4c43e6024a2279d75425 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 17:02:33 -0700 Subject: [PATCH 3/3] docs(harness): name the project/group membership asymmetry [SAP-3200] 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) Claude-Session: https://claude.ai/code/session_019o9uM8fivJobCpT5zd4ChM --- packages/harness/web/src/lib/project-live.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/harness/web/src/lib/project-live.ts b/packages/harness/web/src/lib/project-live.ts index e411c90c0..299650d42 100644 --- a/packages/harness/web/src/lib/project-live.ts +++ b/packages/harness/web/src/lib/project-live.ts @@ -49,6 +49,23 @@ const isLive = (session: ScopedSession): boolean => session.status !== "exited"; * reading: until a session is bound, no group can claim it, and picking one * would be a guess printed as a fact. * + * THE ASYMMETRY RUNS THE OTHER WAY TOO, and is deliberate. A project row counts + * by containment alone (`liveSessionsForProject`), while a group counts by + * binding, so a session bound to an agent under a project but rooted OUTSIDE it + * lights the group header and leaves the project row dark: a child marked live + * inside a parent that is not. `POST /api/agents/move` is the way to produce it, + * by moving an agent out from under a running session. + * + * It is left standing rather than fixed, because both halves are already right + * on their own terms and the alternative is worse. A group is a label over + * agents with nothing on disk behind it, so binding is the only membership it + * has; this is the same rule `liveSessionsForFocus` applies to one agent, and + * that agent's own tab strip lists the very same session. Intersecting the + * group rule with the project's containment would make a group mean something + * different from the agent rows inside it, and adding containment to the + * project rule is the second membership answer SAP-3200's first review round + * removed. The mark is briefly odd; the rules stay singular. + * * `samePath`, not `===`, for the reason that function gives: the server * `path.resolve()`s what it stores while the rail holds whatever the registry * reported, so a trailing separator or a `C:/…` spelling would hide a session