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
+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();
});
});