fix(workspace): hide ephemeral quick runtimes

This commit is contained in:
2026-08-31 14:39:17 +02:00
parent 087c177e8e
commit a4ee3eb7a8
4 changed files with 256 additions and 2 deletions
+5 -1
View File
@@ -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,
+77
View File
@@ -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");
+161
View File
@@ -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<string, (message: { payload: any }) => 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);
});
});
+13 -1
View File
@@ -221,10 +221,22 @@ export function useWorkspace() {
void listen<HostEvent>("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 (