diff --git a/.changeset/fresh-maps-plan.md b/.changeset/fresh-maps-plan.md new file mode 100644 index 00000000..3d79058a --- /dev/null +++ b/.changeset/fresh-maps-plan.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": minor +--- + +Open Studio Agent Maps as a dedicated planning workspace with a live, project-scoped planning conversation beside the durable map. Planner tabs can resume, start fresh, rename, and end; transcript updates refetch through content-free invalidations, map and planner failures retry independently, and mobile keeps the conversation primary behind an explicit Agent Map sheet. + +This release adds variants to the public `BusMessage`, `UiEventName`, and `AnalyticsEventType` unions. Consumers that switch over these forward-extensible event types should retain a default arm so later additive events remain source-compatible. diff --git a/packages/harness/src/core/session-record-invalidation.test.ts b/packages/harness/src/core/session-record-invalidation.test.ts new file mode 100644 index 00000000..fa67816e --- /dev/null +++ b/packages/harness/src/core/session-record-invalidation.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { AnalyticsEvent } from "../shared/types.js"; + +import { sessionRecordChangedMessage } from "./session-record-invalidation.js"; + +function event(type: AnalyticsEvent["type"]): AnalyticsEvent { + return { + eventId: `event-${type}`, + type, + ts: "2026-09-01T00:00:00.000Z", + agentSessionId: "agent-session-1", + harnessSessionId: "harness-session-1", + harness: "claude-code", + userId: null, + tenantId: null, + machineId: "machine-1", + seq: 1, + payload: {}, + }; +} + +describe("sessionRecordChangedMessage", () => { + it.each(["prompt.submitted", "turn.completed"] as const)( + "invalidates after %s without projecting content", + (type) => { + expect(sessionRecordChangedMessage(event(type))).toEqual({ + type: "session.record.changed", + harnessSessionId: "harness-session-1", + }); + }, + ); + + it("ignores events that do not change the conversation projection", () => { + expect(sessionRecordChangedMessage(event("tool.call"))).toBeNull(); + expect(sessionRecordChangedMessage(event("session.end"))).toBeNull(); + }); +}); diff --git a/packages/harness/src/core/session-record-invalidation.ts b/packages/harness/src/core/session-record-invalidation.ts new file mode 100644 index 00000000..9692cb0b --- /dev/null +++ b/packages/harness/src/core/session-record-invalidation.ts @@ -0,0 +1,18 @@ +import type { AnalyticsEvent, BusMessage } from "../shared/types.js"; + +type SessionRecordChangedMessage = Extract< + BusMessage, + { type: "session.record.changed" } +>; + +/** Content-free invalidation for events that change the visible transcript. */ +export function sessionRecordChangedMessage( + event: AnalyticsEvent, +): SessionRecordChangedMessage | null { + return event.type === "prompt.submitted" || event.type === "turn.completed" + ? { + type: "session.record.changed", + harnessSessionId: event.harnessSessionId, + } + : null; +} diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index c949ba25..2e1a7577 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -472,12 +472,38 @@ describe("createAgentMapRouter", () => { greeting: { status: "pending" as const }, queuedInputIds: ["input-1"], })); - const retry = vi.fn(async () => {}); + const retry = vi.fn(async () => { + if (!plannerSession.planning) throw new Error("missing planner metadata"); + plannerSession.planning = { + ...plannerSession.planning, + greeting: { status: "generating", attemptId: "attempt-2" }, + }; + }); const fixture = await start({ - planningSessions: { open, requireOwned } as unknown as PlanningSessionService, - plannerGreeting: { enqueue, retry } as unknown as PlannerGreetingCoordinator, + planningSessions: { + open, + requireOwned, + } as unknown as PlanningSessionService, + plannerGreeting: { + enqueue, + retry, + } as unknown as PlannerGreetingCoordinator, }); fixtureProjectId = fixture.project.projectId; + plannerSession.planning = { + identity: { + projectId: fixtureProjectId, + sessionId: plannerSession.id, + userId: "user-1", + role: "map-planner", + }, + greeting: { + status: "failed", + retryable: true, + errorCode: "model_turn_failed", + }, + queuedInputIds: [], + }; const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions`; expect( @@ -517,6 +543,56 @@ describe("createAgentMapRouter", () => { mode: "fresh", harness: "codex", }); + + const message = await fetch(`${route}/${plannerSession.id}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ text: "Build a support triage system" }), + }); + expect(message.status).toBe(202); + expect(await message.json()).toEqual({ + metadata: { + identity: { + projectId: fixture.project.projectId, + sessionId: plannerSession.id, + userId: "user-1", + role: "map-planner", + }, + greeting: { status: "pending" }, + queuedInputIds: ["input-1"], + }, + }); + expect(requireOwned).toHaveBeenCalledWith( + fixture.project.projectId, + plannerSession.id, + ); + expect(enqueue).toHaveBeenCalledWith( + plannerSession.id, + "Build a support triage system", + ); + + const retryResponse = await fetch( + `${route}/${plannerSession.id}/greeting/retry`, + { + method: "POST", + headers: { + "content-type": "application/json", + "X-Harness-Token": "test-token", + }, + body: "{}", + }, + ); + expect(retryResponse.status).toBe(202); + expect(await retryResponse.json()).toEqual({ + metadata: { + ...plannerSession.planning, + greeting: { status: "generating", attemptId: "attempt-2" }, + }, + }); + expect(retry).toHaveBeenCalledWith(plannerSession.id); }); it("rejects foreign planner messages and bounds unavailable retries", async () => { diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index ddc37c37..a997c430 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -60,6 +60,7 @@ import { createClaudeTranscriptEnricher, createSessionRecordReader, } from "../core/session-record.js"; +import { sessionRecordChangedMessage } from "../core/session-record-invalidation.js"; import { buildRehydrationBrief, systemPromptDeliveryFor, @@ -3236,6 +3237,8 @@ export const startServer = async ( void plannerGreeting.onEventPersisted(event).catch((error: unknown) => { console.error("[harness] planner greeting completion failed:", error); }); + const recordChanged = sessionRecordChangedMessage(event); + if (recordChanged) bus.publish(recordChanged); // The normal end of a session: the SessionEnd hook's event is in the // store, so the archived record carries the whole conversation including // its `endedAt`. (The "exited" status handler archives too, for sessions diff --git a/packages/harness/src/server/ingest.test.ts b/packages/harness/src/server/ingest.test.ts index 9a6f5411..6d6e765c 100644 --- a/packages/harness/src/server/ingest.test.ts +++ b/packages/harness/src/server/ingest.test.ts @@ -207,6 +207,43 @@ describe("createIngestRouter", () => { expect(enqueued).toHaveLength(1); }); + it("notifies transcript consumers only after the event is durably appended", async () => { + await new Promise((resolve) => server.close(() => resolve())); + const appendEntered = deferred(); + const appendCommit = deferred(); + const order: string[] = []; + start({ + store: { + append: async (event) => { + order.push(`append:${event.type}`); + appendEntered.resolve(); + await appendCommit.promise; + stored.push(event); + order.push(`persisted:${event.type}`); + }, + }, + onEventPersisted: (event) => order.push(`notify:${event.type}`), + }); + + const res = await postIngest(baseUrl, { + hookEvent: "UserPromptSubmit", + harnessSessionId: "session-1", + payload: { session_id: "agent-1", prompt: "hello" }, + }); + expect(res.status).toBe(200); + await appendEntered.promise; + expect(order).toEqual(["append:prompt.submitted"]); + + appendCommit.resolve(); + await vi.waitFor(() => + expect(order).toEqual([ + "append:prompt.submitted", + "persisted:prompt.submitted", + "notify:prompt.submitted", + ]), + ); + }); + it("assigns a monotonically increasing seq per harnessSessionId, server-side", async () => { await postIngest(baseUrl, { hookEvent: "UserPromptSubmit", diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index cee6b1cb..74256d0b 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -129,6 +129,8 @@ const UI_EVENT_NAMES: readonly UiEventName[] = [ "session.created", "mcp.install", "plan.upgrade_clicked", + "agent_map.entered", + "agent_map.workspace_load_failed", ]; /** diff --git a/packages/harness/src/server/track.test.ts b/packages/harness/src/server/track.test.ts index d2db237a..e11841cb 100644 --- a/packages/harness/src/server/track.test.ts +++ b/packages/harness/src/server/track.test.ts @@ -262,6 +262,10 @@ describe("POST /api/track", () => { "visualize.triggered", "consent.changed", "session.created", + "mcp.install", + "plan.upgrade_clicked", + "agent_map.entered", + "agent_map.workspace_load_failed", ] as const; for (const event of events) { diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index c31adc98..397c910c 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -160,6 +160,11 @@ export interface PlannerMessageRequest { text: string; } +/** Authoritative coordinator state returned after a planner mutation. */ +export interface PlannerSessionMetadataResponse { + metadata: PlannerSessionMetadata; +} + /** * Content-free planner lifecycle telemetry. Callers may persist these fields, * but must never add prompts, assistant text, local paths, or provider errors. diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 18b7b0ac..33f4be7b 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -522,6 +522,12 @@ export type TerminalControlMessage = TerminalResizeMessage; export type BusMessage = | { type: "session.status"; session: HarnessSession } + /** + * A prompt or completed turn is now durable in the local event store. + * Consumers refetch the existing SessionRecord snapshot; conversation + * content never rides the event bus. + */ + | { type: "session.record.changed"; harnessSessionId: string } | { type: "canvas.reload"; harnessSessionId: string } | { type: "port.detected"; @@ -763,7 +769,9 @@ export type UiEventName = | "consent.changed" | "session.created" | "mcp.install" - | "plan.upgrade_clicked"; + | "plan.upgrade_clicked" + | "agent_map.entered" + | "agent_map.workspace_load_failed"; export interface UiTrackRequest { /** Dot-canonical event name — one of the UiEventName literals. */ @@ -793,6 +801,8 @@ export type AnalyticsEventType = | "session.created" | "mcp.install" | "plan.upgrade_clicked" + | "agent_map.entered" + | "agent_map.workspace_load_failed" | "agent_map.workspace_initialized" | "agent_map.workspace_read_failed" | "planner_session.created" diff --git a/packages/harness/web/e2e/agent-map-planning.spec.ts b/packages/harness/web/e2e/agent-map-planning.spec.ts new file mode 100644 index 00000000..6f0eff2e --- /dev/null +++ b/packages/harness/web/e2e/agent-map-planning.spec.ts @@ -0,0 +1,390 @@ +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 a planner beside the honest empty map", async ({ + page, + }) => { + await openDashboardMap(page); + + await expect(page.getByTestId("planning-conversation")).toBeVisible(); + await expect(page.getByTestId("agent-map-empty")).toHaveText( + "Nothing generated yet", + ); + await expect( + page.getByTestId("planner-transcript-assistant"), + ).toContainText("What kind of agent architecture do you want to build?"); + // The automatic greeting is assistant-initiated. No fake user/control + // instruction is projected into the conversation. + await expect( + page + .getByTestId("planner-transcript-turn") + .first() + .locator(".transcript-role-user"), + ).toHaveCount(0); + await expect(page.getByTestId("planner-composer-input")).toBeEnabled(); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + + const [conversation, map] = await Promise.all([ + page.getByTestId("planning-conversation").boundingBox(), + page.getByTestId("agent-map-empty").boundingBox(), + ]); + expect(conversation?.width ?? 0).toBeGreaterThan(200); + expect(map?.width ?? 0).toBeGreaterThan(200); + expect(map?.x ?? 0).toBeGreaterThan( + (conversation?.x ?? 0) + (conversation?.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("a user can proceed while the greeting is generating and the record refetches", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=generating", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openDashboardMap(page); + + await expect(page.getByTestId("planner-greeting-generating")).toBeVisible(); + const composer = page.getByTestId("planner-composer-input"); + await expect(composer).toBeEnabled(); + await composer.fill("A support triage system for customer requests."); + await page.getByTestId("planner-composer-send").click(); + await composer.fill("A follow-up I started while that was queued."); + + await expect(page.getByTestId("planner-queued-inputs")).toContainText( + "Message queued", + ); + await expect(composer).toHaveValue( + "A follow-up I started while that was queued.", + ); + await expect(page.getByTestId("planner-transcript-prompt")).toHaveText( + "A support triage system for customer requests.", + ); + await expect( + page.getByTestId("planner-transcript-assistant"), + ).toContainText("clarifying the outcome"); + await expect(page.getByTestId("planner-greeting-generating")).toHaveCount( + 0, + ); + await expect(page.getByTestId("planner-queued-inputs")).toHaveCount(0); + }); + + test("return resumes the same planner and plus creates a fresh planner tab", async ({ + page, + }) => { + await openDashboardMap(page); + await expect(page.getByTestId("planning-conversation")).toBeVisible(); + const first = await activeSessionId(page); + expect(first).toBeTruthy(); + + await page + .getByTestId("workflow-dashboard-keeper") + .locator("button") + .click(); + await expect(page.getByTestId("planning-conversation")).toHaveCount(0); + await openDashboardMap(page); + await expect(page.getByTestId("planning-conversation")).toBeVisible(); + expect(await activeSessionId(page)).toBe(first); + await expect(page.getByTestId("planner-transcript-assistant")).toHaveCount( + 1, + ); + + 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.getByTestId("planner-transcript-assistant"), + ).toContainText("What kind of agent architecture do you want to build?"); + + 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.getByTestId("planning-conversation")).toBeVisible(); + }); + + test("an explicitly selected planner tab wins over project resume ordering", async ({ + page, + }) => { + await openDashboardMap(page); + await expect(page.getByTestId("planning-conversation")).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.getByTestId("planning-conversation")).toHaveCount(0); + + 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.getByTestId("planning-conversation")).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.getByTestId("planning-conversation")).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.getByTestId("planning-conversation")).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")).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.getByTestId("planning-conversation")).toBeVisible(); + await expect.poll(() => activeSessionId(page)).not.toBe(ended); + }); + + test("greeting retry failures stay local to the greeting", async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=failed&mockGreetingRetry=error", + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + await openDashboardMap(page); + + await page.getByTestId("planner-greeting-retry").click(); + await expect( + page.getByTestId("planner-greeting-retry-error"), + ).toContainText("temporarily unavailable"); + await expect(page.getByTestId("planner-greeting-failed")).toBeVisible(); + await expect(page.getByTestId("planner-composer-input")).toBeEnabled(); + }); + + 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.getByTestId("planning-conversation")).toBeVisible(); + await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); + await expect(page.getByTestId("planner-composer-input")).toBeEnabled(); + 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("conversation 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.getByTestId("planning-conversation")).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.getByTestId("planning-conversation")).toBeVisible(); + await expect(page.locator(".right-pane")).toBeHidden(); + + const composer = page.getByTestId("planner-composer-input"); + await composer.fill("Keep the Agent Map open while this turn starts."); + await page.getByTestId("planner-composer-send").click(); + await page.getByTestId("right-expand").click(); + await expect(page.locator(".right-pane")).toBeVisible(); + + await expect(page.getByTestId("planner-transcript-prompt")).toHaveText( + "Keep the Agent Map open while this turn starts.", + ); + await expect(page.locator(".right-pane")).toBeVisible(); + }); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index cbb02d13..29b3c5fd 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -57,9 +57,14 @@ import type { WorkflowInputContractResponse, } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; -import type { StudioWorkspaceSelection } from "@shared/agent-map"; +import type { + PlannerSessionRequest, + StudioProjectId, + StudioWorkspaceSelection, +} from "@shared/agent-map"; import { CanvasPane } from "./components/CanvasPane"; +import { AgentMapPane } from "./components/AgentMapPane"; import { CommandPalette } from "./components/CommandPalette"; import { ConnectivityBanner, @@ -77,6 +82,7 @@ import { Terminal } from "./components/Terminal"; import { Toast } from "./components/Toast"; import { TooltipLayer } from "./components/TooltipLayer"; import { NewSessionComposer } from "./components/NewSessionComposer"; +import { PlanningConversationPane } from "./components/PlanningConversationPane"; import { HelpOverlay } from "./components/HelpOverlay"; import { CreateAgentDialog } from "./components/CreateAgentDialog"; import { OverviewModal } from "./components/OverviewModal"; @@ -146,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 { toggleTheme } from "./lib/theme"; +import { getTheme, toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; import { useNavigationHistory, @@ -169,6 +175,8 @@ import { type ObservedRun, type RunTarget, } from "./lib/use-harness-state"; +import { useAgentMapEntry } from "./lib/use-agent-map-entry"; +import { usePlannerTranscript } from "./lib/use-planner-transcript"; import { isWorkflowRunnable, workflowDeploymentState, @@ -237,6 +245,7 @@ export const App = (): JSX.Element => { // Live browser connectivity (navigator.onLine + online/offline events). // Combined with the boot-error kind below to pick the honest shell state. const online = useConnectivity(); + const isMobile = useMobileShell(); const [paletteOpen, setPaletteOpen] = useState(false); const [runRequest, setRunRequest] = useState<{ workflow: WorkflowInfo; @@ -286,10 +295,62 @@ export const App = (): JSX.Element => { useState(null); 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 activePlannerForTranscript = harness.state?.sessions.find( + (session) => + session.id === harness.activeSessionId && + session.status !== "exited" && + session.planning?.identity.role === "map-planner" && + session.planning.identity.projectId === plannerProjectId, + ); + const agentMapEntry = useAgentMapEntry({ + projectId: plannerProjectId, + selectedPlanner: activePlannerForTranscript ?? null, + api: harness.api, + harness: () => + loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code", + theme: getTheme, + openPlannerSession: harness.openPlannerSession, + onPlannerReady: handlePlannerReady, + }); + const plannerTranscript = usePlannerTranscript( + activePlannerForTranscript?.id ?? null, + harness.sessionRecord, + harness.subscribeSessionRecordChanges, + ); // A project visit restores its server-owned preference before choosing an - // altitude. Workspace and preference are fetched together so an agent - // restore never flashes the map first. + // altitude. Once map is chosen, `useAgentMapEntry` owns the independent map + // and planner requests; preference restoration must not couple their fate. useEffect(() => { const state = harness.state; const active = state?.sessions.find( @@ -312,11 +373,9 @@ export const App = (): JSX.Element => { return; restoredStudioProjectsRef.current.add(project.projectId); const generation = ++studioRestoreGenerationRef.current; - void Promise.all([ - harness.api.getAgentMapWorkspace(project.projectId), - harness.api.getStudioCurrentWorkspace(project.projectId), - ]) - .then(([, current]) => { + void harness.api + .getStudioCurrentWorkspace(project.projectId) + .then((current) => { if (generation !== studioRestoreGenerationRef.current) return; const restoredSelection = current.selection; const workflow = @@ -338,11 +397,19 @@ export const App = (): JSX.Element => { setStudioSelection({ kind: "agent-map", projectId: project.projectId }); setSelectedProject(null); setFocusedAgentPath(scope.cwd); + if (isMobile) setRightCollapsed(true); }) .catch(() => { - restoredStudioProjectsRef.current.delete(project.projectId); + if (generation !== studioRestoreGenerationRef.current) return; + // Preference storage is not either pane's authority. Fall back to the + // project's default Agent Map once so later session-status frames do + // not repeatedly reset selection or close the mobile map sheet. + setStudioSelection({ kind: "agent-map", projectId: project.projectId }); + setSelectedProject(null); + setFocusedAgentPath(scope.cwd); + if (isMobile) setRightCollapsed(true); }); - }, [harness.activeSessionId, harness.api, harness.state]); + }, [harness.activeSessionId, harness.api, harness.state, isMobile]); // A selected agent that disappears falls back to its map in memory. Only // the server knows whether the project scan is complete enough to persist a @@ -380,9 +447,10 @@ export const App = (): JSX.Element => { setStudioSelection(current.selection); setSelectedProject(null); setFocusedAgentPath(scope.cwd); + if (isMobile) setRightCollapsed(true); }) .catch(() => {}); - }, [harness.api, harness.state, studioSelection]); + }, [harness.api, harness.state, isMobile, studioSelection]); // The project whose FIRST session is being created. The centre pane says so // while the POST and the pty spawn resolve; without it a project you have // just selected flashes the create-new composer for the length of a session @@ -599,7 +667,6 @@ export const App = (): JSX.Element => { // collapse-panel toggle in the right-pane tab bar (the frame itself lives in // CanvasPane, which reads these props). const [canvasExpanded, setCanvasExpanded] = useState(false); - const isMobile = useMobileShell(); // Back/forward across every screen the shell can show. The stack is fed by // the place the shell IS (derived below), not by instrumenting each door, so @@ -648,6 +715,7 @@ export const App = (): JSX.Element => { const manualExpandPendingRef = useRef(false); const paneSlidingRef = useRef(false); const paneElRef = useRef(null); + const rightPaneTriggerRef = useRef(null); const paneObserverRef = useRef(null); const captureExpandedWidth = useCallback( (el: HTMLDivElement | null): void => { @@ -702,6 +770,29 @@ export const App = (): JSX.Element => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { const key = e.key.toLowerCase(); + if (key === "escape" && isMobile && !rightCollapsed) { + // The nearest open layer owns Escape. Dismissable menus/dialogs mark + // the event handled at document; App-owned overlays are guarded by + // state so their own focus restoration wins over the sheet trigger. + if ( + e.defaultPrevented || + paletteOpen || + settingsOpen || + templatesOpen || + overviewOpen || + document.querySelector( + '[role="dialog"], [role="alertdialog"], [role="menu"], [aria-modal="true"]', + ) + ) { + return; + } + e.preventDefault(); + setRightCollapsed(true); + window.requestAnimationFrame(() => + rightPaneTriggerRef.current?.focus(), + ); + return; + } if ((e.metaKey || e.ctrlKey) && (key === "k" || key === "p")) { e.preventDefault(); setPaletteOpen(true); @@ -729,9 +820,17 @@ export const App = (): JSX.Element => { knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); const tabs = - subject.kind === "project" - ? liveSessionsForProject(sessions, subject.root) - : liveSessionsForFocus(sessions, subject.path); + 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 target = tabs[Number(e.key) - 1]; if (target) { e.preventDefault(); @@ -763,6 +862,13 @@ export const App = (): JSX.Element => { harness.settings?.recentDirs, focusedAgentPath, selectedProject, + studioSelection, + isMobile, + rightCollapsed, + paletteOpen, + settingsOpen, + templatesOpen, + overviewOpen, ]); // Where NEW agent projects are created — ONE value, shared by every surface @@ -879,10 +985,16 @@ export const App = (): JSX.Element => { // agent board closes the pane) would otherwise leave a project selection // with nothing on screen but a chat — the mode switch inverted. useEffect(() => { - if (selectedProject || studioSelection?.kind === "agent-map") { + // On mobile the planning conversation is primary. Agent Map selection and + // background loading never open the bottom sheet; its explicit button does. + // Legacy System Graph selection keeps its established auto-open behavior. + if ( + selectedProject || + (!isMobile && studioSelection?.kind === "agent-map") + ) { setRightCollapsed(false); } - }, [selectedProject, studioSelection]); + }, [isMobile, selectedProject, studioSelection]); // Crossing the breakpoint resets both panes to that mode's default. const prevMobile = useRef(isMobile); @@ -1003,6 +1115,7 @@ export const App = (): JSX.Element => { setStudioSelection({ kind: "agent-map", projectId: visit.projectId }); setSelectedProject(null); setFocusedAgentPath(scope.cwd); + if (isMobile) setRightCollapsed(true); } } else { setSelectedProject(null); @@ -1040,7 +1153,7 @@ export const App = (): JSX.Element => { ); } }, - [harness.state, setActiveSessionId], + [harness.state, isMobile, setActiveSessionId], ); // The dead pane's Resume button has to be as honest as a history row's tag, @@ -1221,6 +1334,24 @@ 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; /** * Whose tabs the strip shows: the ACTIVE session's PROJECT (SAP-2980), never @@ -1241,12 +1372,14 @@ export const App = (): JSX.Element => { : (selectedProject?.root ?? null), knownProjectRoots(), ); - const focusTabs = - conversation.kind === "project" + const focusTabs = planningWorkspace + ? plannerSessions + : conversation.kind === "project" ? liveSessionsForProject(state.sessions, conversation.root) : liveSessionsForFocus(state.sessions, conversation.path); const showReview = reviewSummary != null; - const showDead = !showReview && activeSession?.status === "exited"; + const showDead = + !planningWorkspace && !showReview && activeSession?.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. // @@ -1267,6 +1400,7 @@ export const App = (): JSX.Element => { const showAgentEmpty = !showReview && !showDead && + !planningWorkspace && !composing && !atMapAltitude && focusedWorkflow != null && @@ -1279,6 +1413,7 @@ export const App = (): JSX.Element => { const showWorkbench = !showReview && !showDead && + !planningWorkspace && !composing && !showAgentEmpty && activeSession != null && @@ -1286,13 +1421,19 @@ export const App = (): JSX.Element => { // 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 = - !showReview && !showDead && !composing && !showWorkbench && startingProject != null; + !planningWorkspace && + !showReview && + !showDead && + !composing && + !showWorkbench && + startingProject != null; // The composer-first "new session" home: explicit intent, or nothing else to // show (first run, or every session closed). Replaces the WelcomePanel overlay // AND the old "No active session" fallback. const showComposer = !showReview && !showDead && + !planningWorkspace && !showProjectStarting && (composing || (!showAgentEmpty && !showWorkbench)); /** The project a board can cut UP to — derived, so the way back is the same @@ -1308,7 +1449,13 @@ export const App = (): JSX.Element => { // At map altitude the map IS the canvas panel, so a stored `steps` intent is // held (it restores on the way back down) but never rendered. const shownTab: RightTab = stepsDisabled ? "canvas" : rightTab; - const rightPaneSuppressedByComposer = showComposer && !atMapAltitude; + const rightPaneSuppressedByComposer = + (showComposer && !atMapAltitude) || agentMapUnavailable; + const sessionBarSession = planningWorkspace + ? activePlannerSession + : showWorkbench || showDead + ? activeSession + : null; // A live session to return to when the composer was opened over the workbench. const composerCanCancel = composing && activeSession != null && activeSession.status !== "exited"; @@ -1345,6 +1492,12 @@ export const App = (): JSX.Element => { manualExpandPendingRef.current = harness.activeSessionId == null; setRightCollapsed(false); }; + const collapseRightPane = (): void => { + setRightCollapsed(true); + if (isMobile) { + window.requestAnimationFrame(() => rightPaneTriggerRef.current?.focus()); + } + }; const rightPaneDeploymentState = rightPaneWorkflow ? workflowDeploymentState( rightPaneWorkflow, @@ -1434,6 +1587,7 @@ export const App = (): JSX.Element => { const studioProjectId = workspaceScopes.find( (scope) => scope.workspaceKey === workspaceKey, )?.projectId; + let selectedAgentMap = false; if ( studioProjectId && state.studioProjects?.some( @@ -1448,6 +1602,7 @@ export const App = (): JSX.Element => { setStudioSelection(selection); setSelectedProject(null); void harness.api.putStudioCurrentWorkspace(studioProjectId, selection); + selectedAgentMap = true; } else { setStudioSelection(null); setSelectedProject({ workspaceKey, root, label }); @@ -1462,6 +1617,13 @@ 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. + if (selectedAgentMap) { + if (isMobile) setRightCollapsed(true); + return; + } const decision = sessionForFocus({ focusPath: root, active: activeSession, @@ -2000,6 +2162,24 @@ 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, + ); + return; + } // 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); @@ -2522,7 +2702,10 @@ export const App = (): JSX.Element => { }} onOpenProject={async (requestedRoot) => { const openedRoot = await harness.openProject(requestedRoot); - let restoringProjectId: string | null = null; + let restoringProject: { + projectId: StudioProjectId; + cwd: string; + } | null = null; try { const refreshed = await harness.api.getState(); const scope = refreshed.workspaceScopes?.find((candidate) => @@ -2532,13 +2715,15 @@ export const App = (): JSX.Element => { (candidate) => candidate.projectId === scope?.projectId, ); if (!scope?.projectId || !project) return; - restoringProjectId = project.projectId; + restoringProject = { + projectId: project.projectId, + cwd: scope.cwd, + }; restoredStudioProjectsRef.current.add(project.projectId); const generation = ++studioRestoreGenerationRef.current; - const [, current] = await Promise.all([ - harness.api.getAgentMapWorkspace(project.projectId), - harness.api.getStudioCurrentWorkspace(project.projectId), - ]); + const current = await harness.api.getStudioCurrentWorkspace( + project.projectId, + ); if (generation !== studioRestoreGenerationRef.current) return; const restoredSelection = current.selection; if (restoredSelection.kind === "agent") { @@ -2562,9 +2747,21 @@ export const App = (): JSX.Element => { }); setSelectedProject(null); setFocusedAgentPath(scope.cwd); + if (isMobile) setRightCollapsed(true); } catch { - if (restoringProjectId) { - restoredStudioProjectsRef.current.delete(restoringProjectId); + if (restoringProject) { + // Preference restoration is best-effort. The project itself + // opened successfully, so fall back to its stable map rather + // than leaving the previous workspace selected. Keep the + // restore guard so later session frames cannot repeat it. + studioRestoreGenerationRef.current += 1; + setStudioSelection({ + kind: "agent-map", + projectId: restoringProject.projectId, + }); + setSelectedProject(null); + setFocusedAgentPath(restoringProject.cwd); + if (isMobile) setRightCollapsed(true); } } }} @@ -2707,35 +2904,42 @@ export const App = (): JSX.Element => {
setComposing(false) : null} - activeSession={ - showWorkbench ? activeSession : showDead ? activeSession : null - } + activeSession={sessionBarSession} sessionName={ - activeSession + sessionBarSession ? sessionDisplayName( - activeSession, + sessionBarSession, state.sessions, sessionNames, ) : null } onRenameSession={renameSession} - boundWorkflowName={boundWorkflow?.name ?? null} - sessions={showWorkbench ? focusTabs : []} + boundWorkflowName={ + planningWorkspace ? null : (boundWorkflow?.name ?? null) + } + sessions={ + planningWorkspace + ? plannerSessions + : showWorkbench + ? focusTabs + : [] + } busySessionIds={harness.busySessionIds} onSelectSession={selectTab} labelOf={(session) => sessionDisplayName(session, state.sessions, sessionNames) } busy={ - activeSession != null && - harness.busySessionIds.has(activeSession.id) + sessionBarSession != null && + harness.busySessionIds.has(sessionBarSession.id) } onCloseSession={(id) => void harness.closeSession(id)} onOpenInEditor={openInEditor} @@ -2744,16 +2948,31 @@ export const App = (): JSX.Element => { onExpandRail={ railCollapsed ? () => setRailCollapsed(false) : null } - onExpandRight={rightCollapsed ? expandRightPane : null} + onExpandRight={ + !agentMapUnavailable && rightCollapsed ? expandRightPane : null + } + expandRightLabel={ + planningWorkspace ? "Agent Map" : "Expand canvas panel" + } + showExpandRightLabel={isMobile && planningWorkspace} + expandRightRef={rightPaneTriggerRef} subjectName={ - focusedWorkflow?.name ?? - (activeSession ? basenameOf(activeSession.cwd) : null) + planningWorkspace + ? (selectedStudioProject?.displayName ?? "Agent Map") + : (focusedWorkflow?.name ?? + (activeSession ? basenameOf(activeSession.cwd) : null)) + } + newSessionPending={ + planningWorkspace + ? agentMapEntry.state.planner.status === "loading" + : siblingSessionPending } - newSessionPending={siblingSessionPending} onNewSession={ - activeSession - ? () => handleStartSiblingSession(activeSession) - : null + planningWorkspace + ? agentMapEntry.openFreshPlanner + : activeSession + ? () => handleStartSiblingSession(activeSession) + : null } /* The agent action cluster shares the same row as the tabs. Its subject AND its gating are `rightPaneWorkflow` — the same @@ -2782,7 +3001,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={ - rightPaneWorkflow ? ( + !planningWorkspace && rightPaneWorkflow ? ( { />
- {showReview && reviewSummary ? ( + {agentMapUnavailable ? ( + + Retry + + } + /> + ) : planningWorkspace ? ( + agentMapEntry.state.planner.status === "error" ? ( + + Retry conversation + + } + /> + ) : agentMapEntry.state.planner.status === "loading" ? ( + + ) : activePlannerSession?.planning ? ( + { + await harness.sendPlannerMessage( + studioView.projectId, + activePlannerSession.id, + { text }, + ); + }} + onRetryGreeting={async () => { + await harness.retryPlannerGreeting( + studioView.projectId, + activePlannerSession.id, + ); + }} + disabled={activePlannerSession.status === "exited"} + /> + ) : ( + + New planning session + + } + /> + ) + ) : showReview && reviewSummary ? ( { className="shell-scrim" data-testid="right-sheet-scrim" aria-hidden="true" - onClick={() => setRightCollapsed(true)} + onClick={collapseRightPane} /> )} @@ -3025,7 +3329,7 @@ export const App = (): JSX.Element => { data-testid="right-tab-canvas" > - Canvas + {planningWorkspace ? "Agent Map" : "Canvas"} {/* Steps are an AGENT's steps. At map altitude there is no meaningful step list for a whole project, and a tab that @@ -3097,9 +3401,17 @@ export const App = (): JSX.Element => { @@ -3116,11 +3428,9 @@ export const App = (): JSX.Element => { behind the map. Keyed by project, so switching projects is a fresh load rather than a mutation of the one on screen. */} {studioView?.altitude === "map" ? ( - ) : legacyView.altitude === "map" ? ( void; +} + +/** The honest E1 map: durable state around the existing neutral canvas empty. */ +export function AgentMapPane({ + state, + onRetry, +}: AgentMapPaneProps): JSX.Element { + if (state.status === "error") { + return ( + + Retry map + + } + /> + ); + } + if (state.status !== "ready") { + return ( + + ); + } + return ( + + ); +} diff --git a/packages/harness/web/src/components/CommandPalette.tsx b/packages/harness/web/src/components/CommandPalette.tsx index 8a8985db..35c9ca14 100644 --- a/packages/harness/web/src/components/CommandPalette.tsx +++ b/packages/harness/web/src/components/CommandPalette.tsx @@ -286,6 +286,9 @@ export function CommandPalette({ const item = items[selectedIndex]; if (item) activate(item); } else if (e.key === "Escape") { + // Claim Escape for this layer so window-level shell shortcuts cannot + // also collapse a pane after the palette handles the same key. + e.preventDefault(); // A typed query is the nearer state to undo — clear it first, close on // the second press. if (query) setQuery(""); diff --git a/packages/harness/web/src/components/EndSessionConfirm.tsx b/packages/harness/web/src/components/EndSessionConfirm.tsx index b6bb6392..46c87a58 100644 --- a/packages/harness/web/src/components/EndSessionConfirm.tsx +++ b/packages/harness/web/src/components/EndSessionConfirm.tsx @@ -17,11 +17,15 @@ export function EndSessionConfirm({ onCancel, onConfirm, triggerRef, + description = + "This kills the live terminal; anything the agent is doing right now stops. The session stays resumable from history once it has an agent session id.", }: { onCancel: () => void; onConfirm: () => void; /** Focus returns here on Escape. */ triggerRef?: RefObject; + /** Surface-specific consequence while retaining one confirmation primitive. */ + description?: string; }): JSX.Element { const confirmRef = useRef(null); useDismissable(true, { onDismiss: onCancel, containerRef: confirmRef, triggerRef }); @@ -48,10 +52,7 @@ export function EndSessionConfirm({
-

- This kills the live terminal; anything the agent is doing right now stops. The session - stays resumable from history once it has an agent session id. -

+

{description}

{/* Initial focus lands on the SAFE action: Enter keeps the session; diff --git a/packages/harness/web/src/components/OverviewModal.tsx b/packages/harness/web/src/components/OverviewModal.tsx index 1ae7b0e7..3fa70697 100644 --- a/packages/harness/web/src/components/OverviewModal.tsx +++ b/packages/harness/web/src/components/OverviewModal.tsx @@ -56,7 +56,10 @@ export function OverviewModal({ useEffect(() => { if (addOpen) return; const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") dismissRef.current(); + if (e.key !== "Escape") return; + // Claim Escape for the card before the shell's pane shortcut sees it. + e.preventDefault(); + dismissRef.current(); }; // Attached a tick late: the Esc that closes the nested picker flushes // state mid-dispatch, so listening immediately would catch the SAME diff --git a/packages/harness/web/src/components/PlanningConversationPane.tsx b/packages/harness/web/src/components/PlanningConversationPane.tsx new file mode 100644 index 00000000..fa437d58 --- /dev/null +++ b/packages/harness/web/src/components/PlanningConversationPane.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef, useState } from "react"; +import type { FormEvent, JSX, KeyboardEvent } from "react"; +import type { + PlannerGreetingState, + PlannerSessionMetadata, +} from "@shared/agent-map"; +import type { SessionRecord, SessionRecordTurn } from "@shared/types"; + +import type { SessionRecordState } from "../lib/use-session-record"; +import { formatClockTime, toolCallLabel } from "../lib/session-record-view"; +import { Icon } from "./Icon"; +import { Markdown } from "./Markdown"; + +interface PlanningConversationPaneProps { + metadata: PlannerSessionMetadata; + transcript: SessionRecordState; + onRetryTranscript: () => void; + onSend: (text: string) => Promise; + onRetryGreeting: () => Promise; + disabled?: boolean; +} + +function hasUserProceeded( + greeting: PlannerGreetingState, + metadata: PlannerSessionMetadata, + transcript: SessionRecordState, +): boolean { + if (greeting.status === "skipped" || metadata.queuedInputIds.length > 0) { + return true; + } + return ( + transcript.status === "ready" && + transcript.record.turns.some((turn) => turn.prompt !== null) + ); +} + +function GreetingStatus({ + metadata, + transcript, + retrying, + onRetry, +}: { + metadata: PlannerSessionMetadata; + transcript: SessionRecordState; + retrying: boolean; + onRetry: () => void; +}): JSX.Element | null { + const greeting = metadata.greeting; + if (greeting.status === "pending" || greeting.status === "generating") { + return ( +
+
+ ); + } + if (greeting.status !== "failed") return null; + const canRetry = + greeting.retryable && + (transcript.status === "empty" || transcript.status === "ready") && + !hasUserProceeded(greeting, metadata, transcript) && + !retrying; + return ( +
+ + + The automatic greeting did not arrive. You can keep planning below. + + {canRetry && ( + + )} +
+ ); +} + +/** + * Planner-only projection of SessionRecord. + * + * A null prompt is the private assistant-initiated greeting control turn, so + * it renders only the assistant response — never a fabricated "You" row. + * Tool traffic is kept behind one collapsed disclosure so the conversation is + * the primary surface without pretending the calls did not happen. + */ +export function PlanningTranscript({ + record, +}: { + record: SessionRecord; +}): JSX.Element { + return ( +
    + {record.turns.map((turn) => ( + + ))} +
+ ); +} + +function PlanningTurn({ turn }: { turn: SessionRecordTurn }): JSX.Element { + const promptAt = formatClockTime(turn.promptAt); + return ( +
  • + {turn.prompt !== null && ( + <> +
    + You + {promptAt && {promptAt}} +
    +
    + {turn.prompt} +
    + + )} + + {turn.toolCalls.length > 0 && ( +
    + + {turn.toolCalls.length === 1 + ? "1 planning action" + : `${turn.toolCalls.length} planning actions`} + +
      + {turn.toolCalls.map((call, index) => ( +
    • + {toolCallLabel(call.name, call.input)} +
    • + ))} +
    +
    + )} + + {turn.assistantText !== null ? ( + <> +
    + Planner +
    +
    + +
    + + ) : turn.incomplete ? ( +
    +
    + ) : null} +
  • + ); +} + +export function PlanningConversationPane({ + metadata, + transcript, + onRetryTranscript, + onSend, + onRetryGreeting, + disabled = false, +}: PlanningConversationPaneProps): JSX.Element { + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [retryingGreeting, setRetryingGreeting] = useState(false); + const [greetingRetryError, setGreetingRetryError] = useState( + null, + ); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + const turnCount = + transcript.status === "ready" ? transcript.record.turns.length : 0; + useEffect(() => { + const scroller = scrollRef.current; + if (!scroller) return; + const distanceFromBottom = + scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; + if (distanceFromBottom < 160) { + scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); + } + }, [turnCount, metadata.queuedInputIds.length, metadata.greeting.status]); + + useEffect(() => { + if (metadata.greeting.status !== "failed") { + setGreetingRetryError(null); + } + }, [metadata.greeting.status]); + + const submit = async (event?: FormEvent): Promise => { + event?.preventDefault(); + const submittedDraft = draft; + const text = submittedDraft.trim(); + if (!text || sending || disabled) return; + setSending(true); + setSendError(null); + try { + await onSend(text); + // The composer remains editable while the request is in flight. Do not + // erase text the user started typing for their next message. + setDraft((current) => (current === submittedDraft ? "" : current)); + inputRef.current?.focus(); + } catch (error) { + setSendError( + error instanceof Error + ? error.message + : "The message could not be queued.", + ); + } finally { + setSending(false); + } + }; + + const retryGreeting = async (): Promise => { + if (retryingGreeting) return; + setRetryingGreeting(true); + setGreetingRetryError(null); + try { + await onRetryGreeting(); + } catch (error) { + setGreetingRetryError( + error instanceof Error + ? error.message + : "The greeting could not be retried.", + ); + } finally { + setRetryingGreeting(false); + } + }; + + const onComposerKeyDown = ( + event: KeyboardEvent, + ): void => { + if ( + event.key !== "Enter" || + event.shiftKey || + event.nativeEvent.isComposing + ) + return; + event.preventDefault(); + void submit(); + }; + + return ( +
    +
    + {transcript.status === "loading" ? ( +
    +
    + ) : transcript.status === "error" ? ( +
    + Conversation history could not be loaded. + +
    + ) : transcript.status === "ready" ? ( + + ) : null} + + void retryGreeting()} + /> + + {greetingRetryError && ( +
    + + {greetingRetryError} +
    + )} + + {metadata.queuedInputIds.length > 0 && ( +
    +
    + )} +
    + +
    void submit(event)} + > +
    +