feat: add multi-session desktop workspace

This commit is contained in:
2026-08-13 21:34:04 +02:00
parent 12b9b83d51
commit 475f95ba70
56 changed files with 8998 additions and 3805 deletions
+504 -777
View File
File diff suppressed because it is too large Load Diff
+508 -1615
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.map((part) => {
if (part.type === "text") return part.text ?? "";
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function todos(messages: RuntimeView["messages"]) {
const order: Record<TodoTask["status"], number> = {
in_progress: 0,
pending: 1,
completed: 2,
deleted: 3,
};
for (let index = messages.length - 1; index >= 0; index--) {
if (
messages[index].role === "toolResult" &&
messages[index].toolName === "todo" &&
Array.isArray(messages[index].details?.tasks)
)
return messages[index]
.details!.tasks!.filter((task) => task.status !== "deleted")
.sort((a, b) => order[a.status] - order[b.status]);
}
return [] as TodoTask[];
}
export function ConversationWorkspace({
runtime,
onDraft,
onSubmit,
onAbort,
onScroll,
onModel,
onThinking,
isSubmitting = false,
}: {
runtime: RuntimeView;
onDraft: (value: string) => void;
onSubmit: () => void;
onAbort: () => void;
onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => void;
onThinking: (level: string) => void;
isSubmitting?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const isWorking =
isSubmitting ||
runtime.progress.phase === "working" ||
runtime.progress.phase === "recovering";
useEffect(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
return (
<div
className="workflow"
id="session-workspace"
role="tabpanel"
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
>
<div className="workflow-main">
<section
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
aria-label="Conversation"
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
onScroll(
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
node.scrollTop,
);
}}
>
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting
? "Sending your prompt…"
: runtime.progress.detail}
</span>
{runtime.progress.tool && (
<small>Using {runtime.progress.tool}</small>
)}
</div>
</div>
)}
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
{!runtime.loading && !runtime.messages.length && (
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<article
className={`message ${message.role ?? "system"}`}
key={index}
>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
))}
{runtime.pendingSubmissions.map((pending) => (
<article
className={`message user pending-message ${pending.phase}`}
key={pending.id}
>
<strong>
You{" "}
<span>
{pending.phase === "sending"
? "Sending…"
: "Sent · waiting for Pi"}
</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
</section>
<aside className="todos-pane" aria-label="Current todos">
<div className="todos-heading">
<strong>Todos</strong>
<span>
{tasks.filter((task) => task.status !== "completed").length}
</span>
</div>
{tasks.length ? (
<ol className="todo-list">
{tasks.map((task) => (
<li className={task.status} key={task.id}>
<div>
<strong>{task.subject}</strong>
{task.activeForm && <small>{task.activeForm}</small>}
</div>
<span>{task.status.replace("_", " ")}</span>
</li>
))}
</ol>
) : (
<p className="muted">No active todos yet.</p>
)}
</aside>
</div>
<footer className="workflow-footer">
{Object.values(runtime.extensionWidgets)
.flatMap((widget) => widget.lines)
.map((line, index) => (
<div className="extension-widget" key={`${line}-${index}`}>
{line}
</div>
))}
<section
className="pi-controls"
aria-label="Model and thinking controls"
>
<label>
Model
<select
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
onChange={(event) => {
const [provider, id] = event.currentTarget.value.split("/");
onModel({ provider, id });
}}
>
<option value="">Current model</option>
{runtime.models.map((model) => (
<option
key={`${model.provider}/${model.id}`}
value={`${model.provider}/${model.id}`}
>
{model.provider}/{model.id ?? model.name}
</option>
))}
</select>
</label>
<label>
Thinking
<select
value={runtime.state.thinkingLevel ?? "medium"}
onChange={(event) => onThinking(event.currentTarget.value)}
>
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => (
<option key={level}>{level}</option>
),
)}
</select>
</label>
</section>
<div
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
role="status"
aria-live="polite"
>
<span className="work-dot" />
<span>
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div>
<div className="composer">
<textarea
aria-label="Prompt"
value={runtime.draft}
disabled={isSubmitting}
onChange={(event) => onDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
}
}}
placeholder="Ask Pi…"
/>
<div className="composer-actions">
{runtime.state.isStreaming && (
<button type="button" className="quiet" onClick={onAbort}>
Abort
</button>
)}
<button
type="button"
disabled={
isSubmitting ||
!runtime.summary.agentId ||
!runtime.draft.trim()
}
onClick={onSubmit}
>
{isSubmitting ? "Sending…" : "Send"}
</button>
</div>
</div>
</footer>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { DirectoryWorkspace, WorkspaceState } from "../workspace/types";
type Props = {
state: WorkspaceState;
onSelect: (path: string) => void;
onAdd: () => void;
onForget: (directory: DirectoryWorkspace) => void;
};
export function DirectorySidebar({ state, onSelect, onAdd, onForget }: Props) {
return <aside className="directory-sidebar" aria-label="Directories">
<div className="sidebar-heading"><strong>Directories</strong><button onClick={onAdd} aria-label="Add directory">+</button></div>
<nav>{state.directoryOrder.map((path) => {
const directory = state.directoriesByPath[path]; if (!directory) return null;
const selected = path === state.selectedDirectoryPath;
return <div className={`directory-row ${selected ? "selected" : ""}`} key={path}>
<button className="directory-select" aria-current={selected ? "page" : undefined} onClick={() => onSelect(path)} title={path}>
<span>{path.split("/").filter(Boolean).slice(-1)[0] || path}</span>
<small>{directory.workingCount ? `${directory.workingCount} working` : `${directory.openCount} open`}</small>
<span className="directory-badges" aria-label={`${directory.attentionCount} attention, ${directory.errorCount} errors`}>
{directory.attentionCount > 0 && <b className="attention">!{directory.attentionCount}</b>}
{directory.errorCount > 0 && <b className="error">×{directory.errorCount}</b>}
</span>
</button>
{!directory.isHome && <button className="forget-directory" aria-label={`Forget ${path}`} onClick={() => onForget(directory)}>×</button>}
</div>;
})}</nav>
</aside>;
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) {
return [...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? [])];
}
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
useEffect(() => {
if (!extension) return;
opener.current = document.activeElement as HTMLElement | null;
setValue(extension.initialValue ?? extension.text ?? "");
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
if (!extension) return null;
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>}
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>}
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button>
</section></div>;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import type { DirectorySession } from "../workspace/types";
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
const dialogRef = useRef<HTMLElement>(null);
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])];
controls()[0]?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
if (event.key !== "Tab") return;
const focusable = controls(); const first = focusable[0]; const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
window.addEventListener("keydown", keydown);
return () => { window.removeEventListener("keydown", keydown); openerRef.current?.focus(); };
}, [onClose]);
return <div className="modal-backdrop"><section ref={dialogRef} className="session-picker" role="dialog" aria-modal="true" aria-labelledby="session-picker-title">
<div className="picker-heading"><h2 id="session-picker-title">Saved sessions</h2><button onClick={onClose}>Close</button></div>
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}>
<span>{session.name ?? session.firstMessage ?? "Untitled session"}</span><small>{session.runtimeId ? "Already open" : `${session.messageCount} messages · ${new Date(session.modified).toLocaleDateString()}`}</small>
</button>)}</div> : <p className="muted">No saved sessions for this directory.</p>}
</section></div>;
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import type { RuntimeView } from "../workspace/types";
type Props = {
runtimes: RuntimeView[];
selectedId?: string;
onSelect: (id: string) => void;
onClose: (runtime: RuntimeView) => void;
onNew: () => void;
onResume: () => void;
busy?: boolean;
closingRuntimeId?: string;
restoreFocus?: boolean;
onFocusRestored?: () => void;
};
export function SessionTabs({
runtimes,
selectedId,
onSelect,
onClose,
onNew,
onResume,
busy,
closingRuntimeId,
restoreFocus,
onFocusRestored,
}: Props) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
const newSessionRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!restoreFocus) return;
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
onFocusRestored?.();
}, [restoreFocus, selectedId, onFocusRestored]);
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next: number | undefined;
if (!runtimes.length) return;
if (event.key === "ArrowRight") next = (index + 1) % runtimes.length;
else if (event.key === "ArrowLeft")
next = (index - 1 + runtimes.length) % runtimes.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = runtimes.length - 1;
if (next === undefined) return;
event.preventDefault();
refs.current[runtimes[next].summary.runtimeId]?.focus();
onSelect(runtimes[next].summary.runtimeId);
}
return (
<div className="session-strip">
<div className="session-tab-bar" aria-label="Sessions">
<div className="session-tabs" role="tablist" aria-label="Open sessions">
{runtimes.map((runtime, index) => {
const summary = runtime.summary;
const selected = summary.runtimeId === selectedId;
return (
<div
className={`session-tab ${selected ? "selected" : ""}`}
key={summary.runtimeId}
>
<button
role="tab"
id={`session-tab-${summary.runtimeId}`}
aria-controls="session-workspace"
aria-selected={selected}
tabIndex={selected || (!selectedId && index === 0) ? 0 : -1}
ref={(element) => {
refs.current[summary.runtimeId] = element;
}}
onKeyDown={(event) => keydown(event, index)}
onClick={() => onSelect(summary.runtimeId)}
>
<span>{summary.label}</span>
<small className={summary.state}>
{summary.state}
{summary.queueCount
? ` · ${summary.queueCount} queued`
: ""}
</small>
{runtime.unread && (
<i className="unread-dot" aria-label="Unread activity" />
)}
{summary.attention && (
<b className="attention-dot" aria-label="Needs attention">
!
</b>
)}
</button>
<button
className="close-session"
aria-label={`Close ${summary.label}`}
disabled={closingRuntimeId === summary.runtimeId}
onClick={() => onClose(runtime)}
>
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
</button>
</div>
);
})}
</div>
<div className="session-tab-actions" aria-label="Session actions">
<button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span>
{busy ? "Working…" : "Load"}
</button>
<button
className="new-session"
ref={newSessionRef}
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">+</span>
{busy ? "Starting…" : "New"}
</button>
</div>
</div>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar";
import { SessionTabs } from "./SessionTabs";
import { SessionPicker } from "./SessionPicker";
import {
initialWorkspaceState,
reconcileWorkspace,
runtimeView,
} from "../workspace/reducer";
import type { Workspace } from "../workspace/types";
const summary = (id: string, state = "idle") => ({
runtimeId: id,
worktreePath: "/repo",
state,
label: id,
attention: id === "b",
queueCount: id === "b" ? 2 : 0,
lastActivity: id,
openedAt: id,
});
const workspace: Workspace = {
bridgeInstanceId: "e",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: false,
openCount: 2,
workingCount: 1,
attentionCount: 1,
recoveringCount: 0,
errorCount: 1,
runtimes: [summary("a"), summary("b", "streaming")],
},
],
};
describe("multi-session navigation", () => {
it("renders directory aggregates and accessible keyboard session tabs", async () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const onSelect = vi.fn();
const { rerender } = render(
<>
<DirectorySidebar
state={state}
onSelect={vi.fn()}
onAdd={vi.fn()}
onForget={vi.fn()}
/>
<SessionTabs
runtimes={[
runtimeView(summary("a")),
runtimeView(summary("b", "streaming")),
]}
selectedId="a"
onSelect={onSelect}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
/>
</>,
);
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
const tabs = screen.getAllByRole("tab");
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
await userEvent.click(tabs[0]);
await userEvent.keyboard("{ArrowRight}");
expect(onSelect).toHaveBeenCalledWith("b");
rerender(
<SessionTabs
runtimes={[runtimeView(summary("b", "streaming"))]}
selectedId="b"
onSelect={onSelect}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
/>,
);
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
});
it("restores focus to the selected adjacent tab after close", () => {
const onFocusRestored = vi.fn();
render(
<SessionTabs
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
selectedId="b"
onSelect={vi.fn()}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
restoreFocus
onFocusRestored={onFocusRestored}
/>,
);
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled();
});
it("traps and restores focus for the saved-session picker", async () => {
const user = userEvent.setup();
const opener = document.createElement("button");
document.body.append(opener);
opener.focus();
const onClose = vi.fn();
const { unmount } = render(
<SessionPicker
sessions={[
{
path: "/s",
id: "s",
cwd: "/repo",
modified: new Date().toISOString(),
messageCount: 0,
},
]}
onOpen={vi.fn()}
onClose={onClose}
/>,
);
const close = screen.getByRole("button", { name: "Close" });
expect(close).toHaveFocus();
await user.keyboard("{Tab}");
expect(
screen.getByRole("button", { name: /Untitled session/ }),
).toHaveFocus();
await user.keyboard("{Tab}");
expect(close).toHaveFocus();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
unmount();
expect(opener).toHaveFocus();
document.body.removeChild(opener);
});
it("routes new, saved-open, and close controls through callbacks", async () => {
const onNew = vi.fn(),
onResume = vi.fn(),
onClose = vi.fn();
render(
<SessionTabs
runtimes={[runtimeView(summary("a"))]}
selectedId="a"
onSelect={vi.fn()}
onClose={onClose}
onNew={onNew}
onResume={onResume}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "New" }));
await userEvent.click(screen.getByRole("button", { name: "Load" }));
await userEvent.click(screen.getByLabelText("Close a"));
expect(onNew).toHaveBeenCalled();
expect(onResume).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
+4
View File
@@ -0,0 +1,4 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(cleanup);
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from "vitest";
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" }] }] };
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);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { invoke } from "@tauri-apps/api/core";
import type { UiState, WorkspaceState } from "./types";
export const selectedPathKey = "pi-status-ui.selected-worktree";
export const uiScaleKey = "pi-status-ui.interface-scale";
export function presentationState(state: WorkspaceState): UiState {
const drafts: Record<string, string> = {};
const scroll: UiState["scroll"] = {};
const lastSeen: UiState["lastSeen"] = {};
for (const [id, runtime] of Object.entries(state.runtimesById)) {
if (runtime.draft) drafts[id] = runtime.draft;
scroll[id] = runtime.scroll;
if (!runtime.unread && state.bridge.instanceId)
lastSeen[id] = {
bridgeInstanceId: state.bridge.instanceId,
seq: state.bridge.latestSeq,
lastActivity: runtime.summary.lastActivity,
};
}
return {
version: 1,
directoryOrder: state.directoryOrder,
runtimeOrderByDirectory: state.runtimeOrderByDirectory,
selectedRuntimeId: state.selectedRuntimeId,
drafts,
scroll,
lastSeen,
workspaceCursor: state.bridge.instanceId
? {
bridgeInstanceId: state.bridge.instanceId,
seq: state.bridge.latestSeq,
}
: undefined,
interfaceScale: state.interfaceScale,
dismissedCollisionWarning: state.dismissedCollisionWarning,
};
}
export async function loadPresentation() {
return invoke<UiState>("load_ui_state");
}
export async function savePresentation(state: UiState) {
await invoke("save_ui_state", { state });
}
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
const path = storage.getItem(selectedPathKey) ?? undefined;
const raw = Number(storage.getItem(uiScaleKey));
const scale =
Number.isFinite(raw) && raw >= 0.9 && raw <= 1.4 ? raw : undefined;
return { path, scale };
}
export function removeLegacyPreferences(storage: Pick<Storage, "removeItem">) {
storage.removeItem(selectedPathKey);
storage.removeItem(uiScaleKey);
}
+311
View File
@@ -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);
});
});
+507
View File
@@ -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 },
};
}
+232
View File
@@ -0,0 +1,232 @@
export type RuntimeError = { code?: string; message?: string };
export type RuntimeSummary = {
runtimeId: string;
worktreePath: string;
state: string;
label: string;
attention: boolean;
queueCount: number;
activeTool?: string;
lastActivity: string;
openedAt: string;
agentId?: string;
sessionId?: string;
sessionPath?: string;
error?: RuntimeError;
};
export type DirectoryWorkspace = {
worktreePath: string;
isHome: boolean;
openCount: number;
workingCount: number;
attentionCount: number;
recoveringCount: number;
errorCount: number;
runtimes: RuntimeSummary[];
};
export type Workspace = {
bridgeInstanceId: string;
latestSeq: number;
directories: DirectoryWorkspace[];
issue?: { code?: string; message?: string };
};
export type WorkspaceSummary = {
bridgeInstanceId: string;
latestSeq: number;
openCount: number;
workingCount: number;
attentionCount: number;
recoveringCount: number;
errorCount: number;
directoryCount: number;
resourceWarning: boolean;
};
export type TodoTask = {
id: number;
subject: string;
activeForm?: string;
status: "pending" | "in_progress" | "completed" | "deleted";
};
export type Message = {
role?: string;
toolName?: string;
details?: { tasks?: TodoTask[] };
content?:
| string
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
};
export type AgentState = {
isStreaming?: boolean;
thinkingLevel?: string;
sessionName?: string;
sessionId?: string;
messageCount?: number;
pendingMessageCount?: number;
model?: {
provider?: string;
id?: string;
name?: string;
contextWindow?: number;
};
};
export type SessionStats = {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
contextUsage?: {
tokens?: number | null;
contextWindow?: number;
percent?: number | null;
};
};
export type Command = { name?: string; description?: string };
export type Model = { provider?: string; id?: string; name?: string };
export type Extension = {
id?: string;
method?: string;
title?: string;
message?: string;
options?: string[];
placeholder?: string;
initialValue?: string;
text?: string;
notifyType?: "info" | "warning" | "error";
statusKey?: string;
statusText?: string;
widgetKey?: string;
widgetLines?: string[];
widgetPlacement?: "aboveEditor" | "belowEditor";
};
export type WorkProgress = {
phase: "idle" | "working" | "recovering" | "error";
detail: string;
tool?: string;
queueCount?: number;
toolCount: number;
};
export type PendingSubmission = {
id: number;
text: string;
userMessageCount: number;
phase: "sending" | "sent";
};
export type RuntimeSnapshot = {
bridgeInstanceId: string;
latestSeq: number;
runtime: RuntimeSummary;
state?: { data?: AgentState };
stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } };
extensions: Extension[];
};
export type DirectorySession = {
path: string;
id: string;
cwd: string;
name?: string;
modified: string;
messageCount: number;
firstMessage?: string;
runtimeId?: string;
};
export type WorkspaceEvent = {
bridgeInstanceId: string;
seq: number;
type?: string;
agentId?: string;
data?: {
runtimeId?: string;
worktreePath?: string;
sessionId?: string;
runtime?: RuntimeSummary;
eventType?: string;
eventData?: { state?: string; event?: Record<string, unknown> };
recovery?: Record<string, unknown>;
};
};
export type HostEvent =
| {
kind: "connected";
bridgeInstanceId: string;
firstAvailableSeq: number;
latestSeq: number;
}
| { kind: "event"; event: WorkspaceEvent }
| {
kind: "resetRequired";
bridgeInstanceId: string;
latestSeq: number;
reason: string;
}
| { kind: "disconnected"; message: string; retryInMs: number };
export type RuntimeView = {
summary: RuntimeSummary;
state: AgentState;
stats: SessionStats;
messages: Message[];
commands: Command[];
models: Model[];
extensions: Extension[];
extensionStatuses: Record<string, string>;
extensionWidgets: Record<
string,
{ lines: string[]; placement: "aboveEditor" | "belowEditor" }
>;
notifications: Extension[];
progress: WorkProgress;
pendingSubmissions: PendingSubmission[];
draft: string;
scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean;
loading: boolean;
error?: string;
requestGeneration: number;
loadedThroughSeq: number;
};
export type WorkspaceState = {
bridge: {
instanceId?: string;
latestSeq: number;
status: "connecting" | "ready" | "resyncing" | "error";
error?: string;
};
directoriesByPath: Record<string, DirectoryWorkspace>;
directoryOrder: string[];
runtimesById: Record<string, RuntimeView>;
runtimeOrderByDirectory: Record<string, string[]>;
selectedDirectoryPath?: string;
selectedRuntimeId?: string;
windowActive: boolean;
resourceWarning: boolean;
dismissedCollisionWarning: boolean;
interfaceScale: number;
};
export type UiState = {
version: 1;
directoryOrder: string[];
runtimeOrderByDirectory: Record<string, string[]>;
selectedRuntimeId?: string;
drafts: Record<string, string>;
scroll: Record<string, { atBottom: boolean; scrollTop?: number }>;
lastSeen: Record<
string,
{ bridgeInstanceId: string; seq: number; lastActivity?: string }
>;
workspaceCursor?: { bridgeInstanceId: string; seq: number };
interfaceScale?: number;
dismissedCollisionWarning?: boolean;
};
+147
View File
@@ -0,0 +1,147 @@
import { invoke } from "@tauri-apps/api/core";
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";
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 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 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(() => {
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(() => {
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 };
}