From a4ee3eb7a8c4863ab87d9f6ee78fa3b687017f6d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 31 Aug 2026 14:39:17 +0200 Subject: [PATCH] fix(workspace): hide ephemeral quick runtimes --- src/bridge/agent-registry.js | 6 +- test/quick-runtime-promotion.test.js | 77 ++++++++++++ ui/src/workspace/useWorkspace.test.tsx | 161 +++++++++++++++++++++++++ ui/src/workspace/useWorkspace.ts | 14 ++- 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 ui/src/workspace/useWorkspace.test.tsx diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index e802aaa..b167f24 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -347,6 +347,7 @@ export function createAgentRegistry({ let stopping = false; function publishWorkspace(type, runtime, data = {}) { + if (runtime?.ephemeral) return undefined; const event = { version: "v1", bridgeInstanceId, @@ -1322,6 +1323,7 @@ export function createAgentRegistry({ [...managedWorktrees].map((worktreePath) => [worktreePath, []]), ); for (const runtime of runtimesByRuntimeId.values()) { + if (runtime.ephemeral) continue; let group = grouped.get(runtime.worktreePath); if (!group) { group = []; @@ -1562,7 +1564,9 @@ export function createAgentRegistry({ }, getWorkspace: workspace, getWorkspaceSummary() { - const runtimes = [...runtimesByRuntimeId.values()]; + const runtimes = [...runtimesByRuntimeId.values()].filter( + (runtime) => !runtime.ephemeral, + ); return { bridgeInstanceId, latestSeq: workspaceSequence, diff --git a/test/quick-runtime-promotion.test.js b/test/quick-runtime-promotion.test.js index 74869c2..8a5c1ad 100644 --- a/test/quick-runtime-promotion.test.js +++ b/test/quick-runtime-promotion.test.js @@ -47,6 +47,83 @@ function adapters() { }; } +test("keeps quick runtimes out of workspace state until promotion", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-isolation-")); + const home = join(root, "home"); + const feature = join(root, "feature"); + await Promise.all([mkdir(home), mkdir(feature)]); + const fixture = adapters(); + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot: join(root, "sessions"), + startAdapter: fixture.startAdapter, + }); + await registry.start(); + assert.equal( + registry.workspaceEventsAfter(0).events.at(-1).type, + "runtime_opened", + ); + const cursor = registry.getWorkspace().latestSeq; + const received = []; + const subscription = registry.subscribeWorkspace(cursor, (event) => + received.push(event), + ); + const quick = await Promise.all( + Array.from({ length: 5 }, () => registry.createQuickRuntime(feature)), + ); + fixture.calls.at(-1).options.onEvent({ + type: "transcript", + data: { event: { type: "message_end" } }, + }); + + assert.deepEqual( + registry + .getWorkspace() + .directories.map((directory) => directory.worktreePath), + [home], + ); + assert.equal(registry.getWorkspace().directories[0].openCount, 1); + assert.deepEqual(registry.getWorkspaceSummary(), { + bridgeInstanceId: registry.bridgeInstanceId, + latestSeq: cursor, + openCount: 1, + workingCount: 0, + attentionCount: 0, + recoveringCount: 0, + errorCount: 0, + directoryCount: 1, + resourceWarning: false, + }); + const quickSnapshot = await registry.getSessionRuntimeSnapshot( + quick[0].runtimeId, + ); + assert.equal(quickSnapshot.runtime.runtimeId, quick[0].runtimeId); + await registry.closeQuickRuntime(quick.at(-1).runtimeId); + assert.deepEqual(received, []); + assert.deepEqual(registry.workspaceEventsAfter(cursor).events, []); + + const promoted = await registry.promoteQuickRuntime(quick[0].runtimeId); + const featureDirectory = registry + .getWorkspace() + .directories.find((directory) => directory.worktreePath === feature); + assert.deepEqual( + featureDirectory?.runtimes.map((runtime) => runtime.runtimeId), + [quick[0].runtimeId], + ); + assert.equal(promoted.runtimeId, quick[0].runtimeId); + assert.equal(registry.getWorkspaceSummary().openCount, 2); + assert.deepEqual( + received.map((event) => event.type), + ["runtime_promoted"], + ); + assert.deepEqual( + registry.workspaceEventsAfter(cursor).events.map((event) => event.type), + ["runtime_promoted"], + ); + subscription.unsubscribe(); + await registry.stop(); +}); + test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => { const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-")); const home = join(root, "home"); diff --git a/ui/src/workspace/useWorkspace.test.tsx b/ui/src/workspace/useWorkspace.test.tsx new file mode 100644 index 0000000..9c68429 --- /dev/null +++ b/ui/src/workspace/useWorkspace.test.tsx @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeSnapshot, Workspace, WorkspaceSummary } from "./types"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + listeners: {} as Record void>, + listen: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({}), +})); +vi.mock("./persistence", () => ({ + legacyPreferences: () => ({}), + loadPresentation: async () => ({ + version: 1, + directoryOrder: [], + runtimeOrderByDirectory: {}, + drafts: {}, + scroll: {}, + lastSeen: {}, + }), + presentationState: vi.fn(), + removeLegacyPreferences: vi.fn(), + savePresentation: vi.fn().mockResolvedValue(undefined), +})); + +import { useWorkspace } from "./useWorkspace"; + +const normalRuntime = { + runtimeId: "normal", + worktreePath: "/repo", + state: "idle", + label: "Normal", + attention: false, + queueCount: 0, + lastActivity: "now", + openedAt: "now", +}; +const quickRuntime = { ...normalRuntime, runtimeId: "quick", label: "Quick" }; + +function workspace(runtimes: (typeof normalRuntime)[]): Workspace { + return { + bridgeInstanceId: "bridge", + latestSeq: 1, + directories: [ + { + worktreePath: "/repo", + isHome: true, + openCount: runtimes.length, + workingCount: 0, + attentionCount: 0, + recoveringCount: 0, + errorCount: 0, + runtimes, + }, + ], + }; +} + +const summary: WorkspaceSummary = { + bridgeInstanceId: "bridge", + latestSeq: 1, + openCount: 1, + workingCount: 0, + attentionCount: 0, + recoveringCount: 0, + errorCount: 0, + directoryCount: 1, + resourceWarning: false, +}; + +function snapshot(runtime: typeof normalRuntime): RuntimeSnapshot { + return { + bridgeInstanceId: "bridge", + latestSeq: 1, + runtime, + extensions: [], + }; +} + +describe("useWorkspace Quick runtime isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(mocks.listeners)) delete mocks.listeners[key]; + mocks.listen.mockImplementation((event, listener) => { + mocks.listeners[event] = listener; + return Promise.resolve(() => delete mocks.listeners[event]); + }); + }); + + it("ignores unknown Quick events and exposes promoted runtime once", async () => { + let promoted = false; + mocks.invoke.mockImplementation( + (command: string, payload?: { runtimeId?: string }) => { + if (command === "get_workspace") + return Promise.resolve( + workspace(promoted ? [normalRuntime, quickRuntime] : [normalRuntime]), + ); + if (command === "get_workspace_summary") return Promise.resolve(summary); + if (command === "get_session_runtime_snapshot") + return Promise.resolve( + snapshot(payload?.runtimeId === "quick" ? quickRuntime : normalRuntime), + ); + if (command === "get_pending_promoted_runtime") + return Promise.resolve(null); + return Promise.resolve(undefined); + }, + ); + const { result } = renderHook(() => useWorkspace()); + + await waitFor(() => + expect(result.current.state.runtimesById.normal).toBeDefined(), + ); + await waitFor(() => + expect(mocks.listeners["workspace-bridge"]).toBeDefined(), + ); + const workspaceCalls = () => + mocks.invoke.mock.calls.filter(([command]) => command === "get_workspace"); + + await act(async () => { + mocks.listeners["workspace-bridge"]({ + payload: { + kind: "event", + event: { + bridgeInstanceId: "bridge", + seq: 2, + type: "runtime_event", + data: { + runtimeId: "quick", + runtime: quickRuntime, + eventType: "transcript", + eventData: { event: { type: "message_end" } }, + }, + }, + }, + }); + }); + expect(result.current.state.runtimesById.quick).toBeUndefined(); + expect(workspaceCalls()).toHaveLength(1); + + promoted = true; + await act(async () => { + mocks.listeners["workspace-promoted-runtime"]({ + payload: { runtimeId: "quick" }, + }); + }); + await waitFor(() => + expect(result.current.state.runtimesById.quick).toBeDefined(), + ); + expect(result.current.state.runtimeOrderByDirectory["/repo"]).toEqual([ + "normal", + "quick", + ]); + expect(result.current.state.selectedRuntimeId).toBe("quick"); + expect(workspaceCalls()).toHaveLength(2); + }); +}); diff --git a/ui/src/workspace/useWorkspace.ts b/ui/src/workspace/useWorkspace.ts index 05784ea..612380d 100644 --- a/ui/src/workspace/useWorkspace.ts +++ b/ui/src/workspace/useWorkspace.ts @@ -221,10 +221,22 @@ export function useWorkspace() { void listen("workspace-bridge", (message) => { if (disposed.current) return; const host = message.payload; + const runtimeId = + host.kind === "event" ? host.event.data?.runtimeId : undefined; + if ( + runtimeId && + !stateRef.current.runtimesById[runtimeId] && + [ + "runtime_event", + "runtime_diagnostic", + "runtime_recovery", + "runtime_closing", + ].includes(host.kind === "event" ? (host.event.type ?? "") : "") + ) + return; dispatch({ type: "hostEvent", host }); if (host.kind === "resetRequired") void refresh(undefined, true); else if (host.kind === "event") { - const runtimeId = host.event.data?.runtimeId; const eventType = host.event.type; const piType = String(host.event.data?.eventData?.event?.type ?? ""); if (