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);