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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/project-live-indicator.md
Original file line number Diff line number Diff line change
@@ -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.
187 changes: 187 additions & 0 deletions packages/harness/web/e2e/rail-live-indicator.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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",
);
});
});
34 changes: 33 additions & 1 deletion packages/harness/web/src/components/GroupRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +91,7 @@ export function GroupRow({
collapsed,
onToggleCollapsed,
isUngrouped = false,
liveCount = 0,
isDropTarget = false,
onRename,
onDelete,
Expand Down Expand Up @@ -214,6 +229,12 @@ export function GroupRow({
</button>
)}

{/* 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. */}
<LiveMark count={liveCount} testId={`group-live-${label}`} />

{/* Hidden while editing: the input owns the row's width, and clicking an
action would blur-commit and act in one gesture. */}
{canRename && !editing && (
Expand Down Expand Up @@ -345,6 +366,7 @@ export function GroupSections({
onToggleCollapsed,
focusedAgentPath,
onFocusAgent,
sessions,
onCreate,
onRename,
onDelete,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions packages/harness/web/src/components/ProjectTreeRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span
className="session-dot project-live-dot"
data-status="running"
data-testid={testId}
role="img"
aria-label={label}
data-tooltip={label}
/>
);
}

/**
* The nested rows inside a project: directories that actually branch, and the
* agents under them.
Expand Down
14 changes: 14 additions & 0 deletions packages/harness/web/src/components/WorkflowsRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { UpdateCard } from "./UpdateCard";
import { SettingsPopover } from "./SettingsPopover";
import { describeUpdateOutcome, getDesktopBridge } from "../lib/desktop";
import {
LiveMark,
ProjectRow,
ProjectTreeRows,
dirKey,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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. */}
<LiveMark
count={
liveSessionsForProject(sessions, project.root).length
}
testId={`project-live-${project.label}`}
/>
{creating && (
<span
className="workspace-row-spinner"
Expand Down Expand Up @@ -1560,6 +1573,7 @@ export function WorkflowsRail({
onToggleCollapsed={toggleCollapsed}
focusedAgentPath={focusedAgentPath}
onFocusAgent={focusProjectAgent}
sessions={sessions}
onCreate={() => {
const label = nextGroupLabel(groupNodes);
railGroups.edit(project.root, groupAgents, (state) =>
Expand Down
7 changes: 6 additions & 1 deletion packages/harness/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading