From cf2369c481aed15cc2985a01a9df3ddf469d7904 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 5 Sep 2026 12:12:00 +0000 Subject: [PATCH] feat(harness): restore project navigation [Agent Map 03/15] --- .changeset/plan-agents-label.md | 6 - .changeset/planner-owned-agent-creation.md | 6 - .changeset/unified-project-agents.md | 18 + packages/harness/README.md | 116 +- packages/harness/src/server/index.ts | 34 +- packages/harness/src/server/rest.test.ts | 90 ++ packages/harness/src/server/rest.ts | 46 +- packages/harness/src/shared/types.ts | 3 + .../web/e2e/agent-map-planning.spec.ts | 594 --------- .../web/e2e/new-session-composer.spec.ts | 134 +- packages/harness/web/e2e/open-project.spec.ts | 81 +- .../harness/web/e2e/polsia-workspace.spec.ts | 11 +- .../harness/web/e2e/project-altitude.spec.ts | 105 +- packages/harness/web/e2e/project-axis.spec.ts | 123 +- .../e2e/project-map-empty-recovery.spec.ts | 38 + .../web/e2e/project-map-navigation.spec.ts | 636 ++++++++++ .../harness/web/e2e/selection-canvas.spec.ts | 89 +- packages/harness/web/e2e/smoke.spec.ts | 7 +- packages/harness/web/src/App.tsx | 1081 ++++++++--------- .../web/src/components/AgentMapPane.tsx | 14 +- .../web/src/components/ProjectTreeRows.tsx | 94 +- .../harness/web/src/components/SessionBar.tsx | 146 +-- .../web/src/components/WorkflowsRail.tsx | 171 ++- .../harness/web/src/lib/agent-map.test.ts | 72 ++ packages/harness/web/src/lib/agent-map.ts | 35 +- packages/harness/web/src/lib/api.test.ts | 63 - packages/harness/web/src/lib/api.ts | 484 ++------ .../web/src/lib/canvas-altitude.test.ts | 56 +- .../harness/web/src/lib/canvas-altitude.ts | 31 +- .../harness/web/src/lib/session-name.test.ts | 51 +- .../harness/web/src/lib/session-scope.test.ts | 562 +++++++-- packages/harness/web/src/lib/session-scope.ts | 128 +- .../web/src/lib/use-agent-map-entry.ts | 194 +-- .../harness/web/src/lib/use-harness-state.ts | 364 +++--- 34 files changed, 2973 insertions(+), 2710 deletions(-) delete mode 100644 .changeset/plan-agents-label.md delete mode 100644 .changeset/planner-owned-agent-creation.md create mode 100644 .changeset/unified-project-agents.md delete mode 100644 packages/harness/web/e2e/agent-map-planning.spec.ts create mode 100644 packages/harness/web/e2e/project-map-empty-recovery.spec.ts create mode 100644 packages/harness/web/e2e/project-map-navigation.spec.ts diff --git a/.changeset/plan-agents-label.md b/.changeset/plan-agents-label.md deleted file mode 100644 index c5b8be1e7..000000000 --- a/.changeset/plan-agents-label.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@sapiom/harness": patch -"@sapiom/harness-desktop": patch ---- - -Rename the pinned Agent Map tab in the Studio project sidebar to Plan Agents so its purpose is clear. The underlying Agent Map view and behavior are unchanged. diff --git a/.changeset/planner-owned-agent-creation.md b/.changeset/planner-owned-agent-creation.md deleted file mode 100644 index 75c311e8e..000000000 --- a/.changeset/planner-owned-agent-creation.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@sapiom/harness": minor -"@sapiom/harness-desktop": patch ---- - -Make Agent Map planning the only agent-creation route in Studio projects. Empty sidebar rows no longer offer to create the first agent directly, and project menus no longer offer direct create or in-session scaffold actions. diff --git a/.changeset/unified-project-agents.md b/.changeset/unified-project-agents.md new file mode 100644 index 000000000..e95027a50 --- /dev/null +++ b/.changeset/unified-project-agents.md @@ -0,0 +1,18 @@ +--- +"@sapiom/harness": minor +--- + +Unify Studio project sessions around one ordinary coding-agent identity and +make the project name open the shared Agent Map without starting a session. +Conversation tabs restore their exact session and Canvas independently of the +map and workflow Steps selection. + +**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): +`HarnessSession.agentMapIdentity` is now the role-neutral +`ProjectAgentSession { projectId, userId, sessionId }`. Stop branching on the +former `role` or `assignment` fields. Valid persisted legacy metadata is +normalized while session/provider IDs, cwd, title, transcript, and Canvas are +preserved. Malformed or conflicting authority fails closed. Optional +`projectBootstrap` describes lifecycle state only. Generic session routes +revalidate project ownership on resume; migrated startup queues retain their +durable FIFO input boundary during the coordinator transition. diff --git a/packages/harness/README.md b/packages/harness/README.md index f843a0837..6a357f009 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -84,80 +84,46 @@ Architecture: a single Node process (Express + ws + node-pty) serves the built SPA, a small REST API, terminal WebSocket streams, and the local telemetry ingest endpoint. The interface contract lives in `src/shared/types.ts`. -### Agent Map planner sessions - -In Studio, the pinned **Plan Agents** tab opens the project's Agent Map. -**Plan Agents** names the planning entry point; **Agent Map** remains the name -of the proposal view and its underlying protocol. - -The authenticated local API owns planner identity; a model or generic session -request cannot assign itself the `map-planner` role. The public planner surface -is project-scoped: - -- `POST /api/projects/:projectId/planner-sessions` with - `{ "mode": "resume-or-create" }` deterministically reuses the latest owned - live/resumable planner or creates one. Use `{ "mode": "fresh" }` to always - create a new planner. -- `POST /api/projects/:projectId/planner-sessions/:sessionId/messages` durably - accepts planner input and releases it FIFO after greeting resolution. -- `POST /api/projects/:projectId/planner-sessions/:sessionId/greeting/retry` - retries an eligible failed automatic greeting. - -Planner metadata is part of the session registry. Its input FIFO and greeting -attempt state live at -`/agent-map/planner-sessions//input-queue.json`; corrupt -queue files are quarantined beside that file so one session cannot block boot. -An adjacent content-free `accepted-inputs.json` ledger commits PTY-accepted FIFO -entries before they are removed from the queue, so a failed queue rewrite can -finish after restart without replaying the message. A write-ahead dispatch -intent without that durable acknowledgement is never guessed or automatically -replayed: it is resolved at-most-once with a bounded -`planner_session.input_delivery_uncertain` event, then later FIFO entries may -continue. A PTY write and a filesystem write cannot provide true exactly-once -delivery without an idempotent external acknowledgement. -When vendor resume falls back to a replacement planner, the whole coordinator -directory is atomically handed to that exact successor before it can receive -input. A later replacement follows the queue-owning predecessor while its -focused rehydration brief may still come from an older recorded ancestor, so a -pre-ready exit cannot orphan or duplicate accepted FIFO work. - -The focused system context contains only bounded project/session identity, -current workspace pointer IDs, and binding references. The current workspace -store does not yet own revision, proposal, or build-plan detail records, so -their bounded digest, summary, status, and warning slots are honestly -`null`/empty until those records land. Local root paths and source inventories -are never included. - -The browser/host token gates every `/api` planner route and is never injected -into a coding-agent PTY. Each PTY instead receives a random `/ingest` capability -bound to its exact session ID; presenting it with another event `sessionId` is -rejected, it grants no `/api` authority, and it is rotated or revoked with the -process lifecycle. A vendor resume pointer is pinned to one harness session; -only a short-lived, one-shot `/clear` or `/resume` transition observed on the -trusted terminal/input path may rotate it, and a pointer already owned by -another harness session is always rejected. Current and rotated pointers are -reserved in a server-private, SHA-256-keyed, mode-`0600` sidecar next to the -session registry; raw historical aliases never enter a browser DTO. Planner -reuse and input additionally require the session cwd to remain one of the -project's current active root bindings and its owner to match the live signed-in -identity (or stable machine-local principal while signed out). - -**Migration note (breaking):** `POST /api/sessions` now rejects unknown fields, -including client-authored planner metadata. Generic -`POST /api/sessions/:id/input`, `POST /api/sessions/:id/resume`, and -`POST /api/sessions/adopt` reject planner sessions. Adopt also returns a -bounded `AGENT_SESSION_IDENTITY_RESERVED` 409 for any ordinary current-owner -conflict or durable historical alias (including a pre-`/clear` or -pre-`/resume` identity), before probing or spawning an agent. -Clients must open, message, and retry planners through the project-scoped -routes above. Generic coding-agent sessions also use the durable vendor-ID pin; -their only rotation exception is the same trusted `/clear`/`/resume` gesture. -On upgrade, if legacy `sessions.json` rows contain the same vendor resume -pointer, the first persisted row keeps it and later duplicate rows are repaired -to `agentSessionId: null`. This does not delete the provider's transcript or -conversation history, but the losing local row can no longer resume or adopt -that fenced identity. Start a fresh session in the losing row's directory to -continue there. +### Project sessions and Agent Map navigation + +Every coding-agent session in a project uses the same writable +`ProjectAgentSession { projectId, userId, sessionId }` identity and common +project instructions. Map access comes from that trusted identity. Roles and +agent assignments do not grant a separate kind of session or capability. + +Click the project name to open its shared Agent Map without starting a coding +agent. Conversation tabs identify exact sessions, and selecting a session +restores its own terminal and Canvas. The map selection and agent Steps +view remain independent of the selected conversation. There is no pinned +planning-session tab; existing session titles and conversations are preserved. + +The authenticated local API resolves project identity from durable project +roots. New sessions, resume, and transcript adoption use the generic session +routes. Resuming a project session revalidates its current signed-in owner and +active root binding before launching a process. Nested coding-agent directories +resolve to their containing project instead of inventing duplicate roots. + +**Migration note (breaking):** `HarnessSession.agentMapIdentity` now contains +only project, user, and session IDs. Stop branching on its former `role` or +`assignment` fields. Valid persisted legacy metadata is normalized while +preserving the session/provider IDs, cwd, title, transcript, and Canvas; +conflicting or malformed authority fails closed. Optional `projectBootstrap` +is lifecycle metadata, not an authority or session type. The legacy startup +projection and project-scoped aliases remain temporarily for existing durable +startup queues. Migrated sessions use ordinary HTTP input, with the server +retaining FIFO ownership until those queues finish. + +`POST /api/sessions` accepts the content-free +`initialUserInputPending: true` hint when the caller owns the first prompt. +Client-authored authority and other unknown fields are rejected. The browser +still submits that prompt to the returned exact session after readiness. + +The browser/host token gates `/api` routes and is never injected into a +coding-agent PTY. Each PTY receives a separate `/ingest` capability bound to its +session ID. Vendor resume pointers remain pinned to one harness session; +current owners and durable historical aliases cannot be adopted into another +session. Duplicate persisted provider IDs are repaired conservatively during +boot, preserving the first owner and clearing the later duplicate pointer. ### Agent Map MCP @@ -173,7 +139,7 @@ renews its inactivity lease, while session exit, resume rotation, signed-in principal changes, and server shutdown revoke it. Consumers should not copy, persist, log, or reuse the capability outside the launched session. -Every trusted Agent Map role receives the same three project-wide tools: +Every trusted project session receives the same three project-wide tools: - `agent_map_read` reads the current confirmed workspace and shared proposal. - `agent_map_validate` validates one complete operation batch without mutating diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 43ada43c0..61d9d06c7 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -101,7 +101,7 @@ import { } from "../core/inject/retention.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; import { projectAgentPromptAppendix } from "../profiles/project-agent.js"; -import { ProjectSessionScopeUnavailableError } from "../core/session-manager.js"; +import { ProjectSessionScopeUnavailableError, SessionNotReadyError } from "../core/session-manager.js"; import { localProjectPrincipal } from "../core/project-session.js"; import { fetchSystemPromptForActiveEnvironment } from "../profiles/system-prompt-fetch.js"; import { agentCoreTemplatesDir } from "../core/agent-core-templates.js"; @@ -178,7 +178,7 @@ import { localPlanningPrincipal, PlanningSessionService, } from "../core/planning-session.js"; -import { PlannerGreetingCoordinator } from "../core/planner-greeting.js"; +import { PlannerDispatchForbiddenError, PlannerGreetingCoordinator } from "../core/planner-greeting.js"; import { IngestCredentialRegistry } from "../core/ingest-credentials.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; @@ -3069,6 +3069,36 @@ export const startServer = async ( workflowsCache.find((w) => w.path === workflowPath) ?? null, writeWorkspaceContext: writeSessionContext, renderCanvas, + // Private compatibility boundary: migrated sessions use the ordinary + // HTTP endpoint while their persisted startup FIFO remains authoritative. + // The project bootstrap cutover replaces this coordinator in one place. + submitSessionInput: async (sessionId, text, submit) => { + const session = sessionManager.get(sessionId); + if (session?.planning) { + if (!(await isPlannerDispatchAuthorized({ + session, + currentPrincipal: () => localPlanningPrincipal(planningUserId, machineId), + resolveProject: (projectId) => studioProjectCatalog.resolveIdentity(projectId), + }))) throw new ProjectSessionScopeUnavailableError(sessionId); + if (submit) { + try { + await plannerGreeting.enqueue(sessionId, text); + } catch (error) { + if (error instanceof PlannerDispatchForbiddenError) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + throw error; + } + return true; + } + // Draft text cannot interleave with a pending automatic greeting. + if (session.planning.greeting.status === "pending" || + session.planning.greeting.status === "generating") { + throw new SessionNotReadyError(sessionId); + } + } + return sessionManager.submitInput(sessionId, text, submit); + }, onTelemetryOptInChange: (optIn) => batcher.setTelemetryOptIn(optIn), onSessionCreated: (cwd, harnessSessionId) => { scanWorkflowsAndBroadcast(cwd, "session-create", { dirty: true }) diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index 250f9bd76..a007e6461 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -24,6 +24,7 @@ import type { } from "../shared/types.js"; import { SessionManager, + ProjectSessionScopeUnavailableError, SessionNotReadyError, UnknownSessionError, } from "../core/session-manager.js"; @@ -110,6 +111,20 @@ function exitedSession( }; } +/** A valid legacy record normalized by SessionManager during boot. */ +function migratedProjectSession(): HarnessSession { + const identity = { projectId: "project-1", sessionId: "migrated-1", userId: "user-1" }; + return exitedSession({ + id: identity.sessionId, + agentMapIdentity: identity, + planning: { + identity: { ...identity, role: "map-planner" }, + greeting: { status: "delivered", messageId: "message-1" }, + queuedInputIds: [], + }, + }); +} + /** A history adapter whose resumability answer and transcript listing are both * controllable — the two independent inputs the history endpoint merges. */ function historyAdapter( @@ -440,6 +455,27 @@ describe("createRestRouter", () => { }); describe("POST /sessions", () => { + it("accepts the composer's content-free first-input hint", async () => { + const sessionManager = fakeSessionManager(); + (sessionManager.create as ReturnType).mockResolvedValue( + exitedSession({ status: "starting" }), + ); + start({ sessionManager }); + const response = await fetch(`${baseUrl}/sessions`, { + method: "POST", + headers: { ...TOKEN_HEADER, "content-type": "application/json" }, + body: JSON.stringify({ + cwd: "/tmp/proj", + harness: "claude-code", + initialUserInputPending: true, + }), + }); + expect(response.status).toBe(201); + expect(sessionManager.create).toHaveBeenCalledWith( + expect.objectContaining({ initialUserInputPending: true }), + ); + }); + it("calls onSessionCreated with the new session's cwd and id", async () => { const onSessionCreated = vi.fn(); const sessionManager = fakeSessionManager(); @@ -738,6 +774,37 @@ describe("createRestRouter", () => { }); describe("POST /sessions/:id/input", () => { + it("routes migrated session input through the durable lifecycle boundary", async () => { + const session = migratedProjectSession(); + const sessionManager = fakeSessionManager([session]); + const submitSessionInput = vi.fn(async () => true); + start({ sessionManager, submitSessionInput }); + const response = await fetch(`${baseUrl}/sessions/${session.id}/input`, { + method: "POST", + headers: { ...TOKEN_HEADER, "content-type": "application/json" }, + body: JSON.stringify({ text: "keep this request" }), + }); + expect(response.status).toBe(200); + expect(submitSessionInput).toHaveBeenCalledWith(session.id, "keep this request", true); + expect(sessionManager.submitInput).not.toHaveBeenCalled(); + }); + + it("returns a scope conflict when a migrated session loses authority", async () => { + const session = migratedProjectSession(); + const sessionManager = fakeSessionManager([session]); + const submitSessionInput = vi.fn(async () => { + throw new ProjectSessionScopeUnavailableError(session.id); + }); + start({ sessionManager, submitSessionInput }); + const response = await fetch(`${baseUrl}/sessions/${session.id}/input`, { + method: "POST", + headers: { ...TOKEN_HEADER, "content-type": "application/json" }, + body: JSON.stringify({ text: "must not dispatch" }), + }); + expect(response.status).toBe(409); + expect(sessionManager.submitInput).not.toHaveBeenCalled(); + }); + it("submits input and returns ok:true", async () => { const sessionManager = fakeSessionManager(); start({ sessionManager }); @@ -965,6 +1032,19 @@ describe("createRestRouter", () => { }); describe("POST /sessions/:id/resume — error class → HTTP status mapping", () => { + it("resumes a migrated project session by its exact ordinary session id", async () => { + const session = migratedProjectSession(); + const sessionManager = fakeSessionManager([session]); + (sessionManager.resume as ReturnType).mockResolvedValue(session); + start({ sessionManager }); + const response = await fetch(`${baseUrl}/sessions/${session.id}/resume`, { + method: "POST", + headers: TOKEN_HEADER, + }); + expect(response.status).toBe(200); + expect(sessionManager.resume).toHaveBeenCalledWith(session.id); + }); + it("requires planner resume to use the trusted project resolver", async () => { const planner = exitedSession({ id: "planner-1", @@ -1400,6 +1480,16 @@ describe("createRestRouter", () => { expect(sessionManager.resume).toHaveBeenCalledWith("sess-existing"); }); + it("adopts a migrated project conversation through its existing session", async () => { + const session = { ...migratedProjectSession(), agentSessionId: body.agentSessionId }; + const sessionManager = fakeSessionManager([session]); + (sessionManager.resume as ReturnType).mockResolvedValue(session); + start({ sessionManager, adapters: { "claude-code": historyAdapter() } }); + expect((await adopt(body)).status).toBe(200); + expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); + expect(sessionManager.resume).toHaveBeenCalledWith(session.id); + }); + it("requires an existing foreign-owned planner to use its scoped route without mutation", async () => { const planner = exitedSession({ id: "planner-existing", diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index a59ca718f..3744064c2 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -86,6 +86,7 @@ const createSessionSchema = z.object({ profile: z.string().optional(), rehydrateFrom: z.string().min(1).optional(), theme: z.enum(["light", "dark"]).optional(), + initialUserInputPending: z.boolean().optional(), }).strict() satisfies z.ZodType; const injectInputSchema = z.object({ @@ -232,6 +233,13 @@ export interface RestRouterOptions { * session in a new project discovers them without a manual "+ Connect") * and, when the scan discovers one, render the new session's canvas. */ onSessionCreated?: (cwd: string, harnessSessionId: string) => void; + /** Keeps migrated sessions on their durable startup FIFO while the + * project bootstrap coordinator replaces the legacy implementation. */ + submitSessionInput?: ( + sessionId: string, + text: string, + submit: boolean, + ) => Promise; /** The directory the CLI was launched against — surfaced in AppState so the * SPA can prefill the new-session modal with it. */ launchDir: string; @@ -728,9 +736,9 @@ export function createRestRouter(options: RestRouterOptions): Router { const { agentSessionId, harness, title, lastActiveAt } = parsed.data; const cwd = normalizeCwd(parsed.data.cwd); try { - // Resolve an already-owned registry row before probing or mutating any - // adapter state. Generic adoption must never bypass the project/user - // authority and focused-context checks on the scoped planner route. + // Resolve ownership before probing or mutating adapter state. Valid + // migrated records use ordinary resume, whose manager revalidates their + // neutral project identity. Unmigrated legacy authority stays fenced. const durableOwner = sessionManager.getAgentSessionOwner(agentSessionId); const identityReserved = sessionManager.isAgentSessionIdentityReserved(agentSessionId); @@ -738,8 +746,10 @@ export function createRestRouter(options: RestRouterOptions): Router { .list() .filter((session) => session.agentSessionId === agentSessionId); if ( - durableOwner?.planning !== undefined || - identityOwners.some((session) => session.planning !== undefined) + (durableOwner?.planning !== undefined && !durableOwner.agentMapIdentity) || + identityOwners.some( + (session) => session.planning !== undefined && !session.agentMapIdentity, + ) ) { res.status(409).json({ code: "planner_session_requires_scoped_route", @@ -747,10 +757,8 @@ export function createRestRouter(options: RestRouterOptions): Router { }); return; } - // For ordinary sessions, cwd remains part of the historical-record - // identity. It is deliberately checked only after the vendor id has - // been fenced from every planner owner above: client-supplied cwd must - // not alias around the scoped planner route. + // Cwd remains part of the historical-record identity. Client-supplied + // paths cannot alias around current ownership or a durable reservation. const existing = identityOwners.find( (session) => normalizeCwd(session.cwd) === cwd, ); @@ -825,7 +833,8 @@ export function createRestRouter(options: RestRouterOptions): Router { }); router.post("/sessions/:id/resume", async (req, res, next) => { - if (sessionManager.get(req.params.id)?.planning) { + const existing = sessionManager.get(req.params.id); + if (existing?.planning && !existing.agentMapIdentity) { res.status(409).json({ code: "planner_session_requires_scoped_route", error: "Planner sessions must be resumed through their project route", @@ -857,7 +866,11 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(400).json({ error: parsed.error.message }); return; } - if (sessionManager.get(req.params.id)?.planning) { + const existing = sessionManager.get(req.params.id); + if ( + existing?.planning && + (!existing.agentMapIdentity || !options.submitSessionInput) + ) { res.status(409).json({ code: "planner_session_requires_scoped_route", error: "Planner input must use the project-scoped message route", @@ -866,11 +879,9 @@ export function createRestRouter(options: RestRouterOptions): Router { } try { const submit = parsed.data.submit ?? true; - const ok = await sessionManager.submitInput( - req.params.id, - parsed.data.text, - submit, - ); + const ok = options.submitSessionInput + ? await options.submitSessionInput(req.params.id, parsed.data.text, submit) + : await sessionManager.submitInput(req.params.id, parsed.data.text, submit); if (!ok) { res.status(404).json({ error: "session not found or has no live pty" }); return; @@ -879,7 +890,8 @@ export function createRestRouter(options: RestRouterOptions): Router { } catch (err) { if ( err instanceof SessionNotReadyError || - err instanceof ExternalHarnessError + err instanceof ExternalHarnessError || + err instanceof ProjectSessionScopeUnavailableError ) { res.status(409).json({ error: err.message, code: err.code }); return; diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 75b222688..02761e590 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -1087,6 +1087,9 @@ export interface AgentScaffoldResponse { export type UiTheme = "light" | "dark"; export interface CreateSessionRequest { + /** An explicit UI action already owns the first user input. Bootstrap consumes + * this hint when activated; it never contributes session authority. */ + initialUserInputPending?: boolean; cwd: string; harness: HarnessKind; /** Profile id; omit for default. */ diff --git a/packages/harness/web/e2e/agent-map-planning.spec.ts b/packages/harness/web/e2e/agent-map-planning.spec.ts deleted file mode 100644 index 38b6ce05c..000000000 --- a/packages/harness/web/e2e/agent-map-planning.spec.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { expect, test } from "@playwright/test"; -import type { Page } from "@playwright/test"; - -async function openDashboardMap(page: Page): Promise { - const group = page.getByTestId("workspace-group-dashboard-keeper"); - await expect(group.getByTestId("agent-map-row")).toBeVisible(); - await group.getByTestId("agent-map-select").click(); -} - -async function activeSessionId(page: Page): Promise { - return page.getByTestId("session-context").getAttribute("data-session-id"); -} - -async function openPlannerSessionCallCount(page: Page): Promise { - return page.evaluate( - () => - ( - window as unknown as { - __HARNESS_TEST__?: { openPlannerSessionCalls?: unknown[] }; - } - ).__HARNESS_TEST__?.openPlannerSessionCalls?.length ?? 0, - ); -} - -test.describe("SAP-3058 Agent Map planning workspace", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); - await expect(page.locator(".rail-workflows")).toBeVisible(); - }); - - test("first open starts the raw planner CLI beside the honest empty map", async ({ - page, - }) => { - await openDashboardMap(page); - - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); - await expect(page.getByTestId("agent-map-empty")).toHaveText( - "Nothing generated yet", - ); - - const [cli, map] = await Promise.all([ - terminal.boundingBox(), - page.getByTestId("agent-map-empty").boundingBox(), - ]); - expect(cli?.width ?? 0).toBeGreaterThan(200); - expect(map?.width ?? 0).toBeGreaterThan(200); - expect(map?.x ?? 0).toBeGreaterThan((cli?.x ?? 0) + (cli?.width ?? 0) - 2); - await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning.png", - fullPage: true, - }); - - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).map((event) => event.event), - ), - ) - .toContain("agent_map.entered"); - }); - - test("renders the stock-research proposal and a coding-agent follow-up live", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("agent-map-live")).toBeVisible({ - timeout: 1_000, - }); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).map((event) => event.event), - ), - ) - .toContain("agent_map.proposal_visible"); - - const nodes = page.locator("[data-proposal-state='proposed']"); - await expect(nodes).toHaveCount(6); - for (const kind of [ - "agent", - "subagent", - "resource", - "connector", - "artifact", - ]) { - await expect( - page.locator(`[data-node-kind='${kind}']`).first(), - ).toBeVisible(); - } - await expect( - page.getByText("Stock Research", { exact: true }), - ).toBeVisible(); - await expect(page.getByText("Marketing", { exact: true })).toBeVisible(); - await expect( - page.getByText("Research Database", { exact: true }), - ).toBeVisible(); - await expect(page.getByText("TikTok", { exact: true })).toBeVisible(); - await expect( - page.getByText("ResearchReport", { exact: true }), - ).toBeVisible(); - await expect(page.getByText("News Editor", { exact: true })).toBeVisible(); - await expect( - page.getByTestId("agent-map-live").getByText(/capability/i), - ).toHaveCount(0); - - const researchReport = page.getByRole("button", { - name: "ResearchReport, artifact, Proposed", - }); - await researchReport.click(); - const inspector = page.getByTestId("agent-map-inspector"); - await expect(inspector).toContainText("Purpose"); - await expect(inspector).toContainText("Contracts"); - await expect(inspector).toContainText("Map planner"); - await page.getByRole("button", { name: "Close node details" }).click(); - await expect(inspector).toHaveCount(0); - await expect(researchReport).toBeFocused(); - - await researchReport.click(); - await page.keyboard.press("Escape"); - await expect(inspector).toHaveCount(0); - await expect(researchReport).toBeFocused(); - - await page.getByRole("button", { name: "Zoom in" }).click(); - const mapSubject = page.getByTestId("agent-map-subject"); - const transformedView = await mapSubject.evaluate( - (element) => (element as HTMLElement).style.transform, - ); - - const projectId = await page - .getByTestId("agent-map-live") - .getAttribute("data-project-id"); - expect(projectId).toBeTruthy(); - await page.evaluate((activeProjectId) => { - const test = ( - window as unknown as { - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - } - ).__HARNESS_TEST__; - test?.publish?.({ - type: "agent-map.proposal.changed", - delta: { - schemaVersion: 1, - projectId: activeProjectId, - proposalId: "proposal_00000000-0000-7000-8000-000000000101", - fromVersion: 1, - version: 2, - operationIds: ["operation_00000000-0000-7000-8000-000000000401"], - operations: [ - { - kind: "update-node", - nodeId: "node_00000000-0000-7000-8000-000000000102", - changes: { name: "Campaign Marketing" }, - }, - ], - actor: { - userId: "user_mock", - sessionId: "builder_mock", - role: "agent-builder", - assignment: { kind: "unplanned" }, - }, - acceptedAt: new Date().toISOString(), - }, - }); - }, projectId); - await expect( - page.getByText("Campaign Marketing", { exact: true }), - ).toBeVisible(); - await expect - .poll(() => - mapSubject.evaluate( - (element) => (element as HTMLElement).style.transform, - ), - ) - .toBe(transformedView); - await page.getByText("Campaign Marketing", { exact: true }).click(); - await expect( - page.getByTestId("agent-map-latest-attribution"), - ).toContainText("Agent builder · unplanned"); - await expect(page.locator("[data-proposal-state='proposed']")).toHaveCount( - 6, - ); - - await page.evaluate((activeProjectId) => { - const test = ( - window as unknown as { - __HARNESS_TEST__?: { publish?: (message: unknown) => void }; - } - ).__HARNESS_TEST__; - test?.publish?.({ - type: "agent-map.proposal.changed", - delta: { - schemaVersion: 1, - projectId: activeProjectId, - proposalId: "proposal_00000000-0000-7000-8000-000000000101", - fromVersion: 2, - version: 3, - operationIds: ["operation_00000000-0000-7000-8000-000000000402"], - operations: [ - { - kind: "update-node", - nodeId: "node_00000000-0000-7000-8000-000000000101", - changes: { name: "Equity Research" }, - }, - ], - actor: { - userId: "user_mock", - sessionId: "planner_mock", - role: "map-planner", - assignment: null, - }, - acceptedAt: new Date().toISOString(), - }, - }); - }, projectId); - await expect( - page.getByText("Equity Research", { exact: true }), - ).toBeVisible(); - await expect( - page.getByTestId("agent-map-latest-attribution"), - ).toContainText("Agent builder · unplanned"); - }); - - test("expands the Agent Map in place and unwinds its inspector before full view", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - - const map = page.getByTestId("agent-map-live"); - await expect(map).toBeVisible(); - const subject = page.getByTestId("agent-map-subject"); - await page.getByRole("button", { name: "Zoom in" }).click(); - const adjustedView = await subject.evaluate( - (element) => (element as HTMLElement).style.transform, - ); - - const expand = page.getByTestId("canvas-expand"); - await expect(expand).toHaveAccessibleName("Expand Agent Map"); - await expand.click(); - - const frame = page.getByTestId("agent-map-frame"); - await expect(frame).toHaveClass(/is-expanded/); - await expect(frame).toHaveCSS("position", "fixed"); - await expect(page.getByTestId("resize-handle-rail")).toHaveCount(0); - await expect(page.getByTestId("resize-handle-canvas")).toHaveCount(0); - await expect - .poll(() => - subject.evaluate((element) => (element as HTMLElement).style.transform), - ) - .toBe(adjustedView); - - await page - .getByRole("button", { name: "ResearchReport, artifact, Proposed" }) - .click(); - await expect(page.getByTestId("agent-map-inspector")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("agent-map-inspector")).toHaveCount(0); - await expect(frame).toHaveClass(/is-expanded/); - - await page.keyboard.press("Escape"); - await expect(frame).not.toHaveClass(/is-expanded/); - await expect(page.getByTestId("resize-handle-rail")).toBeVisible(); - await expect(page.getByTestId("resize-handle-canvas")).toBeVisible(); - - await expand.click(); - await page.getByTestId("canvas-expand-exit").click(); - await expect(frame).not.toHaveClass(/is-expanded/); - }); - - test("a generating greeting still renders the raw planner CLI", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=generating", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); - }); - - test("return resumes the same planner and plus creates a fresh planner tab", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const first = await activeSessionId(page); - expect(first).toBeTruthy(); - - await page - .getByTestId("workflow-dashboard-keeper") - .locator("button") - .click(); - // Moving down from the project map to an agent changes only the right-hand - // subject. The coding-agent CLI stays mounted in the centre and the - // selected agent's canvas/step graph becomes available beside it. - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); - await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); - await expect(page.locator(".canvas-iframe")).toBeVisible(); - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("right-tab-canvas")).toContainText( - "Agent Map", - ); - await expect(page.getByTestId("right-tab-steps")).toBeDisabled(); - expect(await activeSessionId(page)).toBe(first); - - await page.getByTestId("session-tab-new").click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - const second = await activeSessionId(page); - await expect( - page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"), - ).toHaveCount(2); - await expect(page.locator(".harness-terminal")).toBeVisible(); - - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await page.getByTestId("end-session-confirm-btn").click(); - expect(second).not.toBe(first); - await expect.poll(() => activeSessionId(page)).toBe(first); - await expect( - page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"), - ).toHaveCount(1); - await expect(page.locator(".harness-terminal")).toBeVisible(); - }); - - test("an explicitly selected planner tab wins over project resume ordering", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const first = await activeSessionId(page); - expect(first).toBeTruthy(); - - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-rename").click(); - const rename = page.getByTestId("session-rename-input"); - await rename.fill("Planner A"); - await rename.press("Enter"); - - await page.getByTestId("session-tab-new").click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - const callsBeforeExplicitSelection = - await openPlannerSessionCallCount(page); - - await page - .getByTestId("workflow-dashboard-keeper") - .locator("button") - .click(); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); - await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); - - await page.getByTestId("palette-trigger").click(); - await page.getByTestId("command-palette-input").fill("Planner A"); - await page - .getByTestId("command-palette-list") - .getByText("Planner A", { exact: true }) - .click(); - - await expect(page.locator(".harness-terminal")).toBeVisible(); - expect(await activeSessionId(page)).toBe(first); - await expect(page.getByTestId("planner-loading")).toHaveCount(0); - expect(await openPlannerSessionCallCount(page)).toBe( - callsBeforeExplicitSelection, - ); - }); - - test("planner session chrome retains rename/end and omits path/editor actions", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - - await page.getByTestId("session-menu").click(); - const menu = page.getByTestId("session-menu-popover"); - await expect(menu.getByTestId("session-rename")).toBeVisible(); - await expect(menu.getByTestId("session-end-btn")).toBeVisible(); - await expect(menu.getByText("Copy path", { exact: true })).toHaveCount(0); - await expect(menu.getByTestId("session-open-editor")).toHaveCount(0); - }); - - test("ending the planner exposes an immediate fresh-session path", async ({ - page, - }) => { - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - const ended = await activeSessionId(page); - - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "stops the planning conversation", - ); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "fresh planning session from Plan Agents", - ); - await expect(page.getByTestId("end-session-confirm")).not.toContainText( - "live terminal", - ); - await page.getByTestId("end-session-confirm-btn").click(); - - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); - const startFresh = page.getByTestId("session-tab-new"); - await expect(startFresh).toHaveAttribute( - "aria-label", - "New planning session", - ); - await startFresh.click(); - - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect.poll(() => activeSessionId(page)).not.toBe(ended); - }); - - test("a failed greeting still leaves the raw planner CLI visible", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=failed", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); - }); - - test("workspace and planner failures stay local, while unauthorized is whole-workspace", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=error", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { - trackEvents?: Array<{ - event: string; - data?: Record; - }>; - }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).some( - (event) => - event.event === "agent_map.workspace_load_failed" && - event.data?.pane === "map", - ), - ), - ) - .toBe(true); - - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockPlanner=error", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.getByTestId("planner-load-error")).toBeVisible(); - await expect(page.getByTestId("agent-map-empty")).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { - trackEvents?: Array<{ - event: string; - data?: Record; - }>; - }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).some( - (event) => - event.event === "agent_map.workspace_load_failed" && - event.data?.pane === "planner", - ), - ), - ) - .toBe(true); - - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=unauthorized", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.getByTestId("agent-map-unavailable")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - await expect(page.getByTestId("resize-handle-canvas")).toHaveCount(0); - }); -}); - -test.describe("SAP-3058 mobile Agent Map", () => { - test.use({ viewport: { width: 375, height: 812 } }); - - test("the raw CLI stays primary and the explicit sheet restores focus on close", async ({ - page, - }) => { - await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); - await expect(page.getByTestId("rail-expand")).toBeVisible(); - await page.getByTestId("rail-expand").click(); - await openDashboardMap(page); - - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - await expect(page.getByTestId("session-menu")).toBeVisible(); - const openMap = page.getByTestId("right-expand"); - await expect(openMap).toHaveText("Agent Map"); - - await openMap.click(); - await expect(page.locator(".right-pane")).toBeVisible(); - await expect(page.getByTestId("agent-map-empty")).toBeVisible(); - await expect(page.getByTestId("right-sheet-scrim")).toBeVisible(); - - await page.keyboard.press("Control+K"); - await expect(page.getByTestId("command-palette-input")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("command-palette-input")).toHaveCount(0); - await expect(page.locator(".right-pane")).toBeVisible(); - - await page.getByTestId("right-collapse").click(); - await expect(page.locator(".right-pane")).toBeHidden(); - await expect(page.getByTestId("right-expand")).toBeFocused(); - await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning-mobile.png", - fullPage: true, - }); - }); - - test("a failed preference restore cannot repeatedly close the map sheet", async ({ - page, - }) => { - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockStudioPreference=error", - ); - await expect(page.locator(".harness-terminal")).toBeVisible(); - await expect(page.locator(".right-pane")).toBeHidden(); - - // The mock preference read has one 180 ms round trip. Let its one-shot - // failure settle before the user explicitly opens the sheet; the assertion - // below then guards against later session updates closing it again. - await page.waitForTimeout(250); - const first = await activeSessionId(page); - const newSession = page.getByTestId("session-tab-new"); - await expect(newSession).toBeEnabled(); - await newSession.click(); - await expect.poll(() => activeSessionId(page)).not.toBe(first); - - // Open the sheet while the new planner is still launching. Its automatic - // ready/status event arrives later and must not replay the failed preference - // restore or collapse the user-opened Agent Map. - await page.getByTestId("right-expand").click(); - await expect(page.locator(".right-pane")).toBeVisible(); - await expect( - page.locator(".session-dot[data-status='running']"), - ).toBeVisible(); - await expect(page.locator(".right-pane")).toBeVisible(); - }); -}); diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index b04986a1f..4994aac31 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -39,7 +39,6 @@ const sessionEvidence = ( injectInputCalls: number; injectedSessionId: string | null; injectedText: string; - openPlannerSessionCalls: number; }> => page.evaluate(() => { const testState = ( @@ -48,7 +47,6 @@ const sessionEvidence = ( createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; lastInjectInput?: { id?: string; req?: { text?: string } }; - openPlannerSessionCalls?: unknown[]; }; } ).__HARNESS_TEST__; @@ -61,7 +59,6 @@ const sessionEvidence = ( injectInputCalls: testState?.injectInputCalls?.length ?? 0, injectedSessionId: testState?.lastInjectInput?.id ?? null, injectedText: testState?.lastInjectInput?.req?.text ?? "", - openPlannerSessionCalls: testState?.openPlannerSessionCalls?.length ?? 0, }; }); @@ -107,25 +104,64 @@ test("describing an outcome starts a session and hands the agent that outcome", .toContain("Diff our competitors' pricing pages"); }); -test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents is explicitly selected", async ({ +test("Enter keeps a new-agent prompt in its exact session while the project map is inspected", async ({ page, }) => { await page.goto("/?seed=0&mockNoLiveSessions=1&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); - // The parent project exists, but with no live session it has never restored - // its default Plan Agents workspace. Creating beneath it must not give that - // parent restore a head start over the explicit standalone builder intent. + // The parent project exists, but has no live session or restored workspace. + // Creating beneath it must not give preference restoration a head start over + // the explicit standalone-session intent. await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); const before = await sessionEvidence(page); expect(before.activeSessionId).toBeNull(); expect(before.createSessionCalls).toBe(0); - expect(before.openPlannerSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a sales outreach agent."; await page.getByTestId("composer-input").fill(idea); + await page.evaluate(() => { + // Hold the explicit create open long enough for the project's automatic + // first session to arrive and become active first. + ( + window as unknown as { __MOCK_CREATE_SESSION_DELAY_MS__?: number } + ).__MOCK_CREATE_SESSION_DELAY_MS__ = 2_000; + }); await page.getByTestId("composer-input").press("Enter"); + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-competing-plan-agents", + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code", + cwd: "/Users/demo/acme-app/projects/build-sales-outreach", + title: "Plan Agents", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + ready: true, + agentMapIdentity: { + projectId: "project_ffffffff-ffff-4fff-8fff-ffffffffffff", + userId: "user_mock", + sessionId: "sess-competing-plan-agents", + }, + }, + }); + }); + await selectMockSessionFromPalette(page, "Plan Agents"); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-competing-plan-agents", + ); + await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect .poll(async () => (await sessionEvidence(page)).injectedText) @@ -133,40 +169,57 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents const evidence = await sessionEvidence(page); expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); - expect(evidence.openPlannerSessionCalls).toBe(before.openPlannerSessionCalls); expect(evidence.injectedSessionId).not.toBeNull(); + expect(evidence.injectedSessionId).not.toBe("sess-competing-plan-agents"); expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); expect(evidence.activeSessionId).not.toBe(before.activeSessionId); expect(evidence.injectInputCalls).toBe(before.injectInputCalls + 1); + expect( + await page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + lastCreateSession?: { + req?: { initialUserInputPending?: boolean }; + }; + }; + } + ).__HARNESS_TEST__?.lastCreateSession?.req?.initialUserInputPending, + ), + ).toBe(true); const project = page.getByTestId( "workspace-group-acme-app/projects/build-sales-outreach", ); - const planAgents = project.getByTestId("agent-map-select"); - await expect(planAgents).toHaveAttribute("aria-pressed", "false"); + const projectMap = project.getByTestId( + "project-select-acme-app/projects/build-sales-outreach", + ); + await expect(projectMap).toHaveAttribute("aria-pressed", "false"); + await expect(project.getByTestId("agent-map-row")).toHaveCount(0); - await planAgents.click(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBe(before.openPlannerSessionCalls + 1); - await expect(planAgents).toHaveAttribute("aria-pressed", "true"); + const beforeMap = await sessionEvidence(page); + await projectMap.click(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(projectMap).toHaveAttribute("aria-pressed", "true"); + expect(await sessionEvidence(page)).toEqual(beforeMap); + + await page + .getByTestId(`session-tab-main-${evidence.activeSessionId}`) + .click(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + expect((await sessionEvidence(page)).activeSessionId).toBe( + evidence.activeSessionId, + ); }); -test("returning to an in-progress standalone builder does not restore Plan Agents", async ({ +test("returning to an in-progress standalone session does not restore the project map", async ({ page, }) => { await page.goto("/?seed=0&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBeGreaterThan(0); - await expect - .poll(async () => { - const evidence = await sessionEvidence(page); - return evidence.createSessionCalls - evidence.openPlannerSessionCalls; - }) - .toBe(0); const before = await sessionEvidence(page); + expect(before.createSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a revisit guard agent."; @@ -192,7 +245,8 @@ test("returning to an in-progress standalone builder does not restore Plan Agent .poll(async () => (await sessionEvidence(page)).injectedText) .toContain(idea); // Let the session we deliberately visited finish its own normal restore; - // only planner work caused by returning to the builder is under test. + // only map restoration caused by returning to the explicit session is under + // test. await page.waitForTimeout(500); const beforeReturn = await sessionEvidence(page); @@ -204,14 +258,12 @@ test("returning to an in-progress standalone builder does not restore Plan Agent expect((await sessionEvidence(page)).activeSessionId).not.toBe(awaySessionId); await page.waitForTimeout(500); const afterReturn = await sessionEvidence(page); - expect(afterReturn.openPlannerSessionCalls).toBe( - beforeReturn.openPlannerSessionCalls, - ); await expect( page .getByTestId("workspace-group-acme-app/projects/build-revisit-guard") - .getByTestId("agent-map-select"), + .getByTestId("project-select-acme-app/projects/build-revisit-guard"), ).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); }); test("a picked file reaches the first request without naming the project", async ({ @@ -637,7 +689,9 @@ for (const agent of [ // Make the next session never reach ready on its own — the stand-in for a // user still on an agent's login, trust, or onboarding screen. await page.addInitScript(() => { - (window as unknown as { __MOCK_WITHHOLD_READY__?: boolean }).__MOCK_WITHHOLD_READY__ = true; + ( + window as unknown as { __MOCK_WITHHOLD_READY__?: boolean } + ).__MOCK_WITHHOLD_READY__ = true; }); await page.goto("/?seed=0"); await expect(page.locator(".rail-workflows")).toBeVisible(); @@ -646,7 +700,9 @@ for (const agent of [ if (agent.id === "codex") { await page.getByTestId("composer-harness-select").click(); await page.getByTestId("composer-harness-option-codex").click(); - await expect(page.getByTestId("composer-harness-select")).toContainText("Codex"); + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Codex", + ); } const prompt = `Summarise my ${agent.label} inbox every morning.`; await page.getByTestId("composer-input").fill(prompt); @@ -661,7 +717,9 @@ for (const agent of [ () => ( window as unknown as { - __HARNESS_TEST__?: { lastCreateSession?: { req?: { harness?: string } } }; + __HARNESS_TEST__?: { + lastCreateSession?: { req?: { harness?: string } }; + }; } ).__HARNESS_TEST__?.lastCreateSession?.req?.harness, ); @@ -679,7 +737,9 @@ for (const agent of [ // not inject the held intent a second time. await page.evaluate(() => ( - window as unknown as { __HARNESS_TEST__?: { promoteReady?: () => void } } + window as unknown as { + __HARNESS_TEST__?: { promoteReady?: () => void }; + } ).__HARNESS_TEST__?.promoteReady?.(), ); await expect.poll(() => lastInjectText(page)).toContain(prompt); @@ -687,7 +747,9 @@ for (const agent of [ await page.evaluate(() => ( - window as unknown as { __HARNESS_TEST__?: { promoteReady?: () => void } } + window as unknown as { + __HARNESS_TEST__?: { promoteReady?: () => void }; + } ).__HARNESS_TEST__?.promoteReady?.(), ); await page.waitForTimeout(500); diff --git a/packages/harness/web/e2e/open-project.spec.ts b/packages/harness/web/e2e/open-project.spec.ts index a3af716e5..dd14596e9 100644 --- a/packages/harness/web/e2e/open-project.spec.ts +++ b/packages/harness/web/e2e/open-project.spec.ts @@ -46,6 +46,8 @@ test.describe("the header + opens a project", () => { test("a folder with NO agent in it becomes a project row, and survives a reload", async ({ page, }) => { + await page.goto("/?mockFixtures=agent-map&mockAutoPlanAgents=1"); + await expect(page.locator(".rail-workflows")).toBeVisible(); await expect(page.getByTestId("project-row-blank-slate")).toHaveCount(0); await page.getByTestId("rail-add-project").click(); @@ -68,15 +70,53 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); const group = page.getByTestId("workspace-group-blank-slate"); - // First visit lands on the pinned Agent Map without a label click. - await expect(group.getByTestId("agent-map-row")).toBeVisible(); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "true", - ); + // The project itself owns its read-only Agent Map destination; there is no + // pinned planning row masquerading as a session. + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect( + group.getByTestId("project-select-blank-slate"), + ).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toHaveText( "Nothing generated yet", ); + // The server-owned project-open lifecycle contributes one real ordinary + // session. Plan Agents is only that tab's initial title—never a pinned row + // or a second synthetic navigation element. + const tabs = page.locator(".session-tabs-list > .session-tab"); + await expect(tabs).toHaveCount(1); + await expect(page.getByText("Plan Agents", { exact: true })).toHaveCount(1); + const firstSessionId = ( + await tabs.first().getAttribute("data-testid") + )?.replace("session-tab-", ""); + expect(firstSessionId).toBeTruthy(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { createSessionCalls?: unknown[] }; + } + ).__HARNESS_TEST__?.createSessionCalls?.length ?? 0, + ), + ) + .toBe(0); + + await page.getByTestId(`session-tab-main-${firstSessionId}`).click(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + firstSessionId!, + ); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + + await group.getByTestId("project-select-blank-slate").click(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + firstSessionId!, + ); + await expect(tabs).toHaveCount(1); // The row is REMEMBERED, not just rendered: `recentDirs` is the harness's // one workspace list, and the whole rail re-derives from it when the axis // changes. (A cross-RELOAD assertion belongs against a real server — the @@ -88,7 +128,7 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); }); - test("a planning project keeps sessions direct while hiding standalone agent creation", async ({ + test("a Studio project keeps ordinary session and agent creation available", async ({ page, }) => { await page.getByTestId("rail-add-project").click(); @@ -96,37 +136,28 @@ test.describe("the header + opens a project", () => { await page.getByTestId("open-project").click(); const group = page.getByTestId("workspace-group-blank-slate"); - await expect(group.getByTestId("agent-map-row")).toBeVisible(); + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); - // Once the initial planner is no longer a live bare session, the old rail - // exposed its standalone-agent shortcut again. Ending it reproduces the - // stable empty-project state from the reported sidebar. - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await page.getByTestId("end-session-confirm-btn").click(); - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); - - await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0); + await expect(group.getByTestId("project-empty-blank-slate")).toBeVisible(); await expect( group.getByTestId("project-start-session-blank-slate"), ).toHaveAttribute("aria-label", "Start a session in blank-slate"); - // The visible empty row and the project menu used to be two doors into the - // same direct-create flow. A planning project exposes neither: generated - // agents enter through an approved map, while project removal remains an - // ordinary project-level action. + // The map is a view, not an authorization gate. Both direct agent creation + // and project removal remain ordinary project-level actions. await openProjectMenu(page, "blank-slate"); await expect( page.getByTestId("project-create-agent-blank-slate"), - ).toHaveCount(0); + ).toBeVisible(); await expect(page.getByTestId("project-remove-blank-slate")).toBeVisible(); await page.keyboard.press("Escape"); - // The same ownership rule covers a bare project with an existing session: - // its former in-session scaffold action cannot bypass planning either. + // A bare project with an existing ordinary session retains its scaffold + // action too. await openProjectMenu(page, "scratch"); - await expect(page.getByTestId("workspace-scaffold-scratch")).toHaveCount(0); + await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible(); await expect(page.getByTestId("project-remove-scratch")).toBeVisible(); // NOT the rail-wide empty state leaking down: that one says "No agents yet" diff --git a/packages/harness/web/e2e/polsia-workspace.spec.ts b/packages/harness/web/e2e/polsia-workspace.spec.ts index 54449dafa..d91d2a2f9 100644 --- a/packages/harness/web/e2e/polsia-workspace.spec.ts +++ b/packages/harness/web/e2e/polsia-workspace.spec.ts @@ -116,13 +116,10 @@ test("a Polsia-style Project opens its complete graph without a session and reus 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); - // The way back UP is derived from containment, so it exists on an agent - // reached from the map exactly as it does on one reached from the rail. - await expect(page.getByTestId("canvas-altitude-up")).toHaveAttribute( - "aria-label", - "Back to the polsia map", - ); - await page.getByTestId("canvas-altitude-up").click(); + // 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); }); diff --git a/packages/harness/web/e2e/project-altitude.spec.ts b/packages/harness/web/e2e/project-altitude.spec.ts index 6bf7a645b..cb23e82dc 100644 --- a/packages/harness/web/e2e/project-altitude.spec.ts +++ b/packages/harness/web/e2e/project-altitude.spec.ts @@ -122,15 +122,23 @@ test("E3.3 — the tab strip is the PROJECT's sessions, including the ones bound // 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(); + 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 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(); + 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 ({ @@ -247,7 +255,7 @@ test("E3.5 — crossing to another project DOES hand the conversation over", asy ); }); -test("E3.7/E3.8 — drilling from a map node cuts down and the way back cuts up, rail following both", async ({ +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(); @@ -268,9 +276,8 @@ test("E3.7/E3.8 — drilling from a map node cuts down and the way back cuts up, ); await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); - const up = page.getByTestId("canvas-altitude-up"); - await expect(up).toHaveAttribute("aria-label", "Back to the acme-app map"); - await up.click(); + 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(); @@ -340,9 +347,17 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a project selected over an EXITED session gave the strip the project's tabs and the handler the exited session's own subject, and Cmd+1 activated a session that was not tab 1. */ - await page.goto("/?seed=0&mockFixtures=deep&mockNoLiveSessions=1"); + await page.goto( + "/?seed=0&mockFixtures=deep&mockNoLiveSessions=1&mockStudioProjects=present&mockAgentMapGolden=1", + ); await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); - await page.evaluate(() => { + await page.getByTestId("project-select-polsia").click(); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + const projectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + expect(projectId).toBeTruthy(); + await page.evaluate((selectedProjectId) => { const publish = ( window as unknown as { __HARNESS_TEST__?: { @@ -367,6 +382,11 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a title: "mailer", createdAt: "2026-08-01T10:00:00.000Z", lastActiveAt: "2026-08-01T10:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-1", + }, }, }); publish?.({ @@ -378,12 +398,16 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a title: "rollup", createdAt: "2026-08-01T11:00:00.000Z", lastActiveAt: "2026-08-01T11:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-2", + }, }, }); - }); + }, projectId!); - await page.getByTestId("project-select-polsia").click(); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); await expect(tabs).toHaveCount(2); @@ -398,15 +422,17 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a "data-session-id", "sess-polsia-1", ); - // ...and the project is still the subject: a number key is a tab jump, not a - // navigation out of the project the tabs belong to. - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + // A number key is exact conversation navigation, just like clicking the + // corresponding tab. The project map therefore gives way to that ordinary + // session's Canvas/Steps experience while the project-wide tab set remains. + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("right-tab-canvas")).toBeEnabled(); - // The state where the two resolvers actually diverge: the ACTIVE session has - // exited, so it can no longer name the subject, and only the selected project - // can. Both of this project's remaining conversations are BOUND to its - // agents, so the agent rule finds none of them and Cmd+1 reaches nothing. - await page.evaluate(() => { + // Even after the active tab exits, its neutral project identity keeps the + // shortcut resolver on the exact same project-wide set. The remaining live + // tab becomes Cmd/Ctrl+1 rather than disappearing behind an agent-path rule. + await page.evaluate((selectedProjectId) => { ( window as unknown as { __HARNESS_TEST__?: { @@ -428,9 +454,14 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a ready: false, createdAt: "2026-08-01T10:00:00.000Z", lastActiveAt: "2026-08-01T10:00:00.000Z", + agentMapIdentity: { + projectId: selectedProjectId, + userId: "user_mock", + sessionId: "sess-polsia-1", + }, }, }); - }); + }, projectId!); await expect(page.getByTestId("dead-session-pane")).toBeVisible(); await page.keyboard.press("ControlOrMeta+1"); await expect(page.getByTestId("session-context")).toHaveAttribute( @@ -439,12 +470,12 @@ test("Cmd/Ctrl+1..9 addresses the tabs the STRIP rendered, not a second list", a ); }); -test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing else", async ({ +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 moves neither the focus - nor the project selection, so with `activeSessionId` missing from the + 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. @@ -503,14 +534,15 @@ test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing e const tabs = page.getByRole("tablist", { name: "Sessions" }).getByRole("tab"); await expect(tabs).toHaveCount(2); - // Click the NESTED project's tab. Nothing else moves: the rail selection and - // the focus are untouched, and the map stays open. + // 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")).toBeVisible(); + 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 @@ -522,13 +554,12 @@ test("Cmd/Ctrl+1..9 follows a TAB CLICK, the one activation that moves nothing e ); }); -test("the tab + and a tab click stay INSIDE the project — the map does not close under them", async ({ +test("the tab + and every tab click open an ordinary project session", async ({ page, }) => { - /* Measured, because it is one line of state away from being wrong: every - session door used to clear the project selection unconditionally, so - starting one of the project's own tabs closed the map it was started - beside — the same mode switch, one click later. */ + /* 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(); @@ -537,15 +568,17 @@ test("the tab + and a tab click stay INSIDE the project — the map does not clo "data-session-id", "sess-leasing-2", ); - await expect(page.getByTestId("workspace-graph-view")).toBeVisible(); + 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")).toBeVisible(); - await expect(page.getByTestId("project-row-acme-app")).toHaveClass( + 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 b34df84c6..28f8d2969 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -164,7 +164,7 @@ test.describe("ordering", () => { }); }); -test.describe("the plan-first project children", () => { +test.describe("durable Studio project navigation", () => { test("the project plus starts a coding session at its root without creating an agent", async ({ page, }) => { @@ -199,14 +199,12 @@ test.describe("the plan-first project children", () => { "project-menu-dashboard-keeper", ]); - // Prove the shortcut also works from map altitude: the new generic session - // becomes the visible workbench, while the planner remains resumable from - // Plan Agents and no scaffold request is made. - await group.getByTestId("agent-map-select").click(); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "true", - ); + // The ordinary project action also works while its read-only map is open. + // A successful create selects the exact new conversation and no scaffold + // operation is smuggled into that session action. + const map = group.getByTestId("project-select-dashboard-keeper"); + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await start.click(); await expect .poll(() => @@ -232,10 +230,7 @@ test.describe("the plan-first project children", () => { }, }, }); - await expect(group.getByTestId("agent-map-select")).toHaveAttribute( - "aria-pressed", - "false", - ); + await expect(map).toHaveAttribute("aria-pressed", "false"); await expect(page.getByTestId("session-context-title")).toContainText( "dashboard-keeper", ); @@ -252,78 +247,87 @@ test.describe("the plan-first project children", () => { ).not.toContain("scaffold:/Users/demo/dashboard-keeper"); }); - test("a failed project session keeps Plan Agents selected", async ({ + test("a failed project session keeps the map and active conversation intact", async ({ page, }) => { const group = page.getByTestId("workspace-group-dashboard-keeper"); - const map = group.getByTestId("agent-map-select"); + const map = group.getByTestId("project-select-dashboard-keeper"); + const start = group.getByTestId("project-start-session-dashboard-keeper"); + + // Establish a real conversation in this project first. Cross-project map + // navigation deliberately clears an unrelated active session, so it cannot + // supply the conversation whose preservation this scenario verifies. Open + // the map first so its true -> false transition is also the completion + // signal for the asynchronous successful create. + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); + await start.click(); + await expect(map).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("session-context-title")).toContainText( + "dashboard-keeper", + ); + await expect(page.locator(".harness-terminal")).toBeVisible(); await map.click(); await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".harness-terminal")).toBeVisible(); + const activeBefore = await page + .getByTestId("session-context") + .getAttribute("data-session-id"); await page.evaluate(() => { ( window as unknown as { __MOCK_CREATE_SESSION_FAIL_ONCE__?: boolean } ).__MOCK_CREATE_SESSION_FAIL_ONCE__ = true; }); - await group.getByTestId("project-start-session-dashboard-keeper").click(); + await start.click(); await expect(page.getByTestId("toast")).toContainText( "mock: couldn't create session", ); await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + activeBefore ?? "", + ); }); - test("a root agent is a separate target below the pinned Agent Map", async ({ + test("the project label and its disclosure are separate from the root agent", async ({ page, }) => { const group = page.getByTestId("workspace-group-dashboard-keeper"); const project = group.getByTestId("project-row-dashboard-keeper"); - const map = group.getByTestId("agent-map-row"); + const map = group.getByTestId("project-select-dashboard-keeper"); const agent = group.getByTestId("workflow-dashboard-keeper"); await expect(project).toBeVisible(); - await expect(map).toBeVisible(); - await expect(map.getByTestId("agent-map-select")).toHaveText("Plan Agents"); - await expect(map.getByTestId("agent-map-select")).toHaveAttribute( - "data-tooltip", - "Open Plan Agents", + await expect(group.getByTestId("agent-map-row")).toHaveCount(0); + await expect(map).toHaveAccessibleName( + "Open Agent Map for dashboard-keeper", ); await expect(agent).toBeVisible(); - await expect(group.locator(":scope > *")).toHaveCount(3); + await expect(group.locator(":scope > *")).toHaveCount(2); - // The project label is disclosure-only; the two children remain distinct. - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeHidden(); - await expect(agent).toBeHidden(); - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeVisible(); - - await group.getByTestId("agent-map-select").click(); - await expect(map).toHaveClass(/is-selected/); - await expect(map.getByTestId("agent-map-select")).toHaveAttribute( - "data-tooltip", - "Plan Agents selected", - ); + // The label selects the map and does not fold the root-agent child. + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await expect(agent).toBeVisible(); - // A selected child expands on selection, but an intentional disclosure - // click stays collapsed until the user expands it again. - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeHidden(); - await expect( - page.getByTestId("project-disclosure-dashboard-keeper"), - ).toHaveAttribute("aria-expanded", "false"); - await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(map).toBeVisible(); - await expect(map).toHaveClass(/is-selected/); + // Only the chevron owns collapse; the selected map remains mounted. + const disclosure = page.getByTestId("project-disclosure-dashboard-keeper"); + await disclosure.click(); + await expect(disclosure).toHaveAttribute("aria-expanded", "false"); + await expect(agent).toBeHidden(); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await map.click(); + await expect(agent).toBeHidden(); + await disclosure.click(); + await expect(agent).toBeVisible(); - await agent.locator("button").click(); + await agent.locator(".workflow-item-trigger").click(); await expect(agent).toHaveClass(/is-focused/); - // Every durable project has at least the Agent Map child to disclose. - await expect( - page.getByTestId("project-disclosure-dashboard-keeper"), - ).toHaveCount(1); - await expect(page.getByTestId("project-disclosure-polsia")).toHaveCount(1); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); }); test("the project row carries no deploy glyph; the agent child does", async ({ @@ -340,7 +344,10 @@ test.describe("the plan-first project children", () => { .getByTestId("workflow-dashboard-keeper") .locator(".workflow-status"), ).toHaveCount(1); - // The rail also offers no per-project `+`. + await expect( + group.getByTestId("project-start-session-dashboard-keeper"), + ).toBeVisible(); + // The removed legacy shortcut is not a second project-level `+`. await expect( page.locator('.rail-list [data-testid^="workspace-new-session-"]'), ).toHaveCount(0); @@ -351,14 +358,14 @@ test.describe("the plan-first project children", () => { }) => { const map = page .getByTestId("workspace-group-dashboard-keeper") - .getByTestId("agent-map-row"); - await map.getByTestId("agent-map-select").click(); - await expect(map).toHaveClass(/is-selected/); + .getByTestId("project-select-dashboard-keeper"); + await map.click(); + await expect(map).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("agent-map-empty")).toBeVisible(); await page.getByTestId("rail-templates").click(); await expect(page.getByTestId("templates-panel")).toBeVisible(); - await expect(map).not.toHaveClass(/is-selected/); + await expect(map).toHaveAttribute("aria-pressed", "false"); }); }); diff --git a/packages/harness/web/e2e/project-map-empty-recovery.spec.ts b/packages/harness/web/e2e/project-map-empty-recovery.spec.ts new file mode 100644 index 000000000..b869f8337 --- /dev/null +++ b/packages/harness/web/e2e/project-map-empty-recovery.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +async function openEmptyProjectMap(page: Page, failure = ""): Promise { + await page.goto(`/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockNoLiveSessions=1${failure ? `&mockAgentMapWorkspace=${failure}` : ""}`); + await page.getByTestId("project-select-acme-app").click(); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); +} + +async function createRequests(page: Page): Promise> { + return page.evaluate(() => (window as unknown as { + __HARNESS_TEST__?: { createSessionCalls?: Array<{ req: { cwd: string } }> }; + }).__HARNESS_TEST__?.createSessionCalls ?? []); +} + +test("a project map with no sessions starts its first exact project conversation", async ({ page }) => { + await openEmptyProjectMap(page); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + expect(await createRequests(page)).toHaveLength(0); + await page.getByTestId("project-start-session").click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + await expect(page.getByTestId("project-session-empty")).toHaveCount(0); + const calls = await createRequests(page); + expect(calls).toHaveLength(1); + expect(calls[0]?.req.cwd).toBe("/Users/demo/acme-app"); +}); + +for (const [journey, failure] of [["deleted", "missing"], ["foreign", "unauthorized"]]) { + test(`a ${journey} project shows an unavailable state instead of a permanent retry`, async ({ page }) => { + await openEmptyProjectMap(page, failure); + const unavailable = page.getByTestId("agent-map-project-unavailable"); + await expect(unavailable).toBeVisible(); + await expect(unavailable).toContainText("Select another project to continue"); + await expect(page.getByTestId("agent-map-retry")).toHaveCount(0); + await expect(page.getByTestId("project-start-session")).toHaveCount(0); + expect(await createRequests(page)).toHaveLength(0); + }); +} diff --git a/packages/harness/web/e2e/project-map-navigation.spec.ts b/packages/harness/web/e2e/project-map-navigation.spec.ts new file mode 100644 index 000000000..228be22d8 --- /dev/null +++ b/packages/harness/web/e2e/project-map-navigation.spec.ts @@ -0,0 +1,636 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { selectMockSessionFromPalette } from "./mock-navigation"; + +async function openProjectMap(page: Page, label: string): Promise { + const group = page.getByTestId(`workspace-group-${label}`); + await group.getByTestId(`project-select-${label}`).click(); + await expect(group.getByTestId(`project-select-${label}`)).toHaveAttribute( + "aria-pressed", + "true", + ); +} + +const activeSessionId = (page: Page): Promise => + page.getByTestId("session-context").getAttribute("data-session-id"); + +interface NavigationEvidence { + activeSessionId: string | null; + createSessionCalls: number; + injectInputCalls: number; +} + +async function navigationEvidence(page: Page): Promise { + const active = await activeSessionId(page); + return page.evaluate((activeSession) => { + const state = ( + window as unknown as { + __HARNESS_TEST__?: { + createSessionCalls?: unknown[]; + injectInputCalls?: unknown[]; + }; + } + ).__HARNESS_TEST__; + return { + activeSessionId: activeSession, + createSessionCalls: state?.createSessionCalls?.length ?? 0, + injectInputCalls: state?.injectInputCalls?.length ?? 0, + }; + }, active); +} + +test.describe("SAP-3148 project Agent Map navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); + await expect(page.locator(".rail-workflows")).toBeVisible(); + }); + + test("the project name opens the durable map without touching its active conversation", async ({ + page, + }) => { + const before = await navigationEvidence(page); + expect(before.activeSessionId).toBeTruthy(); + + await openProjectMap(page, "acme-app"); + + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + await expect(page.getByTestId("agent-map-empty")).toHaveText( + "Nothing generated yet", + ); + await expect(page.getByTestId("agent-map-row")).toHaveCount(0); + await expect(page.getByTestId("agent-map-select")).toHaveCount(0); + expect(await navigationEvidence(page)).toEqual(before); + + const [cli, map] = await Promise.all([ + page.locator(".harness-terminal").boundingBox(), + page.getByTestId("agent-map-empty").boundingBox(), + ]); + expect(cli?.width ?? 0).toBeGreaterThan(200); + expect(map?.width ?? 0).toBeGreaterThan(200); + expect(map?.x ?? 0).toBeGreaterThan((cli?.x ?? 0) + (cli?.width ?? 0) - 2); + + await expect + .poll(() => + page.evaluate(() => + ( + ( + window as unknown as { + __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; + } + ).__HARNESS_TEST__?.trackEvents ?? [] + ).map((event) => event.event), + ), + ) + .toContain("agent_map.entered"); + }); + + test("a project map never renders another project's active conversation", async ({ + page, + }) => { + const before = await navigationEvidence(page); + expect(before.activeSessionId).toBe("sess-boot"); + + await openProjectMap(page, "dashboard-keeper"); + + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + await expect(page.getByTestId("project-session-empty")).toContainText( + "No active session in this project", + ); + // Project-name navigation does not solve containment by selecting a + // different session. The foreign active ID stays untouched but its CLI is + // not mounted beside this project's map. + const after = await navigationEvidence(page); + expect(after).toMatchObject({ + createSessionCalls: before.createSessionCalls, + injectInputCalls: before.injectInputCalls, + }); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0); + expect( + await page.evaluate(() => + ( + ( + window as unknown as { + __HARNESS_TEST__?: { trackEvents?: Array<{ event: string }> }; + } + ).__HARNESS_TEST__?.trackEvents ?? [] + ).filter((event) => event.event === "session.switched"), + ), + ).toHaveLength(0); + }); + + test("the project name remains the map action on the Group axis", async ({ + page, + }) => { + const before = await navigationEvidence(page); + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + + await openProjectMap(page, "polsia"); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + await expect(page.getByTestId("agent-map-row")).toHaveCount(0); + const after = await navigationEvidence(page); + expect(after).toMatchObject({ + createSessionCalls: before.createSessionCalls, + injectInputCalls: before.injectInputCalls, + }); + }); + + test("neutral project identity owns restoration instead of a foreign bound Canvas path", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockRestoreBindingConflict=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + + const acme = page + .getByTestId("workspace-group-acme-app") + .getByTestId("project-select-acme-app"); + await expect(acme).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-boot", + ); + const evidence = await navigationEvidence(page); + expect(evidence).toMatchObject({ + activeSessionId: "sess-boot", + createSessionCalls: 0, + injectInputCalls: 0, + }); + }); + + test("neutral project identity hands an overlapping nested-project agent to its own session", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockNoLiveSessions=1&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + + await openProjectMap(page, "polsia"); + const outerProjectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + await openProjectMap(page, "polsia/services/workers"); + const nestedProjectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + expect(outerProjectId).toBeTruthy(); + expect(nestedProjectId).toBeTruthy(); + expect(nestedProjectId).not.toBe(outerProjectId); + + await page.evaluate( + ({ outerId, nestedId }) => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + const base = { + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code" as const, + status: "running" as const, + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + ready: true, + }; + publish?.({ + type: "session.status", + session: { + ...base, + id: "sess-overlap-outer", + cwd: "/Users/demo/polsia", + title: "Outer project session", + agentMapIdentity: { + projectId: outerId, + userId: "user_mock", + sessionId: "sess-overlap-outer", + }, + }, + }); + publish?.({ + type: "session.status", + session: { + ...base, + id: "sess-overlap-nested", + cwd: "/Users/demo/polsia/services/workers", + title: "Nested project session", + agentMapIdentity: { + projectId: nestedId, + userId: "user_mock", + sessionId: "sess-overlap-nested", + }, + }, + }); + }, + { outerId: outerProjectId!, nestedId: nestedProjectId! }, + ); + + await selectMockSessionFromPalette(page, "Outer project session"); + await expect.poll(() => activeSessionId(page)).toBe("sess-overlap-outer"); + + const nestedProject = page.getByTestId( + "workspace-group-polsia/services/workers", + ); + await nestedProject + .getByTestId("workflow-queue") + .locator(".workflow-item-trigger") + .click(); + + await expect.poll(() => activeSessionId(page)).toBe("sess-overlap-nested"); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); + }); + + test("renders E2 structured state and applies attributed deltas without resetting the viewport", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMap(page, "dashboard-keeper"); + await expect(page.getByTestId("agent-map-live")).toBeVisible({ + timeout: 1_000, + }); + + const nodes = page.locator("[data-proposal-state='proposed']"); + await expect(nodes).toHaveCount(6); + for (const kind of [ + "agent", + "subagent", + "resource", + "connector", + "artifact", + ]) { + await expect( + page.locator(`[data-node-kind='${kind}']`).first(), + ).toBeVisible(); + } + await expect( + page.getByText("Stock Research", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Marketing", { exact: true })).toBeVisible(); + await expect( + page.getByText("Research Database", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("TikTok", { exact: true })).toBeVisible(); + await expect( + page.getByText("ResearchReport", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("News Editor", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("agent-map-live").getByText(/capability/i), + ).toHaveCount(0); + + const researchReport = page.getByRole("button", { + name: "ResearchReport, artifact, Proposed", + }); + await researchReport.click(); + const inspector = page.getByTestId("agent-map-inspector"); + await expect(inspector).toContainText("Purpose"); + await expect(inspector).toContainText("Contracts"); + await expect(inspector).toContainText("Map planner"); + await page.getByRole("button", { name: "Close node details" }).click(); + await expect(inspector).toHaveCount(0); + await expect(researchReport).toBeFocused(); + + await researchReport.click(); + await page.keyboard.press("Escape"); + await expect(inspector).toHaveCount(0); + await expect(researchReport).toBeFocused(); + + await page.getByRole("button", { name: "Zoom in" }).click(); + const mapSubject = page.getByTestId("agent-map-subject"); + const transformedView = await mapSubject.evaluate( + (element) => (element as HTMLElement).style.transform, + ); + + const projectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + expect(projectId).toBeTruthy(); + await page.evaluate((activeProjectId) => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "agent-map.proposal.changed", + delta: { + schemaVersion: 1, + projectId: activeProjectId, + proposalId: "proposal_00000000-0000-7000-8000-000000000101", + fromVersion: 1, + version: 2, + operationIds: ["operation_00000000-0000-7000-8000-000000000401"], + operations: [ + { + kind: "update-node", + nodeId: "node_00000000-0000-7000-8000-000000000102", + changes: { name: "Campaign Marketing" }, + }, + ], + actor: { + userId: "user_mock", + sessionId: "builder_mock", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }, + acceptedAt: new Date().toISOString(), + }, + }); + }, projectId); + await expect( + page.getByText("Campaign Marketing", { exact: true }), + ).toBeVisible(); + await expect + .poll(() => + mapSubject.evaluate( + (element) => (element as HTMLElement).style.transform, + ), + ) + .toBe(transformedView); + await page.getByText("Campaign Marketing", { exact: true }).click(); + await expect( + page.getByTestId("agent-map-latest-attribution"), + ).toContainText("Agent builder"); + await expect(nodes).toHaveCount(6); + }); + + test("expands the production Agent Map and unwinds its inspector before full view", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMap(page, "dashboard-keeper"); + + const map = page.getByTestId("agent-map-live"); + await expect(map).toBeVisible(); + const subject = page.getByTestId("agent-map-subject"); + await page.getByRole("button", { name: "Zoom in" }).click(); + const adjustedView = await subject.evaluate( + (element) => (element as HTMLElement).style.transform, + ); + + const expand = page.getByTestId("canvas-expand"); + await expect(expand).toHaveAccessibleName("Expand Agent Map"); + await expand.click(); + + const frame = page.getByTestId("agent-map-frame"); + await expect(frame).toHaveClass(/is-expanded/); + await expect(frame).toHaveCSS("position", "fixed"); + await expect(page.getByTestId("resize-handle-rail")).toHaveCount(0); + await expect(page.getByTestId("resize-handle-canvas")).toHaveCount(0); + await expect + .poll(() => + subject.evaluate((element) => (element as HTMLElement).style.transform), + ) + .toBe(adjustedView); + + await page + .getByRole("button", { name: "ResearchReport, artifact, Proposed" }) + .click(); + await expect(page.getByTestId("agent-map-inspector")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-map-inspector")).toHaveCount(0); + await expect(frame).toHaveClass(/is-expanded/); + + await page.keyboard.press("Escape"); + await expect(frame).not.toHaveClass(/is-expanded/); + await expect(page.getByTestId("resize-handle-rail")).toBeVisible(); + await expect(page.getByTestId("resize-handle-canvas")).toBeVisible(); + }); + + test("Plan Agents and every sibling are ordinary exact-session tabs", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockStudioProjects=present&mockPlanAgentsSession=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMap(page, "acme-app"); + + const planAgents = page.getByTestId("session-tab-main-sess-boot"); + const sibling = page.getByTestId("session-tab-main-sess-leasing-2"); + await expect(planAgents).toHaveText(/Plan Agents/); + await expect(page.getByText("Plan Agents", { exact: true })).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-bg")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + + const mapTabIds = await page + .locator(".session-tabs-list > .session-tab") + .evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute("data-testid")).filter(Boolean), + ); + + // A repeated status projection for the same durable session replaces its + // row; it cannot manufacture a second user-visible tab. + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-boot", + agentSessionId: null, + boundWorkflowPath: "/Users/demo/acme-app/leasing", + harness: "claude-code", + cwd: "/Users/demo/acme-app", + title: "Plan Agents", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: new Date().toISOString(), + ready: true, + agentMapIdentity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + userId: "user_mock", + sessionId: "sess-boot", + }, + }, + }); + }); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); + + await planAgents.click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-boot"); + await expect + .poll(() => + page.evaluate(() => + ( + ( + window as unknown as { + __HARNESS_TEST__?: { + trackEvents?: Array<{ + event: string; + data?: { navigation_kind?: string }; + harnessSessionId?: string; + }>; + }; + } + ).__HARNESS_TEST__?.trackEvents ?? [] + ).some( + (event) => + event.event === "session.switched" && + event.data?.navigation_kind === "session_tab" && + event.harnessSessionId === "sess-boot", + ), + ), + ) + .toBe(true); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-tab-canvas")).toContainText("Canvas"); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); + await expect(page.locator(".canvas-iframe")).toBeVisible(); + expect( + await page + .locator(".session-tabs-list > .session-tab") + .evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute("data-testid")).filter(Boolean), + ), + ).toEqual(mapTabIds); + + await page.getByTestId("session-menu").click(); + const menu = page.getByTestId("session-menu-popover"); + await expect(menu.getByText("Copy path", { exact: true })).toBeVisible(); + await expect(menu.getByTestId("session-rename")).toBeVisible(); + await expect(menu.getByTestId("session-open-editor")).toBeVisible(); + await expect(menu.getByTestId("session-end-btn")).toBeVisible(); + await page.keyboard.press("Escape"); + + await sibling.click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("agent-view")).toBeVisible(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + + await openProjectMap(page, "acme-app"); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(1); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); + }); + + test("an exited active session never mounts dead-session chrome over the map", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockStudioProjects=present&mockPlanAgentsSession=1", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMap(page, "acme-app"); + + await page.evaluate(() => { + const publish = ( + window as unknown as { + __HARNESS_TEST__?: { publish?: (message: unknown) => void }; + } + ).__HARNESS_TEST__?.publish; + publish?.({ + type: "session.status", + session: { + id: "sess-boot", + agentSessionId: null, + boundWorkflowPath: "/Users/demo/acme-app/leasing", + harness: "claude-code", + cwd: "/Users/demo/acme-app", + title: "Plan Agents", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: new Date().toISOString(), + exitCode: 0, + ready: false, + agentMapIdentity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + userId: "user_mock", + sessionId: "sess-boot", + }, + }, + }); + }); + + await expect(page.getByTestId("agent-map-frame")).toBeVisible(); + await expect(page.getByTestId("dead-session-pane")).toHaveCount(0); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0); + await expect(page.getByTestId("session-tab-sess-leasing-2")).toHaveCount(1); + await expect(page.getByTestId("session-tab-new")).toHaveCount(1); + await expect(page.getByTestId("planner-session-ended")).toHaveCount(0); + await expect( + page.getByText("New planning session", { exact: true }), + ).toHaveCount(0); + + await page.getByTestId("session-tab-main-sess-leasing-2").click(); + await expect.poll(() => activeSessionId(page)).toBe("sess-leasing-2"); + await expect(page.getByTestId("dead-session-pane")).toHaveCount(0); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("agent-view")).toBeVisible(); + }); + + test("map failures stay in the map pane and never replace the conversation", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=error", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + const before = await navigationEvidence(page); + await openProjectMap(page, "acme-app"); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=unauthorized", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openProjectMap(page, "acme-app"); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("agent-map-project-unavailable")).toBeVisible(); + }); +}); + +test.describe("SAP-3148 mobile Agent Map", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("the project name opens the map sheet without replacing the CLI", async ({ + page, + }) => { + await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); + await expect(page.getByTestId("rail-expand")).toBeVisible(); + const before = await navigationEvidence(page); + await page.getByTestId("rail-expand").click(); + // Selecting a mobile rail destination closes the drawer, so its pressed + // state is intentionally no longer mounted after this click. + await page + .getByTestId("workspace-group-acme-app") + .getByTestId("project-select-acme-app") + .click(); + + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.locator(".right-pane")).toBeVisible(); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await expect(page.getByTestId("right-sheet-scrim")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + + await page.getByTestId("right-collapse").click(); + await expect(page.locator(".right-pane")).toBeHidden(); + await expect(page.getByTestId("right-expand")).toBeFocused(); + await expect(page.getByTestId("right-expand")).toHaveText("Agent Map"); + }); +}); diff --git a/packages/harness/web/e2e/selection-canvas.spec.ts b/packages/harness/web/e2e/selection-canvas.spec.ts index 77091514c..a896a087c 100644 --- a/packages/harness/web/e2e/selection-canvas.spec.ts +++ b/packages/harness/web/e2e/selection-canvas.spec.ts @@ -36,12 +36,10 @@ import type { Page } from "@playwright/test"; * in this fixture are used — `services/workers` is opened as its own project * as well as nested, so its agents have two rows each. */ const select = async (page: Page, name: string): Promise => { - await page.getByTestId(`workflow-${name}`).locator(".workflow-item-trigger").click(); - // A root-agent row belongs to the Project axis first: its label opens the - // Project graph, and the graph card is the separate agent-selection door. - if (await page.getByTestId("workspace-graph-view").isVisible()) { - await page.getByRole("button", { name: `Open ${name}`, exact: true }).click(); - } + await page + .getByTestId(`workflow-${name}`) + .locator(".workflow-item-trigger") + .click(); await expect(page.getByTestId(`workflow-${name}`)).toHaveClass(/is-focused/); }; @@ -69,7 +67,6 @@ async function openRightPane(page: Page): Promise { if ((await expand.count()) > 0) await expand.click(); } - /** The agent the pane says it is about (Steps surface). */ const paneSubject = (page: Page) => page.getByTestId("right-panel-canvas").locator(".workflow-actions-name"); @@ -102,7 +99,9 @@ const boardFrame = (page: Page) => page.locator(".canvas-iframe"); * unavailable), so the accident is gone. */ const boardMounted = async (page: Page): Promise => { - await expect.poll(() => boardFrame(page).count(), { timeout: 10_000 }).toBeGreaterThan(0); + await expect + .poll(() => boardFrame(page).count(), { timeout: 10_000 }) + .toBeGreaterThan(0); }; /** @@ -143,7 +142,7 @@ async function startSessionOn(page: Page, name: string): Promise { } test.beforeEach(async ({ page }) => { - await page.goto("/?mockFixtures=deep"); + await page.goto("/?mockFixtures=deep&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); await expect(page.getByTestId("workspace-group-polsia")).toBeVisible(); }); @@ -161,7 +160,10 @@ test.describe("the board follows the selection; the session does not", () => { // The board is `outreach`'s, served by the workflow-keyed route — which is // reached ONLY when the subject and the session's binding differ, so this // attribute is itself the proof that they have. - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /outreach — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /outreach — mock agent board/, + ); // …while the session below it is untouched. Both halves matter: either one // alone is satisfied by the old coupled behaviour. expect(await activeSessionId(page)).toBe(sessionId); @@ -178,11 +180,16 @@ test.describe("the board follows the selection; the session does not", () => { await select(page, "outreach"); await boardMounted(page); // Canvas: the document on screen is outreach's. - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /outreach — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /outreach — mock agent board/, + ); // Steps: the same agent, read from the other projection. await openSteps(page); await expect(paneSubject(page)).toHaveText("outreach"); - await expect(page.getByTestId("canvas-steps-count")).toHaveText("2 steps · 1 exit"); + await expect(page.getByTestId("canvas-steps-count")).toHaveText( + "2 steps · 1 exit", + ); }); test("a selection in another project MOVES the active session, and back again", async ({ @@ -219,7 +226,9 @@ test.describe("verb gating", () => { await startSessionOn(page, "mailer"); const prod = page.getByTestId("session-step-prod"); await expect(prod).toBeEnabled(); - await expect(prod).toHaveAccessibleName("Open mailer in the Sapiom dashboard"); + await expect(prod).toHaveAccessibleName( + "Open mailer in the Sapiom dashboard", + ); await page.getByRole("button", { name: "Choose run target" }).click(); await expect(page.getByTestId("session-step-run")).toBeEnabled(); await page.keyboard.press("Escape"); @@ -228,14 +237,20 @@ test.describe("verb gating", () => { // underneath a session that is still bound to the deployed agent. const sessionId = await activeSessionId(page); await select(page, "sender"); - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /sender — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /sender — mock agent board/, + ); // The session did not move, so the binding is still the DEPLOYED agent. expect(await activeSessionId(page)).toBe(sessionId); // Prod: disabled, and its reason readable from BOTH channels. await expect(prod).toBeDisabled(); await expect(prod).toHaveAccessibleName("Prod: Not deployed yet"); - await expect(prod).toHaveAttribute("data-tooltip", "Prod: Not deployed yet"); + await expect(prod).toHaveAttribute( + "data-tooltip", + "Prod: Not deployed yet", + ); // Run (the cloud target): same. await page.getByRole("button", { name: "Choose run target" }).click(); @@ -256,18 +271,24 @@ test.describe("verb gating", () => { await expect(page.getByTestId("session-step-prod")).toBeEnabled(); }); - test("the run sheet opens on the SELECTION, not on the bound agent", async ({ page }) => { + test("the run sheet opens on the SELECTION, not on the bound agent", async ({ + page, + }) => { // Gating is only half of it: the action target has to move too, and the // sheet's own title is the honest readout of which agent is about to run. await startSessionOn(page, "mailer"); await select(page, "sender"); await page.getByTestId("session-step-local").click(); - await expect(page.getByRole("dialog", { name: "Run sender" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Run sender" }), + ).toBeVisible(); }); }); test.describe("boards for agents with no session", () => { - test("an agent that has never hosted a session shows a REAL board", async ({ page }) => { + test("an agent that has never hosted a session shows a REAL board", async ({ + page, + }) => { // No fixture session is rooted in polsia, so `gateway` has never had one: // before IA-01's workflow-keyed route this pane could only say "no running // session for gateway". The board is now served from `sapiom.json` alone. @@ -280,7 +301,10 @@ test.describe("boards for agents with no session", () => { await expect( page.frameLocator(".canvas-iframe").getByTestId("mock-workflow-board"), ).toBeVisible(); - await expect(boardFrame(page)).toHaveAttribute("srcdoc", /gateway — mock agent board/); + await expect(boardFrame(page)).toHaveAttribute( + "srcdoc", + /gateway — mock agent board/, + ); // The theme bridge: a `srcdoc` frame has no URL, so the served document's // `?theme=` reader has nothing to read and the app hands it the theme in an // appended script instead. Without it the board uses the light product @@ -292,23 +316,33 @@ test.describe("boards for agents with no session", () => { // the count is read from the posted graph, not from the document's markup. await openSteps(page); await expect(paneSubject(page)).toHaveText("gateway"); - await expect(page.getByTestId("canvas-steps-count")).toHaveText("2 steps · 1 exit"); + await expect(page.getByTestId("canvas-steps-count")).toHaveText( + "2 steps · 1 exit", + ); // The state this replaces is gone, not merely covered up. await expect(page.getByTestId("canvas-empty-no-session")).toHaveCount(0); }); - test("`preparing`, `empty` and `error` are three distinct honest states", async ({ page }) => { + test("`preparing`, `empty` and `error` are three distinct honest states", async ({ + page, + }) => { // Not one generic failure: `preparing` is a fresh scaffold with no deps // installed and must never surface a build error to someone who has just // created an agent; `empty` is a registered agent with no readable // sapiom.json (absent ⇒ empty); `error` is an extraction that ran and // failed. Collapsing them was how the first became the third. - const seed = async (status: string, reason: string | null): Promise => { + const seed = async ( + status: string, + reason: string | null, + ): Promise => { await page.evaluate( ({ status, reason }) => { ( window as unknown as { - __MOCK_WORKFLOW_GRAPH__?: Record; + __MOCK_WORKFLOW_GRAPH__?: Record< + string, + { status: string; reason: string | null } + >; } ).__MOCK_WORKFLOW_GRAPH__ = { "/Users/demo/polsia/services/gateway": { status, reason }, @@ -329,7 +363,10 @@ test.describe("boards for agents with no session", () => { await expect(page.getByTestId("canvas-empty-route-error")).toHaveCount(0); await expect(page.getByTestId("canvas-empty-route-empty")).toHaveCount(0); - await seed("empty", "This agent has no sapiom.json, so there is no graph to render yet."); + await seed( + "empty", + "This agent has no sapiom.json, so there is no graph to render yet.", + ); await select(page, "rollup"); await expect(page.getByTestId("canvas-empty-route-empty")).toContainText( "no sapiom.json", @@ -352,7 +389,9 @@ test.describe("run evidence", () => { // account of what ran, in the surface whose whole job is to say what ran. await startSessionOn(page, "mailer"); await page.getByTestId("session-step-local").click(); - await expect(page.getByRole("dialog", { name: "Run mailer" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Run mailer" }), + ).toBeVisible(); await page.getByTestId("run-sheet-submit").click(); await openSteps(page); await expect(page.getByTestId("run-workspace")).toBeVisible(); diff --git a/packages/harness/web/e2e/smoke.spec.ts b/packages/harness/web/e2e/smoke.spec.ts index cc2eb83e5..5643ebdf2 100644 --- a/packages/harness/web/e2e/smoke.spec.ts +++ b/packages/harness/web/e2e/smoke.spec.ts @@ -643,10 +643,9 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { "true", ); - // ...and the way back UP is there, so the map is no longer a one-way door. - const up = page.getByTestId("canvas-altitude-up"); - await expect(up).toHaveAttribute("aria-label", "Back to the acme-app map"); - await up.click(); + // 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/, diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 340891cd1..eeeef41a1 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -49,6 +49,7 @@ import { } from "react"; import type { JSX } from "react"; import type { + AppState, HarnessKind, HarnessSession, MacroDef, @@ -58,7 +59,6 @@ import type { } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; import type { - PlannerSessionRequest, StudioProjectId, StudioWorkspaceSelection, } from "@shared/agent-map"; @@ -104,6 +104,7 @@ import { conversationSubject, liveSessionsForFocus, liveSessionsForProject, + liveSessionsForStudioProject, mergeSubjectRuns, projectRootForAgent, rootContains, @@ -115,7 +116,6 @@ import { } from "./lib/session-scope"; import { canvasView, - projectAbove, studioCanvasView, stepsDisabledReason, secretsDisabledReason, @@ -152,7 +152,7 @@ import { directActionKind } from "./lib/macro-actions"; import { describeWorkflowPrompt } from "./lib/describe-prompt"; import { sessionDisplayName } from "./lib/session-name"; import type { PaletteAction } from "./lib/palette"; -import { getTheme, toggleTheme } from "./lib/theme"; +import { toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; import { useNavigationHistory, @@ -207,6 +207,32 @@ const knownRootsOf = ( launchDir: string | null | undefined, ): string[] => [...(recentDirs ?? []), ...(launchDir ? [launchDir] : [])]; +/** Resolve the one Studio workspace selection every map consumer observes. */ +const effectiveStudioWorkspaceSelection = ( + selection: StudioWorkspaceSelection | null, + state: Pick | null | undefined, +): StudioWorkspaceSelection | null => { + if (!selection || !state) return null; + if ( + !state.studioProjects?.some( + (project) => project.projectId === selection.projectId, + ) + ) { + return null; + } + if (selection.kind === "agent-map") return selection; + const agentStillExists = state.workflows.some((workflow) => + workflow.studioBindings?.some( + (binding) => + binding.projectId === selection.projectId && + binding.agentId === selection.agentId, + ), + ); + return agentStillExists + ? selection + : { kind: "agent-map", projectId: selection.projectId }; +}; + /** * How long a held initial prompt waits for the coding agent to become ready * (i.e. the user to finish any sign-in, trust, or onboarding step) before we @@ -229,6 +255,8 @@ interface CreateSessionAtOptions { keepComposerOpen?: boolean; /** Keep an explicit new-agent builder active when its root joins Studio. */ standaloneBuilder?: boolean; + /** The caller already owns the session's first real user-authored turn. */ + initialUserInputPending?: boolean; } /** @@ -279,12 +307,13 @@ export const App = (): JSX.Element => { const [siblingSessionPending, setSiblingSessionPending] = useState(false); const siblingSessionPendingRef = useRef(false); // The focused agent (or bare-scaffold folder) path — the rail's single - // selection and the main panel's tab-strip subject. The active tab's - // session is harness.activeSessionId. + // 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); - // The project the canvas is at MAP altitude for. Not a destination any more - // (SAP-2980): the chat stays in the centre and the map draws beside it, so - // this selects a SUBJECT rather than replacing the workbench. + // The legacy project the canvas is at MAP altitude for. Studio projects use + // `studioSelection` below, but retain the same invariant: the chat stays in + // the centre and the map draws beside it, so this selects a SUBJECT rather + // than replacing the workbench. // // ONE value, not the key and a parallel meta record it used to be: half the // doors cleared only the key and left the meta behind, so every reader had to @@ -305,83 +334,41 @@ export const App = (): JSX.Element => { ); const restoredStudioProjectsRef = useRef(new Set()); const studioRestoreGenerationRef = useRef(0); - const plannerProjectId = - studioSelection?.kind === "agent-map" ? studioSelection.projectId : null; - const handlePlannerReady = useCallback( - ( - response: { session: HarnessSession }, - mode: PlannerSessionRequest["mode"], - ): void => { - const selected = harness.state?.sessions.find( - (session) => session.id === harness.activeSessionId, - ); - // An explicit palette/history selection is more specific than the - // project-level resume ordering. Keep that chosen live tab; fresh mode - // remains an explicit request to select the newly-created planner. - if ( - mode === "resume-or-create" && - selected?.status !== "exited" && - selected?.planning?.identity.role === "map-planner" && - selected.planning.identity.projectId === - response.session.planning?.identity.projectId - ) { - return; - } - harness.setActiveSessionId(response.session.id); - }, - [ - harness.activeSessionId, - harness.setActiveSessionId, - harness.state?.sessions, - ], - ); - const activePlannerForProject = harness.state?.sessions.find( - (session) => - session.id === harness.activeSessionId && - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === plannerProjectId, + const effectiveStudioSelection = effectiveStudioWorkspaceSelection( + studioSelection, + harness.state, ); + const agentMapProjectId = + effectiveStudioSelection?.kind === "agent-map" + ? effectiveStudioSelection.projectId + : null; const agentMapEntry = useAgentMapEntry({ - projectId: plannerProjectId, - selectedPlanner: activePlannerForProject ?? null, + projectId: agentMapProjectId, api: harness.api, - harness: () => - loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code", - theme: getTheme, - openPlannerSession: harness.openPlannerSession, - onPlannerReady: handlePlannerReady, subscribeProposalChanges: harness.subscribeAgentMapProposalChanges, subscribeReconnects: harness.subscribeEventReconnects, }); // A project visit restores its server-owned preference before choosing an - // altitude. Once map is chosen, `useAgentMapEntry` owns the independent map - // and planner requests; preference restoration must not couple their fate. + // altitude. Once map is chosen, `useAgentMapEntry` owns only the independent + // map read; preference restoration must never turn that view change into a + // session create, resume, or selection. useEffect(() => { const state = harness.state; const active = state?.sessions.find( (session) => session.id === harness.activeSessionId, ); - // Bind the intent as soon as createSession() selects its session — before - // that call's slower recent-directory/catalog refresh finishes. Once - // bound, the session id makes the intent safe to retain across focus - // changes; only that session disappearing or exiting expires it. + // Once the create response binds an intent to its exact session, retain it + // across focus changes until that session disappears or exits. A pending + // null is deliberately NOT inferred from the active same-cwd session: a + // new project's automatic Plan Agents session can arrive while an explicit + // create is in flight, and cwd cannot correlate those two launches. for (const [ root, sessionId, ] of pendingStandaloneBuilderSessionsRef.current) { - if (sessionId === null) { - if ( - active && - active.status !== "exited" && - samePath(root, active.cwd) - ) { - pendingStandaloneBuilderSessionsRef.current.set(root, active.id); - } - continue; - } + if (sessionId === null) continue; const builder = state?.sessions.find( (session) => session.id === sessionId, ); @@ -391,14 +378,35 @@ export const App = (): JSX.Element => { } if (!state?.studioProjects || !active) return; + const identityProjectId = active.agentMapIdentity?.projectId ?? null; + const identityProject = identityProjectId + ? state.studioProjects.find( + (candidate) => candidate.projectId === identityProjectId, + ) + : null; + // A neutral principal is the exact server-derived authority. Never let a + // bound Canvas subject reclassify that session under a nested project. The + // path-only branch exists solely for rolling compatibility with sessions + // persisted before project principals were recorded. + if (identityProjectId && !identityProject) return; + const eligibleProjects = identityProject + ? [identityProject] + : state.studioProjects; + const eligibleScopes = identityProject + ? (state.workspaceScopes ?? []).filter( + (candidate) => candidate.projectId === identityProject.projectId, + ) + : (state.workspaceScopes ?? []); const scope = mostSpecificStudioScope( - active.boundWorkflowPath ?? active.cwd, - state.workspaceScopes ?? [], - state.studioProjects, - ); - const project = state.studioProjects.find( - (candidate) => candidate.projectId === scope?.projectId, + active.cwd, + eligibleScopes, + eligibleProjects, ); + const project = + identityProject ?? + state.studioProjects.find( + (candidate) => candidate.projectId === scope?.projectId, + ); if (!scope?.projectId || !project) return; const standaloneBuilderRoot = [ ...pendingStandaloneBuilderSessionsRef.current, @@ -532,20 +540,23 @@ export const App = (): JSX.Element => { * this epic removes, one click later. A functional updater so the rule can * be applied from handlers that do not close over the current selection. */ - const leaveProjectUnlessInside = useCallback((cwd: string | null): void => { - setSelectedProject((current) => - current && cwd && rootContains(current.root, cwd) ? current : null, - ); - setStudioSelection((current) => { - if (!current || !cwd) return null; - const ownsTarget = (harness.state?.workspaceScopes ?? []).some( - (scope) => - scope.projectId === current.projectId && - rootContains(scope.cwd, cwd), + const leaveProjectUnlessInside = useCallback( + (cwd: string | null): void => { + setSelectedProject((current) => + current && cwd && rootContains(current.root, cwd) ? current : null, ); - return ownsTarget ? current : null; - }); - }, [harness.state]); + setStudioSelection((current) => { + if (!current || !cwd) return null; + const ownsTarget = (harness.state?.workspaceScopes ?? []).some( + (scope) => + scope.projectId === current.projectId && + rootContains(scope.cwd, cwd), + ); + return ownsTarget ? current : null; + }); + }, + [harness.state], + ); // "Open in Studio" deep links (sapiom://agent/). The applier is a ref // because it needs `state`/`handleFocusAgent`, which exist only past the loading // guard; the effects below reach it through the ref. The cold-start target rides @@ -562,6 +573,7 @@ export const App = (): JSX.Element => { const selectProjectRef = useRef< ((workspaceKey: WorkspaceKey, root: string, label: string) => void) | null >(null); + const openSessionRef = useRef<((sessionId: string) => void) | null>(null); const coldDeepLinkRef = useRef(deepLinkFromSearch()); const coldDeepLinkHandledRef = useRef(false); // A clone kicked off from a remote-only deep link: focus the agent once the @@ -861,8 +873,10 @@ export const App = (): JSX.Element => { // second copy of the rule it would address a different list than the one // the user is counting along. const sessions = harness.state?.sessions ?? []; + const shortcutActive = + sessions.find((s) => s.id === harness.activeSessionId) ?? null; const subject = conversationSubject( - sessions.find((s) => s.id === harness.activeSessionId) ?? null, + shortcutActive, focusedAgentPath, // The SELECTED project, exactly as the strip passes it. Left null // here, the two resolvers agree only while the active session is live @@ -874,29 +888,19 @@ export const App = (): JSX.Element => { selectedProject?.root ?? null, knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); - const tabs = - studioSelection?.kind === "agent-map" - ? sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - studioSelection.projectId, - ) - : subject.kind === "project" - ? liveSessionsForProject(sessions, subject.root) - : liveSessionsForFocus(sessions, subject.path); + const studioProjectId = + effectiveStudioSelection?.projectId ?? + shortcutActive?.agentMapIdentity?.projectId ?? + null; + const tabs = studioProjectId + ? liveSessionsForStudioProject(sessions, studioProjectId) + : subject.kind === "project" + ? liveSessionsForProject(sessions, subject.root) + : liveSessionsForFocus(sessions, subject.path); const target = tabs[Number(e.key) - 1]; if (target) { e.preventDefault(); - setComposing(false); - setReviewSummary(null); - leaveProjectUnlessInside(target.cwd); - // A tab jump is a navigation: leave any full-width destination that - // is standing in for the workbench, or it would linger over the tab. - setTemplatesOpen(false); - setOverviewOpen(false); - harness.setActiveSessionId(target.id); + openSessionRef.current?.(target.id); } } }; @@ -917,7 +921,7 @@ export const App = (): JSX.Element => { harness.settings?.recentDirs, focusedAgentPath, selectedProject, - studioSelection, + effectiveStudioSelection, isMobile, rightCollapsed, paletteOpen, @@ -1045,11 +1049,11 @@ export const App = (): JSX.Element => { // Legacy System Graph selection keeps its established auto-open behavior. if ( selectedProject || - (!isMobile && studioSelection?.kind === "agent-map") + (!isMobile && effectiveStudioSelection?.kind === "agent-map") ) { setRightCollapsed(false); } - }, [isMobile, selectedProject, studioSelection]); + }, [effectiveStudioSelection?.kind, isMobile, selectedProject]); // Crossing the breakpoint resets both panes to that mode's default. const prevMobile = useRef(isMobile); @@ -1091,8 +1095,11 @@ export const App = (): JSX.Element => { applyingVisitRef.current = false; return; } - if (studioSelection?.kind === "agent-map") { - recordVisit({ kind: "agent-map", projectId: studioSelection.projectId }); + if (effectiveStudioSelection?.kind === "agent-map") { + recordVisit({ + kind: "agent-map", + projectId: effectiveStudioSelection.projectId, + }); } else if (selectedProject) { recordVisit({ kind: "project", @@ -1121,7 +1128,7 @@ export const App = (): JSX.Element => { }, [ recordVisit, selectedProject, - studioSelection, + effectiveStudioSelection, templatesOpen, reviewSummary, composing, @@ -1324,32 +1331,36 @@ export const App = (): JSX.Element => { const boundWorkflow = state.workflows.find((w) => w.path === boundWorkflowPath) ?? null; const workspaceScopes = state.workspaceScopes ?? []; - const selectedStudioProject = studioSelection + const studioScopeForAgentProject = ( + path: string, + projectId: string | null | undefined, + ) => { + if (!projectId) return null; + const project = state.studioProjects?.find( + (candidate) => candidate.projectId === projectId, + ); + if (!project) return null; + return mostSpecificStudioScope( + path, + workspaceScopes.filter((scope) => scope.projectId === projectId), + [project], + ); + }; + const selectedStudioProject = effectiveStudioSelection ? (state.studioProjects?.find( - (project) => project.projectId === studioSelection.projectId, + (project) => project.projectId === effectiveStudioSelection.projectId, ) ?? null) : null; const selectedStudioWorkflow = - studioSelection?.kind === "agent" + effectiveStudioSelection?.kind === "agent" ? (state.workflows.find((workflow) => workflow.studioBindings?.some( (binding) => - binding.projectId === studioSelection.projectId && - binding.agentId === studioSelection.agentId, + binding.projectId === effectiveStudioSelection.projectId && + binding.agentId === effectiveStudioSelection.agentId, ), ) ?? null) : null; - // Missing client inventory is an in-memory map fallback, never a durable - // repair. The server owns the completeness decision in the effect above. - const effectiveStudioSelection = - studioSelection && selectedStudioProject - ? studioSelection.kind === "agent" && !selectedStudioWorkflow - ? ({ - kind: "agent-map", - projectId: studioSelection.projectId, - } satisfies StudioWorkspaceSelection) - : studioSelection - : null; const selectedStudioScopes = effectiveStudioSelection ? workspaceScopes.filter( (scope) => scope.projectId === effectiveStudioSelection.projectId, @@ -1389,24 +1400,7 @@ export const App = (): JSX.Element => { : null; const view = studioView ?? legacyView; const atMapAltitude = view.altitude === "map"; - const planningWorkspace = studioView?.altitude === "map"; - const agentMapUnavailable = - planningWorkspace && agentMapEntry.state.unavailable !== null; - const plannerSessions = planningWorkspace - ? state.sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === studioView.projectId, - ) - : []; - const activePlannerSession = - planningWorkspace && - activeSession?.status !== "exited" && - activeSession?.planning?.identity.role === "map-planner" && - activeSession.planning.identity.projectId === studioView.projectId - ? activeSession - : null; + const projectMapSelected = studioView?.altitude === "map"; /** * Whose tabs the strip shows: the ACTIVE session's PROJECT (SAP-2980), never @@ -1427,14 +1421,27 @@ export const App = (): JSX.Element => { : (selectedProject?.root ?? null), knownProjectRoots(), ); - const focusTabs = planningWorkspace - ? plannerSessions + const studioConversationProjectId = + planFirstSelection?.projectId ?? + activeSession?.agentMapIdentity?.projectId ?? + null; + const focusTabs = studioConversationProjectId + ? liveSessionsForStudioProject(state.sessions, studioConversationProjectId) : conversation.kind === "project" ? liveSessionsForProject(state.sessions, conversation.root) : liveSessionsForFocus(state.sessions, conversation.path); + // Project-name navigation cannot activate a session. Keep an already-active + // conversation only when it belongs to that exact project; a foreign CLI is + // hidden until the user explicitly selects one of this project's tabs. + const activeProjectTab = projectMapSelected + ? (focusTabs.find((session) => session.id === harness.activeSessionId) ?? + null) + : activeSession; + const conversationSession = projectMapSelected + ? activeProjectTab + : activeSession; const showReview = reviewSummary != null; - const showDead = - !planningWorkspace && !showReview && activeSession?.status === "exited"; + const showDead = !showReview && conversationSession?.status === "exited"; // An agent selected with no session that can WORK on it: honest absence, and // opening one lands on the "start a session" state. // @@ -1455,7 +1462,6 @@ export const App = (): JSX.Element => { const showAgentEmpty = !showReview && !showDead && - !planningWorkspace && !composing && !atMapAltitude && focusedWorkflow != null && @@ -1463,20 +1469,27 @@ export const App = (): JSX.Element => { activeSession, effectiveFocusedAgentPath, knownProjectRoots(), + planFirstSelection?.kind === "agent" + ? planFirstSelection.projectId + : null, + planFirstSelection?.kind === "agent" ? selectedStudioScope?.cwd : null, ); // The workbench: a live active session. const showWorkbench = !showReview && !showDead && - !planningWorkspace && !composing && !showAgentEmpty && - activeSession != null && - activeSession.status !== "exited"; + conversationSession != null && + conversationSession.status !== "exited"; + const showMapConversationEmpty = + !showReview && + !composing && + projectMapSelected && + conversationSession == null; // A project selected with no session yet: its first one is on the way, and // the centre says so rather than flashing the create-new composer. const showProjectStarting = - !planningWorkspace && !showReview && !showDead && !composing && @@ -1488,18 +1501,9 @@ export const App = (): JSX.Element => { const showComposer = !showReview && !showDead && - !planningWorkspace && !showProjectStarting && + !showMapConversationEmpty && (composing || (!showAgentEmpty && !showWorkbench)); - /** The project a board can cut UP to — derived, so the way back is the same - * door whether the agent was reached from the rail or from the map. */ - const upToProject = atMapAltitude - ? null - : projectAbove( - effectiveFocusedAgentPath, - knownProjectRoots(), - workspaceScopes, - ); const stepsDisabled = stepsDisabledReason(view.altitude); const secretsDisabled = secretsDisabledReason(view.altitude); // At map altitude the map IS the canvas panel, so a stored `steps` intent is @@ -1509,13 +1513,12 @@ export const App = (): JSX.Element => { (secretsDisabled && rightTab === "secrets") ? "canvas" : rightTab; - const rightPaneSuppressedByComposer = - (showComposer && !atMapAltitude) || agentMapUnavailable; - const sessionBarSession = planningWorkspace - ? activePlannerSession - : showWorkbench || showDead - ? activeSession - : null; + const rightPaneSuppressedByComposer = showComposer && !atMapAltitude; + const sessionBarSession = + showWorkbench || showDead ? conversationSession : null; + const newTabSource = projectMapSelected + ? (activeProjectTab ?? focusTabs[0] ?? null) + : activeSession; // A live session to return to when the composer was opened over the workbench. const composerCanCancel = composing && activeSession != null && activeSession.status !== "exited"; @@ -1623,21 +1626,7 @@ export const App = (): JSX.Element => { if (isMobile) setRailCollapsed(true); }; - /** - * The rail verb: SELECT a project. - * - * A project is somewhere you WORK, not somewhere you look (SAP-2980), so - * this does two things that used to be one. The canvas goes to map altitude - * — and the conversation becomes the project's, through the SAME decision - * function an agent selection uses. Reusing it is the point: crossing from - * one project to another has to hand the conversation over exactly once, - * with one rule, and a second hand-written copy here is how the two answers - * come to disagree. - * - * A project with no live session gets one at its root. Otherwise a project is - * a row you can select but cannot talk to — an empty workbench beside a map, - * which is the failure this criterion names. - */ + /** Select a project's durable map without touching session state. */ const handleSelectWorkspace = ( workspaceKey: WorkspaceKey, root: string, @@ -1677,11 +1666,10 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); closeMobileDrawer(); - // Stable Studio projects talk through their trusted map-planner. The - // selection effect starts resume-or-create; an ordinary project-root PTY - // here would race it and briefly make the wrong conversation authoritative. + // A Studio project-name click is a read-only navigation action. It must not + // choose, create, resume, focus, or prompt any session. if (selectedAgentMap) { - if (isMobile) setRightCollapsed(true); + if (isMobile) setRightCollapsed(false); return; } const decision = sessionForFocus({ @@ -1732,15 +1720,21 @@ export const App = (): JSX.Element => { } }; + const handleStartProjectSession = async (root: string, label: string): Promise => { + const started = await startProjectSession(root, label, preferredHarness()); + if (!started) return; + studioRestoreGenerationRef.current += 1; + setStudioSelection(null); + setSelectedProject(null); + }; + /** * The provider a create-initiated session boots with — the same stored * preference the rail used to read before it dispatched. It moved here with * the create itself; the rail no longer starts sessions. */ function preferredHarness(): HarnessKind { - return loadUiPrefs().preferredHarness === "codex" - ? "codex" - : "claude-code"; + return loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code"; } /** @@ -1768,10 +1762,10 @@ export const App = (): JSX.Element => { ): Promise => { if (!options.keepComposerOpen) setComposing(false); setReviewSummary(null); - // A session started INSIDE the selected project is one of ITS tabs — the - // tab `+`, the palette's "new session in this folder", the project's own - // first session. Closing its map under them would be the mode switch this - // epic removes, one click later. + // Preserve a same-project selection while the create is in flight. The + // caller owns the final destination: a project-row create can deliberately + // stay put until success, while a tab-strip create opens the new ordinary + // conversation/canvas once its exact session exists. leaveProjectUnlessInside(cwd); setOverviewOpen(false); setFocusedAgentPath(cwd); @@ -1789,10 +1783,25 @@ export const App = (): JSX.Element => { studioRestoreGenerationRef.current += 1; } try { - const session = await harness.createSession({ - cwd, - harness: agentHarness, - }); + const session = await harness.createSession( + { + cwd, + harness: agentHarness, + ...((options.initialUserInputPending ?? options.standaloneBuilder) + ? { initialUserInputPending: true } + : {}), + }, + (created) => { + if ( + options.standaloneBuilder && + pendingStandaloneBuilderSessionsRef.current.get(cwd) === null + ) { + // The POST response—not an active same-cwd status projection—is + // the first trustworthy correlation for this explicit create. + pendingStandaloneBuilderSessionsRef.current.set(cwd, created.id); + } + }, + ); if ( options.standaloneBuilder && pendingStandaloneBuilderSessionsRef.current.get(cwd) === null @@ -1859,7 +1868,9 @@ export const App = (): JSX.Element => { agentHarness: HarnessKind, idea?: string, ): Promise => { - const session = await createSessionAt(cwd, agentHarness); + const session = await createSessionAt(cwd, agentHarness, { + initialUserInputPending: true, + }); sendScaffoldPrompt(session, cwd, idea); }; @@ -1947,7 +1958,9 @@ export const App = (): JSX.Element => { : null; const session = existing ?? - (await createSessionAt(request.root, preferredHarness())); + (await createSessionAt(request.root, preferredHarness(), { + initialUserInputPending: input.instruction.trim().length > 0, + })); await harness.bindWorkflow(session.id, created.path); harness.setActiveSessionId(session.id); setFocusedAgentPath(created.path); @@ -1995,6 +2008,9 @@ export const App = (): JSX.Element => { void (async () => { try { const session = await createSessionAt(cwd, source.harness); + studioRestoreGenerationRef.current += 1; + setStudioSelection(null); + setSelectedProject(null); if (!workflowPath) { setFocusedAgentPath(cwd); return; @@ -2111,7 +2127,9 @@ export const App = (): JSX.Element => { // the dialog shows it verbatim. const parent = parentOf(cwd); if (!parent) - throw new Error(`Can't create an agent at ${cwd} — pick a folder inside a project.`); + throw new Error( + `Can't create an agent at ${cwd} — pick a folder inside a project.`, + ); const created = await harness.scaffoldAgent( parent, basenameOf(cwd), @@ -2125,7 +2143,9 @@ export const App = (): JSX.Element => { setFocusedAgentPath(created.path); return; } - const session = await createSessionAt(cwd, "claude-code"); + const session = await createSessionAt(cwd, "claude-code", { + initialUserInputPending: true, + }); trackUse(); sendPromptWhenReady( session.id, @@ -2245,46 +2265,51 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); const session = state.sessions.find((s) => s.id === id); - if (session?.planning?.identity.role === "map-planner") { - const selection: StudioWorkspaceSelection = { - kind: "agent-map", - projectId: session.planning.identity.projectId, - }; - restoredStudioProjectsRef.current.add(selection.projectId); - setStudioSelection(selection); - setSelectedProject(null); - setFocusedAgentPath(session.cwd); - closeMobileDrawer(); - if (isMobile) setRightCollapsed(true); - harness.setActiveSessionId(id); - void harness.api.putStudioCurrentWorkspace( - selection.projectId, - selection, + studioRestoreGenerationRef.current += 1; + setSelectedProject(null); + setStudioSelection(null); + closeMobileDrawer(); + if (session) { + if (session.agentMapIdentity?.projectId) { + // An explicit session selection owns its destination. Mark its neutral + // project visited even when the session has no agent binding, so a + // late preference read cannot replace this exact ordinary tab. + restoredStudioProjectsRef.current.add( + session.agentMapIdentity.projectId, + ); + } + const focusPath = boundWorkflowPathOf(session) ?? session.cwd; + setFocusedAgentPath(focusPath); + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, focusPath), ); - return; + const binding = workflow?.studioBindings?.find( + (candidate) => + candidate.projectId === session.agentMapIdentity?.projectId, + ); + if (binding) { + const selection: StudioWorkspaceSelection = { + kind: "agent", + projectId: binding.projectId, + agentId: binding.agentId, + }; + restoredStudioProjectsRef.current.add(binding.projectId); + setStudioSelection(selection); + void harness.api.putStudioCurrentWorkspace( + binding.projectId, + selection, + ); + } } - // Opening one of the selected project's own sessions is not a navigation - // away from it — only a session somewhere else is. - leaveProjectUnlessInside(session?.cwd ?? null); - closeMobileDrawer(); - if (session) - setFocusedAgentPath(boundWorkflowPathOf(session) ?? session.cwd); harness.setActiveSessionId(id); }; + openSessionRef.current = openSession; - // Select a tab in the strip — same as openSession, but the tab always - // belongs to the current focus, so focus never moves. + // Tabs are exact conversation navigation. Selecting one leaves the project + // map and renders that ordinary session's own conversation/canvas. const selectTab = (id: string): void => { - setComposing(false); - setReviewSummary(null); - setTemplatesOpen(false); - setOverviewOpen(false); - // The tabs ARE the project's tabs at map altitude — picking one must not - // close the map they are rendered beside. - leaveProjectUnlessInside( - state.sessions.find((s) => s.id === id)?.cwd ?? null, - ); - harness.setActiveSessionId(id); + openSession(id); + track("session.switched", { navigation_kind: "session_tab" }, id); }; // One entry point for reviewing a past (transcript) session. @@ -2331,7 +2356,7 @@ export const App = (): JSX.Element => { const selectStudioAgent = ( workflow: WorkflowInfo, preferred?: { projectId: string; agentId: string }, - ): void => { + ): { projectId: string; agentId: string } | null => { const bindings = workflow.studioBindings ?? []; const owningScope = mostSpecificStudioScope( workflow.path, @@ -2350,7 +2375,8 @@ export const App = (): JSX.Element => { (candidate) => candidate.projectId === owningScope?.projectId, ) ?? bindings.find( - (candidate) => candidate.projectId === studioSelection?.projectId, + (candidate) => + candidate.projectId === effectiveStudioSelection?.projectId, ) ?? [...bindings].sort( (left, right) => @@ -2359,7 +2385,7 @@ export const App = (): JSX.Element => { )[0]; if (!binding) { setStudioSelection(null); - return; + return null; } const selection: StudioWorkspaceSelection = { kind: "agent", @@ -2379,6 +2405,7 @@ export const App = (): JSX.Element => { setStudioSelection(current.selection); }) .catch(() => {}); + return binding; }; const handleFocusAgent = ( @@ -2395,14 +2422,22 @@ export const App = (): JSX.Element => { const workflow = state.workflows.find((candidate) => samePath(candidate.path, path), ); - if (workflow) selectStudioAgent(workflow, preferredStudioBinding); - else setStudioSelection(null); + const studioBinding = workflow + ? selectStudioAgent(workflow, preferredStudioBinding) + : null; + if (!workflow) setStudioSelection(null); closeMobileDrawer(); + const targetScope = studioScopeForAgentProject( + path, + studioBinding?.projectId, + ); const decision = sessionForFocus({ focusPath: path, active: activeSession, sessions: state.sessions, roots: knownProjectRoots(), + targetProjectId: studioBinding?.projectId, + targetProjectRoot: targetScope?.cwd, }); if ( decision.kind === "switch" && @@ -2464,7 +2499,9 @@ export const App = (): JSX.Element => { pendingCloneFocusRef.current = target.definitionId; setRightCollapsed(true); // terminal-first, like the template flow try { - const session = await createSessionAt(cwd, "claude-code"); + const session = await createSessionAt(cwd, "claude-code", { + initialUserInputPending: true, + }); sendPromptWhenReady( session.id, cloneDefinitionPrompt(target.definitionId, cwd), @@ -2492,9 +2529,38 @@ export const App = (): JSX.Element => { const handleBindWorkflow = async (path: string): Promise => { setSelectedProject(null); closeMobileDrawer(); + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, path), + ); + const selectedBinding = + effectiveStudioSelection?.kind === "agent" + ? workflow?.studioBindings?.find( + (candidate) => + candidate.projectId === effectiveStudioSelection.projectId && + candidate.agentId === effectiveStudioSelection.agentId, + ) + : undefined; + const inferredScope = + selectedBinding || !state.studioProjects + ? null + : mostSpecificStudioScope(path, workspaceScopes, state.studioProjects); + const targetBinding = + selectedBinding ?? + workflow?.studioBindings?.find( + (candidate) => candidate.projectId === inferredScope?.projectId, + ); + const targetProjectId = targetBinding?.projectId ?? null; + const targetScope = studioScopeForAgentProject(path, targetProjectId); const live = state.sessions.filter((s) => s.status !== "exited"); const ownsPath = (s: HarnessSession): boolean => - s.boundWorkflowPath === path || isWithinDir(s.cwd, path); + (samePath(s.boundWorkflowPath ?? "", path) || isWithinDir(s.cwd, path)) && + sessionReachesFocus( + s, + path, + knownProjectRoots(), + targetProjectId, + targetScope?.cwd, + ); // Prefer the ACTIVE tab when it already owns the workflow, so running a // macro against the current agent never yanks the workbench to a sibling // session in the same workspace (e.g. re-visualize on a two-tab agent). @@ -2520,7 +2586,10 @@ export const App = (): JSX.Element => { } else { try { targetId = ( - await createSessionAt(sessionCwdForAgent(path), "claude-code") + await createSessionAt( + targetScope?.cwd ?? sessionCwdForAgent(path), + "claude-code", + ) ).id; } catch (err) { harness.showToast( @@ -2531,10 +2600,7 @@ export const App = (): JSX.Element => { } await harness.bindWorkflow(targetId, path); setFocusedAgentPath(path); - const workflow = state.workflows.find((candidate) => - samePath(candidate.path, path), - ); - if (workflow) selectStudioAgent(workflow); + if (workflow) selectStudioAgent(workflow, targetBinding); return targetId; }; @@ -2713,20 +2779,12 @@ export const App = (): JSX.Element => { sessions={state.sessions} pendingWorkspaces={harness.pendingWorkspaces} activeSessionId={harness.activeSessionId} - focusedAgentPath={ - atMapAltitude ? null : effectiveFocusedAgentPath - } + focusedAgentPath={atMapAltitude ? null : effectiveFocusedAgentPath} workspaceScopes={state.workspaceScopes} studioProjects={state.studioProjects} studioSelection={planFirstSelection} selectedWorkspaceKey={selectedProject?.workspaceKey ?? null} onSelectWorkspace={handleSelectWorkspace} - onSelectAgentMap={(projectId, root, label) => { - const scope = workspaceScopes.find( - (candidate) => candidate.projectId === projectId, - ); - if (scope) handleSelectWorkspace(scope.workspaceKey, root, label); - }} onSelectStudioAgent={(workflow, projectId, agentId) => handleFocusAgent(workflow.path, { projectId, agentId }) } @@ -2768,16 +2826,13 @@ export const App = (): JSX.Element => { closedProjects={harness.closedProjects} unsearchedCheckouts={harness.unsearchedCheckouts} onRemoveProject={async (root) => { - if ( - selectedProject && - samePath(selectedProject.root, root) - ) { + if (selectedProject && samePath(selectedProject.root, root)) { setSelectedProject(null); } const removedProjectId = workspaceScopes.find((scope) => samePath(scope.cwd, root), )?.projectId; - if (studioSelection?.projectId === removedProjectId) { + if (effectiveStudioSelection?.projectId === removedProjectId) { studioRestoreGenerationRef.current += 1; setStudioSelection(null); } @@ -2850,21 +2905,7 @@ export const App = (): JSX.Element => { }} launchDir={state.launchDir ?? null} listDir={harness.listDir} - onStartProjectSession={async (root, label) => { - // A project-row `+` explicitly asks to see a fresh coding - // session, even when Plan Agents or a legacy project map is the - // current altitude. Change views only after creation succeeds so - // a failed launch leaves the planner in place and resumable. - const started = await startProjectSession( - root, - label, - preferredHarness(), - ); - if (!started) return; - studioRestoreGenerationRef.current += 1; - setStudioSelection(null); - setSelectedProject(null); - }} + onStartProjectSession={handleStartProjectSession} listHarnesses={harness.listHarnesses} onCreateAgent={handleCreateAgentInProject} onScaffoldInSession={handleScaffoldInSession} @@ -3001,12 +3042,18 @@ export const App = (): JSX.Element => {
0) + } onBack={composerCanCancel ? () => setComposing(false) : null} activeSession={sessionBarSession} sessionName={ @@ -3019,16 +3066,8 @@ export const App = (): JSX.Element => { : null } onRenameSession={renameSession} - boundWorkflowName={ - planningWorkspace ? null : (boundWorkflow?.name ?? null) - } - sessions={ - planningWorkspace - ? plannerSessions - : showWorkbench - ? focusTabs - : [] - } + boundWorkflowName={boundWorkflow?.name ?? null} + sessions={showWorkbench || projectMapSelected ? focusTabs : []} busySessionIds={harness.busySessionIds} onSelectSession={selectTab} labelOf={(session) => @@ -3045,31 +3084,23 @@ export const App = (): JSX.Element => { onExpandRail={ railCollapsed ? () => setRailCollapsed(false) : null } - onExpandRight={ - !agentMapUnavailable && rightCollapsed ? expandRightPane : null - } + onExpandRight={rightCollapsed ? expandRightPane : null} expandRightLabel={ - planningWorkspace ? "Agent Map" : "Expand canvas panel" + projectMapSelected ? "Agent Map" : "Expand canvas panel" } - showExpandRightLabel={isMobile && planningWorkspace} + showExpandRightLabel={isMobile && projectMapSelected} expandRightRef={rightPaneTriggerRef} subjectName={ - planningWorkspace + projectMapSelected ? (selectedStudioProject?.displayName ?? "Agent Map") : (focusedWorkflow?.name ?? (activeSession ? basenameOf(activeSession.cwd) : null)) } - newSessionPending={ - planningWorkspace - ? agentMapEntry.state.planner.status === "loading" - : siblingSessionPending - } + newSessionPending={siblingSessionPending} onNewSession={ - planningWorkspace - ? agentMapEntry.openFreshPlanner - : activeSession - ? () => handleStartSiblingSession(activeSession) - : null + newTabSource + ? () => handleStartSiblingSession(newTabSource) + : null } /* The agent action cluster shares the same row as the tabs. Its subject AND its gating are `rightPaneWorkflow` — the same @@ -3098,7 +3129,7 @@ export const App = (): JSX.Element => { that is the SAP-2931 trap itself — the verbs staying live against the bound agent while the pane showed another. */ actions={ - !planningWorkspace && rightPaneWorkflow ? ( + !atMapAltitude && rightPaneWorkflow ? ( { />
- {agentMapUnavailable ? ( - - Retry - - } - /> - ) : planningWorkspace ? ( - agentMapEntry.state.planner.status === "error" ? ( - - Retry session - - } - /> - ) : agentMapEntry.state.planner.status === "loading" ? ( - - ) : activePlannerSession?.planning ? ( - /* Agent Map planning is still an ordinary coding-agent - session. Keep the exact same raw CLI surface used for - every agent: trust/auth prompts, slash commands, tool - output, and provider chrome must remain visible rather - than being replaced by a transcript/composer facsimile. */ -
-
- -
-
- ) : ( - - New planning session - - } - /> - ) - ) : showReview && reviewSummary ? ( + {showReview && reviewSummary ? ( { }} onClose={() => setReviewSummary(null)} /> - ) : showDead && activeSession ? ( + ) : showDead && conversationSession ? ( void harness.resumeSession(activeSession.id)} + onResume={() => + void harness.resumeSession(conversationSession.id) + } onContinue={() => void harness.rehydrateSession({ - cwd: activeSession.cwd, - harness: activeSession.harness, - from: activeSession.id, + cwd: conversationSession.cwd, + harness: conversationSession.harness, + from: conversationSession.id, }) } - onClose={() => void harness.closeSession(activeSession.id)} + onClose={() => + void harness.closeSession(conversationSession.id) + } /> ) : showAgentEmpty && focusedWorkflow ? ( /* Honest absence: no session that can WORK on this agent — its @@ -3274,6 +3226,30 @@ export const App = (): JSX.Element => { } /> + ) : showMapConversationEmpty ? ( + void handleStartProjectSession( + selectedStudioScope.cwd, + selectedStudioProject?.displayName ?? basenameOf(selectedStudioScope.cwd), + )} + > + Start session + + ) : null + } + /> ) : showProjectStarting && startingProject ? ( /* E3.2: a project you can select but not talk to is the failure. Its first session is on the way — say so, instead @@ -3286,13 +3262,13 @@ export const App = (): JSX.Element => { title={`Starting a session in ${startingProject.label}…`} body="Sessions boot at the project root, so the coding agent comes up with this project's instructions and skills." /> - ) : showWorkbench && harness.activeSessionId ? ( + ) : showWorkbench && conversationSession ? (
@@ -3338,29 +3314,29 @@ export const App = (): JSX.Element => { !isMobile && !rightPaneSuppressedByComposer && !canvasExpanded && ( -
- )} +
+ )} {isMobile && !rightCollapsed && (
{ role="tablist" aria-label="Right pane" > - {/* THE WAY BACK UP. The map was a one-way door: you could drill - into an agent and never return, which is most of why it felt - like a dead end. Derived from containment (not from the click - that got here), so the same door exists whether the agent was - reached from the rail or from a map node. */} - {upToProject && ( - - )} {/* Steps are an AGENT's steps. At map altitude there is no meaningful step list for a whole project, and a tab that @@ -3463,7 +3413,8 @@ export const App = (): JSX.Element => { aria-label={secretsDisabled ?? undefined} data-tooltip={secretsDisabled ?? undefined} className={ - "right-pane-tab" + (shownTab === "secrets" ? " is-active" : "") + "right-pane-tab" + + (shownTab === "secrets" ? " is-active" : "") } onClick={() => setRightTab("secrets")} data-testid="right-tab-secrets" @@ -3501,20 +3452,20 @@ export const App = (): JSX.Element => { )} {/* Full view belongs to the graph surface currently shown: Agent Map at project altitude, Canvas / Focus below it. */} - {(!atMapAltitude || planningWorkspace) && ( + {(!atMapAltitude || projectMapSelected) && (
diff --git a/packages/harness/web/src/components/AgentMapPane.tsx b/packages/harness/web/src/components/AgentMapPane.tsx index 74bc19ab8..44ac401fe 100644 --- a/packages/harness/web/src/components/AgentMapPane.tsx +++ b/packages/harness/web/src/components/AgentMapPane.tsx @@ -10,6 +10,7 @@ import { Icon } from "./Icon"; interface AgentMapPaneProps { state: AgentMapWorkspacePaneState; + unavailable: string | null; onRetry: () => void; expanded: boolean; onToggleExpanded: () => void; @@ -18,6 +19,7 @@ interface AgentMapPaneProps { /** The honest E1 map: durable state around the existing neutral canvas empty. */ export function AgentMapPane({ state, + unavailable, onRetry, expanded, onToggleExpanded, @@ -60,7 +62,17 @@ export function AgentMapPane({ }, [closeInspector, expanded, onToggleExpanded, selected]); let content: JSX.Element; - if (state.status === "error") { + if (state.status === "error" && unavailable) { + content = ( + + ); + } else if (state.status === "error") { content = ( `project:${root}`; export const dirKey = (path: string): string => `dir:${path}`; -/** The durable project's pinned first child. It is a workspace, not an agent. */ -export function AgentMapRow({ - selected, - onSelect, -}: { - selected: boolean; - onSelect: () => void; -}): JSX.Element { - return ( -
-
- ); -} - /** * The row's left slot: identity at rest, disclosure on hover. * @@ -365,9 +332,9 @@ export function ProjectRow({ disclosable = true, mainTestid, tooltip, + projectViewLabel = "dependency graph", busy = false, drag, - disclosureOnly = false, }: { label: string; root: string; @@ -400,13 +367,13 @@ export function ProjectRow({ disclosable?: boolean; mainTestid?: string; tooltip?: string; + /** The project-level view selected by the label. */ + projectViewLabel?: string; busy?: boolean; /** The project row is a DROP TARGET (the root is a directory like any other), * never a drag source: moving the folder the project IS would move the * project, which is what removing and adding one is for. */ drag?: RailDrag; - /** Plan-first project labels disclose children instead of selecting a child. */ - disclosureOnly?: boolean; }): JSX.Element { const agentPath = rootAgent?.workflow.path ?? null; // The row's identity, and the click that follows from it, are settled at the @@ -475,13 +442,11 @@ export function ProjectRow({ merged row through the trailing control the rail passes, so nothing is lost, and the two subjects stop competing for one click. */ onClick={ - disclosureOnly - ? onToggleCollapsed - : focusTarget - ? () => onFocusAgent(focusTarget) - : workspaceKey - ? () => onSelectProject(workspaceKey, root, label) - : undefined + focusTarget + ? () => onFocusAgent(focusTarget) + : workspaceKey + ? () => onSelectProject(workspaceKey, root, label) + : undefined } /* DOUBLE-CLICK TOGGLES DISCLOSURE — the platform convention for a disclosure row, and its absence read as breakage: the chevron was @@ -513,38 +478,29 @@ export function ProjectRow({ is; the title answers where it lives. */ title={root} aria-pressed={ - disclosureOnly - ? undefined - : focusTarget - ? busy - ? undefined - : isFocused - : workspaceKey - ? selected - : undefined + focusTarget + ? busy + ? undefined + : isFocused + : workspaceKey + ? selected + : undefined } - aria-expanded={disclosureOnly ? !collapsed : undefined} aria-busy={busy ? true : undefined} aria-label={ - disclosureOnly - ? `${collapsed ? "Expand" : "Collapse"} ${label}` - : focusTarget - ? `Focus ${label}` - : workspaceKey - ? `Open dependency graph for ${label}` - : undefined + focusTarget + ? `Focus ${label}` + : workspaceKey + ? `Open ${projectViewLabel} for ${label}` + : undefined } data-tooltip={ tooltip ?? - (disclosureOnly - ? collapsed - ? "Expand" - : "Collapse" - : focusTarget - ? "Focus this agent" - : workspaceKey - ? "Open dependency graph" - : undefined) + (focusTarget + ? "Focus this agent" + : workspaceKey + ? `Open ${projectViewLabel}` + : undefined) } > {label} diff --git a/packages/harness/web/src/components/SessionBar.tsx b/packages/harness/web/src/components/SessionBar.tsx index 9fac5ece6..504ea7cd8 100644 --- a/packages/harness/web/src/components/SessionBar.tsx +++ b/packages/harness/web/src/components/SessionBar.tsx @@ -20,9 +20,6 @@ function workspaceLabelOf(path: string): string { const EMPTY_BUSY_SESSION_IDS: ReadonlySet = new Set(); interface SessionBarProps { - /** Planner sessions keep normal tabs/rename/end but have no meaningful - * filesystem path or editor action in this workspace. */ - planning?: boolean; /** The main panel is showing the Overview/intro, not a session. */ overviewMode?: boolean; /** Set while an agent is open whose workspace has no live session. */ @@ -86,7 +83,6 @@ interface SessionBarProps { * its caret, while agent actions remain right-anchored on the same row. */ export function SessionBar({ - planning = false, overviewMode = false, openedAgentName = null, reviewTitle = null, @@ -219,18 +215,19 @@ export function SessionBar({ ) : null - ) : activeSession && - activeSession.status !== "exited" && - sessions.length > 0 && + ) : sessions.length > 0 && onSelectSession && onNewSession && labelOf ? ( setMenuOpen((open) => !open)} menuTriggerRef={menuTriggerRef} menuTooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` + activeSession + ? `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` + : undefined } renaming={renaming} renameDraft={renameDraft} @@ -274,11 +271,7 @@ export function SessionBar({ data-testid="session-menu" aria-haspopup="menu" aria-expanded={menuOpen} - data-tooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` - } + data-tooltip={`${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}`} onClick={() => setMenuOpen((open) => !open)} {...trackingAttrs({ object: "session" })} > @@ -332,22 +325,20 @@ export function SessionBar({
)} - {!planning && ( - - )} + - {!planning && ( - - )} + {activeSession.status !== "exited" && ( - )} - - {planning && onNewSession && !activeSession && !composing && ( - - )} + {onNewSession && + activeSession?.status === "exited" && + sessions.length === 0 && + !composing && ( + + )} {actions} @@ -455,11 +428,6 @@ export function SessionBar({ {confirmingClose && activeSession && ( setConfirmingClose(false)} onConfirm={() => { setConfirmingClose(false); diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index b4f2efcf4..26e55a25f 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -39,7 +39,6 @@ import { describeUpdateOutcome, getDesktopBridge } from "../lib/desktop"; import { ProjectRow, ProjectTreeRows, - AgentMapRow, dirKey, projectKey, } from "./ProjectTreeRows"; @@ -73,6 +72,7 @@ import { buildProjectTree, projectIsEmpty, projectRoots, + projectSessionRoot, unrootedAgents, } from "../lib/project-tree"; import { @@ -123,7 +123,6 @@ interface WorkflowsRailProps { root: string, label: string, ) => void; - onSelectAgentMap: (projectId: string, root: string, label: string) => void; onSelectStudioAgent: ( workflow: WorkflowInfo, projectId: string, @@ -267,8 +266,8 @@ const SORT_LABELS: Record = { * * The adjacent `+` has one stable meaning: start a coding-agent session at this * project's root. This menu keeps the lower-frequency, explicitly named - * project actions. On legacy servers that includes scaffolding a Sapiom agent; - * on current plan-first projects, agent creation remains owned by Plan Agents. + * project actions, including creating or scaffolding a Sapiom agent. Opening + * the Agent Map never takes ownership of those ordinary build controls. * * Named items say it instead. Each carries the project's own label, so the * subject is read rather than inferred, and the destructive one is last and @@ -284,10 +283,9 @@ function ProjectRowMenu({ onRemove, }: { label: string; - /** The compatibility create action this project currently offers, or null - * when its Agent Map owns creation / while one is mid-creation. A bare - * project (sessions, no agent) scaffolds into its existing session; every - * other project starts a new one rooted at the project. */ + /** The create action this project currently offers, or null while one is + * mid-creation. A bare project (sessions, no agent) scaffolds into its + * existing session; every other project starts a new one at the root. */ create: { kind: "create" | "scaffold"; testid: string; @@ -463,7 +461,6 @@ export function WorkflowsRail({ studioSelection, selectedWorkspaceKey, onSelectWorkspace, - onSelectAgentMap, onSelectStudioAgent, onFocusAgent, onOpenPalette, @@ -698,10 +695,29 @@ export function WorkflowsRail({ const shown = (path: string): boolean => !hiddenByClosedProject(path, closedProjects, openRoots); const visibleWorkflows = workflows.filter((workflow) => shown(workflow.path)); + const durableRootCandidates = (workspaceScopes ?? []).flatMap((scope) => + scope.projectId ? [{ projectId: scope.projectId, cwd: scope.cwd }] : [], + ); + const rootedSessions = sessions.flatMap((session) => { + if (!session.agentMapIdentity) return [session]; + const root = projectSessionRoot( + { + cwd: session.cwd, + projectId: session.agentMapIdentity.projectId, + }, + durableRootCandidates, + ); + // Match the server scope catalog: a neutral session contributes its + // trusted durable root, never a descendant cwd or a stale binding. + return root ? [{ ...session, cwd: root }] : []; + }); const roots = projectRoots({ recentDirs, - sessions, + sessions: rootedSessions, pendingCwds, + pinnedRoots: (workspaceScopes ?? []) + .filter((scope) => scope.projectId != null) + .map((scope) => scope.cwd), // Hidden agents are deliberately NOT passed. A removed project's agents are // not on screen, so they cannot be the reason a folder is filed away. agentPaths: visibleWorkflows.map((workflow) => workflow.path), @@ -738,8 +754,8 @@ export function WorkflowsRail({ * without touching disk is the Group axis, above.) * * Offered ONLY on the project axis. The plan comes from `lib/agent-move.ts`; - * the endpoint guards itself again, so a refusal can still arrive for a plan - * this rail blessed — a planner is not a permission system. + * the endpoint guards itself again, so a refusal can still arrive for a move + * this rail considered valid — client geometry is not server authority. */ const drag: RailDrag | undefined = axis === "project" @@ -1232,12 +1248,10 @@ export function WorkflowsRail({ const studioProject = studioProjects?.find( (candidate) => candidate.projectId === workspaceScope?.projectId, ); - // Current servers issue a durable Studio project for every scope, - // and that project's Agent Map owns creation. The absent case is a - // compatibility payload, not a second creation mode. Keep ownership - // independent of the selected axis so Group cannot restore a bypass. - const mapOwnsCreation = studioProject != null; - const planFirst = axis === "project" && mapOwnsCreation; + // Current servers issue a durable Studio project for every scope. + // Its project label owns Agent Map navigation; creation remains an + // ordinary project action available beside that read-only view. + const planFirst = studioProject != null; const mapSelected = planFirst && studioSelection?.kind === "agent-map" && @@ -1311,18 +1325,20 @@ export function WorkflowsRail({ workspaceScope?.workspaceKey === selectedWorkspaceKey) } onSelectProject={onSelectWorkspace} + projectViewLabel={planFirst ? "Agent Map" : undefined} focusedAgentPath={focusedAgentPath} onFocusAgent={focusProjectAgent} - focusable={creating || bare != null} + focusable={!planFirst && (creating || bare != null)} disclosable={ planFirst - ? true + ? project.rootAgent != null || + project.dirs.length > 0 || + project.agents.length > 0 : axis === "group" ? showGroups || soloAgents.length > 0 : project.dirs.length > 0 || project.agents.length > 0 } busy={creating} - disclosureOnly={planFirst} drag={drag} mainTestid={ workspaceScope @@ -1387,7 +1403,7 @@ export function WorkflowsRail({ before the overflow menu. Its accessible name supplies the noun the glyph cannot: this starts a coding-agent SESSION at the project root. It does not scaffold a - Sapiom agent or bypass Plan Agents. */} + Sapiom agent. */} {!pending && ( -
- )} +
+
{/* THE BOUNDARY'S OWN ANSWER, when there is one. A scan stops at every separate checkout, so a folder that is not itself a repo but holds several clones finds @@ -1578,7 +1577,7 @@ export function WorkflowsRail({ collapsedKeys={collapsedKeys} onToggleCollapsed={toggleCollapsed} focusedAgentPath={focusedAgentPath} - onFocusAgent={onFocusAgent} + onFocusAgent={focusProjectAgent} onCreate={() => { const label = nextGroupLabel(groupNodes); railGroups.edit(project.root, groupAgents, (state) => @@ -1629,7 +1628,7 @@ export function WorkflowsRail({ prefix={node?.prefix ?? ""} prefixFull={node?.prefixFull ?? ""} isFocused={workflow.path === focusedAgentPath} - onFocus={onFocusAgent} + onFocus={focusProjectAgent} /> ); })} diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 9d7625c32..58afe6472 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -264,6 +264,78 @@ describe("mostSpecificStudioScope", () => { )?.projectId, ).toBe(nestedProjectId); }); + + it("fails closed when two durable projects claim the same nearest root", () => { + const otherProjectId = "project_00000000-0000-4000-8000-000000000002"; + + expect( + mostSpecificStudioScope( + "/work/services/agent", + [ + { workspaceKey: "scope-a", cwd: "/work/services", projectId }, + { + workspaceKey: "scope-b", + cwd: "/work/services", + projectId: otherProjectId, + }, + ], + [validResponseProject(projectId), validResponseProject(otherProjectId)], + ), + ).toBeNull(); + }); + + it("selects the most-specific binding of one Windows project across case variants", () => { + expect( + mostSpecificStudioScope( + "c:/users/alice/project/PACKAGES/app/src", + [ + { + workspaceKey: "project-root", + cwd: "C:\\Users\\Alice\\Project", + projectId, + }, + { + workspaceKey: "packages-root", + cwd: "C:\\Users\\Alice\\Project\\packages", + projectId, + }, + { + workspaceKey: "sibling-project", + cwd: "C:\\Users\\Alice\\Project-two", + projectId: "project_00000000-0000-4000-8000-000000000003", + }, + ], + [validResponseProject(projectId)], + )?.workspaceKey, + ).toBe("packages-root"); + }); + + it("resolves disjoint bindings of one durable project independently of scope order", () => { + const scopes = [ + { + workspaceKey: "research-root", + cwd: "C:\\Projects\\Research", + projectId, + }, + { + workspaceKey: "publisher-root", + cwd: "D:\\Projects\\Publisher", + projectId, + }, + ]; + for (const ordered of [scopes, [...scopes].reverse()]) { + expect( + mostSpecificStudioScope("c:/projects/research/src", ordered, [ + validResponseProject(projectId), + ])?.workspaceKey, + ).toBe("research-root"); + expect( + mostSpecificStudioScope("d:/projects/publisher/src", ordered, [ + validResponseProject(projectId), + ])?.workspaceKey, + ).toBe("publisher-root"); + } + }); }); function validResponseProject(id: string): StudioProjectSummary { diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index 41aa0f308..807e2cb3a 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -12,8 +12,9 @@ import { parseMapChangeProposal, } from "@shared/agent-map-codec"; import type { WorkspaceScopeSummary } from "@shared/system-graph"; +import { resolveProjectRootForPath } from "../../../src/shared/project-roots.js"; -import { isWithinDir, stripTrailingSep } from "./paths"; +import { samePath } from "./paths"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -330,23 +331,19 @@ export function mostSpecificStudioScope( projects: readonly StudioProjectSummary[], ): (WorkspaceScopeSummary & { projectId: string }) | null { const projectIds = new Set(projects.map((project) => project.projectId)); - return ( - scopes - .filter((scope): scope is WorkspaceScopeSummary & { projectId: string } => - Boolean( - scope.projectId && - projectIds.has(scope.projectId) && - isWithinDir(scope.cwd, targetPath), - ), - ) - .map((scope) => ({ - scope, - depth: stripTrailingSep(scope.cwd).length, - })) - .sort( - (left, right) => - right.depth - left.depth || - left.scope.projectId.localeCompare(right.scope.projectId), - )[0]?.scope ?? null + const candidates = scopes.filter( + (scope): scope is WorkspaceScopeSummary & { projectId: string } => + Boolean(scope.projectId && projectIds.has(scope.projectId)), ); + const resolved = resolveProjectRootForPath(targetPath, candidates); + if (!resolved) return null; + return candidates + .filter( + (scope) => + scope.projectId === resolved.projectId && + samePath(scope.cwd, resolved.cwd), + ) + .sort((left, right) => + left.workspaceKey.localeCompare(right.workspaceKey), + )[0]!; } diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index 7a4eae7f0..dec7d1555 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -493,69 +493,6 @@ describe("RealApi.getSystemGraph", () => { }); }); -describe("RealApi planner mutations", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("retains the authoritative metadata returned by send and greeting retry", async () => { - if (isMockMode()) return; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - const accepted = { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner" as const, - }, - greeting: { status: "skipped" as const, reason: "user-proceeded" }, - queuedInputIds: ["input-1"], - }; - const retrying = { - ...accepted, - greeting: { - status: "generating" as const, - attemptId: "attempt-2", - }, - queuedInputIds: [], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: accepted }), { status: 202 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: retrying }), { status: 202 }), - ); - vi.stubGlobal("fetch", fetchMock); - - const api = createApi(); - await expect( - api.sendPlannerMessage("project-1", "planner-1", { text: "hello" }), - ).resolves.toEqual({ metadata: accepted }); - await expect( - api.retryPlannerGreeting("project-1", "planner-1"), - ).resolves.toEqual({ metadata: retrying }); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "/api/projects/project-1/planner-sessions/planner-1/messages", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ text: "hello" }), - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "/api/projects/project-1/planner-sessions/planner-1/greeting/retry", - expect.objectContaining({ method: "POST", body: "{}" }), - ); - }); -}); - describe("progressiveLeasingRun", () => { const at = (elapsed: number) => progressiveLeasingRun("exec-mock-prod-1", elapsed); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 207985f29..1fecc758b 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -48,10 +48,6 @@ import type { AcceptedProposalDelta, AgentMapWorkspaceResponse, MapOperation, - PlannerMessageRequest, - PlannerSessionMetadataResponse, - PlannerSessionRequest, - PlannerSessionResponse, PutStudioCurrentWorkspaceRequest, StudioCurrentWorkspaceResponse, StudioProjectId, @@ -379,23 +375,6 @@ export interface HarnessApi { projectId: StudioProjectId, selection: StudioWorkspaceSelection, ): Promise; - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise; - /** Compatibility surface for coordinator-driven clients. The Studio renders - * the planner's raw CLI and does not project this protocol into a second - * transcript/composer UI. */ - sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise; - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ - retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise; /** Revisioned local dependency projection for one server-issued workspace key. */ getSystemGraph( workspaceKey: WorkspaceKey, @@ -690,37 +669,6 @@ class RealApi implements HarnessApi { return parseStudioCurrentWorkspaceResponse(value, projectId); } - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/messages`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/greeting/retry`, - { method: "POST", body: "{}" }, - ); - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -2075,9 +2023,6 @@ export class MockApi implements HarnessApi { this.fresh || this.noLiveSessions ? [] : MOCK_SESSIONS.map((session) => ({ ...session })); - /** Live planner records are mutable mock state, unlike the fixed history - * fixtures. They exercise the same record-refetch path as the real server. */ - private plannerSessionRecords = new Map(); private workflowsStore: WorkflowInfo[] = this.fresh ? [] : [ @@ -2262,6 +2207,60 @@ export class MockApi implements HarnessApi { })); } + /** Mirror the server's neutral project principal in opt-in Studio fixtures. */ + private studioSession( + session: HarnessSession, + projects: readonly StudioProjectSummary[] | undefined, + ): HarnessSession { + if (!projects) return session; + const projectIds = new Set(projects.map((project) => project.projectId)); + const matches = this.workspaceScopes() + .filter( + (scope) => + scope.projectId && + projectIds.has(scope.projectId) && + isWithinDir(scope.cwd, session.cwd), + ) + .sort( + (left, right) => + right.cwd.length - left.cwd.length || + left.cwd.localeCompare(right.cwd), + ); + const nearestDepth = matches[0]?.cwd.length; + const nearestProjectIds = new Set( + matches + .filter((scope) => scope.cwd.length === nearestDepth) + .map((scope) => scope.projectId), + ); + const projectId = + nearestProjectIds.size === 1 ? matches[0]?.projectId : undefined; + if (!projectId || !projectIds.has(projectId)) return session; + const usePlanAgentsFixture = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get( + "mockPlanAgentsSession", + ) === "1" && + session.id === "sess-boot"; + const useRestoreBindingConflictFixture = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get( + "mockRestoreBindingConflict", + ) === "1" && + session.id === "sess-boot"; + return { + ...session, + ...(usePlanAgentsFixture ? { title: "Plan Agents" } : {}), + ...(useRestoreBindingConflictFixture + ? { boundWorkflowPath: "/Users/demo/polsia/services/workers" } + : {}), + agentMapIdentity: { + projectId, + userId: "user_mock", + sessionId: session.id, + }, + }; + } + private studioWorkflows(): WorkflowInfo[] { const scopes = this.workspaceScopes(); return this.workflows.map((workflow, index) => { @@ -2353,7 +2352,9 @@ export class MockApi implements HarnessApi { ) === "off" ? false : true, - sessions: this.sessions, + sessions: this.sessions.map((session) => + this.studioSession(session, studioProjects), + ), workflows: this.studioWorkflows(), workspaceScopes: this.workspaceScopes(), ...(studioProjects ? { studioProjects } : {}), @@ -2423,7 +2424,7 @@ export class MockApi implements HarnessApi { "Studio project is not available", ); } - if (!project) { + if (failure === "missing" || !project) { throw new ApiError( 404, "Studio project not found", @@ -2517,311 +2518,6 @@ export class MockApi implements HarnessApi { return { ...current, selection, repaired: !valid }; } - async openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - if (typeof window !== "undefined") { - const win = window as unknown as { - __HARNESS_TEST__?: Record; - }; - const previous = - (win.__HARNESS_TEST__?.openPlannerSessionCalls as - | unknown[] - | undefined) ?? []; - win.__HARNESS_TEST__ = { - ...(win.__HARNESS_TEST__ ?? {}), - openPlannerSessionCalls: [...previous, { projectId, request }], - }; - } - const failure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockPlanner"); - if (failure === "error") { - throw new ApiError( - 503, - "Planner service is unavailable", - "Planner service is unavailable", - ); - } - if (failure === "unauthorized") { - throw new ApiError( - 403, - "Planner project is not available", - "Planner project is not available", - ); - } - const existing = this.sessions - .filter( - (session) => - session.status !== "exited" && - session.planning?.identity.projectId === projectId && - session.planning.identity.userId === "user_mock", - ) - .sort((left, right) => - right.lastActiveAt.localeCompare(left.lastActiveAt), - )[0]; - if (request.mode === "resume-or-create" && existing) { - return { session: existing, resolution: "live" }; - } - const root = [...this.studioProjectIds.entries()].find( - ([, id]) => id === projectId, - )?.[0]; - if (!root) { - throw new ApiError( - 404, - "Studio project not found", - "Studio project not found", - ); - } - const session = await this.createSession({ - cwd: root, - harness: request.harness ?? "claude-code", - ...(request.theme ? { theme: request.theme } : {}), - }); - const greetingFixture = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreeting"); - session.planning = { - identity: { - projectId, - sessionId: session.id, - userId: "user_mock", - role: "map-planner", - }, - greeting: - greetingFixture === "generating" - ? { status: "generating", attemptId: "attempt_mock" } - : greetingFixture === "failed" - ? { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - } - : { - status: "delivered", - messageId: "message_mock_greeting", - }, - queuedInputIds: [], - }; - const now = new Date().toISOString(); - this.plannerSessionRecords.set(session.id, { - harnessSessionId: session.id, - mergedSessionIds: [session.id], - agentSessionId: session.agentSessionId, - harness: session.harness, - cwd: session.cwd, - startedAt: now, - endedAt: null, - turns: - session.planning.greeting.status === "delivered" - ? [ - { - index: 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I’m your project planning agent. We’ll plan the agents, responsibilities, data flow, resources, and connectors together. What kind of agent architecture do you want to build?", - model: "mock-planner", - usage: null, - completedAt: now, - incomplete: false, - }, - ] - : [], - turnCount: 0, - eventCount: session.planning.greeting.status === "delivered" ? 2 : 0, - reconstructed: true, - archivedAt: null, - limitations: [], - }); - return { session, resolution: "created" }; - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - if (session?.planning?.identity.projectId !== projectId) { - throw new ApiError( - 403, - "Forbidden planner session", - "Forbidden planner session", - ); - } - const inputId = `input_mock_${Date.now()}`; - session.planning = { - ...session.planning, - greeting: - session.planning.greeting.status === "delivered" || - session.planning.greeting.status === "skipped" - ? session.planning.greeting - : { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [...session.planning.queuedInputIds, inputId], - }; - await this.injectInput(sessionId, { text: request.text }); - const accepted = structuredClone(session.planning); - const project = this.studioProjects()?.find( - (candidate) => candidate.projectId === projectId, - ); - const goldenFixtureEnabled = - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("mockAgentMapGolden") === - "1"; - setTimeout( - () => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; - const completedAt = new Date().toISOString(); - const turns = [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: request.text, - promptAt: completedAt, - toolCalls: [], - assistantText: - "Let’s start by clarifying the outcome, the actors involved, and the information they need to exchange.", - model: "mock-planner", - usage: null, - completedAt, - incomplete: false, - }, - ]; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns, - turnCount: record.turnCount + 1, - eventCount: record.eventCount + 2, - }); - current.planning = { - ...current.planning, - queuedInputIds: current.planning.queuedInputIds.filter( - (candidate) => candidate !== inputId, - ), - }; - void import("./events").then(({ publishMockBusMessage }) => { - if (goldenFixtureEnabled && !this.agentMapSnapshots.has(projectId)) { - if (!project) return; - const fixture = goldenAgentMapFixture( - project, - new Date().toISOString(), - accepted.identity.userId, - sessionId, - ); - this.agentMapSnapshots.set(projectId, fixture.snapshot); - publishMockBusMessage({ - type: "agent-map.proposal.changed", - delta: fixture.delta, - }); - } - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, - goldenFixtureEnabled ? 0 : 250, - ); - return { metadata: accepted }; - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - if (session?.planning?.identity.projectId !== projectId) { - throw new ApiError( - 403, - "Forbidden planner session", - "Forbidden planner session", - ); - } - const retryFailure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreetingRetry"); - if (retryFailure === "error") { - throw new ApiError( - 503, - "Greeting retry is temporarily unavailable", - "Greeting retry is temporarily unavailable", - ); - } - if ( - session.planning.greeting.status !== "failed" || - !session.planning.greeting.retryable || - session.planning.queuedInputIds.length > 0 - ) { - throw new ApiError( - 409, - "Greeting retry is not available", - "Greeting retry is not available", - ); - } - session.planning = { - ...session.planning, - greeting: { status: "generating", attemptId: "attempt_mock_retry" }, - }; - const retrying = structuredClone(session.planning); - setTimeout(() => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; - const completedAt = new Date().toISOString(); - current.planning = { - ...current.planning, - greeting: { - status: "delivered", - messageId: "message_mock_greeting_retry", - }, - }; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns: [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I’m your project planning agent. What kind of agent architecture do you want to build?", - model: "mock-planner", - usage: null, - completedAt, - incomplete: false, - }, - ], - eventCount: record.eventCount + 2, - }); - void import("./events").then(({ publishMockBusMessage }) => { - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, 250); - return { metadata: retrying }; - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -3020,7 +2716,16 @@ export class MockApi implements HarnessApi { } async createSession(req: CreateSessionRequest): Promise { - await delay(300); + const requestedDelay = + typeof window === "undefined" + ? null + : (window as unknown as { __MOCK_CREATE_SESSION_DELAY_MS__?: number }) + .__MOCK_CREATE_SESSION_DELAY_MS__; + await delay( + typeof requestedDelay === "number" && requestedDelay >= 0 + ? requestedDelay + : 300, + ); if (typeof window !== "undefined") { const win = window as unknown as { __HARNESS_TEST__?: Record; @@ -3040,7 +2745,7 @@ export class MockApi implements HarnessApi { throw new Error("mock: couldn't create session"); } } - const session: HarnessSession = { + let session: HarnessSession = { id: `sess-mock-${this.sessions.length + 1}`, agentSessionId: null, boundWorkflowPath: null, @@ -3060,6 +2765,10 @@ export class MockApi implements HarnessApi { ready: false, }; this.sessions = [...this.sessions, session]; + session = this.studioSession(session, this.studioProjects()); + this.sessions = this.sessions.map((candidate) => + candidate.id === session.id ? session : candidate, + ); // Mirror the real server: create answers "starting", and the event bus // promotes the session to running/ready moments later. Without this, a // mock-created session would stay unready forever and gate the action @@ -3168,9 +2877,7 @@ export class MockApi implements HarnessApi { await delay(); // Null for an id with no fixture — the same "nothing recorded" answer the // real client returns for a 404, so the empty state is exercised too. - return ( - this.plannerSessionRecords.get(id) ?? MOCK_SESSION_RECORDS[id] ?? null - ); + return MOCK_SESSION_RECORDS[id] ?? null; } async resumeSession(id: string): Promise { @@ -3667,8 +3374,51 @@ export class MockApi implements HarnessApi { // reload can (and in the spec does) start before this delay resolves. A // write behind the delay would lose the dismiss to its own fixture. if (patch.helpSeen !== undefined) writeMockHelpSeen(patch.helpSeen); + const previousRecentDirs = new Set(this.settings.recentDirs); await delay(); this.settings = { ...this.settings, ...patch }; + // Opt-in parity fixture for the production project-open lifecycle: a newly + // durable project gets one ordinary first session titled Plan Agents. This + // is intentionally not routed through the mock create-session endpoint; + // the server owns it, so a project-name click still makes zero client + // session requests. + const autoPlanAgents = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("mockAutoPlanAgents") === + "1"; + const addedRoots = (patch.recentDirs ?? []).filter( + (root) => !previousRecentDirs.has(root), + ); + if (autoPlanAgents && addedRoots.length > 0) { + const { publishMockBusMessage } = await import("./events"); + for (const root of addedRoots) { + if (this.sessions.some((session) => samePath(session.cwd, root))) { + continue; + } + const projectId = this.studioProjectId(root); + const id = `sess-plan-agents-${this.sessions.length + 1}`; + const now = new Date().toISOString(); + const session: HarnessSession = { + id, + agentSessionId: null, + boundWorkflowPath: null, + harness: "claude-code", + cwd: root, + title: "Plan Agents", + status: "running", + createdAt: now, + lastActiveAt: now, + ready: true, + agentMapIdentity: { + projectId, + userId: "user_mock", + sessionId: id, + }, + }; + this.sessions = [...this.sessions, session]; + publishMockBusMessage({ type: "session.status", session }); + } + } return this.settings; } diff --git a/packages/harness/web/src/lib/canvas-altitude.test.ts b/packages/harness/web/src/lib/canvas-altitude.test.ts index 35b986b70..52d275a35 100644 --- a/packages/harness/web/src/lib/canvas-altitude.test.ts +++ b/packages/harness/web/src/lib/canvas-altitude.test.ts @@ -5,7 +5,6 @@ import type { WorkspaceScopeSummary } from "@shared/system-graph"; import { canvasView, studioCanvasView, - projectAbove, projectRefForRoot, stepsDisabledReason, } from "./canvas-altitude"; @@ -42,33 +41,43 @@ describe("studioCanvasView", () => { const HOME = "/Users/demo"; const POLSIA = `${HOME}/polsia`; const ADS = `${POLSIA}/backend/src/agents/ads`; -const WORKERS = `${POLSIA}/services/workers`; const scopes: WorkspaceScopeSummary[] = [ { workspaceKey: "ws-polsia", cwd: POLSIA }, - { workspaceKey: "ws-workers", cwd: WORKERS }, ]; const polsia = { workspaceKey: "ws-polsia", root: POLSIA, label: "polsia" }; describe("canvasView: the project wins, and that is stated once", () => { it("is map altitude for a selected project", () => { - expect(canvasView(polsia, null)).toEqual({ altitude: "map", project: polsia }); + expect(canvasView(polsia, null)).toEqual({ + altitude: "map", + project: polsia, + }); }); it("is board altitude for a selected agent", () => { - expect(canvasView(null, ADS)).toEqual({ altitude: "board", agentPath: ADS }); + expect(canvasView(null, ADS)).toEqual({ + altitude: "board", + agentPath: ADS, + }); }); it("still resolves to ONE altitude when a door forgets to clear the other half", () => { // The two are mutually exclusive by construction. This is the case where // that construction has a hole: the answer must be a project's map, never // an agent's board drawn under a project's name. - expect(canvasView(polsia, ADS)).toEqual({ altitude: "map", project: polsia }); + expect(canvasView(polsia, ADS)).toEqual({ + altitude: "map", + project: polsia, + }); }); it("is board altitude with nothing selected at all", () => { - expect(canvasView(null, null)).toEqual({ altitude: "board", agentPath: null }); + expect(canvasView(null, null)).toEqual({ + altitude: "board", + agentPath: null, + }); }); }); @@ -78,7 +87,9 @@ describe("projectRefForRoot: the browser joins, it never invents a key", () => { }); it("matches on segment boundaries, so a trailing separator still joins", () => { - expect(projectRefForRoot(`${POLSIA}/`, "polsia", scopes)?.workspaceKey).toBe("ws-polsia"); + expect( + projectRefForRoot(`${POLSIA}/`, "polsia", scopes)?.workspaceKey, + ).toBe("ws-polsia"); }); it("never joins a neighbouring project by string prefix", () => { @@ -97,35 +108,6 @@ describe("projectRefForRoot: the browser joins, it never invents a key", () => { }); }); -describe("projectAbove: the way back is derived, not remembered", () => { - it("is the agent's project, whichever door reached the agent", () => { - expect(projectAbove(ADS, [POLSIA], scopes)).toEqual({ - workspaceKey: "ws-polsia", - root: POLSIA, - label: "polsia", - }); - }); - - it("takes the NEAREST project when roots overlap, the same one the session booted in", () => { - const nested = `${WORKERS}/queue`; - expect(projectAbove(nested, [POLSIA, WORKERS], scopes)?.workspaceKey).toBe("ws-workers"); - }); - - it("offers no way up for an agent outside every project", () => { - // `projectRootForAgent` falls back to the agent's own folder. Cutting up to - // that would open a map of one agent — itself. - expect(projectAbove(`${HOME}/scratch/bot`, [POLSIA], scopes)).toBeNull(); - }); - - it("offers no way up when the server issued no graph key for the project", () => { - expect(projectAbove(ADS, [POLSIA], [])).toBeNull(); - }); - - it("is null with nothing selected", () => { - expect(projectAbove(null, [POLSIA], scopes)).toBeNull(); - }); -}); - describe("stepsDisabledReason: a disabled tab says why", () => { it("gives the sentence at map altitude, not a bare boolean", () => { // A tab that silently shows the last agent's steps under a project's name diff --git a/packages/harness/web/src/lib/canvas-altitude.ts b/packages/harness/web/src/lib/canvas-altitude.ts index 96548bd25..cd54d6d0d 100644 --- a/packages/harness/web/src/lib/canvas-altitude.ts +++ b/packages/harness/web/src/lib/canvas-altitude.ts @@ -23,7 +23,6 @@ import type { WorkspaceKey, WorkspaceScopeSummary } from "@shared/system-graph"; import type { StudioWorkspaceSelection } from "@shared/agent-map"; import { basenameOf, samePath } from "./paths"; -import { projectRootForAgent } from "./session-scope"; /** A project as both surfaces need it: the rail's row and the graph's key. */ export interface ProjectRef { @@ -92,31 +91,11 @@ export function projectRefForRoot( if (!root) return null; const scope = scopes.find((candidate) => samePath(candidate.cwd, root)); if (!scope) return null; - return { workspaceKey: scope.workspaceKey, root, label: label ?? basenameOf(root) }; -} - -/** - * The project an agent's board can cut UP to, or null. - * - * DERIVED from containment on every call, never remembered from the click that - * drilled down. Remembering it would make the way back depend on how you got - * here — an agent reached from the rail would have no way up while the same - * agent reached from the map would — and it would go stale the moment a - * project is removed or an agent is moved. Derived, the way up is the same - * one door however you arrived, and it disappears exactly when the project does. - */ -export function projectAbove( - agentPath: string | null, - roots: readonly string[], - scopes: readonly WorkspaceScopeSummary[], -): ProjectRef | null { - if (!agentPath) return null; - const root = projectRootForAgent(agentPath, roots); - // `projectRootForAgent` falls back to the agent's own folder when no known - // root contains it. That is not a project, and offering "up" to it would - // send the user to a map of one agent — itself. - if (samePath(root, agentPath)) return null; - return projectRefForRoot(root, null, scopes); + return { + workspaceKey: scope.workspaceKey, + root, + label: label ?? basenameOf(root), + }; } /** diff --git a/packages/harness/web/src/lib/session-name.test.ts b/packages/harness/web/src/lib/session-name.test.ts index bfc84d3c5..29c6b53b0 100644 --- a/packages/harness/web/src/lib/session-name.test.ts +++ b/packages/harness/web/src/lib/session-name.test.ts @@ -32,17 +32,46 @@ describe("sessionDisplayName", () => { }); it("a sibling with a transcript title never pushes the default to 'folder 2'", () => { - const titled = session({ id: "s1", title: "Build the leasing pipeline", createdAt: "2026-07-20T09:00:00.000Z" }); - const untitled = session({ id: "s2", createdAt: "2026-07-20T10:00:00.000Z" }); + const titled = session({ + id: "s1", + title: "Build the leasing pipeline", + createdAt: "2026-07-20T09:00:00.000Z", + }); + const untitled = session({ + id: "s2", + createdAt: "2026-07-20T10:00:00.000Z", + }); const all = [titled, untitled]; - expect(sessionDisplayName(titled, all, {})).toBe("Build the leasing pipeline"); + expect(sessionDisplayName(titled, all, {})).toBe( + "Build the leasing pipeline", + ); expect(sessionDisplayName(untitled, all, {})).toBe("acme-app"); }); it("a user rename beats everything and empties back to the default", () => { const s = session({ title: "Build the leasing pipeline" }); - expect(sessionDisplayName(s, [s], { s1: "Leasing revamp" })).toBe("Leasing revamp"); - expect(sessionDisplayName(s, [s], { s1: " " })).toBe("Build the leasing pipeline"); + expect(sessionDisplayName(s, [s], { s1: "Leasing revamp" })).toBe( + "Leasing revamp", + ); + expect(sessionDisplayName(s, [s], { s1: " " })).toBe( + "Build the leasing pipeline", + ); + }); + + it("treats Plan Agents as an ordinary initial title that can be renamed", () => { + const initial = session({ id: "plan", title: "Plan Agents" }); + const sibling = session({ id: "build", title: "Implement checkout" }); + expect(sessionDisplayName(initial, [initial, sibling], {})).toBe( + "Plan Agents", + ); + expect( + sessionDisplayName(initial, [initial, sibling], { + plan: "Architecture notes", + }), + ).toBe("Architecture notes"); + expect(sessionDisplayName(sibling, [initial, sibling], {})).toBe( + "Implement checkout", + ); }); it("defaults from a Windows cwd's basename too", () => { @@ -51,8 +80,16 @@ describe("sessionDisplayName", () => { }); it("sessions in different folders never collide", () => { - const a = session({ id: "s1", cwd: "/Users/demo/acme-app", title: "acme-app" }); - const b = session({ id: "s2", cwd: "/Users/demo/scratch", title: "scratch" }); + const a = session({ + id: "s1", + cwd: "/Users/demo/acme-app", + title: "acme-app", + }); + const b = session({ + id: "s2", + cwd: "/Users/demo/scratch", + title: "scratch", + }); const all = [a, b]; expect(sessionDisplayName(a, all, {})).toBe("acme-app"); expect(sessionDisplayName(b, all, {})).toBe("scratch"); diff --git a/packages/harness/web/src/lib/session-scope.test.ts b/packages/harness/web/src/lib/session-scope.test.ts index 35e4c10a6..744bf590f 100644 --- a/packages/harness/web/src/lib/session-scope.test.ts +++ b/packages/harness/web/src/lib/session-scope.test.ts @@ -7,6 +7,7 @@ import { conversationSubject, liveSessionsForFocus, liveSessionsForProject, + liveSessionsForStudioProject, mergeSubjectRuns, OBSERVED_RUN_WINDOW, projectRootForAgent, @@ -61,7 +62,9 @@ describe("projectRootForAgent: a session boots at the project root", () => { // Honest degradation: an agent discovered outside every opened project // still opens rather than failing to start. expect(projectRootForAgent(ADS, [])).toBe(ADS); - expect(projectRootForAgent(ADS, [`${HOME}/unrelated`, `${HOME}/other`])).toBe(ADS); + expect( + projectRootForAgent(ADS, [`${HOME}/unrelated`, `${HOME}/other`]), + ).toBe(ADS); }); it("matches on segment boundaries, so a same-prefix sibling root never wins", () => { @@ -99,12 +102,21 @@ describe("projectRootForAgent: a session boots at the project root", () => { // The server hands the SPA native paths and the SPA holds whatever // recentDirs recorded, so the two spellings of one directory must resolve // to the same project (paths.ts's mixed-form contract). - expect(projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", ["C:\\Users\\demo\\polsia"])) - .toBe("C:\\Users\\demo\\polsia"); - expect(projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", ["C:/Users/demo/polsia"])) - .toBe("C:/Users/demo/polsia"); - expect(projectRootForAgent("C:\\Users\\demo\\polsia-old\\ads", ["C:\\Users\\demo\\polsia"])) - .toBe("C:\\Users\\demo\\polsia-old\\ads"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", [ + "C:\\Users\\demo\\polsia", + ]), + ).toBe("C:\\Users\\demo\\polsia"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia\\agents\\ads", [ + "C:/Users/demo/polsia", + ]), + ).toBe("C:/Users/demo/polsia"); + expect( + projectRootForAgent("C:\\Users\\demo\\polsia-old\\ads", [ + "C:\\Users\\demo\\polsia", + ]), + ).toBe("C:\\Users\\demo\\polsia-old\\ads"); }); }); @@ -163,41 +175,64 @@ const session = (over: Partial = {}): ScopedSession => ({ describe("liveSessionsForFocus", () => { const bound = session({ id: "b", boundWorkflowPath: ADS, cwd: POLSIA }); const inFolder = session({ id: "f", cwd: ADS }); - const elsewhere = session({ id: "e", boundWorkflowPath: `${POLSIA}/other`, cwd: POLSIA }); + const elsewhere = session({ + id: "e", + boundWorkflowPath: `${POLSIA}/other`, + cwd: POLSIA, + }); const dead = session({ id: "d", boundWorkflowPath: ADS, status: "exited" }); it("claims bound sessions and unbound sessions sitting in the folder", () => { - expect(liveSessionsForFocus([bound, inFolder, elsewhere, dead], ADS).map((s) => s.id)).toEqual([ - "b", - "f", - ]); + expect( + liveSessionsForFocus([bound, inFolder, elsewhere, dead], ADS).map( + (s) => s.id, + ), + ).toEqual(["b", "f"]); }); it("never claims a session bound elsewhere just because the cwd matches", () => { // A session bound to another agent belongs to that agent's strip, whatever // folder it happens to sit in. - const boundElsewhereInFolder = session({ id: "x", boundWorkflowPath: POLSIA, cwd: ADS }); + const boundElsewhereInFolder = session({ + id: "x", + boundWorkflowPath: POLSIA, + cwd: ADS, + }); expect(liveSessionsForFocus([boundElsewhereInFolder], ADS)).toEqual([]); }); it("orders oldest first, the order Cmd/Ctrl+1..9 selects, and stably on a tie", () => { - const older = session({ id: "z", cwd: ADS, createdAt: "2026-08-01T09:00:00.000Z" }); - const newer = session({ id: "a", cwd: ADS, createdAt: "2026-08-01T11:00:00.000Z" }); - expect(liveSessionsForFocus([newer, older], ADS).map((s) => s.id)).toEqual(["z", "a"]); + const older = session({ + id: "z", + cwd: ADS, + createdAt: "2026-08-01T09:00:00.000Z", + }); + const newer = session({ + id: "a", + cwd: ADS, + createdAt: "2026-08-01T11:00:00.000Z", + }); + expect(liveSessionsForFocus([newer, older], ADS).map((s) => s.id)).toEqual([ + "z", + "a", + ]); // Same timestamp, ordered by id, so the strip never reorders between renders. expect( - liveSessionsForFocus([session({ id: "m", cwd: ADS }), session({ id: "k", cwd: ADS })], ADS).map( - (s) => s.id, - ), + liveSessionsForFocus( + [session({ id: "m", cwd: ADS }), session({ id: "k", cwd: ADS })], + ADS, + ).map((s) => s.id), ).toEqual(["k", "m"]); }); it("compares paths, not strings, so a trailing separator still matches", () => { // The server resolve()s the cwd it stores while the selection is whatever // recentDirs kept — a raw === hid the session the user had just created. - expect(liveSessionsForFocus([session({ id: "t", cwd: `${ADS}/` })], ADS).map((s) => s.id)).toEqual( - ["t"], - ); + expect( + liveSessionsForFocus([session({ id: "t", cwd: `${ADS}/` })], ADS).map( + (s) => s.id, + ), + ).toEqual(["t"]); }); it("is empty for no subject", () => { @@ -218,25 +253,42 @@ describe("liveSessionsForProject: a session belongs to its PROJECT", () => { // EMPTY strip under `liveSessionsForFocus` — a project you can select but // cannot see your own conversations in. expect(liveSessionsForFocus([boundToAds], POLSIA)).toEqual([]); - expect(liveSessionsForProject([boundToAds], POLSIA).map((s) => s.id)).toEqual(["b"]); + expect( + liveSessionsForProject([boundToAds], POLSIA).map((s) => s.id), + ).toEqual(["b"]); }); it("claims every live session inside the root and nothing outside it", () => { expect( - liveSessionsForProject([boundToAds, unbound, nested, elsewhere, dead], POLSIA).map((s) => s.id), + liveSessionsForProject( + [boundToAds, unbound, nested, elsewhere, dead], + POLSIA, + ).map((s) => s.id), ).toEqual(["b", "n", "u"]); }); it("is asymmetric, exactly like sessionReachesFocus: the outer project contains the nested one's sessions, never the reverse", () => { const workers = `${POLSIA}/services/workers`; - expect(liveSessionsForProject([nested], POLSIA).map((s) => s.id)).toEqual(["n"]); + expect(liveSessionsForProject([nested], POLSIA).map((s) => s.id)).toEqual([ + "n", + ]); expect(liveSessionsForProject([unbound], workers)).toEqual([]); }); it("orders oldest first and stably, the same order the strip renders", () => { - const older = session({ id: "z", cwd: POLSIA, createdAt: "2026-08-01T09:00:00.000Z" }); - const newer = session({ id: "a", cwd: POLSIA, createdAt: "2026-08-01T11:00:00.000Z" }); - expect(liveSessionsForProject([newer, older], POLSIA).map((s) => s.id)).toEqual(["z", "a"]); + const older = session({ + id: "z", + cwd: POLSIA, + createdAt: "2026-08-01T09:00:00.000Z", + }); + const newer = session({ + id: "a", + cwd: POLSIA, + createdAt: "2026-08-01T11:00:00.000Z", + }); + expect( + liveSessionsForProject([newer, older], POLSIA).map((s) => s.id), + ).toEqual(["z", "a"]); }); it("is empty for no project", () => { @@ -247,6 +299,88 @@ describe("liveSessionsForProject: a session belongs to its PROJECT", () => { }); }); +describe("liveSessionsForStudioProject: project identity owns session tabs", () => { + const projectId = "project_alpha"; + + it("uses exact project identity instead of cwd containment", () => { + const matchingOutsideRoot = session({ + id: "matching", + cwd: SIDEQUEST, + agentMapIdentity: { projectId }, + }); + const foreignInsideRoot = session({ + id: "foreign", + cwd: POLSIA, + agentMapIdentity: { projectId: "project_beta" }, + }); + + expect( + liveSessionsForStudioProject( + [foreignInsideRoot, matchingOutsideRoot], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["matching"]); + }); + + it("excludes exited, foreign, and missing project identities", () => { + const live = session({ id: "live", agentMapIdentity: { projectId } }); + const exited = session({ + id: "exited", + status: "exited", + agentMapIdentity: { projectId }, + }); + const foreign = session({ + id: "foreign", + agentMapIdentity: { projectId: "project_beta" }, + }); + const missing = session({ id: "missing", agentMapIdentity: null }); + + expect( + liveSessionsForStudioProject( + [exited, foreign, missing, live], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["live"]); + }); + + it("orders oldest first, uses id as a stable tie-breaker, and deduplicates session IDs", () => { + const older = session({ + id: "z", + createdAt: "2026-08-01T09:00:00.000Z", + agentMapIdentity: { projectId }, + }); + const tiedLaterId = session({ + id: "m", + agentMapIdentity: { projectId }, + }); + const tiedEarlierId = session({ + id: "k", + agentMapIdentity: { projectId }, + }); + const duplicate = session({ + id: "z", + createdAt: "2026-08-01T11:00:00.000Z", + agentMapIdentity: { projectId }, + }); + + expect( + liveSessionsForStudioProject( + [tiedLaterId, duplicate, tiedEarlierId, older], + projectId, + ).map((candidate) => candidate.id), + ).toEqual(["z", "k", "m"]); + }); + + it("is empty when no project is selected", () => { + expect( + liveSessionsForStudioProject( + [session({ agentMapIdentity: { projectId } })], + null, + ), + ).toEqual([]); + }); +}); + describe("conversationSubject: the chat belongs to a project", () => { const roots = [POLSIA, SIDEQUEST]; @@ -254,7 +388,10 @@ describe("conversationSubject: the chat belongs to a project", () => { // E3.4 on screen, not merely in the session pointer: the agent selection is // not an input here, so the strip is the SAME set before and after the click. const active = session({ boundWorkflowPath: ADS, cwd: POLSIA }); - expect(conversationSubject(active, ADS, null, roots)).toEqual({ kind: "project", root: POLSIA }); + expect(conversationSubject(active, ADS, null, roots)).toEqual({ + kind: "project", + root: POLSIA, + }); expect(conversationSubject(active, OUTREACH, null, roots)).toEqual({ kind: "project", root: POLSIA, @@ -273,9 +410,17 @@ describe("conversationSubject: the chat belongs to a project", () => { }); it("names the SELECTED project when nothing is running, so a project you just picked is already the subject", () => { - expect(conversationSubject(null, ADS, POLSIA, roots)).toEqual({ kind: "project", root: POLSIA }); + expect(conversationSubject(null, ADS, POLSIA, roots)).toEqual({ + kind: "project", + root: POLSIA, + }); expect( - conversationSubject(session({ status: "exited", cwd: POLSIA }), ADS, POLSIA, roots), + conversationSubject( + session({ status: "exited", cwd: POLSIA }), + ADS, + POLSIA, + roots, + ), ).toEqual({ kind: "project", root: POLSIA }); }); @@ -291,13 +436,22 @@ describe("conversationSubject: the chat belongs to a project", () => { it("falls back to the agent subject for a session outside every known root", () => { // A scaffold folder not yet in recentDirs has no project. Inventing one // from its cwd would give the strip a root no rail row corresponds to. - const loose = session({ cwd: `${HOME}/scratch`, boundWorkflowPath: `${HOME}/scratch/bot` }); + const loose = session({ + cwd: `${HOME}/scratch`, + boundWorkflowPath: `${HOME}/scratch/bot`, + }); expect(conversationSubject(loose, ADS, null, roots)).toEqual({ kind: "focus", path: `${HOME}/scratch/bot`, }); - expect(conversationSubject(null, ADS, null, roots)).toEqual({ kind: "focus", path: ADS }); - expect(conversationSubject(null, null, null, roots)).toEqual({ kind: "focus", path: null }); + expect(conversationSubject(null, ADS, null, roots)).toEqual({ + kind: "focus", + path: ADS, + }); + expect(conversationSubject(null, null, null, roots)).toEqual({ + kind: "focus", + path: null, + }); }); }); @@ -306,7 +460,9 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { // Selecting F while B's session runs must leave B's tabs on screen. Keyed // to the selection, the strip emptied itself under the session still // running in the pane below it. - expect(sessionStripSubject(session({ boundWorkflowPath: ADS }), OUTREACH)).toBe(ADS); + expect( + sessionStripSubject(session({ boundWorkflowPath: ADS }), OUTREACH), + ).toBe(ADS); }); it("falls back to the session's own folder when it is unbound", () => { @@ -316,7 +472,10 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { it("names the rail selection only when there is no live active session", () => { expect(sessionStripSubject(null, ADS)).toBe(ADS); expect( - sessionStripSubject(session({ status: "exited", boundWorkflowPath: POLSIA }), ADS), + sessionStripSubject( + session({ status: "exited", boundWorkflowPath: POLSIA }), + ADS, + ), ).toBe(ADS); expect(sessionStripSubject(null, null)).toBeNull(); }); @@ -324,15 +483,28 @@ describe("sessionStripSubject: the strip follows the ACTIVE session", () => { describe("sessionForFocus: selection moves the session across projects, never within one", () => { const at = (over: Partial): ScopedSession => session(over); - const polsiaSession = at({ id: "p", cwd: POLSIA, lastActiveAt: "2026-08-02T10:00:00.000Z" }); - const otherSession = at({ id: "o", cwd: SIDEQUEST, lastActiveAt: "2026-08-02T09:00:00.000Z" }); + const polsiaSession = at({ + id: "p", + cwd: POLSIA, + lastActiveAt: "2026-08-02T10:00:00.000Z", + }); + const otherSession = at({ + id: "o", + cwd: SIDEQUEST, + lastActiveAt: "2026-08-02T09:00:00.000Z", + }); const roots = [POLSIA, SIDEQUEST]; it("keeps the session when the selected agent is in its project", () => { // The whole point of the decoupling: read F's board while still talking to // B. One session has context on every agent in its project. expect( - sessionForFocus({ focusPath: ADS, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "keep" }); expect( sessionForFocus({ @@ -359,7 +531,12 @@ describe("sessionForFocus: selection moves the session across projects, never wi it("switches to none when the selected project has no live session", () => { expect( - sessionForFocus({ focusPath: ADS, active: otherSession, sessions: [otherSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: otherSession, + sessions: [otherSession], + roots, + }), ).toEqual({ kind: "switch", to: null }); }); @@ -389,9 +566,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Reachable after closing the last tab: the pane said "no running session // for F" while F's project had one, which was a false absence. expect( - sessionForFocus({ focusPath: ADS, active: null, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: ADS, + active: null, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "switch", to: polsiaSession }); - expect(sessionForFocus({ focusPath: ADS, active: null, sessions: [], roots })).toEqual({ + expect( + sessionForFocus({ focusPath: ADS, active: null, sessions: [], roots }), + ).toEqual({ kind: "switch", to: null, }); @@ -438,8 +622,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi }), ).toEqual({ kind: "switch", to: older }); // With no lastActiveAt anywhere, createdAt is the fallback ordering. - const c1 = at({ id: "c1", cwd: POLSIA, createdAt: "2026-07-01T00:00:00.000Z" }); - const c2 = at({ id: "c2", cwd: POLSIA, createdAt: "2026-08-04T00:00:00.000Z" }); + const c1 = at({ + id: "c1", + cwd: POLSIA, + createdAt: "2026-07-01T00:00:00.000Z", + }); + const c2 = at({ + id: "c2", + cwd: POLSIA, + createdAt: "2026-08-04T00:00:00.000Z", + }); expect( sessionForFocus({ focusPath: ADS, @@ -470,8 +662,16 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Not symmetric, and deliberately: a session rooted at the NESTED project // cannot reach up to an agent outside it, so that one hands over — to the // outer project's own most-recently-worked-in session. - const inner = at({ id: "inner", cwd: nested, lastActiveAt: "2026-08-01T00:00:00.000Z" }); - const outerRecent = at({ id: "outer", cwd: POLSIA, lastActiveAt: "2026-08-09T00:00:00.000Z" }); + const inner = at({ + id: "inner", + cwd: nested, + lastActiveAt: "2026-08-01T00:00:00.000Z", + }); + const outerRecent = at({ + id: "outer", + cwd: POLSIA, + lastActiveAt: "2026-08-09T00:00:00.000Z", + }); expect( sessionForFocus({ focusPath: ADS, @@ -482,12 +682,59 @@ describe("sessionForFocus: selection moves the session across projects, never wi ).toEqual({ kind: "switch", to: outerRecent }); }); + it("uses neutral project identity instead of cwd containment for a Studio handoff", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + const outer = at({ + id: "outer", + cwd: POLSIA, + agentMapIdentity: { projectId: "project_outer" }, + }); + const nestedSession = at({ + id: "nested", + cwd: nested, + agentMapIdentity: { projectId: "project_nested" }, + }); + expect( + sessionForFocus({ + focusPath: worker, + active: outer, + sessions: [outer, nestedSession], + roots: [POLSIA, nested], + targetProjectId: "project_nested", + targetProjectRoot: nested, + }), + ).toEqual({ kind: "switch", to: nestedSession }); + }); + + it("falls back safely for identity-less legacy sessions without admitting an outer root", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + const outerLegacy = at({ id: "outer-legacy", cwd: POLSIA }); + const nestedLegacy = at({ id: "nested-legacy", cwd: nested }); + expect( + sessionForFocus({ + focusPath: worker, + active: outerLegacy, + sessions: [outerLegacy, nestedLegacy], + roots: [POLSIA, nested], + targetProjectId: "project_nested", + targetProjectRoot: nested, + }), + ).toEqual({ kind: "switch", to: nestedLegacy }); + }); + it("keeps a session left rooted in an agent's own folder when a sibling is selected", () => { // Older builds rooted sessions at the agent. That session still belongs to // the project around it, so a sibling selection must not read as a jump. const legacy = at({ id: "legacy", cwd: ADS }); expect( - sessionForFocus({ focusPath: OUTREACH, active: legacy, sessions: [legacy], roots }), + sessionForFocus({ + focusPath: OUTREACH, + active: legacy, + sessions: [legacy], + roots, + }), ).toEqual({ kind: "keep" }); }); @@ -496,13 +743,30 @@ describe("sessionForFocus: selection moves the session across projects, never wi // Its own folder is the fallback project, so only sessions inside it can // claim it. expect( - sessionForFocus({ focusPath: orphan, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: orphan, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "switch", to: null }); const inOrphan = at({ id: "orphan", cwd: orphan }); expect( - sessionForFocus({ focusPath: orphan, active: polsiaSession, sessions: [inOrphan], roots }), + sessionForFocus({ + focusPath: orphan, + active: polsiaSession, + sessions: [inOrphan], + roots, + }), ).toEqual({ kind: "switch", to: inOrphan }); - expect(sessionForFocus({ focusPath: orphan, active: null, sessions: [], roots: [] })).toEqual({ + expect( + sessionForFocus({ + focusPath: orphan, + active: null, + sessions: [], + roots: [], + }), + ).toEqual({ kind: "switch", to: null, }); @@ -510,7 +774,12 @@ describe("sessionForFocus: selection moves the session across projects, never wi it("keeps the session when the selection is the project root itself", () => { expect( - sessionForFocus({ focusPath: POLSIA, active: polsiaSession, sessions: [polsiaSession], roots }), + sessionForFocus({ + focusPath: POLSIA, + active: polsiaSession, + sessions: [polsiaSession], + roots, + }), ).toEqual({ kind: "keep" }); }); }); @@ -543,12 +812,20 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => // That route is the one canvas.reload addresses and the run-state bridge // posts into; reaching for the workflow-keyed one here would trade a live // board for a snapshot. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "session", sessionId: "s1", }); // Separator/trailing-slash spellings are the same place. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: `${ADS}/`, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: ADS, + bindingPath: `${ADS}/`, + sessionId: "s1", + }), + ).toEqual({ kind: "session", sessionId: "s1", }); @@ -557,7 +834,13 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => it("uses the workflow-keyed route whenever the session is bound elsewhere", () => { // /canvas/:sessionId/ resolves by the BINDING, so it would serve the wrong // agent's board — this is the mis-draw the route exists to prevent. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: OUTREACH, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: ADS, + bindingPath: OUTREACH, + sessionId: "s1", + }), + ).toEqual({ kind: "agent", path: ADS, }); @@ -566,19 +849,25 @@ describe("canvasSourceFor: which entry point serves the subject's board", () => it("uses the workflow-keyed route for an agent with no session at all", () => { // The criterion IA-01 landed for: an agent that has never hosted a session // still has a board. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: null })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: null }), + ).toEqual({ kind: "agent", path: ADS, }); // A live but unbound session is the same case. - expect(canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: ADS, bindingPath: null, sessionId: "s1" }), + ).toEqual({ kind: "agent", path: ADS, }); }); it("has no source without a subject", () => { - expect(canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "none", }); }); @@ -600,47 +889,67 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at // The prototype's bug in one assertion: subject and enabled-state came from // different agents. One call returns both, so they cannot drift apart. for (const verb of ["prod", "test", "run", "deploy"] as const) { - expect(lifecycleVerbGate(verb, { ...signedIn, subject: draft }).subjectPath).toBe(ADS); + expect( + lifecycleVerbGate(verb, { ...signedIn, subject: draft }).subjectPath, + ).toBe(ADS); } }); it("disables Prod and Run for an undeployed agent, with the reason", () => { // The exact mis-target: selecting the undeployed agent left Prod and Run // live against the deployed one the session was bound to. - expect(lifecycleVerbGate("prod", { ...signedIn, subject: draft }).reason).toBe( - "Not deployed yet", - ); - expect(lifecycleVerbGate("run", { ...signedIn, subject: draft }).reason).toBe( - "Not deployed yet", - ); + expect( + lifecycleVerbGate("prod", { ...signedIn, subject: draft }).reason, + ).toBe("Not deployed yet"); + expect( + lifecycleVerbGate("run", { ...signedIn, subject: draft }).reason, + ).toBe("Not deployed yet"); }); it("leaves Test and Deploy available on an undeployed agent", () => { // They are precisely what you CAN do to it; disabling the verbs that fix // the state you are being told about would be honest about nothing. - expect(lifecycleVerbGate("test", { ...signedIn, subject: draft }).reason).toBeNull(); - expect(lifecycleVerbGate("deploy", { ...signedIn, subject: draft }).reason).toBeNull(); + expect( + lifecycleVerbGate("test", { ...signedIn, subject: draft }).reason, + ).toBeNull(); + expect( + lifecycleVerbGate("deploy", { ...signedIn, subject: draft }).reason, + ).toBeNull(); }); it("enables Prod and Run on a deployed agent", () => { - expect(lifecycleVerbGate("prod", { ...signedIn, subject: deployed }).reason).toBeNull(); - expect(lifecycleVerbGate("run", { ...signedIn, subject: deployed }).reason).toBeNull(); + expect( + lifecycleVerbGate("prod", { ...signedIn, subject: deployed }).reason, + ).toBeNull(); + expect( + lifecycleVerbGate("run", { ...signedIn, subject: deployed }).reason, + ).toBeNull(); }); it("puts the auth gate ahead of the deployment gate on the cloud verbs", () => { // Signed out, "Not deployed yet" would send the user to Deploy, which is // also blocked — the reason has to name the thing they can act on. expect( - lifecycleVerbGate("run", { subject: draft, authenticated: false, deployError: null }).reason, + lifecycleVerbGate("run", { + subject: draft, + authenticated: false, + deployError: null, + }).reason, ).toBe("Connect your account first"); expect( - lifecycleVerbGate("deploy", { subject: draft, authenticated: false, deployError: null }) - .reason, + lifecycleVerbGate("deploy", { + subject: draft, + authenticated: false, + deployError: null, + }).reason, ).toBe("Connect your account first"); // Prod is a read, not a cloud call: it needs a definition, not a session. expect( - lifecycleVerbGate("prod", { subject: deployed, authenticated: false, deployError: null }) - .reason, + lifecycleVerbGate("prod", { + subject: deployed, + authenticated: false, + deployError: null, + }).reason, ).toBeNull(); }); @@ -656,8 +965,11 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at ).toBe("Last deploy failed — retry Deploy"); // A ready build outlives a stale failure (workflow-deployment's rule). expect( - lifecycleVerbGate("run", { subject: deployed, authenticated: true, deployError: "boom" }) - .reason, + lifecycleVerbGate("run", { + subject: deployed, + authenticated: true, + deployError: "boom", + }).reason, ).toBeNull(); // Prod only asks whether there is a page to open. expect( @@ -673,11 +985,17 @@ describe("lifecycleVerbGate: the verbs are gated BY the selection, not aimed at expect( lifecycleVerbGate("run", { ...signedIn, - subject: agent({ definitionId: 4821, activeBuildRunStatus: "building" }), + subject: agent({ + definitionId: 4821, + activeBuildRunStatus: "building", + }), }).reason, ).toBe("Build in progress"); expect( - lifecycleVerbGate("run", { ...signedIn, subject: agent({ definitionId: 4821 }) }).reason, + lifecycleVerbGate("run", { + ...signedIn, + subject: agent({ definitionId: 4821 }), + }).reason, ).toBe("No ready deployment yet"); }); @@ -717,7 +1035,9 @@ describe("run evidence follows the subject, not the session", () => { // unattributed runs on EVERY subject — that is how a run no agent produced // appears under one that never ran it. Matches this repo's existing // `observedRunMatchesWorkflow`. - expect(runsForSubject([mine, theirs, unattributed], null)).toEqual([unattributed]); + expect(runsForSubject([mine, theirs, unattributed], null)).toEqual([ + unattributed, + ]); }); it("keeps the shown run only while it belongs to the subject", () => { @@ -768,25 +1088,32 @@ describe("run evidence follows the subject, not the session", () => { // They are its own and some are still polling; evicting them for another // session's finished history would drop the live half of the evidence. const observed = [run("a", ADS), run("b", ADS), run("c", ADS)]; - expect(mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 3)).toEqual(observed); + expect( + mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 3), + ).toEqual(observed); // Room for exactly one extra, and it is the newest of them. expect( - mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 4).map((r) => r.run.executionId), + mergeSubjectRuns(observed, [run("x", ADS), run("y", ADS)], 4).map( + (r) => r.run.executionId, + ), ).toEqual(["a", "b", "c", "y"]); }); it("keeps the newest observed runs when the observed list alone exceeds the window", () => { const observed = [run("old", ADS), run("mid", ADS), run("new", ADS)]; - expect(mergeSubjectRuns(observed, [run("x", ADS)], 2).map((r) => r.run.executionId)).toEqual([ - "mid", - "new", - ]); + expect( + mergeSubjectRuns(observed, [run("x", ADS)], 2).map( + (r) => r.run.executionId, + ), + ).toEqual(["mid", "new"]); expect(mergeSubjectRuns(observed, [], 0)).toEqual([]); }); it("is the observed list when there is nothing to add, and vice versa", () => { expect(mergeSubjectRuns([mine], [])).toEqual([mine]); - expect(mergeSubjectRuns([], [theirs]).map((r) => r.run.executionId)).toEqual(["x2"]); + expect( + mergeSubjectRuns([], [theirs]).map((r) => r.run.executionId), + ).toEqual(["x2"]); expect(mergeSubjectRuns([], [])).toEqual([]); }); @@ -820,7 +1147,9 @@ describe("sessionReachesFocus: one containment answer, two callers", () => { expect(sessionReachesFocus(at({ cwd: POLSIA }), ADS, [POLSIA])).toBe(true); // The session's own PROJECT, not its raw cwd: a session an older build left // rooted in the agent's folder still belongs to the project around it. - expect(sessionReachesFocus(at({ cwd: ADS }), OUTREACH, [POLSIA])).toBe(true); + expect(sessionReachesFocus(at({ cwd: ADS }), OUTREACH, [POLSIA])).toBe( + true, + ); }); it("is false for an agent in another project", () => { @@ -828,13 +1157,48 @@ describe("sessionReachesFocus: one containment answer, two callers", () => { // tab in a project falls back to whatever else is running, and the // workbench then pointed at a project that does not contain the agent on // screen. - expect(sessionReachesFocus(at({ cwd: SIDEQUEST }), ADS, [POLSIA, SIDEQUEST])).toBe(false); + expect( + sessionReachesFocus(at({ cwd: SIDEQUEST }), ADS, [POLSIA, SIDEQUEST]), + ).toBe(false); + }); + + it("treats a neutral Studio principal as the project boundary", () => { + const nested = `${POLSIA}/services/workers`; + const worker = `${nested}/ads`; + expect( + sessionReachesFocus( + at({ + cwd: POLSIA, + agentMapIdentity: { projectId: "project_outer" }, + }), + worker, + [POLSIA, nested], + "project_nested", + nested, + ), + ).toBe(false); + expect( + sessionReachesFocus( + at({ + cwd: POLSIA, + agentMapIdentity: { projectId: "project_nested" }, + }), + worker, + [POLSIA, nested], + "project_nested", + nested, + ), + ).toBe(true); }); it("is false with nothing active, an exited session, or no selection", () => { expect(sessionReachesFocus(null, ADS, [POLSIA])).toBe(false); - expect(sessionReachesFocus(at({ cwd: POLSIA, status: "exited" }), ADS, [POLSIA])).toBe(false); - expect(sessionReachesFocus(at({ cwd: POLSIA }), null, [POLSIA])).toBe(false); + expect( + sessionReachesFocus(at({ cwd: POLSIA, status: "exited" }), ADS, [POLSIA]), + ).toBe(false); + expect(sessionReachesFocus(at({ cwd: POLSIA }), null, [POLSIA])).toBe( + false, + ); }); it("agrees with sessionForFocus, which is the point of having one answer", () => { @@ -862,14 +1226,26 @@ describe("canvasSourceFor: a session bound to nothing still draws its own board" // live session whose board the pane has always drawn and whose empty-state // copy speaks about it. Reading "no subject" as "no source" replaced that // with a fresh-install "No session" message under a running session. - expect(canvasSourceFor({ subjectPath: null, bindingPath: null, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: null, + bindingPath: null, + sessionId: "s1", + }), + ).toEqual({ kind: "session", sessionId: "s1", }); }); it("is nothing when there is neither a subject nor a session", () => { - expect(canvasSourceFor({ subjectPath: null, bindingPath: null, sessionId: null })).toEqual({ + expect( + canvasSourceFor({ + subjectPath: null, + bindingPath: null, + sessionId: null, + }), + ).toEqual({ kind: "none", }); }); @@ -877,7 +1253,9 @@ describe("canvasSourceFor: a session bound to nothing still draws its own board" it("is nothing when the subject is suppressed under a bound session", () => { // The create-new draft or a review owns the centre: an absence must not // have the bound agent's board sitting behind it. - expect(canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" })).toEqual({ + expect( + canvasSourceFor({ subjectPath: null, bindingPath: ADS, sessionId: "s1" }), + ).toEqual({ kind: "none", }); }); diff --git a/packages/harness/web/src/lib/session-scope.ts b/packages/harness/web/src/lib/session-scope.ts index 87c1270fd..1282ca056 100644 --- a/packages/harness/web/src/lib/session-scope.ts +++ b/packages/harness/web/src/lib/session-scope.ts @@ -51,7 +51,10 @@ export function rootContains(root: string, agentPath: string): boolean { * the old behaviour, returned verbatim — so an agent discovered outside every * opened project still starts a session rather than failing to start. */ -export function projectRootForAgent(agentPath: string, roots: readonly string[]): string { +export function projectRootForAgent( + agentPath: string, + roots: readonly string[], +): string { return ( roots .filter((root) => rootContains(root, agentPath)) @@ -82,6 +85,9 @@ export interface ScopedSession { cwd: string; status: string; boundWorkflowPath?: string | null; + /** Server-derived project identity. Unlike cwd containment, this remains + * exact when one opened project is nested inside another. */ + agentMapIdentity?: { projectId: string } | null; createdAt: string; /** Last activity, when the adapter reports it. "Most recent session" means * the one most recently WORKED IN, not the one most recently made: after a @@ -129,9 +135,13 @@ export function liveSessionsForFocus( (s) => s.status !== "exited" && (samePath(s.boundWorkflowPath ?? "", focusPath) || - ((s.boundWorkflowPath ?? null) == null && samePath(s.cwd, focusPath))), + ((s.boundWorkflowPath ?? null) == null && + samePath(s.cwd, focusPath))), ) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ); } /** @@ -167,7 +177,42 @@ export function liveSessionsForProject( if (!projectRoot) return []; return sessions .filter((s) => s.status !== "exited" && rootContains(projectRoot, s.cwd)) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ); +} + +/** + * Live sessions belonging to one durable Studio project. + * + * A Studio project is an authority boundary, not a path prefix. Session tabs + * therefore use the server-derived principal and never absorb a nested + * project's sessions merely because their cwd happens to be contained by the + * selected root. The final identity guard also keeps a malformed duplicate + * projection from manufacturing two visible tabs for one session ID. + */ +export function liveSessionsForStudioProject( + sessions: readonly S[], + projectId: string | null, +): S[] { + if (!projectId) return []; + const seen = new Set(); + return sessions + .filter( + (session) => + session.status !== "exited" && + session.agentMapIdentity?.projectId === projectId, + ) + .sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ) + .filter((session) => { + if (seen.has(session.id)) return false; + seen.add(session.id); + return true; + }); } /** @@ -230,7 +275,8 @@ export function conversationSubject( const live = active && active.status !== "exited" ? active : null; if (live) { const owner = roots.find((root) => rootContains(root, live.cwd)); - if (owner) return { kind: "project", root: projectRootForAgent(live.cwd, roots) }; + if (owner) + return { kind: "project", root: projectRootForAgent(live.cwd, roots) }; } if (projectRoot) return { kind: "project", root: projectRoot }; return { kind: "focus", path: sessionStripSubject(active, focusPath) }; @@ -256,8 +302,21 @@ export function sessionReachesFocus( active: ScopedSession | null, focusPath: string | null, roots: readonly string[], + targetProjectId: string | null = null, + targetProjectRoot: string | null = null, ): boolean { if (!active || active.status === "exited" || focusPath == null) return false; + if (targetProjectId) { + const activeProjectId = active.agentMapIdentity?.projectId; + if (activeProjectId) return activeProjectId === targetProjectId; + + // Rolling compatibility for sessions persisted before neutral principals: + // match the canonical project root exactly. Plain downward containment is + // unsafe here because an outer project also contains every nested project. + const focusRoot = + targetProjectRoot ?? projectRootForAgent(focusPath, roots); + return samePath(projectRootForAgent(active.cwd, roots), focusRoot); + } return rootContains(projectRootForAgent(active.cwd, roots), focusPath); } @@ -270,6 +329,11 @@ export interface FocusSessionInput { sessions: readonly S[]; /** The project roots the user has opened (`knownProjectRoots()`). */ roots: readonly string[]; + /** Exact server-derived Studio project selected for this agent. Omitted only + * for legacy sessions/projects that predate durable project identity. */ + targetProjectId?: string | null; + /** The selected project's exact root/binding containing `focusPath`. */ + targetProjectRoot?: string | null; } export type FocusSessionDecision = @@ -302,33 +366,49 @@ function byRecency(a: S, b: S): number { * are in different projects. So the session follows the selection into the new * scope, landing on that project's own session or on none. * - * "Can this session reach that agent" is asked ONE way, downward: does the - * active session's project CONTAIN the selected agent? That is what makes - * overlapping roots behave. With `~/polsia` and `~/polsia/services/workers` - * both open, an agent under `workers/` resolves (longest root wins) to the - * nested project, but a session at `~/polsia` still genuinely contains it — so - * selecting that agent KEEPS the session rather than appearing to jump - * projects for a row the session can already work on. The reverse is not - * symmetric and must not be: a session rooted at `~/polsia/services/workers` - * cannot reach up to an agent at `~/polsia`, so that one does hand over. + * For durable Studio projects, the server-derived project ID is the boundary: + * overlapping roots never let an outer project session impersonate a nested + * project's session. Path containment remains only for rolling compatibility + * with sessions and projects that predate neutral project principals. */ export function sessionForFocus({ focusPath, active, sessions, roots, + targetProjectId = null, + targetProjectRoot = null, }: FocusSessionInput): FocusSessionDecision { const live = sessions.filter((session) => session.status !== "exited"); - if (sessionReachesFocus(active, focusPath, roots)) return { kind: "keep" }; + if ( + sessionReachesFocus( + active, + focusPath, + roots, + targetProjectId, + targetProjectRoot, + ) + ) + return { kind: "keep" }; // The agent's own session wins the handover: navigating to F should land on // F's session when it has one. Then the project's most recent session, since // any session in the project can work on the agent. Then none. - const focusRoot = projectRootForAgent(focusPath, roots); - const own = liveSessionsForFocus(live, focusPath).sort(byRecency)[0]; - const inProject = live - .filter((session) => rootContains(focusRoot, session.cwd)) + const focusRoot = targetProjectRoot ?? projectRootForAgent(focusPath, roots); + const eligible = targetProjectId + ? live.filter((session) => { + const sessionProjectId = session.agentMapIdentity?.projectId; + return sessionProjectId + ? sessionProjectId === targetProjectId + : samePath(projectRootForAgent(session.cwd, roots), focusRoot); + }) + : live; + const own = liveSessionsForFocus(eligible, focusPath).sort(byRecency)[0]; + const inProject = eligible + .filter((session) => + targetProjectId ? true : rootContains(focusRoot, session.cwd), + ) .sort(byRecency)[0]; return { kind: "switch", to: own ?? inProject ?? null }; } @@ -580,7 +660,10 @@ export function mergeSubjectRuns( const room = limit - kept.length; if (room <= 0) return kept; const seen = new Set(kept.map((entry) => entry.run.executionId)); - return [...kept, ...extra.filter((entry) => !seen.has(entry.run.executionId)).slice(-room)]; + return [ + ...kept, + ...extra.filter((entry) => !seen.has(entry.run.executionId)).slice(-room), + ]; } /** @@ -616,7 +699,10 @@ export function shownRunForSubject( runs: readonly R[], sessionRun: R | null, ): R | null { - if (sessionRun && runs.some((entry) => entry.run.executionId === sessionRun.run.executionId)) { + if ( + sessionRun && + runs.some((entry) => entry.run.executionId === sessionRun.run.executionId) + ) { return sessionRun; } return runs.length > 0 ? runs[runs.length - 1] : null; diff --git a/packages/harness/web/src/lib/use-agent-map-entry.ts b/packages/harness/web/src/lib/use-agent-map-entry.ts index 7178c7a92..867c883fa 100644 --- a/packages/harness/web/src/lib/use-agent-map-entry.ts +++ b/packages/harness/web/src/lib/use-agent-map-entry.ts @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentMapWorkspaceResponse, AcceptedProposalDelta, - PlannerSessionRequest, - PlannerSessionResponse, StudioProjectId, } from "@shared/agent-map"; -import type { HarnessKind, HarnessSession, UiTheme } from "@shared/types"; import { ApiError, errorMessage, type HarnessApi } from "./api"; import { track } from "./track"; @@ -22,39 +19,17 @@ export type AgentMapWorkspacePaneState = } | { status: "error"; message: string }; -export type AgentMapPlannerPaneState = - | { status: "idle" } - | { status: "loading" } - | { status: "ready"; value: PlannerSessionResponse } - | { status: "error"; message: string }; - export interface AgentMapEntryState { projectId: StudioProjectId | null; workspace: AgentMapWorkspacePaneState; - planner: AgentMapPlannerPaneState; - /** Missing/deleted/foreign projects are the only errors that replace both - * panes. Every ordinary read/launch failure stays local to its own pane. */ + /** Missing/deleted/foreign projects are bounded separately from ordinary + * map-read failures so the renderer can present the right recovery copy. */ unavailable: string | null; } interface AgentMapEntryOptions { projectId: StudioProjectId | null; - /** The user's explicit live planner selection, when it already belongs to - * this project. It is more specific than project-level resume ordering. */ - selectedPlanner: HarnessSession | null; api: HarnessApi; - /** Read at launch time so a theme/provider change made while the workspace - * is open is honored by the next explicit fresh-session action. */ - harness: () => HarnessKind; - theme: () => UiTheme; - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; - onPlannerReady: ( - response: PlannerSessionResponse, - mode: PlannerSessionRequest["mode"], - ) => void; subscribeProposalChanges: ( listener: (delta: AcceptedProposalDelta) => void, ) => () => void; @@ -64,7 +39,6 @@ interface AgentMapEntryOptions { const EMPTY_ENTRY: AgentMapEntryState = { projectId: null, workspace: { status: "idle" }, - planner: { status: "idle" }, unavailable: null, }; @@ -93,18 +67,6 @@ function failureDimensions( }; } -function selectedPlannerResponse( - projectId: StudioProjectId | null, - session: HarnessSession | null, -): PlannerSessionResponse | null { - return projectId !== null && - session?.status !== "exited" && - session?.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === projectId - ? { session, resolution: "live" } - : null; -} - /** The shared gate before an accepted delta may mutate state or telemetry. */ export function shouldCommitAcceptedDelta( currentProjectId: StudioProjectId | null, @@ -123,42 +85,27 @@ export function shouldCommitAcceptedDelta( } /** - * Opens the two halves of the first Agent Map experience concurrently. + * Loads the durable Agent Map for the selected project. * - * Workspace reads and planner launches have separate request generations and - * separate retry verbs. A late response from a project the user already left - * is ignored. The hook intentionally does not own session state: the returned - * planner session is handed to the central harness store through - * `openPlannerSession`, so tabs, status events, and planner metadata share one - * canonical `HarnessSession` projection. + * This hook deliberately owns no session behavior. Selecting a project is a + * read-only navigation action: it may read map state, but it cannot create, + * resume, focus, or prompt a coding session. A late response from a project the + * user already left is ignored. */ export function useAgentMapEntry({ projectId, - selectedPlanner, api, - harness, - theme, - openPlannerSession, - onPlannerReady, subscribeProposalChanges, subscribeReconnects, }: AgentMapEntryOptions): { state: AgentMapEntryState; retryWorkspace: () => void; - retryPlanner: () => void; - retryAll: () => void; - openFreshPlanner: () => void; } { const [state, setState] = useState(EMPTY_ENTRY); const currentProjectRef = useRef(projectId); const startedProjectRef = useRef(null); const workspaceRequestRef = useRef(0); - const plannerRequestRef = useRef(0); const apiRef = useRef(api); - const harnessRef = useRef(harness); - const themeRef = useRef(theme); - const openPlannerRef = useRef(openPlannerSession); - const onPlannerReadyRef = useRef(onPlannerReady); const visibleProposalRef = useRef(new Map()); const visibleDeltaRef = useRef( new Map(), @@ -166,15 +113,6 @@ export function useAgentMapEntry({ currentProjectRef.current = projectId; apiRef.current = api; - harnessRef.current = harness; - themeRef.current = theme; - openPlannerRef.current = openPlannerSession; - onPlannerReadyRef.current = onPlannerReady; - - const selectedResponse = useMemo( - () => selectedPlannerResponse(projectId, selectedPlanner), - [projectId, selectedPlanner], - ); const loadWorkspace = useCallback((target: StudioProjectId): void => { const request = ++workspaceRequestRef.current; @@ -183,10 +121,10 @@ export function useAgentMapEntry({ ? current : { projectId: target, - planner: { status: "idle" } as AgentMapPlannerPaneState, unavailable: null, }), projectId: target, + unavailable: null, workspace: { status: "loading" }, })); void agentMapLoader.load(apiRef.current, target).then( @@ -201,15 +139,11 @@ export function useAgentMapEntry({ visibleProposalRef.current.get(target) !== value.proposal.id ) { visibleProposalRef.current.set(target, value.proposal.id); - const latest = value.proposal.history.at(-1); - track("agent_map.proposal_created", { - author_role: latest?.actor.role ?? "unknown", - assignment_kind: latest?.actor.assignment?.kind ?? "none", - }); + track("agent_map.proposal_created"); } setState((current) => current.projectId === target - ? { ...current, workspace: { status: "ready", value } } + ? { ...current, unavailable: null, workspace: { status: "ready", value } } : current, ); }, @@ -234,7 +168,7 @@ export function useAgentMapEntry({ workspace: { status: "error", message }, unavailable: isWholeWorkspaceUnavailable(error) ? message - : current.unavailable, + : null, } : current, ); @@ -300,8 +234,6 @@ export function useAgentMapEntry({ : current, ); track("agent_map.proposal_visible", { - author_role: delta.actor.role, - assignment_kind: delta.actor.assignment?.kind ?? "none", visible_latency_ms: visibleLatency, }); }; @@ -332,95 +264,25 @@ export function useAgentMapEntry({ }; }, [loadWorkspace, projectId, subscribeProposalChanges, subscribeReconnects]); - const loadPlanner = useCallback( - (target: StudioProjectId, mode: PlannerSessionRequest["mode"]): void => { - const request = ++plannerRequestRef.current; - setState((current) => ({ - ...(current.projectId === target - ? current - : { - projectId: target, - workspace: { status: "idle" } as AgentMapWorkspacePaneState, - unavailable: null, - }), - projectId: target, - planner: { status: "loading" }, - })); - void openPlannerRef - .current(target, { - mode, - harness: harnessRef.current(), - theme: themeRef.current(), - }) - .then( - (value) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - onPlannerReadyRef.current(value, mode); - setState((current) => - current.projectId === target - ? { ...current, planner: { status: "ready", value } } - : current, - ); - }, - (error: unknown) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - track("agent_map.workspace_load_failed", { - ...failureDimensions(target, error), - pane: "planner", - }); - const message = errorMessage( - error, - "The planning conversation could not be opened.", - ); - setState((current) => - current.projectId === target - ? { - ...current, - planner: { status: "error", message }, - unavailable: isWholeWorkspaceUnavailable(error) - ? message - : current.unavailable, - } - : current, - ); - }, - ); - }, - [], - ); - useEffect(() => { if (projectId === null) { startedProjectRef.current = null; workspaceRequestRef.current += 1; - plannerRequestRef.current += 1; setState(EMPTY_ENTRY); return; } // The ref survives React StrictMode's setup/cleanup probe, preventing two - // planner launches and duplicate entry telemetry for one visible visit. + // map reads and duplicate entry telemetry for one visible visit. if (startedProjectRef.current === projectId) return; startedProjectRef.current = projectId; setState({ projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }); track("agent_map.entered", { project_id: projectId }); loadWorkspace(projectId); - if (!selectedResponse) loadPlanner(projectId, "resume-or-create"); - }, [loadPlanner, loadWorkspace, projectId, selectedResponse]); + }, [loadWorkspace, projectId]); const retryWorkspace = useCallback((): void => { const target = currentProjectRef.current; @@ -429,28 +291,6 @@ export function useAgentMapEntry({ loadWorkspace(target); }, [loadWorkspace]); - const retryPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner]); - - const retryAll = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadWorkspace(target); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner, loadWorkspace]); - - const openFreshPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "fresh"); - }, [loadPlanner]); - return { state: state.projectId === projectId @@ -460,14 +300,8 @@ export function useAgentMapEntry({ : { projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }, retryWorkspace, - retryPlanner, - retryAll, - openFreshPlanner, }; } diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 1c77e2e54..65c51c426 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -24,11 +24,7 @@ import type { TemplateDetailView, TemplateListResponse, } from "@shared/types"; -import type { - PlannerSessionRequest, - PlannerSessionResponse, - StudioProjectId, -} from "@shared/agent-map"; +import type { StudioProjectId } from "@shared/agent-map"; import { ApiError, @@ -167,7 +163,13 @@ export interface HarnessStateHook { /** Loads past sessions across a set of directories (one global * resume view, not one directory at a time). */ loadHistory: (cwds: string[]) => Promise; - createSession: (req: CreateSessionRequest) => Promise; + createSession: ( + req: CreateSessionRequest, + /** Runs as soon as the POST identifies the exact session, before selecting + * it or refreshing the project catalog. This lets an explicit create own + * its pending UI intent without guessing from same-cwd status events. */ + onCreated?: (session: HarnessSession) => void, + ) => Promise; attachFile: ( sessionId: string, request: AttachFileRequest, @@ -184,12 +186,6 @@ export interface HarnessStateHook { /** A past session's reconstructed transcript (null when nothing was * recorded for it). Stable identity — safe as an effect dependency. */ sessionRecord: (id: string) => Promise; - /** Opens the trusted map-planner for a project and publishes the returned - * session into the same store that backs the normal session strip. */ - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; resumeSession: (harnessSessionId: string) => Promise; /** * Portable continue: a fresh session in `cwd`, seeded with our own @@ -1204,193 +1200,157 @@ export function useHarnessState(): HarnessStateHook { return [...(workflowProjectionOrder.current() ?? workflowsRef.current)]; }, [workflowProjectionOrder]); - /** One session projection for REST mutations and bus updates alike. */ - const upsertSession = useCallback((next: HarnessSession): void => { - setState((prev) => { - if (!prev) return prev; - const sessions = prev.sessions.some((session) => session.id === next.id) - ? prev.sessions.map((session) => - session.id === next.id ? next : session, - ) - : [...prev.sessions, next]; - return { ...prev, sessions }; - }); - }, []); - - const openPlannerSession = useCallback( - async ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise => { - const response = await api.openPlannerSession(projectId, request); - // A launch can emit session.status before its HTTP response crosses the - // wire. Preserve that newer full-session projection when it is already - // present; still insert the response if no bus-backed row exists. - if (!sessionStatusRevisions.current.has(response.session.id)) { - upsertSession(response.session); - } else { - setState((prev) => { - if (!prev) return prev; - return prev.sessions.some( - (session) => session.id === response.session.id, - ) - ? prev - : { ...prev, sessions: [...prev.sessions, response.session] }; - }); - } - return response; - }, - [upsertSession], - ); - useEffect(() => { - return subscribeEvents((message) => { - // 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. - if (message.type !== "session.record.changed") setLastMessage(message); - setSystemGraphAnnouncements((current) => - systemGraphAnnouncementsAfterMessage(current, message), - ); - if (message.type === "session.status") { - sessionStatusRevisions.current.set( - message.session.id, - (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, + return subscribeEvents( + (message) => { + // 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. + if (message.type !== "session.record.changed") setLastMessage(message); + setSystemGraphAnnouncements((current) => + systemGraphAnnouncementsAfterMessage(current, message), ); - setState((prev) => { - if (!prev) return prev; - const exists = prev.sessions.some( - (session) => session.id === message.session.id, + if (message.type === "session.status") { + sessionStatusRevisions.current.set( + message.session.id, + (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, ); - const sessions = exists - ? prev.sessions.map((session) => - session.id === message.session.id ? message.session : session, - ) - : [...prev.sessions, message.session]; - return { ...prev, sessions }; - }); - // An exited session can never produce more output — drop any pending - // busy state/timer for it rather than leaving a stale pulse on a tab - // that's about to move to the history menu. - if (message.session.status === "exited") { - const id = message.session.id; - const timer = busyTimers.current.get(id); - if (timer) { - clearTimeout(timer); - busyTimers.current.delete(id); - } - setBusySessionIds((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; + setState((prev) => { + if (!prev) return prev; + const exists = prev.sessions.some( + (session) => session.id === message.session.id, + ); + const sessions = exists + ? prev.sessions.map((session) => + session.id === message.session.id ? message.session : session, + ) + : [...prev.sessions, message.session]; + return { ...prev, sessions }; }); - } - } else if (message.type === "session.record.changed") { - sessionRecordChangeListeners.current.forEach((listener) => - listener(message.harnessSessionId), - ); - } else if (message.type === "workflows.changed") { - // The workspace watcher saw a sapiom.json appear/change — the one - // client signal for an agent built in-app. Emit agent.created for any - // path we haven't already baselined (load) or imported (scan/connect). - // Capture this at message receipt, not response settlement: boot can - // resolve while this list is in flight. Anything announced before boot - // established its baseline belongs to that baseline regardless of HTTP - // completion order. - const baselineOnly = seenAgentPathsRef.current === null; - void refreshWorkflows() - .then((workflows) => { - const seen = seenAgentPathsRef.current; - if (baselineOnly || seen === null) { - // Lost the race with the initial load — baseline, don't emit. - const baseline = (seenAgentPathsRef.current ??= new Set()); - for (const workflow of workflows) baseline.add(workflow.path); - return; - } - for (const path of newAgentPaths(seen, workflows)) { - seen.add(path); - trackProduct("agent.created", { - workflow_slug: slugFromPath(path), - ...agentProvenance(workflows.find((w) => w.path === path)), - }); + // An exited session can never produce more output — drop any pending + // busy state/timer for it rather than leaving a stale pulse on a tab + // that's about to move to the history menu. + if (message.session.status === "exited") { + const id = message.session.id; + const timer = busyTimers.current.get(id); + if (timer) { + clearTimeout(timer); + busyTimers.current.delete(id); } - }) - // A bus refresh is best-effort. Keep the last successful projection - // 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.proposal.changed") { - agentMapProposalChangeListeners.current.forEach((listener) => - listener(message.delta), - ); - } else if (message.type === "execution.started") { - startRunPolling( - message.harnessSessionId, - message.executionId, - message.target, - ); - } else if (message.type === "port.detected") { - setPreviewBySession((prev) => - new Map(prev).set(message.harnessSessionId, { - port: message.port, - url: message.url, - }), - ); - } else if (message.type === "task.status") { - // Each frame is a full snapshot of one task — upsert by id. - setTasks((prev) => { - const exists = prev.some((task) => task.id === message.task.id); - return exists - ? prev.map((task) => - task.id === message.task.id ? message.task : task, - ) - : [...prev, message.task]; - }); - } else if (message.type === "session.activity") { - const id = message.harnessSessionId; - setBusySessionIds((prev) => - prev.has(id) ? prev : new Set(prev).add(id), - ); - const existingTimer = busyTimers.current.get(id); - if (existingTimer) clearTimeout(existingTimer); - busyTimers.current.set( - id, - setTimeout(() => { - busyTimers.current.delete(id); setBusySessionIds((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); - }, BUSY_WINDOW_MS), - ); - } else if (message.type === "auth.changed") { - // Real-time auth state update from the server — update AppState in - // place so SettingsPopover, WorkflowsRail, and deploy gating all - // react without a full reload or polling. - setState((prev) => - prev - ? { - ...prev, - authenticated: message.authenticated, - organizationName: message.organizationName, + } + } else if (message.type === "session.record.changed") { + sessionRecordChangeListeners.current.forEach((listener) => + listener(message.harnessSessionId), + ); + } else if (message.type === "workflows.changed") { + // The workspace watcher saw a sapiom.json appear/change — the one + // client signal for an agent built in-app. Emit agent.created for any + // path we haven't already baselined (load) or imported (scan/connect). + // Capture this at message receipt, not response settlement: boot can + // resolve while this list is in flight. Anything announced before boot + // established its baseline belongs to that baseline regardless of HTTP + // completion order. + const baselineOnly = seenAgentPathsRef.current === null; + void refreshWorkflows() + .then((workflows) => { + const seen = seenAgentPathsRef.current; + if (baselineOnly || seen === null) { + // Lost the race with the initial load — baseline, don't emit. + const baseline = (seenAgentPathsRef.current ??= new Set()); + for (const workflow of workflows) baseline.add(workflow.path); + return; } - : prev, - ); - // Definition build evidence is authenticated enrichment. Re-list on - // both sign-in and sign-out so a post-boot login can enable a ready - // agent and a logout cannot leave tenant metadata pinned in memory. - void refreshWorkflows().catch(() => undefined); - } - }, () => { - eventReconnectListeners.current.forEach((listener) => listener()); - }); + for (const path of newAgentPaths(seen, workflows)) { + seen.add(path); + trackProduct("agent.created", { + workflow_slug: slugFromPath(path), + ...agentProvenance(workflows.find((w) => w.path === path)), + }); + } + }) + // A bus refresh is best-effort. Keep the last successful projection + // 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.proposal.changed") { + agentMapProposalChangeListeners.current.forEach((listener) => + listener(message.delta), + ); + } else if (message.type === "execution.started") { + startRunPolling( + message.harnessSessionId, + message.executionId, + message.target, + ); + } else if (message.type === "port.detected") { + setPreviewBySession((prev) => + new Map(prev).set(message.harnessSessionId, { + port: message.port, + url: message.url, + }), + ); + } else if (message.type === "task.status") { + // Each frame is a full snapshot of one task — upsert by id. + setTasks((prev) => { + const exists = prev.some((task) => task.id === message.task.id); + return exists + ? prev.map((task) => + task.id === message.task.id ? message.task : task, + ) + : [...prev, message.task]; + }); + } else if (message.type === "session.activity") { + const id = message.harnessSessionId; + setBusySessionIds((prev) => + prev.has(id) ? prev : new Set(prev).add(id), + ); + const existingTimer = busyTimers.current.get(id); + if (existingTimer) clearTimeout(existingTimer); + busyTimers.current.set( + id, + setTimeout(() => { + busyTimers.current.delete(id); + setBusySessionIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }, BUSY_WINDOW_MS), + ); + } else if (message.type === "auth.changed") { + // Real-time auth state update from the server — update AppState in + // place so SettingsPopover, WorkflowsRail, and deploy gating all + // react without a full reload or polling. + setState((prev) => + prev + ? { + ...prev, + authenticated: message.authenticated, + organizationName: message.organizationName, + } + : prev, + ); + // Definition build evidence is authenticated enrichment. Re-list on + // both sign-in and sign-out so a post-boot login can enable a ready + // agent and a logout cannot leave tenant metadata pinned in memory. + void refreshWorkflows().catch(() => undefined); + } + }, + () => { + eventReconnectListeners.current.forEach((listener) => listener()); + }, + ); }, [refreshWorkflows, startRunPolling]); /** @@ -1516,8 +1476,16 @@ export function useHarnessState(): HarnessStateHook { ); const createSession = useCallback( - async (req: CreateSessionRequest): Promise => { + async ( + req: CreateSessionRequest, + onCreated?: (session: HarnessSession) => void, + ): Promise => { const session = await api.createSession(req); + // The response is the first unambiguous client-side correlation between + // this explicit request and its session. Notify its owner before + // selection can trigger project-preference restoration; a status frame + // at the same cwd may belong to the automatic first project session. + onCreated?.(session); // The event bus can deliver this session's first `session.status` before // the POST response resolves (the server broadcasts starting/running // during create) — appending unconditionally then renders a duplicate @@ -1732,19 +1700,14 @@ export function useHarnessState(): HarnessStateHook { setState((prev) => (prev ? { ...prev, sessions: remaining } : prev)); if (activeSessionId === id) { const closed = state?.sessions.find((session) => session.id === id); - const nextPlanner = - closed?.planning?.identity.role === "map-planner" - ? remaining.find( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - closed.planning?.identity.projectId, - ) - : undefined; + const projectId = closed?.agentMapIdentity?.projectId; const nextRunning = - nextPlanner ?? - remaining.find((session) => session.status !== "exited"); + remaining.find( + (session) => + session.status !== "exited" && + projectId != null && + session.agentMapIdentity?.projectId === projectId, + ) ?? remaining.find((session) => session.status !== "exited"); selectSession(nextRunning ? nextRunning.id : null); } }, @@ -2382,7 +2345,6 @@ export function useHarnessState(): HarnessStateHook { getTemplate, getWorkflowInputContract, sessionRecord, - openPlannerSession, resumeSession, rehydrateSession, resumeFromHistory,