546 lines
16 KiB
TypeScript
546 lines
16 KiB
TypeScript
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("");
|
|
}
|
|
|
|
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,
|
|
): 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,
|
|
defaultNewSessionWorktree: undefined,
|
|
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,
|
|
defaultNewSessionWorktree:
|
|
persisted?.defaultNewSessionWorktree ?? state.defaultNewSessionWorktree,
|
|
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 =
|
|
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,
|
|
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: "setDefaultNewSessionWorktree"; worktreePath?: string }
|
|
| { type: "newSessionLaunch"; launch?: WorkspaceState["newSessionLaunch"] }
|
|
| { type: "dismissCollisionWarning" }
|
|
| {
|
|
type: "submissionAdded";
|
|
runtimeId: string;
|
|
submission: RuntimeView["pendingSubmissions"][number];
|
|
}
|
|
| {
|
|
type: "submissionSent";
|
|
runtimeId: string;
|
|
id: number;
|
|
delivery: "prompt" | "follow_up";
|
|
}
|
|
| { 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 === "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;
|
|
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 ?? [];
|
|
view.pendingSubmissions = reconcilePendingSubmissions(
|
|
view.pendingSubmissions,
|
|
view.messages,
|
|
);
|
|
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", delivery: action.delivery }
|
|
: item,
|
|
);
|
|
else if (action.type === "submissionRemoved")
|
|
view.pendingSubmissions = view.pendingSubmissions.filter(
|
|
(item) => item.id !== action.id,
|
|
);
|
|
return {
|
|
...state,
|
|
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
|
};
|
|
}
|