feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
@@ -1,16 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { legacyPreferences, removeLegacyPreferences, presentationState } from "./persistence";
|
||||
import { initialWorkspaceState, reconcileWorkspace, workspaceReducer } from "./reducer";
|
||||
import {
|
||||
legacyPreferences,
|
||||
removeLegacyPreferences,
|
||||
presentationState,
|
||||
} from "./persistence";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./reducer";
|
||||
import type { Workspace } from "./types";
|
||||
const workspace: Workspace = { bridgeInstanceId: "e", latestSeq: 7, directories: [{ worktreePath: "/repo", isHome: true, openCount: 1, workingCount: 0, attentionCount: 0, recoveringCount: 0, errorCount: 0, runtimes: [{ runtimeId: "r", worktreePath: "/repo", state: "idle", label: "r", attention: false, queueCount: 0, lastActivity: "now", openedAt: "now", agentId: "ephemeral" }] }] };
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "e",
|
||||
latestSeq: 7,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: true,
|
||||
openCount: 1,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: "r",
|
||||
worktreePath: "/repo",
|
||||
state: "idle",
|
||||
label: "r",
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: "now",
|
||||
openedAt: "now",
|
||||
agentId: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe("presentation persistence", () => {
|
||||
it("persists only allowlisted presentation state, never agent or session payloads", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "r", draft: "safe draft" });
|
||||
const persisted = presentationState(state); const encoded = JSON.stringify(persisted);
|
||||
expect(persisted.drafts.r).toBe("safe draft"); expect(encoded).not.toContain("ephemeral"); expect(encoded).not.toContain("messages"); expect(encoded).not.toContain("extensions");
|
||||
});
|
||||
it("reads and removes legacy worktree/scale only after caller saves", () => {
|
||||
const getItem = vi.fn((key: string) => key.includes("selected") ? "/repo" : "1.2"); expect(legacyPreferences({ getItem })).toEqual({ path: "/repo", scale: 1.2 });
|
||||
const removeItem = vi.fn(); removeLegacyPreferences({ removeItem }); expect(removeItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("persists only allowlisted presentation state, never agent or session payloads", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "r",
|
||||
draft: "safe draft",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "setDefaultNewSessionWorktree",
|
||||
worktreePath: "/repo",
|
||||
});
|
||||
const persisted = presentationState(state);
|
||||
const encoded = JSON.stringify(persisted);
|
||||
expect(persisted.drafts.r).toBe("safe draft");
|
||||
expect(persisted.defaultNewSessionWorktree).toBe("/repo");
|
||||
expect(encoded).not.toContain("ephemeral");
|
||||
expect(encoded).not.toContain("messages");
|
||||
expect(encoded).not.toContain("extensions");
|
||||
});
|
||||
it("reads and removes legacy worktree/scale only after caller saves", () => {
|
||||
const getItem = vi.fn((key: string) =>
|
||||
key.includes("selected") ? "/repo" : "1.2",
|
||||
);
|
||||
expect(legacyPreferences({ getItem })).toEqual({
|
||||
path: "/repo",
|
||||
scale: 1.2,
|
||||
});
|
||||
const removeItem = vi.fn();
|
||||
removeLegacyPreferences({ removeItem });
|
||||
expect(removeItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export function presentationState(state: WorkspaceState): UiState {
|
||||
}
|
||||
: undefined,
|
||||
interfaceScale: state.interfaceScale,
|
||||
defaultNewSessionWorktree: state.defaultNewSessionWorktree,
|
||||
dismissedCollisionWarning: state.dismissedCollisionWarning,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +112,70 @@ describe("workspace reducer", () => {
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||
});
|
||||
it("reconciles duplicate pending submissions one message at a time and honors Pi queue totals", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
for (const id of [1, 2])
|
||||
state = workspaceReducer(state, {
|
||||
type: "submissionAdded",
|
||||
runtimeId: "a",
|
||||
submission: {
|
||||
id,
|
||||
text: "Continue",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
transcript: {
|
||||
data: { messages: [{ role: "user", content: "Continue" }] },
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toHaveLength(1);
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "a",
|
||||
eventType: "queue",
|
||||
eventData: {
|
||||
event: {
|
||||
pendingMessageCount: 3,
|
||||
followUp: ["one"],
|
||||
steering: ["two"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.summary.queueCount).toBe(3);
|
||||
});
|
||||
it("keeps a hotkey-triggered new-session startup visible until completion", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "starting", detail: "Starting a new Pi session…" },
|
||||
});
|
||||
expect(state.newSessionLaunch?.phase).toBe("starting");
|
||||
state = workspaceReducer(state, {
|
||||
type: "newSessionLaunch",
|
||||
launch: undefined,
|
||||
});
|
||||
expect(state.newSessionLaunch).toBeUndefined();
|
||||
});
|
||||
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
|
||||
+48
-10
@@ -24,6 +24,28 @@ function messageText(message: RuntimeView["messages"][number]) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function reconcilePendingSubmissions(
|
||||
pendingSubmissions: RuntimeView["pendingSubmissions"],
|
||||
messages: RuntimeView["messages"],
|
||||
) {
|
||||
const userMessages = messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText);
|
||||
const consumed = new Set<number>();
|
||||
return pendingSubmissions.filter((pending) => {
|
||||
for (
|
||||
let index = pending.userMessageCount;
|
||||
index < userMessages.length;
|
||||
index++
|
||||
) {
|
||||
if (consumed.has(index) || userMessages[index] !== pending.text) continue;
|
||||
consumed.add(index);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous?: RuntimeView,
|
||||
@@ -84,6 +106,7 @@ export const initialWorkspaceState: WorkspaceState = {
|
||||
runtimeOrderByDirectory: {},
|
||||
windowActive: true,
|
||||
resourceWarning: false,
|
||||
defaultNewSessionWorktree: undefined,
|
||||
dismissedCollisionWarning: false,
|
||||
interfaceScale: 1,
|
||||
};
|
||||
@@ -169,6 +192,8 @@ export function reconcileWorkspace(
|
||||
selectedDirectoryPath,
|
||||
dismissedCollisionWarning:
|
||||
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
|
||||
defaultNewSessionWorktree:
|
||||
persisted?.defaultNewSessionWorktree ?? state.defaultNewSessionWorktree,
|
||||
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
|
||||
};
|
||||
}
|
||||
@@ -319,9 +344,11 @@ export function applyWorkspaceEvent(
|
||||
(String(payload.type ?? "").endsWith("_start") ? 1 : 0),
|
||||
};
|
||||
} else if (type === "queue") {
|
||||
const queueCount = Array.isArray(payload.followUp)
|
||||
? payload.followUp.length
|
||||
: view.summary.queueCount;
|
||||
const queueCount =
|
||||
typeof payload.pendingMessageCount === "number"
|
||||
? payload.pendingMessageCount
|
||||
: (Array.isArray(payload.followUp) ? payload.followUp.length : 0) +
|
||||
(Array.isArray(payload.steering) ? payload.steering.length : 0);
|
||||
view.summary = { ...view.summary, queueCount };
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
@@ -372,13 +399,20 @@ export type WorkspaceAction =
|
||||
| { type: "setWindowActive"; active: boolean }
|
||||
| { type: "setScale"; scale: number }
|
||||
| { type: "setResourceWarning"; value: boolean }
|
||||
| { type: "setDefaultNewSessionWorktree"; worktreePath?: string }
|
||||
| { type: "newSessionLaunch"; launch?: WorkspaceState["newSessionLaunch"] }
|
||||
| { type: "dismissCollisionWarning" }
|
||||
| {
|
||||
type: "submissionAdded";
|
||||
runtimeId: string;
|
||||
submission: RuntimeView["pendingSubmissions"][number];
|
||||
}
|
||||
| { type: "submissionSent"; runtimeId: string; id: number }
|
||||
| {
|
||||
type: "submissionSent";
|
||||
runtimeId: string;
|
||||
id: number;
|
||||
delivery: "prompt" | "follow_up";
|
||||
}
|
||||
| { type: "submissionRemoved"; runtimeId: string; id: number };
|
||||
|
||||
export function workspaceReducer(
|
||||
@@ -438,6 +472,10 @@ export function workspaceReducer(
|
||||
return { ...state, interfaceScale: action.scale };
|
||||
if (action.type === "setResourceWarning")
|
||||
return { ...state, resourceWarning: action.value };
|
||||
if (action.type === "setDefaultNewSessionWorktree")
|
||||
return { ...state, defaultNewSessionWorktree: action.worktreePath };
|
||||
if (action.type === "newSessionLaunch")
|
||||
return { ...state, newSessionLaunch: action.launch };
|
||||
if (action.type === "dismissCollisionWarning")
|
||||
return { ...state, dismissedCollisionWarning: true };
|
||||
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
|
||||
@@ -463,11 +501,9 @@ export function workspaceReducer(
|
||||
view.state = action.snapshot.state?.data ?? {};
|
||||
view.stats = action.snapshot.stats?.data ?? {};
|
||||
view.messages = action.snapshot.transcript?.data?.messages ?? [];
|
||||
const receivedUserMessages = view.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText);
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
(pending) => !receivedUserMessages.includes(pending.text),
|
||||
view.pendingSubmissions = reconcilePendingSubmissions(
|
||||
view.pendingSubmissions,
|
||||
view.messages,
|
||||
);
|
||||
view.commands = action.snapshot.commands?.data?.commands ?? [];
|
||||
view.models = action.snapshot.models?.data?.models ?? [];
|
||||
@@ -494,7 +530,9 @@ export function workspaceReducer(
|
||||
view.pendingSubmissions = [...view.pendingSubmissions, action.submission];
|
||||
else if (action.type === "submissionSent")
|
||||
view.pendingSubmissions = view.pendingSubmissions.map((item) =>
|
||||
item.id === action.id ? { ...item, phase: "sent" } : item,
|
||||
item.id === action.id
|
||||
? { ...item, phase: "sent", delivery: action.delivery }
|
||||
: item,
|
||||
);
|
||||
else if (action.type === "submissionRemoved")
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
|
||||
@@ -97,6 +97,7 @@ export type Extension = {
|
||||
message?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
prefill?: string;
|
||||
initialValue?: string;
|
||||
text?: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
@@ -118,6 +119,7 @@ export type PendingSubmission = {
|
||||
text: string;
|
||||
userMessageCount: number;
|
||||
phase: "sending" | "sent";
|
||||
delivery?: "prompt" | "follow_up";
|
||||
};
|
||||
export type RuntimeSnapshot = {
|
||||
bridgeInstanceId: string;
|
||||
@@ -211,6 +213,8 @@ export type WorkspaceState = {
|
||||
selectedRuntimeId?: string;
|
||||
windowActive: boolean;
|
||||
resourceWarning: boolean;
|
||||
newSessionLaunch?: { phase: "starting" | "error"; detail: string };
|
||||
defaultNewSessionWorktree?: string;
|
||||
dismissedCollisionWarning: boolean;
|
||||
interfaceScale: number;
|
||||
};
|
||||
@@ -228,5 +232,6 @@ export type UiState = {
|
||||
>;
|
||||
workspaceCursor?: { bridgeInstanceId: string; seq: number };
|
||||
interfaceScale?: number;
|
||||
defaultNewSessionWorktree?: string;
|
||||
dismissedCollisionWarning?: boolean;
|
||||
};
|
||||
|
||||
+258
-132
@@ -3,145 +3,271 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
||||
import { legacyPreferences, loadPresentation, presentationState, removeLegacyPreferences, savePresentation } from "./persistence";
|
||||
import type { HostEvent, RuntimeSnapshot, UiState, Workspace, WorkspaceSummary } from "./types";
|
||||
import {
|
||||
legacyPreferences,
|
||||
loadPresentation,
|
||||
presentationState,
|
||||
removeLegacyPreferences,
|
||||
savePresentation,
|
||||
} from "./persistence";
|
||||
import type {
|
||||
HostEvent,
|
||||
RuntimeSnapshot,
|
||||
UiState,
|
||||
Workspace,
|
||||
WorkspaceSummary,
|
||||
} from "./types";
|
||||
|
||||
export function useWorkspace() {
|
||||
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
|
||||
const stateRef = useRef(state); stateRef.current = state;
|
||||
const loadGeneration = useRef<Record<string, number>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const initialized = useRef(false);
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const loadGeneration = useRef<Record<string, number>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const initialized = useRef(false);
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
|
||||
const loadSnapshot = useCallback(async (runtimeId: string) => {
|
||||
if (disposed.current) return;
|
||||
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
||||
loadGeneration.current[runtimeId] = generation;
|
||||
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
||||
try {
|
||||
const snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", { runtimeId });
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotFailed", runtimeId, generation, error: String(error) });
|
||||
}
|
||||
}, []);
|
||||
const loadSnapshot = useCallback(async (runtimeId: string) => {
|
||||
if (disposed.current) return;
|
||||
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
||||
loadGeneration.current[runtimeId] = generation;
|
||||
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
||||
try {
|
||||
const snapshot = await invoke<RuntimeSnapshot>(
|
||||
"get_session_runtime_snapshot",
|
||||
{ runtimeId },
|
||||
);
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({
|
||||
type: "snapshotFailed",
|
||||
runtimeId,
|
||||
generation,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleSnapshot = useCallback((runtimeId: string) => {
|
||||
if (timers.current[runtimeId]) return;
|
||||
timers.current[runtimeId] = window.setTimeout(() => {
|
||||
delete timers.current[runtimeId]; void loadSnapshot(runtimeId);
|
||||
}, 120);
|
||||
}, [loadSnapshot]);
|
||||
const scheduleSnapshot = useCallback(
|
||||
(runtimeId: string) => {
|
||||
if (timers.current[runtimeId]) return;
|
||||
timers.current[runtimeId] = window.setTimeout(() => {
|
||||
delete timers.current[runtimeId];
|
||||
void loadSnapshot(runtimeId);
|
||||
}, 120);
|
||||
},
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (initialUiState?: UiState, reloadAllSnapshots = false) => {
|
||||
const generation = ++refreshGeneration.current;
|
||||
const cancelled = () => disposed.current || generation !== refreshGeneration.current;
|
||||
const workspace = await invoke<Workspace>("get_workspace");
|
||||
if (cancelled()) return;
|
||||
let nextPersisted = initialUiState;
|
||||
if (nextPersisted) {
|
||||
const legacy = legacyPreferences(localStorage);
|
||||
const candidate = nextPersisted.selectedRuntimeId
|
||||
? undefined
|
||||
: workspace.directories.find((directory) => directory.worktreePath === legacy.path)?.runtimes[0];
|
||||
nextPersisted = {
|
||||
...nextPersisted,
|
||||
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
|
||||
...(nextPersisted.interfaceScale === undefined && legacy.scale !== undefined
|
||||
? { interfaceScale: legacy.scale }
|
||||
: {}),
|
||||
};
|
||||
legacyMigrationPending.current = Boolean(
|
||||
(!initialUiState?.selectedRuntimeId && candidate) ||
|
||||
(initialUiState?.interfaceScale === undefined && legacy.scale !== undefined),
|
||||
);
|
||||
}
|
||||
dispatch({ type: "workspaceLoaded", workspace, persisted: nextPersisted });
|
||||
const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
|
||||
if (cancelled()) return;
|
||||
dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
|
||||
const selected = nextPersisted?.selectedRuntimeId && workspace.directories.some((directory) => directory.runtimes.some((runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId))
|
||||
? nextPersisted.selectedRuntimeId : workspace.directories[0]?.runtimes[0]?.runtimeId;
|
||||
const runtimeIds = workspace.directories.flatMap((directory) => directory.runtimes.map((runtime) => runtime.runtimeId));
|
||||
for (const runtimeId of reloadAllSnapshots ? runtimeIds : selected ? [selected] : []) {
|
||||
if (cancelled()) return;
|
||||
void loadSnapshot(runtimeId);
|
||||
}
|
||||
if (cancelled()) return;
|
||||
await invoke("subscribe_workspace", { cursor: { bridgeInstanceId: workspace.bridgeInstanceId, seq: workspace.latestSeq } });
|
||||
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
|
||||
}, [loadSnapshot]);
|
||||
const refresh = useCallback(
|
||||
async (initialUiState?: UiState, reloadAllSnapshots = false) => {
|
||||
const generation = ++refreshGeneration.current;
|
||||
const cancelled = () =>
|
||||
disposed.current || generation !== refreshGeneration.current;
|
||||
const workspace = await invoke<Workspace>("get_workspace");
|
||||
if (cancelled()) return;
|
||||
let nextPersisted = initialUiState;
|
||||
if (nextPersisted) {
|
||||
const legacy = legacyPreferences(localStorage);
|
||||
const candidate = nextPersisted.selectedRuntimeId
|
||||
? undefined
|
||||
: workspace.directories.find(
|
||||
(directory) => directory.worktreePath === legacy.path,
|
||||
)?.runtimes[0];
|
||||
nextPersisted = {
|
||||
...nextPersisted,
|
||||
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
|
||||
...(nextPersisted.interfaceScale === undefined &&
|
||||
legacy.scale !== undefined
|
||||
? { interfaceScale: legacy.scale }
|
||||
: {}),
|
||||
};
|
||||
legacyMigrationPending.current = Boolean(
|
||||
(!initialUiState?.selectedRuntimeId && candidate) ||
|
||||
(initialUiState?.interfaceScale === undefined &&
|
||||
legacy.scale !== undefined),
|
||||
);
|
||||
}
|
||||
dispatch({
|
||||
type: "workspaceLoaded",
|
||||
workspace,
|
||||
persisted: nextPersisted,
|
||||
});
|
||||
const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
|
||||
if (cancelled()) return;
|
||||
dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
|
||||
const selected =
|
||||
nextPersisted?.selectedRuntimeId &&
|
||||
workspace.directories.some((directory) =>
|
||||
directory.runtimes.some(
|
||||
(runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId,
|
||||
),
|
||||
)
|
||||
? nextPersisted.selectedRuntimeId
|
||||
: workspace.directories[0]?.runtimes[0]?.runtimeId;
|
||||
const runtimeIds = workspace.directories.flatMap((directory) =>
|
||||
directory.runtimes.map((runtime) => runtime.runtimeId),
|
||||
);
|
||||
for (const runtimeId of reloadAllSnapshots
|
||||
? runtimeIds
|
||||
: selected
|
||||
? [selected]
|
||||
: []) {
|
||||
if (cancelled()) return;
|
||||
void loadSnapshot(runtimeId);
|
||||
}
|
||||
if (cancelled()) return;
|
||||
await invoke("subscribe_workspace", {
|
||||
cursor: {
|
||||
bridgeInstanceId: workspace.bridgeInstanceId,
|
||||
seq: workspace.latestSeq,
|
||||
},
|
||||
});
|
||||
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
|
||||
},
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||
if (disposed.current) return;
|
||||
const host = message.payload;
|
||||
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 (["runtime_opened", "runtime_closed", "runtime_recovered", "runtime_restore_failed"].includes(eventType ?? "")) {
|
||||
void refresh();
|
||||
} else if (runtimeId && (host.event.data?.eventType === "transcript" || piType === "agent_settled" || piType === "compaction_end")) {
|
||||
scheduleSnapshot(runtimeId);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => { if (disposed.current) unlisten(); else stop = unlisten; });
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "hostEvent", host: { kind: "disconnected", message: String(error), retryInMs: 0 } });
|
||||
}
|
||||
})();
|
||||
const appWindow = getCurrentWindow();
|
||||
const visibility = () => dispatch({ type: "setWindowActive", active: document.visibilityState === "visible" && document.hasFocus() });
|
||||
window.addEventListener("focus", visibility); window.addEventListener("blur", visibility); document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
let stopNewRuntime: (() => void) | undefined;
|
||||
void listen<{
|
||||
phase: "starting" | "ready" | "error";
|
||||
runtimeId?: string;
|
||||
detail: string;
|
||||
}>("workspace-new-session", (message) => {
|
||||
const launch = message.payload;
|
||||
if (launch.phase === "starting") {
|
||||
dispatch({
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "starting", detail: launch.detail },
|
||||
});
|
||||
} else if (launch.phase === "ready" && launch.runtimeId) {
|
||||
dispatch({ type: "newSessionLaunch", launch: undefined });
|
||||
void refresh().then(() => {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "selectRuntime", runtimeId: launch.runtimeId! });
|
||||
});
|
||||
} else if (launch.phase === "error") {
|
||||
dispatch({
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "error", detail: launch.detail },
|
||||
});
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stopNewRuntime = unlisten;
|
||||
});
|
||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||
if (disposed.current) return;
|
||||
const host = message.payload;
|
||||
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 (
|
||||
[
|
||||
"runtime_opened",
|
||||
"runtime_closed",
|
||||
"runtime_recovered",
|
||||
"runtime_restore_failed",
|
||||
].includes(eventType ?? "")
|
||||
) {
|
||||
void refresh();
|
||||
} else if (
|
||||
runtimeId &&
|
||||
(host.event.data?.eventType === "transcript" ||
|
||||
piType === "agent_settled" ||
|
||||
piType === "compaction_end")
|
||||
) {
|
||||
scheduleSnapshot(runtimeId);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stop = unlisten;
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "disconnected",
|
||||
message: String(error),
|
||||
retryInMs: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
const appWindow = getCurrentWindow();
|
||||
const visibility = () =>
|
||||
dispatch({
|
||||
type: "setWindowActive",
|
||||
active: document.visibilityState === "visible" && document.hasFocus(),
|
||||
});
|
||||
window.addEventListener("focus", visibility);
|
||||
window.addEventListener("blur", visibility);
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
stopNewRuntime?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void savePresentation(presentationState(state)).then(() => {
|
||||
if (legacyMigrationPending.current) {
|
||||
removeLegacyPreferences(localStorage);
|
||||
legacyMigrationPending.current = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.directoryOrder, state.runtimeOrderByDirectory, state.selectedRuntimeId, state.interfaceScale, state.bridge.latestSeq, state.runtimesById]);
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void savePresentation(presentationState(state))
|
||||
.then(() => {
|
||||
if (legacyMigrationPending.current) {
|
||||
removeLegacyPreferences(localStorage);
|
||||
legacyMigrationPending.current = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
state.directoryOrder,
|
||||
state.runtimeOrderByDirectory,
|
||||
state.selectedRuntimeId,
|
||||
state.defaultNewSessionWorktree,
|
||||
state.interfaceScale,
|
||||
state.bridge.latestSeq,
|
||||
state.runtimesById,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = state.selectedRuntimeId; if (!id) return;
|
||||
if (!state.runtimesById[id]?.loading && !state.runtimesById[id]?.messages.length) void loadSnapshot(id);
|
||||
}, [state.selectedRuntimeId, loadSnapshot]);
|
||||
useEffect(() => {
|
||||
const id = state.selectedRuntimeId;
|
||||
if (!id) return;
|
||||
if (
|
||||
!state.runtimesById[id]?.loading &&
|
||||
!state.runtimesById[id]?.messages.length
|
||||
)
|
||||
void loadSnapshot(id);
|
||||
}, [state.selectedRuntimeId, loadSnapshot]);
|
||||
|
||||
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
|
||||
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user