diff --git a/.changeset/project-live-indicator.md b/.changeset/project-live-indicator.md new file mode 100644 index 00000000..68cb5f23 --- /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 new file mode 100644 index 00000000..482edec2 --- /dev/null +++ b/packages/harness/web/e2e/rail-live-indicator.spec.ts @@ -0,0 +1,187 @@ +/** + * 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. + // + // 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) => { + 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 ({ + 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. + * + * 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 openGroupAxis(page); + + 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", + ); + }); + + 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 9964b6eb..93c81daa 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,18 @@ 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. + * + * 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. */ isDropTarget?: boolean; @@ -77,6 +91,7 @@ export function GroupRow({ collapsed, onToggleCollapsed, isUngrouped = false, + liveCount = 0, isDropTarget = false, onRename, onDelete, @@ -214,6 +229,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 +366,7 @@ export function GroupSections({ onToggleCollapsed, focusedAgentPath, onFocusAgent, + sessions, onCreate, onRename, onDelete, @@ -375,6 +397,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 +469,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 e7d18ad6..e6ad65e7 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 fcce99b6..49818112 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 { 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"; @@ -1379,6 +1381,17 @@ export function WorkflowsRail({ } trailing={ <> + {/* LIVE, at a glance (SAP-3200, D37): something is + 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. */} + {creating && ( { const label = nextGroupLabel(groupNodes); railGroups.edit(project.root, groupAgents, (state) => diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index cdb06d1a..65673d4f 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 05b938a4..73de56e9 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 new file mode 100644 index 00000000..bddc3267 --- /dev/null +++ b/packages/harness/web/src/lib/project-live.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +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`; + +/** 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("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("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([]); + }); +}); + +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 00000000..299650d4 --- /dev/null +++ b/packages/harness/web/src/lib/project-live.ts @@ -0,0 +1,97 @@ +/** + * 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. 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. + * + * 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. + * + * 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 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 about. */ +const isLive = (session: ScopedSession): boolean => session.status !== "exited"; + +/** + * 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. + * + * 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. + * + * 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 + * 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 0657aa7f..28f0da93 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); }