From 42fcaccf759d4548af5ee9f06f93467582617069 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 9 Sep 2026 22:40:01 +0000 Subject: [PATCH 1/2] refactor(harness): remove legacy browser graph entry points (SAP-3090) --- .changeset/tall-maps-stay-durable.md | 5 + .../authority-retirement.md | 10 +- .../web/e2e/agent-map-authority.spec.ts | 9 +- .../web/e2e/agent-map-navigation.spec.ts | 2 +- packages/harness/web/e2e/group-axis.spec.ts | 22 - packages/harness/web/e2e/mobile.spec.ts | 11 +- .../harness/web/e2e/polsia-workspace.spec.ts | 146 ---- .../harness/web/e2e/project-altitude.spec.ts | 377 ++-------- packages/harness/web/e2e/project-axis.spec.ts | 30 +- .../web/e2e/project-map-groups.spec.ts | 395 ---------- packages/harness/web/e2e/smoke.spec.ts | 702 +----------------- packages/harness/web/src/App.tsx | 63 +- packages/harness/web/src/lib/api.ts | 9 +- .../harness/web/src/lib/use-harness-state.ts | 22 +- 14 files changed, 90 insertions(+), 1713 deletions(-) create mode 100644 .changeset/tall-maps-stay-durable.md delete mode 100644 packages/harness/web/e2e/polsia-workspace.spec.ts delete mode 100644 packages/harness/web/e2e/project-map-groups.spec.ts diff --git a/.changeset/tall-maps-stay-durable.md b/.changeset/tall-maps-stay-durable.md new file mode 100644 index 000000000..4140edd6f --- /dev/null +++ b/.changeset/tall-maps-stay-durable.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Remove the older-server project graph fallback. Project clicks now open the durable Agent Map or offer identity recovery without selecting or creating a conversation. Ordinary sessions retain their independent Canvas and Steps views. diff --git a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md index d6f8bccb4..ad294ae08 100644 --- a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md +++ b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md @@ -34,13 +34,19 @@ patch; the combined Harness release takes the higher minor bump. | Missing project identity, ambiguous scope, unsafe path or unavailable catalog | Show an unavailable Agent Map with a project-catalog retry. Keep ordinary sessions and per-agent Canvas reachable through explicit selection. Retry promotes only an exact server-issued workspace-key/project-ID association. | | Selected durable map disappears from the catalog | Keep that selected ID and offer catalog retry. Explicit agent/session selection still opens its ordinary Canvas/Steps. | | Current server receives old graph GET, refresh or navigation | Boot token remains required; authenticated requests receive 410 `legacy_graph_retired` before scope resolution, graph reads or watcher activation. No legacy graph owners are retained. | -| Older server omits `studioProjects` entirely | Temporary browser protocol compatibility only. `WorkspaceGraphView`, its loader and announcements remain for this older payload until SAP-3090. An empty list is not this protocol. | +| Older server omits `studioProjects` entirely | The browser offers the same identity recovery, preserving the selected project and conversation. There is no fallback renderer or implicit session handoff. Ordinary session tabs remain available. | -Current-server graph events are ignored before browser state, cache invalidation +Old graph events are ignored before browser state, cache invalidation or other refresh handlers run. Shared discovery, accepted source evidence, PackageInventory, rail launch edges, ordinary sessions and each agent's Canvas/Steps retain their own consumers; they are not legacy project topology. +SAP-3090 first disconnects `WorkspaceGraphView` from the shell and removes the +older-protocol session handoff. The following layer deletes its now-unreachable +browser modules. `agent-map-authority.spec.ts` includes omitted-catalog recovery +and exact keyboard tabs; `project-altitude.spec.ts` preserves pane geometry, +Steps restoration, independent disclosure and map/agent Back/Forward navigation. + ## Evidence required before browser deletion Attach results to SAP-3089 at the reviewed PR head. Do not treat the presence of diff --git a/packages/harness/web/e2e/agent-map-authority.spec.ts b/packages/harness/web/e2e/agent-map-authority.spec.ts index d307487f2..fc173e3c3 100644 --- a/packages/harness/web/e2e/agent-map-authority.spec.ts +++ b/packages/harness/web/e2e/agent-map-authority.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; type Probe = { - identity: "ready" | "missing-id" | "missing-project"; + identity: "ready" | "missing-id" | "missing-project" | "older-protocol"; reads: number; refreshes: number; navigation: number; @@ -91,6 +91,7 @@ MockApi.prototype.getState = async function() { const state = await stateRead.call(this); authority.projects = Object.fromEntries(state.studioProjects.map(p => [p.displayName, p.projectId])); if (identity === "missing-project") state.studioProjects = []; + if (identity === "older-protocol") delete state.studioProjects; if (identity === "missing-id") { state.workspaceScopes = state.workspaceScopes.map(({ projectId, ...scope }) => scope); } @@ -153,8 +154,8 @@ async function evidence(page: Page) { }); } -for (const identity of ["missing-id", "missing-project"] as const) { - test(`current project with ${identity} offers identity recovery without legacy fallback or session actions`, async ({ +for (const identity of ["missing-id", "missing-project", "older-protocol"] as const) { + test(`project with ${identity} offers identity recovery without legacy fallback or session actions`, async ({ page, }) => { await open(page, identity); @@ -219,7 +220,7 @@ test("recovering another project cannot restore the active conversation's projec expect(await evidence(page)).toEqual(before); }); -for (const identity of ["missing-id", "missing-project"] as const) { +for (const identity of ["missing-id", "missing-project", "older-protocol"] as const) { test(`keyboard session selection matches the visible unresolved project's tabs with ${identity}`, async ({ page, }) => { diff --git a/packages/harness/web/e2e/agent-map-navigation.spec.ts b/packages/harness/web/e2e/agent-map-navigation.spec.ts index afd7e321b..a799093fb 100644 --- a/packages/harness/web/e2e/agent-map-navigation.spec.ts +++ b/packages/harness/web/e2e/agent-map-navigation.spec.ts @@ -425,7 +425,7 @@ for (const boundWorkflowPath of ["/Users/demo/acme-app/leasing", null]) { test(`ending a session retains its own Canvas with binding ${boundWorkflowPath}`, async ({ page, }) => { - await page.goto("/?seed=0&mockStudioProjects=absent"); + await page.goto("/?seed=0&mockStudioProjects=present"); await expect(page.getByTestId("session-context")).toBeVisible(); await updateSession(page, "sess-boot", { boundWorkflowPath }); await page.getByTestId("session-tab-main-sess-boot").click(); diff --git a/packages/harness/web/e2e/group-axis.spec.ts b/packages/harness/web/e2e/group-axis.spec.ts index c35db1b1b..e28715bc0 100644 --- a/packages/harness/web/e2e/group-axis.spec.ts +++ b/packages/harness/web/e2e/group-axis.spec.ts @@ -132,28 +132,6 @@ async function dragAgent( } test.describe("derivation", () => { - test("project selection opens its graph without folding the Group axis", async ({ - page, - }) => { - const project = page.getByTestId(POLSIA); - await expect(project.getByTestId("group-agent-gateway")).toBeVisible(); - - await page.getByTestId("project-select-polsia").click(); - - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("system-graph-node-gateway")).toBeVisible(); - await expect(project.getByTestId("group-agent-gateway")).toBeVisible(); - await expect(page.getByTestId("project-row-polsia")).toHaveClass( - /is-selected/, - ); - - await page.getByTestId("system-graph-node-gateway").click(); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await expect( - project.getByTestId("group-agent-gateway").locator(".workflow-item"), - ).toHaveClass(/is-focused/); - }); - test("launch-connected agents form one group named for its HEAD, with Ungrouped last", async ({ page, }) => { diff --git a/packages/harness/web/e2e/mobile.spec.ts b/packages/harness/web/e2e/mobile.spec.ts index 2a68f615c..1a850db66 100644 --- a/packages/harness/web/e2e/mobile.spec.ts +++ b/packages/harness/web/e2e/mobile.spec.ts @@ -104,9 +104,10 @@ test("right pane opens as a bottom sheet and dismisses from its own collapse con await expect(pane).toHaveCount(1); }); -test("a workspace graph opens in the right sheet, over a workbench that is still there", async ({ +test("Agent Map opens in the right sheet, over a workbench that is still there", async ({ page, }) => { + await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1"); // It used to be a full-main destination that hid BOTH panes — the mode // switch SAP-2980 removes. On mobile the map is the right pane's map, so it // arrives in the sheet, and the conversation is one dismissal away rather @@ -114,7 +115,7 @@ test("a workspace graph opens in the right sheet, over a workbench that is still await page.getByTestId("rail-expand").click(); await page.getByTestId("project-select-acme-app").click(); - const graph = page.getByTestId("workspace-graph-view"); + const graph = page.getByTestId("agent-map-frame"); await expect(graph).toBeVisible(); await expect(page.locator(".rail-workflows")).toHaveCount(0); await expect(page.locator(".right-pane")).toBeVisible(); @@ -137,7 +138,7 @@ test("a workspace graph opens in the right sheet, over a workbench that is still expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBe(812); const controls = await page - .getByTestId("system-graph-controls") + .getByRole("group", { name: "Agent Map view controls" }) .boundingBox(); expect((controls?.x ?? -1) + (controls?.width ?? 0)).toBeLessThanOrEqual(375); expect((controls?.y ?? -1) + (controls?.height ?? 0)).toBeLessThanOrEqual( @@ -149,12 +150,12 @@ test("a workspace graph opens in the right sheet, over a workbench that is still }); expect(overflow).toBe(0); await page.screenshot({ - path: "web/e2e/screenshots/mobile-workspace-graph.png", + path: "web/e2e/screenshots/mobile-agent-map.png", }); // Drilling into a node cuts to board altitude; the sheet's own collapse // control then hands the whole screen back to the conversation. - await page.getByTestId("system-graph-node-leasing").click(); + await page.getByTestId("agent-map-node-node_00000000-0000-7000-8000-000000000101").click(); await expect(graph).toHaveCount(0); await page.getByTestId("right-collapse").click(); await expect(page.locator(".center-pane")).toBeVisible(); diff --git a/packages/harness/web/e2e/polsia-workspace.spec.ts b/packages/harness/web/e2e/polsia-workspace.spec.ts deleted file mode 100644 index d91d2a2f9..000000000 --- a/packages/harness/web/e2e/polsia-workspace.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -const POLSIA = "/Users/demo/polsia"; - -const graphRequestCount = (page: Page): Promise => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ); - -test.beforeEach(async ({ page }) => { - await page.goto("/?seed=0&mockFixtures=deep&mockNoLiveSessions=1"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); -}); - -test("a Polsia-style Project opens its complete graph without a session and reuses it", async ({ - page, -}) => { - const project = page.getByTestId("workspace-group-polsia"); - - // One realistic Project mixes a deployed agent with local-only agents. - await expect( - project.getByTestId( - `workflow-status-${POLSIA}/packages/harness/web/src/components/mailer`, - ), - ).toHaveAttribute("data-deployed", "true"); - await expect( - project.getByTestId(`workflow-status-${POLSIA}/scripts/tools/rollup`), - ).toHaveAttribute("data-deployed", "false"); - // `outreach` is the markerless syntax-discovered fixture: its public rail - // row has null cloud metadata, while the mock's private accepted evidence - // gives the graph its canonical source key (asserted below). - await expect( - project.getByTestId( - `workflow-status-${POLSIA}/backend/src/agents/outreach`, - ), - ).toHaveAttribute("data-deployed", "false"); - await expect(page.getByTestId("session-context")).not.toHaveAttribute( - "data-session-id", - /.+/, - ); - - // E3.2: a project with no live session is given one at its root, so the map - // never draws beside a workbench you cannot talk to. - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - /.+/, - ); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.locator(".system-graph-node")).toHaveCount(8); - for (const agentKey of [ - "ads", - "outreach", - "mailer", - "sender", - "gateway", - "ads-worker", - "queue", - "local:scripts/tools/rollup", - ]) { - await expect( - page.getByTestId(`system-graph-node-${agentKey}`), - ).toBeVisible(); - } - - // The fixture exercises fan-out, fan-in, a cycle, mixed call modes, and one - // disconnected inventory-only agent in one useful product-level graph. - await expect( - page.getByTestId("system-graph-edge-agent:outreach-agent:mailer"), - ).toContainText("blocking + async"); - await expect( - page - .getByTestId("system-graph-edge-agent:ads-agent:gateway") - .locator("path"), - ).toHaveClass(/is-blocking/); - await expect( - page - .getByTestId("system-graph-edge-agent:sender-agent:gateway") - .locator("path"), - ).toHaveClass(/is-async/); - await expect(page.locator('[data-testid^="system-graph-edge-"]')).toHaveCount( - 7, - ); - await expect( - page.locator('[data-testid^="system-graph-edge-"][data-testid*="rollup"]'), - ).toHaveCount(0); - await expect.poll(() => graphRequestCount(page)).toBe(1); - - // The disclosure is only a tree control; it never closes the selected graph. - await page.getByTestId("project-disclosure-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(project.getByTestId("workflow-rollup")).toHaveCount(0); - await page.getByTestId("project-disclosure-polsia").click(); - - // A local fallback identity remains a real agent door. Drilling into it cuts - // to board altitude and moves the rail selection with it (SAP-2980 E3.7/E3.8), - // and the conversation does NOT move: the session this project was given on - // selection already reaches every agent inside it, so `rollup` opens with a - // live terminal rather than the "No running session for rollup" state this - // used to assert. - await page - .getByTestId("system-graph-node-local:scripts/tools/rollup") - .click(); - await expect(project.getByTestId("workflow-rollup")).toHaveClass( - /is-focused/, - ); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - // Returning to the map uses the project name; there is no second right-pane - // navigation route competing with that contract. - await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(() => graphRequestCount(page)).toBe(1); -}); - -test("a failed Polsia graph can retry and the recovered snapshot is cached", async ({ - page, -}) => { - await page.evaluate(() => { - ( - window as unknown as { __MOCK_SYSTEM_GRAPH_FAIL_ONCE__?: boolean } - ).__MOCK_SYSTEM_GRAPH_FAIL_ONCE__ = true; - }); - - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("system-graph-error")).toBeVisible(); - await page.getByRole("button", { name: "Retry" }).click(); - await expect(page.getByTestId("system-graph-node-gateway")).toBeVisible(); - await expect.poll(() => graphRequestCount(page)).toBe(2); - - await page.getByTestId("system-graph-node-gateway").click(); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(() => graphRequestCount(page)).toBe(2); -}); diff --git a/packages/harness/web/e2e/project-altitude.spec.ts b/packages/harness/web/e2e/project-altitude.spec.ts index cb23e82dc..0afac17cc 100644 --- a/packages/harness/web/e2e/project-altitude.spec.ts +++ b/packages/harness/web/e2e/project-altitude.spec.ts @@ -1,27 +1,50 @@ -/** - * SAP-2980 — a project is somewhere you WORK, and the canvas is one surface at - * two altitudes. - * - * These are the criteria that cannot be proven by a unit test, because each one - * is a claim about what is (and is not) on screen after a click. The pure rules - * behind them are pinned in `lib/session-scope.test.ts` and - * `lib/canvas-altitude.test.ts`; a unit test on a pure function cannot show - * that `App.tsx` calls it, and the failure each rule prevents was visual. - * - * Every assertion below was mutation-tested: the handler it depends on was - * stubbed to a no-op and the test confirmed to fail. A count-only assertion - * passes when nothing happened, which is how "429 specs green, five defects in - * a minute of real use" happens. - */ -import { expect, test } from "@playwright/test"; -import type { Page } from "@playwright/test"; +/** Project-map shell and exact conversation navigation after topology retirement. */ +import { expect, test, type Page } from "@playwright/test"; const activeSessionId = (page: Page): Promise => page.getByTestId("session-context").getAttribute("data-session-id"); test.beforeEach(async ({ page }) => { - await page.goto("/?seed=0"); + await page.goto("/?seed=0&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await page.getByTestId("session-tab-main-sess-boot").click(); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); +}); + +test("map disclosure and Back/Forward preserve the exact conversation", async ({ page }) => { + await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1"); + await expect(page.getByTestId("session-context")).toBeVisible(); + const before = await activeSessionId(page); + const actions = () => page.evaluate(() => { + const probe = (window as unknown as { + __HARNESS_TEST__: Record; + }).__HARNESS_TEST__; + return ["createSessionCalls", "resumeSessionCalls", "bindWorkflowCalls", "injectInputCalls"] + .map((key) => probe[key]?.length ?? 0); + }); + const beforeActions = await actions(); + await page.getByTestId("project-select-acme-app").click(); + const map = page.getByTestId("agent-map-live"); + await expect(map).toBeVisible(); + const projectId = await map.getAttribute("data-project-id"); + await page.getByTestId("project-disclosure-acme-app").click(); + await expect(page.getByTestId("workflow-leasing")).toHaveCount(0); + await expect(map).toBeVisible(); + await page.getByTestId("project-disclosure-acme-app").click(); + await expect(page.getByTestId("workflow-leasing")).toBeVisible(); + await page.getByTestId("agent-map-node-node_00000000-0000-7000-8000-000000000101").click(); + await expect(map).toHaveCount(0); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); + const focused = await page.locator(".workflow-item.is-focused").getAttribute("data-testid"); + expect(focused).toBeTruthy(); + await page.getByTestId("session-nav-back").click(); + await expect(map).toHaveAttribute("data-project-id", projectId!); + await page.getByTestId("session-nav-forward").click(); + await expect(map).toHaveCount(0); + await expect(page.getByTestId(focused!)).toHaveClass(/is-focused/); + expect(await activeSessionId(page)).toBe(before); + expect(await actions()).toEqual(beforeActions); }); test("E3.1/E3.6 — the project's map fills the RIGHT pane; the conversation keeps the centre", async ({ @@ -31,7 +54,7 @@ test("E3.1/E3.6 — the project's map fills the RIGHT pane; the conversation kee const before = await activeSessionId(page); await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); // The centre pane is not merely present — it is live. `inert` was the // letterbox pattern this epic removes: the panes stayed mounted behind the @@ -45,7 +68,7 @@ test("E3.1/E3.6 — the project's map fills the RIGHT pane; the conversation kee // it is invisible to any assertion that only counts elements. const [centreBox, mapBox, appBox] = await Promise.all([ centre.boundingBox(), - page.getByTestId("workspace-graph-view").boundingBox(), + page.getByTestId("agent-map-frame").boundingBox(), page.locator(".app").boundingBox(), ]); expect(centreBox!.width).toBeGreaterThan(200); @@ -60,92 +83,11 @@ test("E3.1/E3.6 — the project's map fills the RIGHT pane; the conversation kee await expect(page.getByTestId("resize-handle-canvas")).toBeVisible(); }); -test("E3.3 — the tab strip is the PROJECT's sessions, including the ones bound to DIFFERENT agents", async ({ - page, -}) => { - /* The reason `liveSessionsForProject` exists, in the one fixture shape that - can tell the two rules apart. Since SAP-2927 every session boots at the - project root, so a real project's sessions are mostly BOUND to its agents — - and the agent rule (bound-to-this-path, or unbound-in-this-folder) claims - none of them for a project subject. Under it, this project's strip is - EMPTY while both of its conversations are running. - - Measured on the real 76-agent install: the very first session a project is - given is auto-bound to one of its agents, so the empty strip is not an - edge case — it is the default. */ - await page.goto("/?seed=0&mockFixtures=deep&mockNoLiveSessions=1"); - await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); - - await page.evaluate(() => { - const publish = ( - window as unknown as { - __HARNESS_TEST__?: { - publish?: (message: Record) => void; - }; - } - ).__HARNESS_TEST__?.publish; - const base = { - agentSessionId: null, - harness: "claude-code" as const, - cwd: "/Users/demo/polsia", - status: "running" as const, - ready: true, - }; - publish?.({ - type: "session.status", - session: { - ...base, - id: "sess-polsia-mailer", - boundWorkflowPath: - "/Users/demo/polsia/packages/harness/web/src/components/mailer", - title: "mailer", - createdAt: "2026-08-01T10:00:00.000Z", - lastActiveAt: "2026-08-01T10:00:00.000Z", - }, - }); - publish?.({ - type: "session.status", - session: { - ...base, - id: "sess-polsia-rollup", - boundWorkflowPath: "/Users/demo/polsia/scripts/tools/rollup", - title: "rollup", - createdAt: "2026-08-01T11:00:00.000Z", - lastActiveAt: "2026-08-01T11:00:00.000Z", - }, - }); - }); - - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - - // Both, in tab order (oldest first — the order Cmd/Ctrl+1..9 selects). - const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); - await expect(tabs).toHaveCount(2); - await expect( - page.getByTestId("session-tab-sess-polsia-mailer"), - ).toBeVisible(); - await expect( - page.getByTestId("session-tab-sess-polsia-rollup"), - ).toBeVisible(); - - // And they stay put when the selection drops to one agent's board: the tabs - // are the PROJECT's, so a sibling selection cannot re-key them (E3.4). - await page - .getByTestId("system-graph-node-local:scripts/tools/rollup") - .click(); - await expect(page.getByTestId("workflow-rollup")).toHaveClass(/is-focused/); - await expect(tabs).toHaveCount(2); - await expect( - page.getByTestId("session-tab-sess-polsia-mailer"), - ).toBeVisible(); -}); - test("E3.4 — selecting a sibling agent moves the right pane and NOTHING else", async ({ page, }) => { await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); const before = await activeSessionId(page); const tabsBefore = await page .getByRole("tablist", { name: "Sessions" }) @@ -159,7 +101,7 @@ test("E3.4 — selecting a sibling agent moves the right pane and NOTHING else", await expect(page.getByTestId("workflow-leasing")).toHaveClass(/is-focused/); // The right pane cut to board altitude... - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); await expect(page.getByTestId("right-panel-board")).toBeVisible(); // ...and the conversation did not move. Both halves matter: the session // POINTER staying put is invisible if the strip beneath it re-keys, which is @@ -173,120 +115,6 @@ test("E3.4 — selecting a sibling agent moves the right pane and NOTHING else", ).toEqual(tabsBefore); }); -test("a project selected while ANOTHER project's agent was focused shows the CHAT, not that agent's absence", async ({ - page, -}) => { - /* Measured on a real 76-agent install, not imagined. Selecting a project - while some other project's agent was still focused rendered "No running - session for " in the centre, beside the project's own map — - the conversation the map is meant to sit beside, hidden by a row nobody - had selected. The rail already stopped highlighting that agent, so the - centre and the rail disagreed about what was selected. */ - await page.getByTestId("workflow-rfq").locator(".workspace-row-main").click(); - await expect(page.getByTestId("workflow-rfq")).toHaveClass(/is-focused/); - await expect(page.getByTestId("open-agent-empty")).toContainText( - "No running session for rfq", - ); - - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - await expect(page.locator(".harness-terminal")).toBeVisible(); - // One selection: the project, and no agent alongside it — in the rail, and - // in the verbs. The lifecycle cluster acts on an AGENT, so at map altitude it - // is ABSENT rather than still aimed at whatever was selected before: - // "talking about the project, deploying rfq" is SAP-2931's trap restated one - // altitude up. - await expect(page.locator(".workflow-item.is-focused")).toHaveCount(0); - await expect(page.getByTestId("session-steps")).toHaveCount(0); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( - /is-selected/, - ); -}); - -test("E3.2 — a project whose ROOT is an agent says it is starting a session, not that the agent has none", async ({ - page, -}) => { - /* The one shape where the map-altitude guard on the agent empty-state is the - only thing standing between the user and a wrong sentence: `rfq-agent` is - both a project and an agent, so selecting its map makes the selection a - path that IS a registry agent. Ungoverned, the centre answers for the AGENT - — "No running session for rfq" — while the pane beside it is drawing the - PROJECT, and the session that will serve them both is already being - created. Measured with the guard removed: `open-agent-empty` is what - renders. */ - await page.goto("/?seed=0&mockNoLiveSessions=1"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - - await page.getByTestId("project-map-rfq-agent").click(); - await expect(page.getByTestId("project-session-starting")).toContainText( - "Starting a session in rfq-agent", - ); - await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - - // ...and it lands, in the project's own root. - await expect(page.getByTestId("agent-view")).toBeVisible(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - /^sess-mock-/, - ); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); -}); - -test("E3.5 — crossing to another project DOES hand the conversation over", async ({ - page, -}) => { - // The rule `sessionForFocus` already encoded, reused verbatim for a project - // subject rather than re-written beside it. A session rooted in acme-app - // cannot see an agent in onboarding-flow: its cwd, its CLAUDE.md and its - // skills all belong somewhere else. - await page.getByTestId("project-select-acme-app").click(); - const inAcme = await activeSessionId(page); - expect(inAcme).toBeTruthy(); - - // `rfq-agent`'s row IS its root agent, so its map lives on the row's own - // map control — the same `handleSelectWorkspace` door the label uses. - await page.getByTestId("project-map-rfq-agent").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.locator(".workspace-graph-title")).toHaveText("rfq-agent"); - await expect(page.getByTestId("session-context")).not.toHaveAttribute( - "data-session-id", - inAcme!, - ); -}); - -test("E3.7/E3.8 — drilling from a map node cuts down and only the project name returns to the map", async ({ - page, -}) => { - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - // Map altitude: the PROJECT is selected and no agent is. - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( - /is-selected/, - ); - await expect(page.locator(".workflow-item.is-focused")).toHaveCount(0); - await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); - - await page.getByTestId("system-graph-node-leasing").click(); - - // Board altitude: the AGENT is selected and the project row has let go. - await expect(page.getByTestId("workflow-leasing")).toHaveClass(/is-focused/); - await expect(page.getByTestId("project-row-acme-app")).not.toHaveClass( - /is-selected/, - ); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - - await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); - await page.getByTestId("project-select-acme-app").click(); - - // ...and back, with the rail agreeing again. - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( - /is-selected/, - ); - await expect(page.locator(".workflow-item.is-focused")).toHaveCount(0); -}); - test("E3.9/E3.10 — Steps says why it cannot answer for a project; Code is gone", async ({ page, }) => { @@ -330,7 +158,7 @@ test("E3.9/E3.10 — Steps says why it cannot answer for a project; Code is gone // The held Steps intent is not destroyed by the trip up — it is restored on // the way back down. - await page.getByTestId("system-graph-node-leasing").click(); + await page.getByTestId("workflow-leasing").locator(".workflow-item-trigger").click(); await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); await expect(page.getByTestId("right-tab-steps")).toHaveAttribute( "aria-selected", @@ -469,116 +297,3 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a "sess-polsia-2", ); }); - -test("Cmd/Ctrl+1..9 follows an exact ordinary session-tab activation", async ({ - page, -}) => { - /* The key handler closes over its inputs, so it needs every input the strip - has — the active session included. Clicking a tab now opens that exact - session's ordinary subject; with `activeSessionId` missing from the - effect's deps the listener kept the previous active session and resolved a - different list, healing only on the next session event. - - Overlapping roots is what makes those lists actually differ: `~/polsia` and - `~/polsia/services/workers` are both open, so the outer project's strip - lists the nested project's sessions (it genuinely contains them) while the - nested one lists only its own. */ - await page.goto("/?seed=0&mockFixtures=deep&mockNoLiveSessions=1"); - await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); - await page.evaluate(() => { - const publish = ( - window as unknown as { - __HARNESS_TEST__?: { - publish?: (message: Record) => void; - }; - } - ).__HARNESS_TEST__?.publish; - const base = { - agentSessionId: null, - harness: "claude-code" as const, - status: "running" as const, - ready: true, - boundWorkflowPath: null, - }; - publish?.({ - type: "session.status", - session: { - ...base, - id: "sess-outer", - cwd: "/Users/demo/polsia", - title: "polsia", - createdAt: "2026-08-01T10:00:00.000Z", - // Most recently worked in, so selecting the project lands here. - lastActiveAt: "2026-08-01T12:00:00.000Z", - }, - }); - publish?.({ - type: "session.status", - session: { - ...base, - id: "sess-nested", - cwd: "/Users/demo/polsia/services/workers", - title: "workers", - createdAt: "2026-08-01T11:00:00.000Z", - lastActiveAt: "2026-08-01T11:00:00.000Z", - }, - }); - }); - - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - "sess-outer", - ); - const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); - await expect(tabs).toHaveCount(2); - - // Click the nested project's tab. The exact session becomes active and its - // ordinary conversation/canvas replaces the project map. - await page.getByTestId("session-tab-sess-nested").click(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - "sess-nested", - ); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await expect(page.getByTestId("agent-view")).toBeVisible(); - // The strip re-keyed to the nested project, which holds only this session... - await expect(tabs).toHaveCount(1); - // ...so tab 1 is this session, and Cmd+1 must not reach past it into the - // outer project's list. - await page.keyboard.press("ControlOrMeta+1"); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - "sess-nested", - ); -}); - -test("the tab + and every tab click open an ordinary project session", async ({ - page, -}) => { - /* The map is selected only through the project label. Tabs are real session - handles, so choosing an existing tab or creating a sibling leaves the map - and opens that exact conversation/canvas. */ - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - - await page.getByTestId("session-tab-sess-leasing-2").click(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - "sess-leasing-2", - ); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await expect(page.getByTestId("agent-view")).toBeVisible(); - - await page.getByTestId("session-tab-new").click(); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - /^sess-mock-/, - ); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await expect(page.getByTestId("agent-view")).toBeVisible(); - await expect(page.getByTestId("project-row-acme-app")).not.toHaveClass( - /is-selected/, - ); -}); diff --git a/packages/harness/web/e2e/project-axis.spec.ts b/packages/harness/web/e2e/project-axis.spec.ts index 83ffb55f3..74a82c521 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -19,15 +19,8 @@ import type { Locator, Page } from "@playwright/test"; const ROOT = "/Users/demo/polsia"; /** `polsia/services/workers` opened as its own project. */ const NESTED_LABEL = "polsia/services/workers"; -const LEGACY_CONTAINMENT_TEST = - "parent and nested project graphs follow their visible containment"; - -test.beforeEach(async ({ page }, testInfo) => { - // A server without durable Studio project summaries remains on the legacy - // System Graph path. Every other deep fixture exercises the plan-first path. - const studioProjects = - testInfo.title === LEGACY_CONTAINMENT_TEST ? "absent" : "present"; - await page.goto(`/?mockFixtures=deep&mockStudioProjects=${studioProjects}`); +test.beforeEach(async ({ page }) => { + await page.goto("/?mockFixtures=deep&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); }); @@ -362,25 +355,6 @@ test.describe("durable Studio project navigation", () => { }); test.describe("multi-root", () => { - test(LEGACY_CONTAINMENT_TEST, async ({ page }) => { - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("system-graph-node-gateway")).toBeVisible(); - await expect(page.getByTestId("system-graph-node-queue")).toBeVisible(); - await expect( - page.getByTestId("system-graph-node-ads-worker"), - ).toBeVisible(); - - await page.getByTestId(`project-select-${NESTED_LABEL}`).click(); - await expect(page.getByTestId("system-graph-node-queue")).toBeVisible(); - await expect( - page.getByTestId("system-graph-node-ads-worker"), - ).toBeVisible(); - await expect(page.getByTestId("system-graph-isolated-label")).toHaveText( - "2 agents · no detected relationships", - ); - await expect(page.getByTestId("system-graph-node-gateway")).toHaveCount(0); - }); - test("an agent files under EVERY open root, and the nested project reads parent/child", async ({ page, }) => { diff --git a/packages/harness/web/e2e/project-map-groups.spec.ts b/packages/harness/web/e2e/project-map-groups.spec.ts deleted file mode 100644 index f6af3e26c..000000000 --- a/packages/harness/web/e2e/project-map-groups.spec.ts +++ /dev/null @@ -1,395 +0,0 @@ -/** - * SAP-2983 — the project map draws the groups the rail already has. - * - * The unit tests pin the two pure halves: `lib/system-graph-groups.test.ts` - * decides which node belongs to which container, `lib/system-graph-layout.test.ts` - * decides where the container goes. Neither can see the thing the ticket is - * about — that the map READS the rail's arrangement at all, and that the two - * surfaces agree on screen. A layout rule is not proven by a unit test, and a - * map drawing a second opinion of the same groups would pass every one of them. - * - * `?mockFixtures=deep` is the fixture with a real group axis: `MOCK_LAUNCH_EDGES` - * produces a three-member component (gateway), a two-member one (mailer), an - * edge to an agent this install lacks, and agents no edge reaches — plus - * `MOCK_POLSIA_GRAPH_EDGES`, whose connectors run BETWEEN those groups, which is - * the cross-container case. - * - * Every assertion here was mutation-tested; what each mutation was, and which - * assertion caught it, is on the PR. - */ -import { expect, test } from "@playwright/test"; -import type { Page } from "@playwright/test"; - -/** The container labels the map draws, in DOM order. */ -const mapContainers = (page: Page): Promise<(string | null)[]> => - page - .locator(".system-graph-group") - .evaluateAll((els) => - els.map((el) => el.getAttribute("data-group-label")), - ); - -/** The group rows the RAIL draws for polsia, in DOM order. */ -const railGroups = (page: Page): Promise => - page - .getByTestId("workspace-group-polsia") - .locator('[data-testid^="group-row-"] .tree-row-label') - .allInnerTexts(); - -/** Switch the rail to the Group axis. */ -async function selectGroupAxis(page: Page): Promise { - await page.getByTestId("history-trigger").click(); - await page.getByTestId("filing-group-by").selectOption("group"); - await page.keyboard.press("Escape"); -} - -/** …and wait for it to be EDITABLE: the create row appears only once the - * arrangement and the launch edges have both loaded. */ -async function openGroupAxis(page: Page): Promise { - await selectGroupAxis(page); - await expect(page.getByTestId("group-create-polsia")).toBeVisible(); -} - -test.beforeEach(async ({ page }) => { - await page.goto("/?mockFixtures=deep"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.locator(".system-graph-group").first()).toBeVisible(); -}); - -test("one labelled container per group, named exactly as the rail names it", async ({ - page, -}) => { - // The whole ticket. Two names for one group is the failure it prevents, and - // it is only visible with both surfaces on screen at once. - await expect(page.locator(".system-graph-group")).toHaveCount(3); - expect(await mapContainers(page)).toEqual(["gateway", "mailer", "Ungrouped"]); - - await openGroupAxis(page); - expect(await railGroups(page)).toEqual(await mapContainers(page)); -}); - -test("every card sits inside exactly one container, measured", async ({ - page, -}) => { - /* GEOMETRY, not counts. A container assertion that still passes when the - cards are drawn outside their boxes is worthless — and the boxes are - absolutely positioned siblings of the cards, not their DOM parents, so - "inside" is a claim only measurement can settle. */ - const placement = await page.evaluate(() => { - const box = (el: Element) => el.getBoundingClientRect(); - const groups = [...document.querySelectorAll(".system-graph-group")].map( - (el) => ({ label: el.getAttribute("data-group-label"), rect: box(el) }), - ); - const contains = (outer: DOMRect, inner: DOMRect) => - inner.left >= outer.left - 0.5 && - inner.top >= outer.top - 0.5 && - inner.right <= outer.right + 0.5 && - inner.bottom <= outer.bottom + 0.5; - return [...document.querySelectorAll(".system-graph-node")].map((el) => ({ - key: el.getAttribute("data-agent-key"), - in: groups - .filter((group) => contains(group.rect, box(el))) - .map((group) => group.label), - })); - }); - - expect(placement.length).toBeGreaterThan(0); - for (const card of placement) { - expect(card.in, `${card.key} is in exactly one container`).toHaveLength(1); - } - expect( - placement.filter((card) => card.in[0] === "gateway").map((c) => c.key).sort(), - ).toEqual(["ads-worker", "gateway", "queue"]); - expect( - placement.filter((card) => card.in[0] === "mailer").map((c) => c.key).sort(), - ).toEqual(["mailer", "sender"]); -}); - -test("containers do not overlap, and none is drawn outside the map's own bounds", async ({ - page, -}) => { - /* The subject box IS the layout's bounds, and the viewport's fit, its zoom - floor and its "did the stored view still show anything" check all read - them. A container drawn outside them is a container Fit cannot bring on - screen — and it is invisible to any assertion that only counts boxes, - which is how a row overflowing its rail by 17px shipped. */ - const measured = await page.evaluate(() => { - const rects = [...document.querySelectorAll(".system-graph-group")].map( - (el) => el.getBoundingClientRect(), - ); - const overlaps: string[] = []; - for (let left = 0; left < rects.length; left += 1) { - for (let right = left + 1; right < rects.length; right += 1) { - const a = rects[left]!; - const b = rects[right]!; - if ( - !( - a.right <= b.left || - b.right <= a.left || - a.bottom <= b.top || - b.bottom <= a.top - ) - ) { - overlaps.push(`${left}/${right}`); - } - } - } - const subject = document - .querySelector(".system-graph-subject")! - .getBoundingClientRect(); - const escaping = rects.filter( - (rect) => - rect.left < subject.left - 0.5 || - rect.top < subject.top - 0.5 || - rect.right > subject.right + 0.5 || - rect.bottom > subject.bottom + 0.5, - ).length; - return { count: rects.length, overlaps, escaping }; - }); - expect(measured.count).toBe(3); - expect(measured.overlaps).toEqual([]); - expect(measured.escaping).toBe(0); -}); - -test("a rail edit moves the map, with no reload", async ({ page }) => { - /* The rail and the map are two views of ONE arrangement. Two copies of the - state is exactly how they come to disagree: the file is the only shared - medium and nothing re-reads it, so an edit in the rail would leave the map - drawing what it read on mount. */ - await openGroupAxis(page); - expect(await mapContainers(page)).toContain("gateway"); - - await page.getByTestId("group-rename-gateway").click(); - await page.getByTestId("group-rename-input").fill("Ingest"); - await page.keyboard.press("Enter"); - - await expect - .poll(() => mapContainers(page)) - .toEqual(["Ingest", "mailer", "Ungrouped"]); - expect(await railGroups(page)).toEqual(await mapContainers(page)); -}); - -test("an edge whose ends the user split across groups is still drawn", async ({ - page, -}) => { - /* Pull one member out of a detected system and the connector between the - halves is still real. Dropping it would make the map claim two systems - never touch, which is the one thing an edge is for. */ - await openGroupAxis(page); - const before = await page - .locator('[data-testid^="system-graph-edge-"]') - .count(); - expect(before).toBeGreaterThan(0); - - // `queue` leaves every group — the drop-on-Ungrouped gesture, applied - // through the rail's own delete of the group that holds it. - await page.getByTestId("group-delete-mailer").click(); - await expect.poll(() => mapContainers(page)).toEqual(["gateway", "Ungrouped"]); - - const after = await page - .locator('[data-testid^="system-graph-edge-"]') - .count(); - expect(after).toBe(before); - await expect(page.locator(".system-graph-edge.is-cross-group")).not.toHaveCount( - 0, - ); -}); - -test("a container's name stays inside its own box at every zoom", async ({ - page, -}) => { - /* The name counter-scales against the view zoom, because at the map's own - arrival zoom it renders under 4px tall. A `transform: scale()` would do - that WITHOUT re-laying the line out — the label's on-screen width would - then stay constant while its container's shrank, so below ~70% a long group - name draws past its own box and over its neighbour, invisible to any check - that measures the boxes alone. It grows by font-size instead, so the - ellipsis still applies. - - Asserted at the far end of the clamp, where a transform would be worst. */ - await page.getByTestId("system-graph-zoom-out").click({ clickCount: 8 }); - - const measured = await page.evaluate(() => { - const groups = [...document.querySelectorAll(".system-graph-group")]; - const cards = [...document.querySelectorAll(".system-graph-node")].map( - (el) => el.getBoundingClientRect(), - ); - const hits = (a: DOMRect, b: DOMRect) => - !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); - return { - zoom: document.querySelector('[data-testid="system-graph-zoom-reset"]')! - .textContent, - labelHeight: Math.round( - groups[0]! - .querySelector(".system-graph-group-label")! - .getBoundingClientRect().height, - ), - escaping: groups.filter((group) => { - const outer = group.getBoundingClientRect(); - const label = group - .querySelector(".system-graph-group-label")! - .getBoundingClientRect(); - return ( - label.right > outer.right + 0.5 || label.bottom > outer.bottom + 0.5 - ); - }).length, - overCards: groups.filter((group) => { - const label = group - .querySelector(".system-graph-group-label")! - .getBoundingClientRect(); - return cards.some((card) => hits(label, card)); - }).length, - }; - }); - - // The fixture is only evidence while the label is actually being grown. - expect(Number.parseInt(measured.zoom!, 10)).toBeLessThan(70); - expect(measured.labelHeight).toBeGreaterThan(6); - expect(measured.escaping).toBe(0); - expect(measured.overCards).toBe(0); -}); - -test("a project whose arrangement cannot be READ still draws its groups", async ({ - page, -}) => { - /* The write gate and the draw gate are different questions. A read that fails - answers "nothing stored", which shows the DERIVED groups — the rail renders - those, because a group axis you cannot write to is still one you can look - at. Gating the map on the write gate instead would leave it flat and - unlabelled on a read-only checkout while the rail six inches away named - every system, which is the divergence this whole feature removes. */ - await page.goto("/?mockFixtures=deep"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await page.evaluate(() => { - (window as unknown as { __MOCK_RAIL_STATE_FAIL__?: boolean }).__MOCK_RAIL_STATE_FAIL__ = - true; - }); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - - await expect - .poll(() => mapContainers(page)) - .toEqual(["gateway", "mailer", "Ungrouped"]); - - // The rail draws the same rows — read-only, which is why the map cannot be - // gated on the same signal: `group-create-polsia` is deliberately absent. - await selectGroupAxis(page); - await expect - .poll(() => railGroups(page)) - .toEqual(["gateway", "mailer", "Ungrouped"]); - await expect(page.getByTestId("group-create-polsia")).toHaveCount(0); - expect(await railGroups(page)).toEqual(await mapContainers(page)); -}); - -test("a read that failed is tried again when the map is reopened", async ({ - page, -}) => { - /* The arrangement cache is shared by both surfaces so an edit in one moves - the other. What must NOT be shared is "have I asked for this yet": a - module-level request latch would mean one bad response is permanent, and - that this committable file is never re-read after a branch switch or a hand - edit either. So the latch stays per surface, and a remount re-reads. */ - await page.goto("/?mockFixtures=deep"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await page.evaluate(() => { - // A stored arrangement, so a successful read is distinguishable from a - // failed one by more than timing. - window.localStorage.setItem( - "sapiom-mock-studio-rail:/Users/demo/polsia", - JSON.stringify({ - version: 1, - renames: {}, - groups: [ - { - id: "g_custom", - label: "Custom", - members: [ - "/Users/demo/polsia/services/gateway", - "/Users/demo/polsia/services/workers/queue", - ], - }, - ], - }), - ); - (window as unknown as { __MOCK_RAIL_STATE_FAIL__?: boolean }).__MOCK_RAIL_STATE_FAIL__ = - true; - }); - - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - // The read failed, so the map falls back to the DERIVED groups. - await expect - .poll(() => mapContainers(page)) - .toEqual(["gateway", "mailer", "Ungrouped"]); - - await page.evaluate(() => { - (window as unknown as { __MOCK_RAIL_STATE_FAIL__?: boolean }).__MOCK_RAIL_STATE_FAIL__ = - false; - }); - - // Drill into an agent and back out: the map remounts, and the remount reads. - await page.locator(".system-graph-node.is-navigable").first().click(); - await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - - await expect.poll(() => mapContainers(page)).toEqual(["Custom", "Ungrouped"]); -}); - -test("opening the map cannot undo an edit the rail just made", async ({ - page, -}) => { - /* The arrangement is shared across surfaces but the request latch is per - surface, so opening the map issues its OWN read of the file — and that read - races any write still in flight from an edit a moment earlier. Served - first, it would replace the optimistic arrangement with the pre-edit file: - the rail visibly reverts, and the next edit then materializes from the - reverted state and persists it, losing the edit on disk as well as on - screen. A root this page has written to is never re-read. - - The write is held open here so the race is deterministic rather than a - matter of who happens to win. */ - await page.goto("/?mockFixtures=deep"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openGroupAxis(page); - - let releaseWrite = (): void => {}; - const writeHeld = new Promise((resolve) => { - releaseWrite = resolve; - }); - await page.exposeFunction("__holdRailWrite", () => writeHeld); - await page.evaluate(() => { - const store = window.localStorage; - const original = store.setItem.bind(store); - store.setItem = (key: string, value: string) => { - if (key.startsWith("sapiom-mock-studio-rail:")) { - void (window as unknown as { __holdRailWrite: () => Promise }) - .__holdRailWrite() - .then(() => original(key, value)); - return; - } - original(key, value); - }; - }); - - // The edit: optimistic in memory, its write parked. - await page.getByTestId("group-rename-gateway").click(); - await page.getByTestId("group-rename-input").fill("Ingest"); - await page.keyboard.press("Enter"); - await expect - .poll(() => railGroups(page)) - .toEqual(["Ingest", "mailer", "Ungrouped"]); - - // Opening the map is what issues the second read. - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect - .poll(() => mapContainers(page)) - .toEqual(["Ingest", "mailer", "Ungrouped"]); - - releaseWrite(); - // Still the edit, on both surfaces, after the write lands. - await expect.poll(() => railGroups(page)).toEqual(["Ingest", "mailer", "Ungrouped"]); - expect(await mapContainers(page)).toEqual(["Ingest", "mailer", "Ungrouped"]); -}); diff --git a/packages/harness/web/e2e/smoke.spec.ts b/packages/harness/web/e2e/smoke.spec.ts index 5643ebdf2..f093fed3f 100644 --- a/packages/harness/web/e2e/smoke.spec.ts +++ b/packages/harness/web/e2e/smoke.spec.ts @@ -354,15 +354,9 @@ test("inject macros are enabled once the boot session and a deployed workflow ar }); test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { - test("legacy-server rail is project > agent only, with no session rows", async ({ + test("Studio rail is project > agent only, with no session rows", async ({ page, }) => { - // This assertion preserves the scaffold affordance for older state - // payloads without a durable Studio project catalog. Current Studio - // projects intentionally expose only Agent Map planning. - await page.goto("/?seed=0&mockStudioProjects=absent"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - // Zone 1 is a pure explorer: project rows and agent rows, no sessions // anywhere in the tree. await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); @@ -397,17 +391,7 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { page.getByTestId("workflow-status-/Users/demo/rfq-agent"), ).toHaveCount(0); - // A graphable Project with live sessions but no agent still uses the - // Project destination. Its existing session stays globally reachable, - // while the trailing action can scaffold an agent into it. await expect(page.getByTestId("project-select-scratch")).toBeVisible(); - // The scaffold action moved into the row's ⋮ with every other project - // action (SAP-2982): a Sparkles glyph acting on an AGENT sat adjacent to - // an `×` acting on the PROJECT, same size, same reveal. - await openProjectMenu(page, "scratch"); - await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("workspace-focus-scratch")).toHaveCount(0); // Exactly one filled selection: the focused agent (leasing on load). await expect(page.getByTestId("workflow-leasing")).toHaveClass( @@ -445,690 +429,6 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { }); }); - test("a folder label FILLS the workbench: the conversation stays, the map draws beside it", async ({ - page, - }) => { - /* SAP-2980 E3.1/E3.6. This test used to assert the opposite — a full-main - destination with both panes hidden and inert — a pattern the graph - inherited from the template gallery by analogy. Browsing a gallery is a - detour; looking at your project's shape while talking to it is not, and - the centre pane vanishing on a project click was a mode switch where a - view change had been asked for. */ - const sessionContext = page.getByTestId("session-context"); - await expect(sessionContext).toHaveAttribute( - "data-session-id", - "sess-boot", - ); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("workflow-leasing")).toBeVisible(); - - // The right-pane arrangement is held across altitudes: leave it on Steps - // and prove the trip up to the project and back restores it. - await page.getByTestId("right-tab-steps").click(); - await expect(page.getByTestId("right-tab-steps")).toHaveAttribute( - "aria-selected", - "true", - ); - - // The label owns the project selection; it does not fold the folder or - // navigate the session. - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( - /is-selected/, - ); - await expect(page.getByTestId("workflow-leasing")).toBeVisible(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - // BOTH panes stay live. Neither is hidden, and neither is inert — the - // conversation is still typeable while its project's map is on screen. - await expect(page.locator(".center-pane")).toBeVisible(); - await expect(page.locator(".center-pane")).not.toHaveAttribute("inert", ""); - await expect(page.locator(".right-pane")).toBeVisible(); - await expect(page.locator(".right-pane")).not.toHaveAttribute("inert", ""); - await expect(page.locator(".harness-terminal")).toBeVisible(); - // The board's slot is HIDDEN, never unmounted — CanvasPane keeps its mount - // (probe state, reload key, task tracking) across the trip up. Its - // DOCUMENT follows the subject, and at map altitude the subject is a - // project, so no agent's board is drawn behind the map. - const board = page.getByTestId("right-panel-board"); - await expect(board).toHaveCount(1); - await expect(board).toBeHidden(); - await expect(page.locator(".canvas-iframe")).toHaveCount(0); - - // E3.9: Steps are an AGENT's steps, so the tab says why it cannot answer - // for a whole project rather than silently showing the last agent's list. - const steps = page.getByTestId("right-tab-steps"); - await expect(steps).toBeDisabled(); - await expect(steps).toHaveAttribute( - "data-tooltip", - "Steps belong to one agent — select an agent to see them", - ); - await expect(steps).toHaveAttribute("aria-selected", "false"); - await expect(page.getByTestId("right-tab-canvas")).toHaveAttribute( - "aria-selected", - "true", - ); - // E3.10: the Code tab is gone; its snippets live on the deploy surface. - await expect(page.getByTestId("right-tab-code")).toHaveCount(0); - - // The map fills the right pane's panel, not the whole shell. - const mapBounds = await page - .getByTestId("workspace-graph-view") - .boundingBox(); - const paneBounds = await page - .getByTestId("right-panel-canvas") - .boundingBox(); - expect(mapBounds).toEqual(paneBounds); - const appBounds = await page.locator(".app").boundingBox(); - expect(mapBounds?.width ?? 0).toBeLessThan(appBounds?.width ?? 0); - - await expect(page.getByTestId("system-graph-node-leasing")).toContainText( - "Leasing", - ); - await expect(page.getByTestId("system-graph-node-research")).toContainText( - "Research", - ); - await expect(page.getByTestId("system-graph-node-growth")).toContainText( - "Growth", - ); - // Inventory nodes do not need an incoming or outgoing relationship. - await expect(page.getByTestId("system-graph-node-reporting")).toContainText( - "Reporting", - ); - await expect( - page.getByTestId("system-graph-node-standalone"), - ).toContainText("Standalone"); - await expect(page.getByTestId("system-graph-isolated-label")).toHaveText( - "1 agent · no detected relationships", - ); - await expect( - page.getByTestId("system-graph-edge-agent:research-agent:growth"), - ).toContainText("blocking + async"); - await expect( - page - .getByTestId("system-graph-edge-agent:research-agent:growth") - .locator("path"), - ).toHaveClass(/is-combined/); - await expect( - page - .getByTestId("system-graph-edge-agent:research-agent:growth") - .locator("path"), - ).toHaveCSS("stroke-dasharray", "none"); - await expect( - page - .getByTestId("system-graph-edge-agent:research-agent:leasing") - .locator("path"), - ).toHaveClass(/is-async/); - await expect( - page - .getByTestId("system-graph-edge-agent:reporting-agent:leasing") - .locator("path"), - ).toHaveClass(/is-blocking/); - await expect(page.getByTestId("system-graph-node-leasing")).toHaveAttribute( - "type", - "button", - ); - await expect(page.locator(".system-graph-node-meta").first()).toHaveText( - "agent", - ); - await expect( - page.getByTestId("system-graph-canvas").getByText(/failed|running|cost/i), - ).toHaveCount(0); - await expect(page.getByTestId("system-graph-legend")).toHaveCount(0); - await expect(sessionContext).toHaveAttribute( - "data-session-id", - "sess-boot", - ); - await page.screenshot({ - path: "web/e2e/screenshots/workspace-graph-full.png", - fullPage: true, - }); - await toggleTheme(page); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await page.screenshot({ - path: "web/e2e/screenshots/workspace-graph-full-dark.png", - fullPage: true, - }); - await toggleTheme(page); - - // The dedicated disclosure is independent: folding keeps the selected - // graph and hidden agent surfaces exactly where they are. - await page.getByTestId("project-disclosure-acme-app").click(); - await expect(page.getByTestId("workflow-leasing")).toHaveCount(0); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect(sessionContext).toHaveAttribute( - "data-session-id", - "sess-boot", - ); - await page.getByTestId("project-disclosure-acme-app").click(); - await expect(page.getByTestId("workflow-leasing")).toBeVisible(); - - // Re-selecting the folder is instant in this process: the client request - // cache and server store both preserve the first projection. - await page.getByTestId("project-select-acme-app").click(); - await expect - .poll(() => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ), - ) - .toBe(1); - - // E3.7: a graph card DRILLS to that agent's board — the ordinary - // agent-focus path — and the held Steps arrangement comes back with it. - await page.getByTestId("system-graph-node-leasing").click(); - await expect(page.getByTestId("system-graph-canvas")).toHaveCount(0); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("workflow-leasing")).toHaveClass( - /is-focused/, - ); - // E3.8: one selection — the project row lets go the moment an agent is it. - await expect(page.getByTestId("project-row-acme-app")).not.toHaveClass( - /is-selected/, - ); - await expect(sessionContext).toHaveAttribute( - "data-session-id", - "sess-boot", - ); - await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); - await expect(page.getByTestId("right-tab-steps")).toHaveAttribute( - "aria-selected", - "true", - ); - - // The project name is the sole map-navigation action. - await expect(page.getByTestId("canvas-altitude-up")).toHaveCount(0); - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( - /is-selected/, - ); - await expect(sessionContext).toHaveAttribute( - "data-session-id", - "sess-boot", - ); - }); - - test("workspace graph view controls pan, zoom, reset, fit, and restore per workspace", async ({ - page, - }) => { - await page.getByTestId("project-select-acme-app").click(); - const subject = page.getByTestId("system-graph-subject"); - const reset = page.getByTestId("system-graph-zoom-reset"); - const initialTransform = await subject.evaluate( - (element) => (element as HTMLElement).style.transform, - ); - const initialZoom = Number((await reset.innerText()).replace("%", "")); - - await page.getByTestId("system-graph-zoom-in").click(); - await expect - .poll(async () => Number((await reset.innerText()).replace("%", ""))) - .toBeGreaterThan(initialZoom); - - const viewport = page.getByTestId("system-graph-viewport"); - const box = await viewport.boundingBox(); - if (!box) throw new Error("Missing system graph viewport bounds"); - await page.mouse.move(box.x + box.width / 3, box.y + box.height / 3); - const beforeWheel = Number((await reset.innerText()).replace("%", "")); - await page.mouse.wheel(0, -120); - await expect - .poll(async () => Number((await reset.innerText()).replace("%", ""))) - .toBeGreaterThan(beforeWheel); - - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.down(); - await page.mouse.move( - box.x + box.width / 2 + 60, - box.y + box.height / 2 + 35, - ); - await page.mouse.up(); - await expect - .poll(() => - subject.evaluate((element) => (element as HTMLElement).style.transform), - ) - .not.toBe(initialTransform); - - await reset.click(); - await expect(reset).toHaveText("100%"); - await page.getByTestId("system-graph-fit").click(); - const fittedTransform = await subject.evaluate( - (element) => (element as HTMLElement).style.transform, - ); - await page.getByTestId("system-graph-zoom-in").click(); - await viewport.dblclick({ position: { x: 8, y: 8 } }); - await expect - .poll(() => - subject.evaluate((element) => (element as HTMLElement).style.transform), - ) - .toBe(fittedTransform); - - await page - .getByTestId("workflow-leasing") - .locator(".workflow-item-trigger") - .click(); - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-subject")).toHaveAttribute( - "style", - new RegExp(fittedTransform.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - ); - }); - - test("workspace graph keyboard navigation reveals focus and rejects a blank saved view", async ({ - page, - }) => { - await page.getByTestId("project-select-acme-app").click(); - const viewport = page.getByTestId("system-graph-viewport"); - const subject = page.getByTestId("system-graph-subject"); - const reset = page.getByTestId("system-graph-zoom-reset"); - const viewportBounds = await viewport.boundingBox(); - if (!viewportBounds) - throw new Error("Missing system graph viewport bounds"); - - await viewport.focus(); - await expect(viewport).toBeFocused(); - const beforeKeyboardPan = await subject.evaluate( - (element) => (element as HTMLElement).style.transform, - ); - await page.keyboard.press("ArrowRight"); - await expect - .poll(() => - subject.evaluate((element) => (element as HTMLElement).style.transform), - ) - .not.toBe(beforeKeyboardPan); - await reset.click(); - - const panGraphOffscreen = async () => { - await page.mouse.move(viewportBounds.x + 8, viewportBounds.y + 8); - await page.mouse.down(); - await page.mouse.move( - viewportBounds.x + viewportBounds.width + 2_000, - viewportBounds.y + viewportBounds.height + 2_000, - ); - await page.mouse.up(); - await expect - .poll(async () => { - const [viewportBox, subjectBox] = await Promise.all([ - viewport.boundingBox(), - subject.boundingBox(), - ]); - if (!viewportBox || !subjectBox) return false; - return ( - subjectBox.x >= viewportBox.x + viewportBox.width || - subjectBox.x + subjectBox.width <= viewportBox.x || - subjectBox.y >= viewportBox.y + viewportBox.height || - subjectBox.y + subjectBox.height <= viewportBox.y - ); - }) - .toBe(true); - }; - - // Tabbing from the viewport to an offscreen card must pan that card back - // into view before its visible focus ring is shown. - await panGraphOffscreen(); - await viewport.focus(); - await page.keyboard.press("Tab"); - const focusedCard = page.locator("button.system-graph-node").first(); - await expect(focusedCard).toBeFocused(); - const focusedBounds = await focusedCard.boundingBox(); - if (!focusedBounds) throw new Error("Missing focused graph card bounds"); - expect(focusedBounds.x).toBeGreaterThanOrEqual(viewportBounds.x + 16); - expect(focusedBounds.y).toBeGreaterThanOrEqual(viewportBounds.y + 16); - expect(focusedBounds.x + focusedBounds.width).toBeLessThanOrEqual( - viewportBounds.x + viewportBounds.width - 16, - ); - expect(focusedBounds.y + focusedBounds.height).toBeLessThanOrEqual( - viewportBounds.y + viewportBounds.height - 16, - ); - - // A user may still pan beyond the subject while exploring. Reopening that - // workspace rejects the non-intersecting snapshot and auto-fits again. - await panGraphOffscreen(); - await page - .getByTestId("workflow-leasing") - .locator(".workflow-item-trigger") - .click(); - await page.getByTestId("project-select-acme-app").click(); - const restoredBounds = await page - .getByTestId("system-graph-node-research") - .boundingBox(); - if (!restoredBounds) throw new Error("Missing restored graph card bounds"); - expect(restoredBounds.x + restoredBounds.width).toBeGreaterThan( - viewportBounds.x, - ); - expect(restoredBounds.x).toBeLessThan( - viewportBounds.x + viewportBounds.width, - ); - expect(restoredBounds.y + restoredBounds.height).toBeGreaterThan( - viewportBounds.y, - ); - expect(restoredBounds.y).toBeLessThan( - viewportBounds.y + viewportBounds.height, - ); - }); - - test("a project with no live session is GIVEN one, and its map draws beside it", async ({ - page, - }) => { - /* SAP-2980 E3.2. The graph itself was always session-independent, and this - test used to prove exactly that — a full-main destination over a - no-session agent view. Now the project IS the conversation's subject, so - selecting one that has nothing running opens its first session at the - project root; a project you can select but not talk to is the failure - this criterion names. */ - await page.goto("/?seed=0&mockNoLiveSessions=1"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await expect(page.getByTestId("open-agent-empty")).toContainText( - "No running session for leasing", - ); - await expect(page.getByTestId("session-context")).not.toHaveAttribute( - "data-session-id", - /.+/, - ); - - await page.getByTestId("project-select-acme-app").click(); - - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect(page.getByTestId("system-graph-node-research")).toBeVisible(); - // A live session, rooted at the project (SAP-2927), with the terminal - // beside the map rather than replaced by it. - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - /^sess-mock-/, - ); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - - // E3.4: drilling to a sibling moves the right pane only — that session - // already reaches every agent in its project, so it does not hand over. - const sessionId = await page - .getByTestId("session-context") - .getAttribute("data-session-id"); - await page.getByTestId("system-graph-node-leasing").click(); - await expect(page.getByTestId("workflow-leasing")).toHaveClass( - /is-focused/, - ); - await expect(page.getByTestId("session-context")).toHaveAttribute( - "data-session-id", - sessionId!, - ); - await expect(page.getByTestId("open-agent-empty")).toHaveCount(0); - }); - - test("a failed workspace projection retries instead of poisoning the cache", async ({ - page, - }) => { - await page.evaluate(() => { - ( - window as unknown as { __MOCK_SYSTEM_GRAPH_FAIL_ONCE__?: boolean } - ).__MOCK_SYSTEM_GRAPH_FAIL_ONCE__ = true; - }); - - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-error")).toBeVisible(); - await page.getByRole("button", { name: "Retry" }).click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect - .poll(() => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ), - ) - .toBe(2); - - await page - .getByTestId("workflow-leasing") - .locator(".workflow-item-trigger") - .click(); - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect - .poll(() => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ), - ) - .toBe(2); - }); - - test("a degraded workspace projection retries once on a later open", async ({ - page, - }) => { - await page.evaluate(() => { - ( - window as unknown as { - __MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__?: number; - } - ).__MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__ = 2; - }); - - const openGraph = async () => { - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - }; - const openAgent = async () => { - await page - .getByTestId("workflow-leasing") - .locator(".workflow-item-trigger") - .click(); - await expect(page.getByTestId("system-graph-canvas")).toHaveCount(0); - }; - const requestCount = () => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ); - - await openGraph(); - await expect.poll(requestCount).toBe(1); - await openAgent(); - await openGraph(); - await expect.poll(requestCount).toBe(2); - await openAgent(); - await openGraph(); - await expect.poll(requestCount).toBe(2); - }); - - test("workspace graph revisions invalidate closed views and preserve stale data", async ({ - page, - }) => { - const requestCount = () => - page.evaluate( - () => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests ?? [] - ).length, - ); - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(requestCount).toBe(1); - - const workspaceKey = await page.evaluate( - () => - ( - window as unknown as { - __HARNESS_TEST__?: { systemGraphRequests?: string[] }; - } - ).__HARNESS_TEST__?.systemGraphRequests?.[0] ?? "", - ); - const leasingNode = page.getByTestId("system-graph-node-leasing"); - await page - .getByTestId("workflow-leasing") - .locator(".workflow-item-trigger") - .click(); - - // The graph destination is closed, but the global event subscriber still - // invalidates its process-lifetime browser promise. - await page.evaluate((key) => { - const win = window as unknown as { - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: string; - __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number; - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - }; - win.__MOCK_SYSTEM_GRAPH_REVISION__ = 3; - win.__MOCK_SYSTEM_GRAPH_STATE__ = "ready"; - // Keep the refresh in flight long enough to observe the stale-data - // indicator under both local and loaded parallel CI scheduling. - win.__MOCK_SYSTEM_GRAPH_DELAY_MS__ = 3_000; - win.__HARNESS_TEST__?.publish?.({ - type: "system-graph.changed", - workspaceKey: key, - revision: 2, - state: "stale", - }); - win.__HARNESS_TEST__?.publish?.({ type: "workflows.changed" }); - }, workspaceKey); - - await page.getByTestId("project-select-acme-app").click(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect(page.getByTestId("system-graph-refreshing")).toBeVisible(); - await expect(page.getByTestId("system-graph-node-leasing")).not.toHaveClass( - /is-navigable/, - ); - await page.evaluate(() => { - delete (window as unknown as { __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number }) - .__MOCK_SYSTEM_GRAPH_DELAY_MS__; - }); - await expect.poll(requestCount).toBe(2); - await expect(page.getByTestId("system-graph-refreshing")).toHaveCount(0); - - // A hard refresh failure keeps last-known data visible and labels it stale. - await page.evaluate((key) => { - const win = window as unknown as { - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: string; - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - }; - win.__MOCK_SYSTEM_GRAPH_REVISION__ = 4; - win.__MOCK_SYSTEM_GRAPH_STATE__ = "stale"; - win.__HARNESS_TEST__?.publish?.({ - type: "system-graph.changed", - workspaceKey: key, - revision: 4, - state: "stale", - }); - }, workspaceKey); - await expect(page.getByTestId("system-graph-stale")).toBeVisible(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(requestCount).toBe(3); - // A stale graph and its exact-revision sidecar are still one safe snapshot: - // drill-in and the sidecar-derived group join remain available. - await expect(leasingNode).toHaveClass(/is-navigable/); - await expect( - page.locator('[data-testid^="system-graph-group-"]'), - ).not.toHaveCount(0); - - // A partial refresh keeps valid topology interactive and labels it degraded. - await page.evaluate((key) => { - const win = window as unknown as { - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: string; - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - }; - win.__MOCK_SYSTEM_GRAPH_REVISION__ = 5; - win.__MOCK_SYSTEM_GRAPH_STATE__ = "degraded"; - win.__HARNESS_TEST__?.publish?.({ - type: "system-graph.changed", - workspaceKey: key, - revision: 5, - state: "degraded", - }); - }, workspaceKey); - await expect(page.getByTestId("system-graph-degraded")).toBeVisible(); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(requestCount).toBe(4); - - await page.evaluate(() => { - const win = window as unknown as { - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: string; - }; - win.__MOCK_SYSTEM_GRAPH_REVISION__ = 6; - win.__MOCK_SYSTEM_GRAPH_STATE__ = "ready"; - }); - await page.getByRole("button", { name: "Retry" }).click(); - await expect(page.getByTestId("system-graph-degraded")).toHaveCount(0); - await expect(page.getByTestId("system-graph-canvas")).toBeVisible(); - await expect.poll(requestCount).toBe(5); - - // An unrelated workspace announcement cannot invalidate this view. - await page.evaluate(() => { - const win = window as unknown as { - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - }; - win.__HARNESS_TEST__?.publish?.({ - type: "system-graph.changed", - workspaceKey: "workspace-unrelated", - revision: 99, - state: "stale", - }); - }); - await page.waitForTimeout(250); - expect(await requestCount()).toBe(5); - - // React may batch adjacent event frames. A following unrelated frame must - // not overwrite the graph invalidation or leave old navigation clickable. - await expect(leasingNode).toHaveClass(/is-navigable/); - await page.evaluate((key) => { - const win = window as unknown as { - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: string; - __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number; - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - }; - win.__MOCK_SYSTEM_GRAPH_REVISION__ = 7; - win.__MOCK_SYSTEM_GRAPH_STATE__ = "ready"; - win.__MOCK_SYSTEM_GRAPH_DELAY_MS__ = 3_000; - win.__HARNESS_TEST__?.publish?.({ - type: "system-graph.changed", - workspaceKey: key, - revision: 7, - state: "stale", - }); - win.__HARNESS_TEST__?.publish?.({ type: "workflows.changed" }); - }, workspaceKey); - await expect(leasingNode).not.toHaveClass(/is-navigable/); - await expect(page.getByTestId("system-graph-refreshing")).toBeVisible(); - await page.evaluate(() => { - delete (window as unknown as { __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number }) - .__MOCK_SYSTEM_GRAPH_DELAY_MS__; - }); - await expect.poll(requestCount).toBe(6); - await expect(page.getByTestId("system-graph-refreshing")).toHaveCount(0); - await expect(leasingNode).toHaveClass(/is-navigable/); - }); - test("switching sessions makes the canvas follow the new session's content", async ({ page, }) => { diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 4412600d0..554c03bfe 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -89,7 +89,6 @@ import { HelpOverlay } from "./components/HelpOverlay"; import { CreateAgentDialog } from "./components/CreateAgentDialog"; import { OverviewModal } from "./components/OverviewModal"; import { WorkflowsRail } from "./components/WorkflowsRail"; -import { WorkspaceGraphView } from "./components/WorkspaceGraphView"; import { boundWorkflowPathOf, createApi, errorMessage } from "./lib/api"; import { classifyConnectivity, useConnectivity } from "./lib/connectivity"; import { historyDirs } from "./lib/history-meta"; @@ -356,7 +355,7 @@ export const App = (): JSX.Element => { // selection and the Canvas/Steps subject. The active conversation is always // harness.activeSessionId; choosing the project map does not rewrite it. const [focusedAgentPath, setFocusedAgentPath] = useState(null); - // An unresolved project (or an older-server compatibility selection). + // A project whose durable identity has not resolved yet. // Studio projects use `studioSelection` below with the same invariant: chat stays in // the centre and the map draws beside it, so this selects a SUBJECT rather // than replacing the workbench. @@ -549,7 +548,7 @@ export const App = (): JSX.Element => { }; setStudioSelection(selection); setSelectedProject(null); - void harness.api.putStudioCurrentWorkspace(projectId, selection); + void harness.api.putStudioCurrentWorkspace(projectId, selection).catch(() => {}); }, [harness.api, harness.state, selectedProject]); // A selected agent that disappears falls back to its map in memory. Only @@ -972,8 +971,7 @@ export const App = (): JSX.Element => { selectedProject?.root ?? null, knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); - const unresolvedProject = selectedProject !== null && - harness.state?.studioProjects !== undefined; + const unresolvedProject = selectedProject !== null; const studioProjectId = effectiveStudioSelection?.projectId ?? (unresolvedProject ? null : shortcutActive?.agentMapIdentity?.projectId) ?? @@ -1247,9 +1245,8 @@ export const App = (): JSX.Element => { setStudioSelection(null); } if (visit.kind === "project") { - // Replay through the rail's selection handler: current Studio project - // navigation is read-only; older-server session handoff remains until - // SAP-3090. The ref reaches the handler below the loading guard, where + // Replay through the rail's read-only project selection handler. + // The ref reaches the handler below the loading guard, where // the hydrated `state` is available. selectProjectRef.current?.(visit.workspaceKey, visit.root, visit.label); } else if (visit.kind === "agent-map") { @@ -1489,16 +1486,13 @@ export const App = (): JSX.Element => { * ONE selection, at ONE altitude — the contract the rail, the centre pane and * the canvas all read, so they cannot disagree about what is on screen (E3.8). */ - const legacyView = canvasView(selectedProject, effectiveFocusedAgentPath); const studioView = planFirstSelection ? studioCanvasView(planFirstSelection) : null; - const view = studioView ?? legacyView; + const view = studioView ?? canvasView(selectedProject, effectiveFocusedAgentPath); const atMapAltitude = view.altitude === "map"; - // Missing identity in the current protocol is an unavailable Agent Map. - // Only an older server that omits studioProjects supports the legacy view. - const unresolvedProjectMap = - selectedProject !== null && state.studioProjects !== undefined; + // Missing identity always offers recovery, including older server payloads. + const unresolvedProjectMap = selectedProject !== null; const projectMapSelected = studioView?.altitude === "map" || unresolvedProjectMap; @@ -1747,7 +1741,6 @@ export const App = (): JSX.Element => { const studioProjectId = workspaceScopes.find( (scope) => scope.workspaceKey === workspaceKey, )?.projectId; - let selectedAgentMap = false; if ( studioProjectId && state.studioProjects?.some( @@ -1761,8 +1754,7 @@ export const App = (): JSX.Element => { }; setStudioSelection(selection); setSelectedProject(null); - void harness.api.putStudioCurrentWorkspace(studioProjectId, selection); - selectedAgentMap = true; + void harness.api.putStudioCurrentWorkspace(studioProjectId, selection).catch(() => {}); } else { setStudioSelection(null); setSelectedProject({ workspaceKey, root, label }); @@ -1779,30 +1771,12 @@ export const App = (): JSX.Element => { closeMobileDrawer(); // A Studio project-name click is a read-only navigation action. It must not // choose, create, resume, focus, or prompt any session. - if (selectedAgentMap || state.studioProjects !== undefined) { - if (isMobile) setRightCollapsed(false); - return; - } - // Older-server compatibility only: these payloads omit studioProjects. - // Retain their session handoff until the browser deletion in SAP-3090. - const decision = sessionForFocus({ - focusPath: root, - active: activeSession, - sessions: state.sessions, - roots: knownProjectRoots(), - }); - if (decision.kind === "keep") return; - if (decision.to) { - if (decision.to.id !== harness.activeSessionId) - harness.setActiveSessionId(decision.to.id); - return; - } - void startProjectSession(root, label, selectedHarness); + if (isMobile) setRightCollapsed(false); }; selectProjectRef.current = handleSelectWorkspace; /** - * Start an explicitly requested project session, or an older-server handoff. + * Start an explicitly requested project session. * * Guarded BY ROOT, not by a boolean: two projects can be starting at once * (request one, then another before the first POST resolves) and a single @@ -3722,21 +3696,6 @@ export const App = (): JSX.Element => { expanded={canvasExpanded} onToggleExpanded={toggleCanvasExpanded} /> - ) : legacyView.altitude === "map" ? ( - ) : null}
>(new Map()); - // Compatibility lasts only for a confirmed older-server protocol. Unknown - // boot state and current-server catalog errors must never enable old graphs. - const legacyGraphProtocol = useRef(false); - legacyGraphProtocol.current = - state !== null && state.studioProjects === undefined; - useEffect(() => { if (!state) return; - const workspaceKeys = new Set( - state.studioProjects === undefined - ? (state.workspaceScopes ?? []).map((scope) => scope.workspaceKey) - : [], - ); + const workspaceKeys = new Set(); const projectIds = new Set( (state.studioProjects ?? []).map((project) => project.projectId), ); @@ -1244,11 +1234,7 @@ export function useHarnessState(): HarnessStateHook { useEffect(() => { return subscribeEvents( (message) => { - if ( - message.type === "system-graph.changed" && - !legacyGraphProtocol.current - ) - return; + if (message.type === "system-graph.changed") return; // SessionRecord invalidations have a targeted listener below. Keeping // them out of the legacy last-message slot avoids repainting the entire // Studio for records no mounted transcript is watching. @@ -1324,10 +1310,6 @@ export function useHarnessState(): HarnessStateHook { // and let the next event/auth/manual refresh retry; never create an // unhandled rejection from the event callback. .catch(() => undefined); - } else if (message.type === "system-graph.changed") { - // Invalidate even while its workspace destination is closed. The next - // open must never resurrect a pre-edit process-lifetime promise. - systemGraphLoader.invalidate(message.workspaceKey, message.revision); } else if (message.type === "agent-map.initialization.changed") { try { const status = parseAgentMapInitializationStatus(message.status); From 22452e67fb85dfd4f95af54876f65f1f3ec0efde Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 10 Sep 2026 07:36:34 +0000 Subject: [PATCH 2/2] fix(harness): preserve project map viewports across navigation Restore the browser contracts called out in review: remember manual pan and zoom per project for the current auth lifetime, retain automatic fitting, and cover sibling-agent project tabs. Refs: SAP-3090 --- .changeset/tall-maps-stay-durable.md | 4 +- .../harness/web/e2e/agent-map-recovery.html | 3 + .../web/e2e/agent-map-viewport.spec.ts | 106 ++++++++++++++++++ .../harness/web/e2e/project-altitude.spec.ts | 54 +++++++++ packages/harness/web/src/App.tsx | 9 ++ .../web/src/components/AgentMapCanvas.tsx | 87 ++++++++++---- .../web/src/components/AgentMapPane.tsx | 7 ++ .../harness/web/src/lib/graph-viewport.ts | 4 + 8 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 packages/harness/web/e2e/agent-map-viewport.spec.ts diff --git a/.changeset/tall-maps-stay-durable.md b/.changeset/tall-maps-stay-durable.md index 4140edd6f..0bcb45458 100644 --- a/.changeset/tall-maps-stay-durable.md +++ b/.changeset/tall-maps-stay-durable.md @@ -2,4 +2,6 @@ "@sapiom/harness": patch --- -Remove the older-server project graph fallback. Project clicks now open the durable Agent Map or offer identity recovery without selecting or creating a conversation. Ordinary sessions retain their independent Canvas and Steps views. +Remove unreachable legacy project graph browser code from Studio's bundled client and server. This internal cleanup preserves durable Agent Map navigation, project-wide conversation tabs, and ordinary sessions' independent Canvas and Steps views. + +Restore each project's map pan and zoom when returning from another project or an agent Canvas. If the saved view would leave every node offscreen, fit the map into view. diff --git a/packages/harness/web/e2e/agent-map-recovery.html b/packages/harness/web/e2e/agent-map-recovery.html index 64014f6cb..23cce85f0 100644 --- a/packages/harness/web/e2e/agent-map-recovery.html +++ b/packages/harness/web/e2e/agent-map-recovery.html @@ -6,10 +6,12 @@ import { createElement as h, useState } from "react"; import { createRoot } from "react-dom/client"; import { AgentMapCanvas } from "../src/components/AgentMapCanvas.tsx"; + import { createGraphViewportStore } from "../src/lib/graph-viewport.ts"; import { agentMapPackingFixture } from "./agent-map-packing-fixture.ts"; import "../src/styles.css"; function Fixture() { const [broken, setBroken] = useState(true); + const [viewportStore] = useState(createGraphViewportStore); const proposal = agentMapPackingFixture(); // Direct component props exercise recovery beyond the API's codec guard. if (broken) proposal.nodes.push(proposal.nodes[0]); @@ -25,6 +27,7 @@ "div", { id: "map", style: { width: 600, height: 900, display: "flex" } }, h(AgentMapCanvas, { + viewportStore, proposal, deployments: new Map(), selectedNodeId: null, diff --git a/packages/harness/web/e2e/agent-map-viewport.spec.ts b/packages/harness/web/e2e/agent-map-viewport.spec.ts new file mode 100644 index 000000000..34dd26a2e --- /dev/null +++ b/packages/harness/web/e2e/agent-map-viewport.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function openProject(page: Page, project: string) { + await page.getByTestId(`project-select-${project}`).click(); + await expect(page.getByTestId("agent-map-canvas")).toHaveAttribute( + "data-layout-state", + "ready", + ); +} + +const transform = (page: Page) => + page.getByTestId("agent-map-subject").evaluate((el) => el.style.transform); + +test.beforeEach(async ({ page }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.getByTestId("session-context")).toBeVisible(); + await openProject(page, "acme-app"); +}); + +test("each project's pan and zoom survive another project and an agent Canvas", async ({ + page, +}) => { + const initial = await transform(page); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByTestId("agent-map-viewport").focus(); + await page.keyboard.press("ArrowRight"); + const acmeView = await transform(page); + expect(acmeView).not.toBe(initial); + + await openProject(page, "polsia"); + expect(await transform(page)).not.toBe(acmeView); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByTestId("agent-map-viewport").focus(); + await page.keyboard.press("ArrowDown"); + const polsiaView = await transform(page); + expect(polsiaView).not.toBe(acmeView); + + await openProject(page, "acme-app"); + await expect.poll(() => transform(page)).toBe(acmeView); + await page + .getByTestId("workflow-leasing") + .locator(".workflow-item-trigger") + .click(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); + await openProject(page, "acme-app"); + await expect.poll(() => transform(page)).toBe(acmeView); + await openProject(page, "polsia"); + await expect.poll(() => transform(page)).toBe(polsiaView); +}); + +test("returning to a map with every node offscreen fits it back into view", async ({ + page, +}) => { + const fitted = await transform(page); + // Pan well beyond the map using the actual viewport controls. + await page.getByTestId("agent-map-viewport").focus(); + for (let i = 0; i < 50; i += 1) await page.keyboard.press("ArrowRight"); + expect(await transform(page)).not.toBe(fitted); + await openProject(page, "polsia"); + await openProject(page, "acme-app"); + await expect.poll(() => transform(page)).toBe(fitted); +}); + +test("Fit clears the saved manual view and keeps following pane size after returning", async ({ + page, +}) => { + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByRole("button", { name: "Fit Agent Map to view" }).click(); + await openProject(page, "polsia"); + await openProject(page, "acme-app"); + const normal = await transform(page); + await page.getByTestId("canvas-expand").click(); + await expect.poll(() => transform(page)).not.toBe(normal); + await expect(page.getByTestId("agent-map-canvas")).toHaveAttribute( + "data-layout-state", + "ready", + ); + const automatic = await transform(page); + await page.getByRole("button", { name: "Fit Agent Map to view" }).click(); + expect(await transform(page)).toBe(automatic); +}); + +test("an auth change discards the previous signed-in viewport", async ({ + page, +}) => { + const initial = await transform(page); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByTestId("agent-map-viewport").focus(); + await page.keyboard.press("ArrowRight"); + expect(await transform(page)).not.toBe(initial); + await page.evaluate(() => { + ( + window as unknown as { + __HARNESS_TEST__: { publish: (message: unknown) => void }; + } + ).__HARNESS_TEST__.publish({ + type: "auth.changed", + authenticated: true, + organizationName: "Another account", + }); + }); + await expect.poll(() => transform(page)).toBe(initial); +}); diff --git a/packages/harness/web/e2e/project-altitude.spec.ts b/packages/harness/web/e2e/project-altitude.spec.ts index 0afac17cc..0297e3d55 100644 --- a/packages/harness/web/e2e/project-altitude.spec.ts +++ b/packages/harness/web/e2e/project-altitude.spec.ts @@ -115,6 +115,60 @@ test("E3.4 — selecting a sibling agent moves the right pane and NOTHING else", ).toEqual(tabsBefore); }); +test("E3.3 — sessions bound to different agents stay in the project strip on sibling Canvas navigation", async ({ page }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockNoLiveSessions=1&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.getByTestId("project-select-polsia")).toBeVisible(); + await page.getByTestId("project-select-polsia").click(); + const map = page.getByTestId("agent-map-live"); + await expect(map).toBeVisible(); + const projectId = await map.getAttribute("data-project-id"); + expect(projectId).toBeTruthy(); + await page.evaluate((projectId) => { + const { publish } = (window as unknown as { + __HARNESS_TEST__: { publish: (message: unknown) => void }; + }).__HARNESS_TEST__; + for (const [index, agent] of ["mailer", "rollup"].entries()) { + const id = `sess-polsia-${agent}`; + publish({ + type: "session.status", + session: { + id, + agentSessionId: null, + harness: "claude-code", + cwd: "/Users/demo/polsia", + status: "running", + ready: true, + title: agent, + createdAt: `2026-08-01T1${index}:00:00.000Z`, + lastActiveAt: `2026-08-01T1${index}:00:00.000Z`, + boundWorkflowPath: agent === "mailer" + ? "/Users/demo/polsia/packages/harness/web/src/components/mailer" + : "/Users/demo/polsia/scripts/tools/rollup", + agentMapIdentity: { projectId, userId: "user_mock", sessionId: id }, + }, + }); + } + }, projectId!); + const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); + await expect(tabs).toHaveCount(2); + await expect(tabs.nth(0)).toHaveAttribute("data-testid", "session-tab-main-sess-polsia-mailer"); + await expect(tabs.nth(1)).toHaveAttribute("data-testid", "session-tab-main-sess-polsia-rollup"); + await tabs.nth(0).click(); + await expect(page.getByTestId("session-context")).toHaveAttribute("data-session-id", "sess-polsia-mailer"); + await page.getByTestId("project-select-polsia").click(); + await expect(map).toBeVisible(); + const before = await tabs.allTextContents(); + await page.getByTestId("workflow-rollup").locator(".workflow-item-trigger").click(); + await expect(page.getByTestId("workflow-rollup")).toHaveClass(/is-focused/); + await expect(map).toHaveCount(0); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute("data-session-id", "sess-polsia-mailer"); + await expect(tabs).toHaveCount(2); + expect(await tabs.allTextContents()).toEqual(before); +}); + test("E3.9/E3.10 — Steps says why it cannot answer for a project; Code is gone", async ({ page, }) => { diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 554c03bfe..f856f083c 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -44,6 +44,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -67,6 +68,7 @@ import type { import { CanvasPane } from "./components/CanvasPane"; import { AgentMapPane } from "./components/AgentMapPane"; +import { createGraphViewportStore } from "./lib/graph-viewport"; import { CommandPalette } from "./components/CommandPalette"; import { ConnectivityBanner, @@ -287,6 +289,12 @@ const shellApi = createApi(); export const App = (): JSX.Element => { const harness = useHarnessState(); + // A project map remounts when browsing another project or agent. Keep its + // viewport for this signed-in UI lifetime, without persisting map data. + const agentMapViewportStore = useMemo( + createGraphViewportStore, + [harness.authRevision], + ); const [selectedHarness, setSelectedHarness] = useState( () => loadUiPrefs().preferredHarness ?? DEFAULT_HARNESS, ); @@ -3676,6 +3684,7 @@ export const App = (): JSX.Element => { ) : studioView?.altitude === "map" ? ( { - followsUpdates.current = true; + const commitView = useCallback( + (next: GraphView | ((current: GraphView) => GraphView)) => { + setView((current) => { + const resolved = typeof next === "function" ? next(current) : next; + // Only manual views need restoring. Auto-fit must keep following new + // layouts and pane sizes after navigating away and back. + if (followsUpdates.current) viewportStore.delete(proposal.projectId); + else viewportStore.set(proposal.projectId, resolved); + return resolved; + }); + }, + [proposal.projectId, viewportStore], + ); + + const measureFit = useCallback(() => { const viewport = viewportRef.current; - if (!viewport || !layout) return; + if (!viewport || !layout) return null; const rect = viewport.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; const root = Number.parseFloat( getComputedStyle(document.documentElement).fontSize, ); - const next = fitGraphView( + return fitGraphView( layout.bounds, { width: rect.width, height: rect.height }, Number.isFinite(root) ? root : 16, AGENT_MAP_MIN_ZOOM, ); - setMinZoom(next.minZoom); - setView({ zoom: Math.min(1, next.zoom), x: 0, y: 0 }); }, [layout]); + const fit = useCallback((): void => { + followsUpdates.current = true; + const next = measureFit(); + if (!next) return; + setMinZoom(next.minZoom); + commitView({ zoom: Math.min(1, next.zoom), x: 0, y: 0 }); + }, [commitView, measureFit]); + useLayoutEffect(() => { const viewport = viewportRef.current; if (!viewport || !layout) return; - if (fittedProjectRef.current !== proposal.projectId) - followsUpdates.current = true; const measure = (): void => { - const visible = viewport.getBoundingClientRect().width > 0; - if (!followsUpdates.current || !visible) return; - fittedProjectRef.current = proposal.projectId; - fit(); + const next = measureFit(); + if (!next) return; + setMinZoom(next.minZoom); + if (fittedProjectRef.current !== proposal.projectId) { + fittedProjectRef.current = proposal.projectId; + const saved = viewportStore.get(proposal.projectId); + const restored = saved && { + ...saved, + zoom: clampGraphZoom(saved.zoom, next.minZoom, AGENT_MAP_MIN_ZOOM), + }; + followsUpdates.current = true; + if ( + restored && + graphViewIntersectsViewport( + restored, + layout.bounds, + { width: viewport.clientWidth, height: viewport.clientHeight }, + layout.nodes, + ) + ) { + followsUpdates.current = false; + commitView(restored); + return; + } + } + if (followsUpdates.current) + commitView({ zoom: Math.min(1, next.zoom), x: 0, y: 0 }); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(viewport); return () => observer.disconnect(); - }, [fit, layout, proposal.projectId]); + }, [commitView, layout, measureFit, proposal.projectId, viewportStore]); useEffect(() => { const viewport = viewportRef.current; @@ -135,7 +180,7 @@ export function AgentMapCanvas({ event.preventDefault(); followsUpdates.current = false; const rect = viewport.getBoundingClientRect(); - setView((current) => + commitView((current) => wheelGraphView( current, event.deltaY, @@ -150,7 +195,7 @@ export function AgentMapCanvas({ }; viewport.addEventListener("wheel", wheel, { passive: false }); return () => viewport.removeEventListener("wheel", wheel); - }, [minZoom]); + }, [commitView, minZoom]); const startPan = (event: ReactPointerEvent): void => { if ((event.target as Element).closest("button")) return; @@ -168,7 +213,7 @@ export function AgentMapCanvas({ if (!drag || drag.pointerId !== event.pointerId) return; if (event.clientX !== drag.x || event.clientY !== drag.y) followsUpdates.current = false; - setView({ + commitView({ ...drag.origin, x: drag.origin.x + event.clientX - drag.x, y: drag.origin.y + event.clientY - drag.y, @@ -182,7 +227,7 @@ export function AgentMapCanvas({ const revealNode = (node: GraphRect): void => { const viewport = viewportRef.current; if (!viewport || !layout) return; - setView((current) => { + commitView((current) => { const next = revealGraphRect( current, layout.bounds, @@ -221,7 +266,7 @@ export function AgentMapCanvas({ return; event.preventDefault(); followsUpdates.current = false; - setView((current) => + commitView((current) => panGraphViewWithKeyboard(current, event.key as GraphArrowKey), ); }} @@ -415,7 +460,7 @@ export function AgentMapCanvas({ aria-label="Zoom out" onClick={() => { followsUpdates.current = false; - setView((current) => ({ + commitView((current) => ({ ...current, zoom: clampGraphZoom( current.zoom - GRAPH_ZOOM_STEP, @@ -433,7 +478,7 @@ export function AgentMapCanvas({ aria-label="Reset Agent Map view" onClick={() => { followsUpdates.current = false; - setView(resetGraphView()); + commitView(resetGraphView()); }} > {Math.round(view.zoom * 100)}% @@ -445,7 +490,7 @@ export function AgentMapCanvas({ disabled={view.zoom >= GRAPH_MAX_ZOOM} onClick={() => { followsUpdates.current = false; - setView((current) => ({ + commitView((current) => ({ ...current, zoom: clampGraphZoom( current.zoom + GRAPH_ZOOM_STEP, diff --git a/packages/harness/web/src/components/AgentMapPane.tsx b/packages/harness/web/src/components/AgentMapPane.tsx index fe7528ef9..0b44a6402 100644 --- a/packages/harness/web/src/components/AgentMapPane.tsx +++ b/packages/harness/web/src/components/AgentMapPane.tsx @@ -14,6 +14,7 @@ import type { } from "@shared/agent-map"; import type { WorkflowInfo } from "@shared/types"; import type { HarnessApi } from "../lib/api"; +import type { GraphViewportStore } from "../lib/graph-viewport"; import { agentMapDeployments, type AgentMapDeployments, @@ -33,6 +34,7 @@ import { AgentMapInspector } from "./AgentMapInspector"; import { Icon } from "./Icon"; interface AgentMapPaneProps { + viewportStore: GraphViewportStore; visible: boolean; api: Pick< HarnessApi, @@ -51,6 +53,7 @@ interface AgentMapPaneProps { } export function AgentMapPane({ + viewportStore, visible, api, workflows, @@ -280,6 +283,7 @@ export function AgentMapPane({ } else if (proposal && proposal.nodes.length > 0) { content = ( void; @@ -421,6 +427,7 @@ function PopulatedAgentMap({