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
+69 -370
View File
@@ -2,385 +2,84 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
for (const method of [
"select",
"confirm",
"input",
"editor",
"notify",
"setStatus",
"setWidget",
"setTitle",
"set_editor_text",
]) {
assert.match(source, new RegExp(`case "${method}"`));
}
assert.match(source, /setPendingExtension\(extension\)/);
assert.doesNotMatch(source, /This extension UI is unsupported/);
assert.match(source, /function stripAnsi/);
assert.match(source, /stripAnsi\(extension\.statusText\)/);
assert.match(source, /className="pi-controls"/);
assert.match(source, /aria-label="Model and thinking controls"/);
assert.match(source, /const uiScaleKey/);
assert.match(source, /function clampUiScale/);
assert.match(source, /function updateUiScale/);
assert.match(source, /localStorage\.setItem\(uiScaleKey/);
assert.match(source, /className="scale-settings"/);
assert.match(source, /aria-label="Interface scale"/);
assert.match(source, /aria-label="Decrease interface scale"/);
assert.match(source, /aria-label="Increase interface scale"/);
assert.match(source, /type="range"/);
assert.match(source, /style=\{\{ zoom: uiScale \}\}/);
assert.doesNotMatch(
source,
/className="pi-controls"[\s\S]*aria-label="Interface scale"/,
);
assert.doesNotMatch(source, /<details className="pi-controls">/);
assert.doesNotMatch(
source,
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
);
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
assert.match(
source,
/window\.addEventListener\("keydown", handleKeydown, true\)/,
);
assert.match(source, /useLayoutEffect/);
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
assert.match(source, /const loadGeneration = useRef\(0\)/);
assert.match(
source,
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
);
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
assert.match(source, /function scheduleBridgeReconnect/);
assert.match(source, /Bridge unavailable; retrying in/);
assert.match(
source,
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
);
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
assert.match(source, /void refreshAgents\(preferredPath\)/);
assert.match(source, /bridgeRetryAttempt\.current = 0/);
assert.match(
source,
/const lastEventSequence = useRef<Record<string, number>>/,
);
assert.match(source, /const eventReloadInFlight = useRef\(false\)/);
assert.match(source, /const queuedEventReload = useRef<string \| undefined>/);
assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/);
assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/);
assert.match(source, /let disposed = false;/);
assert.match(source, /if \(disposed\) stop\(\);/);
assert.match(source, /generation !== loadGeneration\.current/);
assert.match(source, /current\.phase !== "working"/);
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
assert.match(source, /\.toFixed\(1\)/);
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
assert.match(source, /formatTokens\(cacheTokens\)/);
assert.match(source, /function updateWorkProgress/);
assert.match(
source,
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
);
assert.match(source, /event\.type === "tool"/);
assert.match(source, /event\.type === "queue"/);
assert.match(source, /composer-progress/);
assert.doesNotMatch(
source,
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
);
const sources = Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/workspace/reducer.ts", "utf8"),
readFile("ui/src/workspace/useWorkspace.ts", "utf8"),
readFile("ui/src/workspace/persistence.ts", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
test("wires the desktop to stable runtime workspace APIs", async () => {
const [app, reducer, hook] = await sources;
for (const operation of [
"create_session_runtime",
"open_session_runtime",
"close_session_runtime",
"list_directory_sessions",
"get_session_runtime_snapshot",
"subscribe_workspace",
])
assert.match(`${app}\n${hook}`, new RegExp(operation));
assert.doesNotMatch(app, /switch_session/);
assert.doesNotMatch(app, /new_session/);
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
assert.match(reducer, /requestGeneration/);
assert.match(reducer, /loadedThroughSeq/);
});
test("exposes accessible directory, session, notification, and extension controls", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => {
const [, , , persistence] = await sources;
assert.match(persistence, /load_ui_state/);
assert.match(persistence, /save_ui_state/);
assert.match(persistence, /directoryOrder/);
assert.match(persistence, /drafts/);
assert.doesNotMatch(persistence, /agentId/);
assert.doesNotMatch(persistence, /messages/);
assert.doesNotMatch(persistence, /extensions/);
});
test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => {
const [app, , , , css] = await sources;
const [sidebar, tabs] = await Promise.all([
readFile("ui/src/components/DirectorySidebar.tsx", "utf8"),
readFile("ui/src/components/SessionTabs.tsx", "utf8"),
]);
assert.match(component, /aria-expanded=\{view === "settings"\}/);
assert.match(
component,
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
);
assert.match(component, /role="tab"/);
assert.match(
component,
/aria-selected=\{directory\.agentId === selectedId\}/,
);
assert.match(component, /aria-controls="directory-workspace"/);
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
assert.match(
component,
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
);
assert.match(component, /role="status" aria-atomic="true"/);
assert.match(component, /aria-hidden="true"/);
assert.match(
component,
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
);
assert.match(component, /role="dialog"/);
assert.match(component, /aria-modal="true"/);
assert.match(component, /aria-labelledby="extension-title"/);
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
assert.match(component, /function focusableExtensionControls/);
assert.match(component, /event\.key !== "Tab"/);
assert.match(component, /data-initial-focus/);
assert.match(component, /className="extension-backdrop"/);
assert.match(component, /htmlFor="extension-value"/);
assert.match(component, /Close current session and start a new session/);
assert.match(component, /Restart Pi for the selected directory/);
assert.match(component, /window\.confirm\(/);
assert.match(styles, /button:focus-visible/);
assert.match(styles, /\*::before,\n\*::after/);
assert.match(styles, /\.sr-only/);
assert.match(styles, /\.extension-backdrop \{/);
assert.match(styles, /animation-duration: 0\.01ms/);
assert.match(sidebar, /aria-label="Directories"/);
assert.match(tabs, /role="tablist"/);
assert.match(tabs, /role="tab"/);
assert.match(tabs, /aria-selected/);
assert.match(tabs, /ArrowRight/);
assert.match(app, /Multiple sessions are open in the same directory/);
assert.match(app, /Many Pi sessions are open/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
});
test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
test("preserves native session naming, compaction, composer, and extension responses", async () => {
const [app] = await sources;
const [conversation, extension] = await Promise.all([
readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"),
readFile("ui/src/components/ExtensionDialog.tsx", "utf8"),
]);
assert.match(component, /startDragging\(\)/);
assert.match(
component,
/closest\(\s*"button, input, select, textarea",?\s*\)/,
);
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(app, /set_session_name/);
assert.match(app, /compact/);
assert.match(app, /submit_prompt/);
assert.match(app, /respond_to_extension/);
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(conversation, /className="todos-pane"/);
assert.match(extension, /role="dialog"/);
assert.match(extension, /aria-modal="true"/);
});
test("opens local follow-up choices for /model and /resume", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /const controlCommands: Command\[\]/);
assert.match(component, /followUp: "model"/);
assert.match(component, /followUp: "resume"/);
assert.match(component, /followUp: "thinking"/);
assert.match(component, /const nativeCommand = controlCommands\.find/);
assert.match(component, /function chooseThinking/);
assert.match(component, /function openCommandFollowUp/);
assert.match(component, /function chooseModel/);
assert.match(component, /className="command-followup"/);
assert.match(component, /New session/);
assert.match(styles, /\.command-followup \{/);
assert.match(styles, /\.command-choice-list \{/);
});
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
);
assert.match(
component,
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
);
assert.match(component, /function chooseDirectory/);
assert.match(component, /function handleDirectoryTabKeyDown/);
assert.match(component, /function forgetDirectory/);
assert.match(component, /window\.confirm/);
assert.match(component, /"forget_directory"/);
assert.match(component, /bridgeEvent\.type === "agent_state"/);
assert.match(component, /setDirectories\(\(current\) =>/);
assert.match(
component,
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
);
assert.match(component, /className="directory-add"/);
assert.match(
component,
/import \{ open \} from "@tauri-apps\/plugin-dialog"/,
);
assert.match(component, /function browseForFolder/);
assert.match(component, /directory: true/);
assert.match(component, /multiple: false/);
assert.match(component, /Browse…/);
assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/);
assert.match(component, /aria-busy=\{isAddingDirectory\}/);
assert.match(component, /className="directory-add-status" role="status"/);
assert.match(component, /pendingDirectoryPath/);
assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/);
assert.match(component, /Add directory/);
assert.doesNotMatch(component, /className="folder-form"/);
assert.doesNotMatch(component, /Remembered folders/);
assert.match(component, /function startNewSession/);
assert.match(component, /function closeCurrentSession/);
assert.match(component, /Use \/resume to reopen it/);
assert.match(component, /function switchSession/);
assert.match(component, /className="session-panel"/);
assert.match(component, /New session/);
assert.match(component, /state\.isStreaming/);
assert.match(styles, /\.directory-add \{/);
assert.match(styles, /\.directory-add-status \{/);
assert.match(styles, /\.directory-add-spinner \{/);
assert.match(styles, /directory-add-spin/);
assert.match(styles, /\.directory-tab \{/);
assert.match(styles, /\.directory-tab-button \{/);
assert.match(styles, /\.directory-close \{/);
assert.match(styles, /\.session-panel \{/);
assert.match(styles, /\.session-list \{/);
});
test("renders todo-plugin state and compact context/token status", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /function currentTodos/);
assert.match(component, /message\.toolName === "todo"/);
assert.match(component, /className="todos-pane"/);
assert.match(component, /className="workflow-main"/);
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
assert.match(styles, /\.workflow-main \{/);
assert.match(styles, /\.todos-pane \{/);
assert.match(styles, /\.status-metric \{/);
});
test("shows an optimistic prompt until the transcript receives it", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /type PendingSubmission/);
assert.match(
component,
/const \[pendingSubmissions, setPendingSubmissions\]/,
);
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
assert.match(component, /phase: "sending"/);
assert.match(component, /phase: "sent"/);
assert.match(component, /Sent · waiting for Pi/);
assert.match(component, /receivedMessages\.some/);
assert.match(component, /pending-message/);
assert.match(styles, /\.pending-message \{/);
assert.match(styles, /\.pending-message\.sending/);
});
test("offers a lower-center control when the conversation is above its latest message", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/,
);
assert.match(component, /function updateTranscriptScrollPosition/);
assert.match(component, /function scrollTranscriptToLatest/);
assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/);
assert.match(component, /!isTranscriptAtBottom/);
assert.match(component, /aria-label="Scroll to latest message"/);
assert.match(component, /className="scroll-to-latest"/);
assert.match(component, /scrollTranscriptToLatest\(\);/);
assert.match(styles, /\.transcript-container \{/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/);
});
test("distinguishes user and assistant messages", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.message\.user \{/);
assert.match(styles, /background: #192536/);
assert.match(styles, /\.message\.assistant \{/);
assert.match(styles, /background: #1a241a/);
});
test("keeps an accessible focus treatment for the prompt editor", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.composer textarea:focus \{/);
assert.doesNotMatch(styles, /outline: none/);
assert.match(styles, /border-color: #6f8b56/);
assert.match(
styles,
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
);
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
assert.match(styles, /textarea:focus-visible/);
});
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
assert.match(source, /onKeyDown=\{\(event\) => \{/);
assert.match(source, /event\.key !== "Enter"/);
assert.match(source, /event\.shiftKey/);
assert.match(source, /event\.nativeEvent\.isComposing/);
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
assert.match(source, /Enter to send · Shift\+Enter for newline/);
});
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /className="workflow-header"/);
assert.match(component, /className="workflow"/);
assert.match(component, /className="workflow-footer"/);
assert.match(component, /className="composer-stack"/);
assert.match(
styles,
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
assert.match(
component,
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
);
assert.match(
styles,
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
);
assert.match(styles, /\.transcript\.working \{/);
assert.match(styles, /conic-gradient\(/);
assert.match(styles, /transcript-border-orbit/);
assert.match(styles, /prefers-reduced-motion: reduce/);
assert.match(styles, /font-size: 18px/);
assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/);
assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/);
assert.match(
styles,
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
);
assert.match(
styles,
/\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
);
assert.match(styles, /\.scale-settings \{/);
assert.match(
styles,
/\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
});
test("configures a larger desktop window and enables the folder dialog", async () => {
const [config, capability, rust] = await Promise.all([
test("configures native window behavior and folder dialog capabilities", async () => {
const [config, capability, cargo] = await Promise.all([
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
readFile("ui/src-tauri/src/lib.rs", "utf8"),
readFile("ui/src-tauri/Cargo.toml", "utf8"),
]);
assert.match(config, /"width": 1100/);
assert.match(config, /"height": 840/);
assert.match(config, /"minWidth": 640/);
assert.match(config, /"minHeight": 520/);
assert.match(capability, /"dialog:default"/);
assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
assert.match(config, /"width"\s*:\s*1100/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(cargo, /tauri-plugin-single-instance/);
assert.match(cargo, /tauri-plugin-dialog/);
});