feat: add multi-session desktop workspace
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
Extension,
|
||||
HostEvent,
|
||||
RuntimeSnapshot,
|
||||
RuntimeSummary,
|
||||
RuntimeView,
|
||||
UiState,
|
||||
Workspace,
|
||||
WorkspaceEvent,
|
||||
WorkspaceState,
|
||||
} from "./types";
|
||||
|
||||
const idleProgress = {
|
||||
phase: "idle" as const,
|
||||
detail: "Ready for your next prompt",
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function runtimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous?: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
summary,
|
||||
state: previous?.state ?? {},
|
||||
stats: previous?.stats ?? {},
|
||||
messages: previous?.messages ?? [],
|
||||
commands: previous?.commands ?? [],
|
||||
models: previous?.models ?? [],
|
||||
extensions: previous?.extensions ?? [],
|
||||
extensionStatuses: previous?.extensionStatuses ?? {},
|
||||
extensionWidgets: previous?.extensionWidgets ?? {},
|
||||
notifications: previous?.notifications ?? [],
|
||||
progress: previous?.progress ?? idleProgress,
|
||||
pendingSubmissions: previous?.pendingSubmissions ?? [],
|
||||
draft: previous?.draft ?? "",
|
||||
scroll: previous?.scroll ?? { atBottom: true },
|
||||
unread: previous?.unread ?? false,
|
||||
loading: previous?.loading ?? false,
|
||||
error: previous?.error,
|
||||
requestGeneration: previous?.requestGeneration ?? 0,
|
||||
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resetRuntimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
...runtimeView(summary, previous),
|
||||
state: {},
|
||||
stats: {},
|
||||
messages: [],
|
||||
commands: [],
|
||||
models: [],
|
||||
extensions: [],
|
||||
extensionStatuses: {},
|
||||
extensionWidgets: {},
|
||||
notifications: [],
|
||||
pendingSubmissions: [],
|
||||
unread: false,
|
||||
progress: idleProgress,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
requestGeneration: 0,
|
||||
loadedThroughSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialWorkspaceState: WorkspaceState = {
|
||||
bridge: { latestSeq: 0, status: "connecting" },
|
||||
directoriesByPath: {},
|
||||
directoryOrder: [],
|
||||
runtimesById: {},
|
||||
runtimeOrderByDirectory: {},
|
||||
windowActive: true,
|
||||
resourceWarning: false,
|
||||
dismissedCollisionWarning: false,
|
||||
interfaceScale: 1,
|
||||
};
|
||||
|
||||
function ordered(current: string[] | undefined, authoritative: string[]) {
|
||||
const valid = new Set(authoritative);
|
||||
return [
|
||||
...(current ?? []).filter((id) => valid.has(id)),
|
||||
...authoritative.filter((id) => !(current ?? []).includes(id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconcileWorkspace(
|
||||
state: WorkspaceState,
|
||||
workspace: Workspace,
|
||||
persisted?: UiState,
|
||||
): WorkspaceState {
|
||||
const epochChanged = Boolean(
|
||||
state.bridge.instanceId &&
|
||||
state.bridge.instanceId !== workspace.bridgeInstanceId,
|
||||
);
|
||||
const directoriesByPath: WorkspaceState["directoriesByPath"] = {};
|
||||
const runtimesById: WorkspaceState["runtimesById"] = {};
|
||||
const runtimeOrderByDirectory: WorkspaceState["runtimeOrderByDirectory"] = {};
|
||||
for (const directory of workspace.directories) {
|
||||
directoriesByPath[directory.worktreePath] = directory;
|
||||
for (const runtime of directory.runtimes) {
|
||||
const previous = state.runtimesById[runtime.runtimeId];
|
||||
runtimesById[runtime.runtimeId] =
|
||||
epochChanged && previous
|
||||
? resetRuntimeView(runtime, previous)
|
||||
: runtimeView(runtime, previous);
|
||||
}
|
||||
const ids = directory.runtimes.map((runtime) => runtime.runtimeId);
|
||||
runtimeOrderByDirectory[directory.worktreePath] = ordered(
|
||||
persisted?.runtimeOrderByDirectory[directory.worktreePath] ??
|
||||
state.runtimeOrderByDirectory[directory.worktreePath],
|
||||
ids,
|
||||
);
|
||||
}
|
||||
const paths = workspace.directories.map(
|
||||
(directory) => directory.worktreePath,
|
||||
);
|
||||
const directoryOrder = ordered(
|
||||
persisted?.directoryOrder ?? state.directoryOrder,
|
||||
paths,
|
||||
);
|
||||
const preferred = persisted?.selectedRuntimeId ?? state.selectedRuntimeId;
|
||||
const selectedRuntimeId =
|
||||
preferred && runtimesById[preferred]
|
||||
? preferred
|
||||
: directoryOrder.flatMap(
|
||||
(path) => runtimeOrderByDirectory[path] ?? [],
|
||||
)[0];
|
||||
const selectedDirectoryPath = selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: directoryOrder[0];
|
||||
if (persisted) {
|
||||
for (const [id, view] of Object.entries(runtimesById)) {
|
||||
view.draft = persisted.drafts[id] ?? view.draft;
|
||||
view.scroll = persisted.scroll[id] ?? view.scroll;
|
||||
const seen = persisted.lastSeen[id];
|
||||
if (
|
||||
seen?.bridgeInstanceId === workspace.bridgeInstanceId &&
|
||||
seen.seq < workspace.latestSeq &&
|
||||
seen.lastActivity !== view.summary.lastActivity
|
||||
)
|
||||
view.unread = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: workspace.bridgeInstanceId,
|
||||
latestSeq: workspace.latestSeq,
|
||||
status: "ready",
|
||||
},
|
||||
directoriesByPath,
|
||||
directoryOrder,
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath,
|
||||
dismissedCollisionWarning:
|
||||
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
|
||||
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
|
||||
};
|
||||
}
|
||||
|
||||
function meaningful(eventType?: string, event?: Record<string, unknown>) {
|
||||
if (["extension_ui_request", "agent_state"].includes(eventType ?? ""))
|
||||
return true;
|
||||
if (eventType === "transcript")
|
||||
return ["message_end", "turn_end", "agent_end"].includes(
|
||||
String(event?.type ?? ""),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function extensionPresentation(view: RuntimeView, extension: Extension) {
|
||||
if (["select", "confirm", "input", "editor"].includes(extension.method ?? ""))
|
||||
view.extensions = [
|
||||
...view.extensions.filter((entry) => entry.id !== extension.id),
|
||||
extension,
|
||||
];
|
||||
else if (extension.method === "notify")
|
||||
view.notifications = [...view.notifications, extension].slice(-4);
|
||||
else if (extension.method === "setStatus" && extension.statusKey) {
|
||||
const next = { ...view.extensionStatuses };
|
||||
if (extension.statusText) next[extension.statusKey] = extension.statusText;
|
||||
else delete next[extension.statusKey];
|
||||
view.extensionStatuses = next;
|
||||
} else if (extension.method === "setWidget" && extension.widgetKey) {
|
||||
const next = { ...view.extensionWidgets };
|
||||
if (extension.widgetLines)
|
||||
next[extension.widgetKey] = {
|
||||
lines: extension.widgetLines,
|
||||
placement: extension.widgetPlacement ?? "aboveEditor",
|
||||
};
|
||||
else delete next[extension.widgetKey];
|
||||
view.extensionWidgets = next;
|
||||
} else if (extension.method === "set_editor_text")
|
||||
view.draft = extension.text ?? "";
|
||||
}
|
||||
|
||||
export function applyWorkspaceEvent(
|
||||
state: WorkspaceState,
|
||||
event: WorkspaceEvent,
|
||||
): WorkspaceState {
|
||||
if (
|
||||
state.bridge.instanceId &&
|
||||
event.bridgeInstanceId !== state.bridge.instanceId
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Bridge restarted",
|
||||
},
|
||||
};
|
||||
if (event.seq <= state.bridge.latestSeq) return state;
|
||||
if (event.seq !== state.bridge.latestSeq + 1)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Workspace event gap",
|
||||
},
|
||||
};
|
||||
const id = event.data?.runtimeId;
|
||||
if (
|
||||
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
|
||||
event.type ?? "",
|
||||
)
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
|
||||
};
|
||||
if (!id || (!state.runtimesById[id] && !event.data?.runtime))
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: event.seq,
|
||||
status: "resyncing",
|
||||
error: "Unknown runtime",
|
||||
},
|
||||
};
|
||||
const runtimesById = { ...state.runtimesById };
|
||||
let view = runtimeView(
|
||||
event.data?.runtime ?? runtimesById[id].summary,
|
||||
runtimesById[id],
|
||||
);
|
||||
view = { ...view, summary: event.data?.runtime ?? view.summary };
|
||||
if (event.type === "runtime_closed") {
|
||||
delete runtimesById[id];
|
||||
const path = view.summary.worktreePath;
|
||||
const order = (state.runtimeOrderByDirectory[path] ?? []).filter(
|
||||
(runtimeId) => runtimeId !== id,
|
||||
);
|
||||
const runtimeOrderByDirectory = {
|
||||
...state.runtimeOrderByDirectory,
|
||||
[path]: order,
|
||||
};
|
||||
const selectedRuntimeId =
|
||||
state.selectedRuntimeId === id
|
||||
? (order[0] ?? Object.keys(runtimesById)[0])
|
||||
: state.selectedRuntimeId;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq },
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath: selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: path,
|
||||
};
|
||||
}
|
||||
const type = event.data?.eventType;
|
||||
const data = event.data?.eventData;
|
||||
const payload = data?.event ?? {};
|
||||
if (type === "agent_state" && data?.state) {
|
||||
view.summary = { ...view.summary, state: data.state };
|
||||
view.progress =
|
||||
data.state === "streaming"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
}
|
||||
: data.state === "recovering"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "recovering",
|
||||
detail: "Recovering the Pi session",
|
||||
}
|
||||
: data.state === "error"
|
||||
? { ...view.progress, phase: "error", detail: "Pi needs attention" }
|
||||
: idleProgress;
|
||||
} else if (type === "tool") {
|
||||
const ending = String(payload.type ?? "").endsWith("_end");
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: ending ? "Continuing after tool result" : "Running a tool",
|
||||
tool: ending ? undefined : String(payload.toolName ?? "tool"),
|
||||
toolCount:
|
||||
view.progress.toolCount +
|
||||
(String(payload.type ?? "").endsWith("_start") ? 1 : 0),
|
||||
};
|
||||
} else if (type === "queue") {
|
||||
const queueCount = Array.isArray(payload.followUp)
|
||||
? payload.followUp.length
|
||||
: view.summary.queueCount;
|
||||
view.summary = { ...view.summary, queueCount };
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
queueCount,
|
||||
detail: queueCount
|
||||
? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued`
|
||||
: view.progress.detail,
|
||||
};
|
||||
} else if (type === "extension_ui_request")
|
||||
extensionPresentation(view, payload as Extension);
|
||||
if (id !== state.selectedRuntimeId && meaningful(type, payload))
|
||||
view.unread = true;
|
||||
view.loadedThroughSeq = Math.max(view.loadedThroughSeq, event.seq);
|
||||
runtimesById[id] = view;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "ready" },
|
||||
runtimesById,
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceAction =
|
||||
| { type: "workspaceLoaded"; workspace: Workspace; persisted?: UiState }
|
||||
| { type: "hostEvent"; host: HostEvent }
|
||||
| { type: "snapshotRequested"; runtimeId: string; generation: number }
|
||||
| {
|
||||
type: "snapshotLoaded";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
snapshot: RuntimeSnapshot;
|
||||
}
|
||||
| {
|
||||
type: "snapshotFailed";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
error: string;
|
||||
}
|
||||
| { type: "selectRuntime"; runtimeId: string }
|
||||
| { type: "selectDirectory"; worktreePath: string }
|
||||
| { type: "draftChanged"; runtimeId: string; draft: string }
|
||||
| {
|
||||
type: "scrollChanged";
|
||||
runtimeId: string;
|
||||
atBottom: boolean;
|
||||
scrollTop?: number;
|
||||
}
|
||||
| { type: "markRead"; runtimeId: string }
|
||||
| { type: "setWindowActive"; active: boolean }
|
||||
| { type: "setScale"; scale: number }
|
||||
| { type: "setResourceWarning"; value: boolean }
|
||||
| { type: "dismissCollisionWarning" }
|
||||
| {
|
||||
type: "submissionAdded";
|
||||
runtimeId: string;
|
||||
submission: RuntimeView["pendingSubmissions"][number];
|
||||
}
|
||||
| { type: "submissionSent"; runtimeId: string; id: number }
|
||||
| { type: "submissionRemoved"; runtimeId: string; id: number };
|
||||
|
||||
export function workspaceReducer(
|
||||
state: WorkspaceState,
|
||||
action: WorkspaceAction,
|
||||
): WorkspaceState {
|
||||
if (action.type === "workspaceLoaded")
|
||||
return reconcileWorkspace(state, action.workspace, action.persisted);
|
||||
if (action.type === "hostEvent") {
|
||||
if (action.host.kind === "event")
|
||||
return applyWorkspaceEvent(state, action.host.event);
|
||||
if (action.host.kind === "resetRequired")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: 0,
|
||||
status: "resyncing",
|
||||
error: action.host.reason,
|
||||
},
|
||||
};
|
||||
if (action.host.kind === "disconnected")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "error",
|
||||
error: action.host.message,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: action.host.bridgeInstanceId,
|
||||
latestSeq: Math.max(state.bridge.latestSeq, action.host.latestSeq),
|
||||
status: "ready",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === "selectDirectory")
|
||||
return {
|
||||
...state,
|
||||
selectedDirectoryPath: action.worktreePath,
|
||||
selectedRuntimeId:
|
||||
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
|
||||
};
|
||||
if (action.type === "selectRuntime")
|
||||
return {
|
||||
...state,
|
||||
selectedRuntimeId: action.runtimeId,
|
||||
selectedDirectoryPath:
|
||||
state.runtimesById[action.runtimeId]?.summary.worktreePath,
|
||||
};
|
||||
if (action.type === "setWindowActive")
|
||||
return { ...state, windowActive: action.active };
|
||||
if (action.type === "setScale")
|
||||
return { ...state, interfaceScale: action.scale };
|
||||
if (action.type === "setResourceWarning")
|
||||
return { ...state, resourceWarning: action.value };
|
||||
if (action.type === "dismissCollisionWarning")
|
||||
return { ...state, dismissedCollisionWarning: true };
|
||||
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
|
||||
if (!runtimeId || !state.runtimesById[runtimeId]) return state;
|
||||
const view = { ...state.runtimesById[runtimeId] };
|
||||
if (action.type === "snapshotRequested") {
|
||||
view.loading = true;
|
||||
view.error = undefined;
|
||||
view.requestGeneration = action.generation;
|
||||
} else if (action.type === "snapshotFailed") {
|
||||
if (view.requestGeneration !== action.generation) return state;
|
||||
view.loading = false;
|
||||
view.error = action.error;
|
||||
} else if (action.type === "snapshotLoaded") {
|
||||
if (
|
||||
view.requestGeneration !== action.generation ||
|
||||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
|
||||
action.snapshot.latestSeq < view.loadedThroughSeq
|
||||
)
|
||||
return state;
|
||||
view.loading = false;
|
||||
view.summary = action.snapshot.runtime;
|
||||
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.commands = action.snapshot.commands?.data?.commands ?? [];
|
||||
view.models = action.snapshot.models?.data?.models ?? [];
|
||||
view.extensions = action.snapshot.extensions ?? [];
|
||||
view.loadedThroughSeq = action.snapshot.latestSeq;
|
||||
view.progress = view.state.isStreaming
|
||||
? {
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
}
|
||||
: idleProgress;
|
||||
} else if (action.type === "draftChanged") view.draft = action.draft;
|
||||
else if (action.type === "scrollChanged")
|
||||
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
|
||||
else if (action.type === "markRead") {
|
||||
if (
|
||||
state.selectedRuntimeId === runtimeId &&
|
||||
state.windowActive &&
|
||||
view.scroll.atBottom
|
||||
)
|
||||
view.unread = false;
|
||||
} else if (action.type === "submissionAdded")
|
||||
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,
|
||||
);
|
||||
else if (action.type === "submissionRemoved")
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
(item) => item.id !== action.id,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user