fix(workspace): hide ephemeral quick runtimes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user