feat: add multi-session desktop workspace
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyWorkspaceEvent,
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./reducer";
|
||||
import type { RuntimeSnapshot, Workspace } from "./types";
|
||||
const runtime = (id: string, path = "/repo") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: path,
|
||||
state: "idle",
|
||||
label: id,
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [runtime("a"), runtime("b")],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("workspace reducer", () => {
|
||||
it("isolates drafts, snapshots and background unread by runtime", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "draft a",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("b"),
|
||||
transcript: { data: { messages: [{ role: "user", content: "b" }] } },
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "b",
|
||||
eventType: "transcript",
|
||||
eventData: { event: { type: "message_end" } },
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("draft a");
|
||||
expect(state.runtimesById.a.messages).toEqual([]);
|
||||
expect(state.runtimesById.b.messages[0].content).toBe("b");
|
||||
expect(state.runtimesById.b.unread).toBe(true);
|
||||
});
|
||||
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "submissionAdded",
|
||||
runtimeId: "a",
|
||||
submission: {
|
||||
id: 1,
|
||||
text: "Hello Pi",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
transcript: {
|
||||
data: {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello Pi" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||
});
|
||||
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
});
|
||||
const snap: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
expect(
|
||||
workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: snap,
|
||||
}).runtimesById.a.loading,
|
||||
).toBe(true);
|
||||
const first = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a", runtime: runtime("a") },
|
||||
});
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a" },
|
||||
}),
|
||||
).toBe(first);
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 3,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "new",
|
||||
seq: 2,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
});
|
||||
it("selects an adjacent tab on close, preserves drafts, and keeps failed dormant runtimes", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace, {
|
||||
version: 1,
|
||||
directoryOrder: ["/repo"],
|
||||
runtimeOrderByDirectory: { "/repo": ["b", "a"] },
|
||||
selectedRuntimeId: "b",
|
||||
drafts: { a: "saved" },
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("saved");
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_closed",
|
||||
data: { runtimeId: "b" },
|
||||
});
|
||||
expect(state.selectedRuntimeId).toBe("a");
|
||||
const failed: Workspace = {
|
||||
...workspace,
|
||||
directories: [
|
||||
{
|
||||
...workspace.directories[0],
|
||||
runtimes: [
|
||||
{
|
||||
...runtime("failed"),
|
||||
state: "failed",
|
||||
attention: true,
|
||||
error: { message: "missing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
|
||||
.summary.agentId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "keep me",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
scrollTop: 42,
|
||||
});
|
||||
state.runtimesById.a.loadedThroughSeq = 100;
|
||||
state = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(state, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
});
|
||||
expect(restored.runtimesById.a.loadedThroughSeq).toBe(0);
|
||||
expect(restored.runtimesById.a.draft).toBe("keep me");
|
||||
expect(restored.runtimesById.a.scroll.scrollTop).toBe(42);
|
||||
const requested = workspaceReducer(restored, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
const accepted = workspaceReducer(requested, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(accepted.runtimesById.a.loadedThroughSeq).toBe(2);
|
||||
expect(accepted.runtimesById.a.extensions).toEqual([]);
|
||||
expect(accepted.runtimesById.a.messages).toEqual([]);
|
||||
});
|
||||
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
|
||||
state.runtimesById.b.extensions = [
|
||||
{ id: "dead-request", method: "confirm" },
|
||||
];
|
||||
state.runtimesById.b.unread = true;
|
||||
const reset = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(reset, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
});
|
||||
expect(restored.runtimesById.b.messages).toEqual([]);
|
||||
expect(restored.runtimesById.b.extensions).toEqual([]);
|
||||
expect(restored.runtimesById.b.unread).toBe(false);
|
||||
});
|
||||
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const next = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_opened",
|
||||
data: { runtimeId: "external", runtime: runtime("external") },
|
||||
});
|
||||
expect(next.bridge.status).toBe("resyncing");
|
||||
});
|
||||
it("clears unread only when selected, active, and at latest", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.a.unread = true;
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: false });
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: true });
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: true,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user