Files
pi-gui/ui/src/App.tsx
T

1204 lines
35 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import { emitTo } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import { DirectorySidebar } from "./components/DirectorySidebar";
import { ExtensionDialog } from "./components/ExtensionDialog";
import { CommandFormDialog } from "./components/CommandFormDialog";
import type { RpcCommand } from "./commands/rpc";
import { SessionPicker } from "./components/SessionPicker";
import { SessionTabs } from "./components/SessionTabs";
import {
catalogModels,
getModelCatalog,
modelIdentity,
modelIdentityFromValue,
modelIdentityValue,
} from "./model-catalog";
import type {
DirectorySession,
DirectoryWorkspace,
Model,
QuickPaneSettings,
RuntimeView,
UiState,
} from "./workspace/types";
import { useWorkspace } from "./workspace/useWorkspace";
const minimumUiScale = 0.9,
maximumUiScale = 1.4;
const clamp = (value: number) =>
Math.min(maximumUiScale, Math.max(minimumUiScale, value));
const defaultQuickPaneSettings: QuickPaneSettings = {
workspace: "",
provider: "",
model: "",
followUpProvider: "",
followUpModel: "",
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
maximumSeconds: 60,
maximumToolCalls: 6,
};
function validateQuickPaneSettings(settings: QuickPaneSettings) {
if (!settings.workspace.startsWith("/"))
return "Workspace must be an absolute path.";
if (!settings.provider || !settings.model)
return "Quick provider and model are required.";
if (!settings.followUpProvider || !settings.followUpModel)
return "Follow-up provider and model are required.";
if (!settings.instructions.trim())
return "Supplemental instructions are required.";
if (
!Number.isInteger(settings.maximumSeconds) ||
settings.maximumSeconds < 1 ||
settings.maximumSeconds > 600
)
return "Seconds must be an integer from 1 to 600.";
if (
!Number.isInteger(settings.maximumToolCalls) ||
settings.maximumToolCalls < 1 ||
settings.maximumToolCalls > 64
)
return "Tool calls must be an integer from 1 to 64.";
}
let submissionId = 0;
type LocalOperation = {
id: number;
kind:
| "creating"
| "opening"
| "closing"
| "listing"
| "submitting"
| "command"
| "restarting"
| "renewing";
detail: string;
runtimeId?: string;
command?: string;
};
type CommandNotice = {
kind: "error";
message: string;
runtimeId: string;
};
const modelRpcCommands = new Set([
"get_available_models",
"set_model",
"cycle_model",
"set_thinking_level",
"cycle_thinking_level",
]);
function requestedCommand(command: string, args: Record<string, unknown>) {
return command === "pi_rpc_command" && typeof args.command === "string"
? args.command
: command;
}
function sessionStateLabel(state?: string) {
return !state || state === "idle" ? "Pi ready" : state;
}
function commandProgress(command: string) {
if (command === "restart") return "Restarting Pi and restoring the session…";
if (command === "renew_session")
return "Clearing session history and starting anew…";
if (command === "get_available_models") return "Loading available Pi models…";
if (command === "set_model") return "Changing Pi model…";
if (command === "cycle_model") return "Cycling Pi model…";
if (command === "set_thinking_level") return "Updating thinking level…";
if (command === "cycle_thinking_level") return "Cycling thinking level…";
return "Updating Pi session…";
}
export default function App() {
const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, string>>(
{},
);
const [commandForms, setCommandForms] = useState<Record<string, RpcCommand>>(
{},
);
const [commandNotices, setCommandNotices] = useState<
Record<string, CommandNotice>
>({});
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [adding, setAdding] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [sessions, setSessions] = useState<DirectorySession[] | undefined>(
undefined,
);
const selected = state.selectedRuntimeId
? state.runtimesById[state.selectedRuntimeId]
: undefined;
const directory = state.selectedDirectoryPath
? state.directoriesByPath[state.selectedDirectoryPath]
: undefined;
const runtimes = useMemo(
() =>
(state.runtimeOrderByDirectory[state.selectedDirectoryPath ?? ""] ?? [])
.map((id) => state.runtimesById[id])
.filter(Boolean),
[
state.runtimeOrderByDirectory,
state.selectedDirectoryPath,
state.runtimesById,
],
);
const mountedRef = useRef(true);
const operationSequence = useRef(0);
const activeAgentCommands = useRef(new Set<string>());
const [operations, setOperations] = useState<Record<number, LocalOperation>>(
{},
);
const [abortingRuntimeIds, setAbortingRuntimeIds] = useState<Set<string>>(
() => new Set(),
);
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const [uiState, setUiState] = useState<UiState>();
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
defaultQuickPaneSettings,
);
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
const setRuntimeStatus = (runtimeId: string, detail: string) =>
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
const clearRuntimeStatus = (runtimeId: string) =>
setRuntimeStatuses((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const startOperation = (
kind: LocalOperation["kind"],
detail: string,
runtimeId?: string,
command?: string,
) => {
const operation = {
id: ++operationSequence.current,
kind,
detail,
runtimeId,
command,
};
setOperations((current) => ({ ...current, [operation.id]: operation }));
if (runtimeId) clearRuntimeStatus(runtimeId);
else setStatus(detail);
return operation.id;
};
const finishOperation = (id: number) =>
setOperations((current) => {
if (!(id in current)) return current;
const next = { ...current };
delete next[id];
return next;
});
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
let active = true;
void invoke<UiState>("load_ui_state")
.then((loaded) => {
if (!active) return;
setUiState(loaded);
setQuickPaneSettings({
...defaultQuickPaneSettings,
...loaded.quickPane,
});
})
.catch((error) => {
if (active)
setQuickPaneSettingsError(
`Could not load Quick Pane settings: ${String(error)}`,
);
});
void getModelCatalog()
.then((models) => {
if (active) setModelCatalog(models);
})
.catch((error) => {
if (active)
setQuickPaneSettingsError(
`Could not load model catalog: ${String(error)}`,
);
});
return () => {
active = false;
};
}, []);
const selectedRuntimeId = selected?.summary.runtimeId;
useEffect(() => {
if (!selectedRuntimeId) return;
requestAnimationFrame(() =>
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
);
}, [selectedRuntimeId, dispatch]);
useEffect(() => {
setAbortingRuntimeIds((current) => {
const next = new Set(
[...current].filter(
(runtimeId) =>
state.runtimesById[runtimeId]?.summary.state === "streaming",
),
);
return next.size === current.size ? current : next;
});
}, [state.runtimesById]);
async function saveQuickPaneSettings() {
const validationError = validateQuickPaneSettings(quickPaneSettings);
if (validationError) {
setQuickPaneSettingsError(validationError);
return;
}
if (!uiState) return;
try {
const current = await invoke<UiState>("load_ui_state");
const next = { ...current, quickPane: quickPaneSettings };
await invoke("save_ui_state", { state: next });
void emitTo(
"quick-pane",
"quick-pane-settings-saved",
quickPaneSettings,
).catch(() => undefined);
setUiState(next);
setQuickPaneSettingsError(undefined);
setQuickPaneSettingsStatus("Quick Pane settings saved.");
} catch (error) {
setQuickPaneSettingsError(
`Could not save Quick Pane settings: ${String(error)}`,
);
}
}
async function createRuntime(path = state.selectedDirectoryPath) {
if (!path || !mountedRef.current) return;
const operationId = startOperation("creating", "Starting a new Pi session…");
try {
const result = await invoke<{ runtime: RuntimeView["summary"] }>(
"create_session_runtime",
{ worktreePath: path },
);
if (!mountedRef.current) return;
await refresh();
if (!mountedRef.current) return;
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
await loadSnapshot(result.runtime.runtimeId);
if (mountedRef.current) setStatus("New session ready");
} catch (error) {
if (mountedRef.current)
setStatus(`Could not start session: ${String(error)}`);
} finally {
if (mountedRef.current) finishOperation(operationId);
}
}
async function closeRuntime(runtime: RuntimeView) {
if (
runtime.summary.state === "streaming" &&
!window.confirm(
"Close this working session? Pi will be stopped, but its history is kept.",
)
)
return;
const operationId = startOperation(
"closing",
`Closing ${runtime.summary.label}…`,
runtime.summary.runtimeId,
);
try {
await invoke("close_session_runtime", {
runtimeId: runtime.summary.runtimeId,
});
if (!mountedRef.current) return;
await refresh();
if (mountedRef.current) setRestoreTabFocus(true);
} catch (error) {
if (mountedRef.current)
setRuntimeStatus(
runtime.summary.runtimeId,
`Could not close session: ${String(error)}`,
);
} finally {
if (mountedRef.current) finishOperation(operationId);
}
}
async function showSessions() {
if (!state.selectedDirectoryPath) return;
const operationId = startOperation("listing", "Loading saved sessions…");
try {
const result = await invoke<{ sessions: DirectorySession[] }>(
"list_directory_sessions",
{ worktreePath: state.selectedDirectoryPath },
);
setSessions(result.sessions);
} catch (error) {
setStatus(`Could not load saved sessions: ${String(error)}`);
} finally {
if (mountedRef.current) finishOperation(operationId);
}
}
async function openSession(session: DirectorySession) {
if (session.runtimeId) {
dispatch({ type: "selectRuntime", runtimeId: session.runtimeId });
setSessions(undefined);
return;
}
if (!state.selectedDirectoryPath || !mountedRef.current) return;
const operationId = startOperation("opening", "Opening saved Pi session…");
try {
const result = await invoke<{ runtime: RuntimeView["summary"] }>(
"open_session_runtime",
{
worktreePath: state.selectedDirectoryPath,
sessionPath: session.path,
},
);
if (!mountedRef.current) return;
await refresh();
if (!mountedRef.current) return;
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
await loadSnapshot(result.runtime.runtimeId);
if (mountedRef.current) setSessions(undefined);
} catch (error) {
if (mountedRef.current)
setStatus(`Could not open session: ${String(error)}`);
} finally {
if (mountedRef.current) finishOperation(operationId);
}
}
async function addDirectory() {
const path = folderPath.trim();
if (!path.startsWith("/")) {
setStatus("Enter an absolute folder path");
return;
}
await createRuntime(path);
if (mountedRef.current) {
setFolderPath("");
setAdding(false);
}
}
async function forgetDirectory(entry: DirectoryWorkspace) {
if (!window.confirm(`Forget ${entry.worktreePath}?`)) return;
try {
await invoke("forget_directory", { worktreePath: entry.worktreePath });
await refresh();
} catch (error) {
setStatus(String(error));
}
}
async function agentCommand(
command: string,
args: Record<string, unknown> = {},
reload = true,
target = selected,
showOperation = true,
) {
const runtimeId = target?.summary.runtimeId,
agentId = target?.summary.agentId;
if (!runtimeId || !agentId) {
if (runtimeId)
setRuntimeStatus(runtimeId, "This session is not currently available");
else setStatus("This session is not currently available");
return false;
}
if (activeAgentCommands.current.has(runtimeId)) return false;
const operationCommand = requestedCommand(command, args);
const restarting = operationCommand === "restart";
const renewing = operationCommand === "renew_session";
activeAgentCommands.current.add(runtimeId);
setCommandNotices((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const operationId = showOperation
? startOperation(
renewing ? "renewing" : restarting ? "restarting" : "command",
commandProgress(operationCommand),
runtimeId,
operationCommand,
)
: undefined;
try {
await invoke(command, { agentId, ...args });
if (reload) await loadSnapshot(runtimeId, { throwOnError: true });
return true;
} catch (error) {
const subject = modelRpcCommands.has(operationCommand)
? "Pi model command failed"
: renewing
? "Session renewal failed"
: restarting
? "Pi restart failed"
: "Pi command failed";
const message = `${subject}: ${String(error)}`;
setCommandNotices((current) => ({
...current,
[runtimeId]: { kind: "error", message, runtimeId },
}));
return false;
} finally {
activeAgentCommands.current.delete(runtimeId);
if (mountedRef.current && operationId) finishOperation(operationId);
}
}
async function submit() {
if (!selected) return;
const text = selected.draft.trim();
if (!text) return;
if (text === "/new") {
dispatch({
type: "draftChanged",
runtimeId: selected.summary.runtimeId,
draft: "",
});
await createRuntime();
return;
}
if (text === "/resume") {
dispatch({
type: "draftChanged",
runtimeId: selected.summary.runtimeId,
draft: "",
});
await showSessions();
return;
}
const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim();
if (text === "/name" || name) {
if (!name) {
setRuntimeStatus(
selected.summary.runtimeId,
"Enter a session name after /name",
);
return;
}
await agentCommand("set_session_name", { name });
dispatch({
type: "draftChanged",
runtimeId: selected.summary.runtimeId,
draft: "",
});
return;
}
const compact = text.match(/^\/compact(?:\s+([\s\S]+))?$/);
if (compact) {
await agentCommand("compact", {
customInstructions: compact[1]?.trim() || undefined,
});
dispatch({
type: "draftChanged",
runtimeId: selected.summary.runtimeId,
draft: "",
});
return;
}
const runtimeId = selected.summary.runtimeId,
id = ++submissionId,
initialDelivery =
selected.summary.state === "streaming" ? "follow_up" : "prompt",
operationId = startOperation(
"submitting",
initialDelivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt to Pi…",
selected.summary.runtimeId,
);
dispatch({
type: "submissionAdded",
runtimeId,
submission: {
id,
text,
userMessageCount: selected.messages.filter(
(message) => message.role === "user",
).length,
phase: "sending",
delivery: initialDelivery,
},
});
dispatch({ type: "draftChanged", runtimeId, draft: "" });
try {
const result = await invoke<{ delivery?: "prompt" | "follow_up" }>(
"submit_prompt",
{ agentId: selected.summary.agentId, message: text },
);
const delivery = result.delivery ?? initialDelivery;
dispatch({ type: "submissionSent", runtimeId, id, delivery });
clearRuntimeStatus(runtimeId);
} catch (error) {
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
dispatch({ type: "submissionRemoved", runtimeId, id });
dispatch({ type: "draftChanged", runtimeId, draft: text });
} finally {
finishOperation(operationId);
}
}
async function abortSelected() {
const target = selected;
if (!target?.summary.agentId) return;
const runtimeId = target.summary.runtimeId;
setAbortingRuntimeIds((current) => new Set(current).add(runtimeId));
clearRuntimeStatus(runtimeId);
try {
await invoke("abort", { agentId: target.summary.agentId });
} catch (error) {
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
setAbortingRuntimeIds((current) => {
const next = new Set(current);
next.delete(runtimeId);
return next;
});
}
}
async function runRpcCommand(
command: RpcCommand,
input: Record<string, unknown> = {},
target = selected,
) {
if (!target) return;
const runtimeId = target.summary.runtimeId;
const succeeded = await agentCommand(
"pi_rpc_command",
{ command: command.command, input },
true,
target,
);
if (succeeded)
setCommandForms((current) => {
const next = { ...current };
delete next[runtimeId];
return next;
});
}
async function respond(response: Record<string, unknown>) {
const target = selected;
const extension = target?.extensions[0];
if (!extension?.id) return;
await agentCommand(
"respond_to_extension",
{ requestId: extension.id, response },
true,
target,
);
}
const closePicker = useCallback(() => setSessions(undefined), []);
const activeOperations = Object.values(operations);
const globalOperation = activeOperations
.filter((entry) => !entry.runtimeId)
.reduce<LocalOperation | undefined>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const selectedOperations = selectedRuntimeId
? activeOperations.filter((entry) => entry.runtimeId === selectedRuntimeId)
: [];
const selectedOperation = selectedOperations.reduce<
LocalOperation | undefined
>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const pendingSubmissions = selected?.pendingSubmissions ?? [];
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
const isSubmitting =
pendingSubmissions.some((entry) => entry.phase === "sending") ||
selectedOperations.some((entry) => entry.kind === "submitting");
const isRestarting = selectedOperations.some(
(entry) => entry.kind === "restarting",
);
const isRenewing = selectedOperations.some(
(entry) => entry.kind === "renewing",
);
const isAborting = selectedRuntimeId
? abortingRuntimeIds.has(selectedRuntimeId)
: false;
const isSessionLocked = isRestarting || isRenewing;
const visibleCommandForm = selectedRuntimeId
? commandForms[selectedRuntimeId]
: undefined;
const commandOperation = selectedOperations.find(
(entry) => entry.kind === "command",
);
const commandFormPending = Boolean(commandOperation);
const modelCommandPending =
commandFormPending && modelRpcCommands.has(commandOperation?.command ?? "");
const visibleCommandNotice = selectedRuntimeId
? commandNotices[selectedRuntimeId]
: undefined;
const displayedStatus =
state.newSessionLaunch?.detail ??
globalOperation?.detail ??
(isAborting ? "Aborting Pi…" : undefined) ??
selectedOperation?.detail ??
(pendingSubmission
? pendingSubmission.phase === "sending"
? pendingSubmission.delivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt to Pi…"
: pendingSubmission.delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…"
: undefined) ??
(selectedRuntimeId ? runtimeStatuses[selectedRuntimeId] : undefined) ??
(selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering"
? selected.progress.detail
: undefined) ??
selected?.error ??
selected?.summary.error?.message ??
selected?.progress.detail ??
status;
const isWorking =
state.newSessionLaunch?.phase === "starting" ||
isSessionLocked ||
isSubmitting ||
selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering";
const sessionStatusLabel =
state.newSessionLaunch?.phase === "starting"
? "Starting"
: isRenewing
? "Renewing"
: isRestarting
? "Restarting"
: isWorking
? "Working"
: sessionStateLabel(selected?.summary.state);
const sessionStatusPhase = isWorking
? ("working" as const)
: (selected?.progress.phase ?? "idle");
const isStartingSession =
state.newSessionLaunch?.phase === "starting" ||
activeOperations.some((entry) => entry.kind === "creating");
const sessionTabsBusy = activeOperations.some(
(entry) =>
!entry.runtimeId &&
(entry.kind === "creating" ||
entry.kind === "opening" ||
entry.kind === "listing"),
);
const closingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const renewingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "renewing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const sameDirectoryCollision = directory && directory.openCount > 1;
const quickPaneModelOptions = catalogModels(modelCatalog, [
{
provider: quickPaneSettings.provider,
modelId: quickPaneSettings.model,
},
{
provider: quickPaneSettings.followUpProvider,
modelId: quickPaneSettings.followUpModel,
},
]);
const quickModelValue =
quickPaneSettings.provider && quickPaneSettings.model
? modelIdentityValue({
provider: quickPaneSettings.provider,
modelId: quickPaneSettings.model,
})
: "";
const followUpModelValue =
quickPaneSettings.followUpProvider && quickPaneSettings.followUpModel
? modelIdentityValue({
provider: quickPaneSettings.followUpProvider,
modelId: quickPaneSettings.followUpModel,
})
: "";
return (
<main
className="app-shell"
style={{ zoom: state.interfaceScale }}
aria-busy={isStartingSession || isSessionLocked}
>
<header
className="workflow-header"
onMouseDown={(event) => {
if (
event.button === 0 &&
!(event.target as HTMLElement).closest("button,input,select,textarea")
)
void getCurrentWindow().startDragging();
}}
>
<div className="workspace-heading">
<span className="brand">PI</span>
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
</div>
<button
className="quiet"
onClick={() =>
setView(view === "conversation" ? "settings" : "conversation")
}
>
{view === "conversation" ? "Controls" : "Back"}
</button>
</header>
{state.resourceWarning && (
<div className="workspace-warning" role="alert">
Many Pi sessions are open. Startup and memory use may be high.
</div>
)}
{sameDirectoryCollision && !state.dismissedCollisionWarning && (
<div className="workspace-warning collision" role="alert">
<span>
Multiple sessions are open in the same directory. Concurrent work can
collide in files and Git; separate worktrees are safer.
</span>
<button
type="button"
className="quiet warning-dismiss"
onClick={() => dispatch({ type: "dismissCollisionWarning" })}
>
Dismiss
</button>
</div>
)}
{visibleCommandNotice && !visibleCommandForm && (
<div className="command-error" role="alert">
<span>{visibleCommandNotice.message}</span>
<button
type="button"
className="quiet warning-dismiss"
onClick={() => {
if (!selectedRuntimeId) return;
setCommandNotices((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
>
Dismiss
</button>
</div>
)}
{selected?.modelError && (
<div className="command-error" role="alert">
<span>{selected.modelError}</span>
</div>
)}
{selected?.notifications.map((notice, index) => (
<div
className={`notification ${notice.notifyType ?? "info"}`}
role={notice.notifyType === "error" ? "alert" : "status"}
key={`${notice.id ?? "notice"}-${index}`}
>
{notice.message}
</div>
))}
<div className="workspace-layout">
<DirectorySidebar
state={state}
onSelect={(path) =>
dispatch({ type: "selectDirectory", worktreePath: path })
}
onAdd={() => setAdding(true)}
onForget={forgetDirectory}
/>
<section className="session-area">
{adding && (
<form
className="directory-add"
onSubmit={(event) => {
event.preventDefault();
void addDirectory();
}}
>
<input
autoFocus
value={folderPath}
onChange={(event) => setFolderPath(event.currentTarget.value)}
placeholder="/absolute/path"
/>
<button
type="button"
onClick={async () => {
const path = await open({ directory: true, multiple: false });
if (path) setFolderPath(path);
}}
>
Browse…
</button>
<button type="submit">Add</button>
<button type="button" className="quiet" onClick={() => setAdding(false)}>
Cancel
</button>
</form>
)}
<SessionTabs
runtimes={runtimes}
selectedId={state.selectedRuntimeId}
onSelect={(runtimeId) => dispatch({ type: "selectRuntime", runtimeId })}
onClose={(runtime) => void closeRuntime(runtime)}
onRenew={(runtime) =>
void agentCommand("renew_session", {}, true, runtime)
}
onNew={() => void createRuntime()}
onResume={() => void showSessions()}
busy={sessionTabsBusy}
closingRuntimeIds={closingRuntimeIds}
renewingRuntimeIds={renewingRuntimeIds}
restoreFocus={restoreTabFocus}
onFocusRestored={() => setRestoreTabFocus(false)}
/>
{view === "settings" ? (
<section className="settings" aria-busy={isSessionLocked}>
<h2>Session controls</h2>
<label className="default-worktree-control">
Default new-session directory
<select
value={state.defaultNewSessionWorktree ?? ""}
onChange={(event) =>
dispatch({
type: "setDefaultNewSessionWorktree",
worktreePath: event.currentTarget.value || undefined,
})
}
>
<option value="">Use system default</option>
{state.directoryOrder.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</select>
<small>
Used by Ctrl+Super+Space. Only currently open directories are listed.
</small>
</label>
<form
className="quick-pane-settings"
onSubmit={(event) => {
event.preventDefault();
void saveQuickPaneSettings();
}}
>
<h2>Quick Pane</h2>
<label>
Workspace
<input
value={quickPaneSettings.workspace}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
workspace: event.currentTarget.value,
})
}
/>
</label>
<label>
Quick model
<select
value={quickModelValue}
onChange={(event) => {
const identity = modelIdentityFromValue(event.currentTarget.value);
if (!identity) return;
setQuickPaneSettings({
...quickPaneSettings,
provider: identity.provider,
model: identity.modelId,
});
}}
>
<option value="" disabled>
Select model
</option>
{quickPaneModelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
<small>Provider comes from selected catalog model.</small>
</label>
<label>
Follow-up model
<select
value={followUpModelValue}
onChange={(event) => {
const identity = modelIdentityFromValue(event.currentTarget.value);
if (!identity) return;
setQuickPaneSettings({
...quickPaneSettings,
followUpProvider: identity.provider,
followUpModel: identity.modelId,
});
}}
>
<option value="" disabled>
Select model
</option>
{quickPaneModelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
<small>Provider comes from selected catalog model.</small>
</label>
<label>
Maximum seconds
<input
type="number"
min="1"
max="600"
value={quickPaneSettings.maximumSeconds}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
maximumSeconds: Number(event.currentTarget.value),
})
}
/>
</label>
<label>
Maximum tool calls
<input
type="number"
min="1"
max="64"
value={quickPaneSettings.maximumToolCalls}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
maximumToolCalls: Number(event.currentTarget.value),
})
}
/>
</label>
<label>
Supplemental instructions
<textarea
value={quickPaneSettings.instructions}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
instructions: event.currentTarget.value,
})
}
/>
</label>
<button type="submit" disabled={!uiState}>
Save Quick Pane settings
</button>
{quickPaneSettingsStatus && (
<p role="status">{quickPaneSettingsStatus}</p>
)}
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
</form>
{selected ? (
<>
<p>{selected.summary.worktreePath}</p>
<p>
{selected.state.messageCount ?? 0} messages ·{" "}
{selected.summary.queueCount} queued
</p>
<div className="settings-actions">
<button
disabled={isSessionLocked}
onClick={() => void agentCommand("retry")}
>
Retry
</button>
<button
className="danger"
disabled={isSessionLocked}
onClick={() => void agentCommand("restart")}
>
{isRestarting ? "Restarting Pi…" : "Restart Pi"}
</button>
</div>
{isRestarting && (
<div className="restart-progress" role="status" aria-live="polite">
<span className="working-pulse" aria-hidden="true" />
<span>
<strong>Restarting Pi</strong>
<small>Restoring this session and its extensions…</small>
</span>
</div>
)}
<label>
Interface scale{" "}
<input
type="range"
min={minimumUiScale}
max={maximumUiScale}
step="0.05"
value={state.interfaceScale}
onChange={(event) =>
dispatch({
type: "setScale",
scale: clamp(Number(event.currentTarget.value)),
})
}
/>
</label>
</>
) : (
<p>No session selected.</p>
)}
</section>
) : selected ? (
<ConversationWorkspace
runtime={selected}
onDraft={(draft) =>
dispatch({
type: "draftChanged",
runtimeId: selected.summary.runtimeId,
draft,
})
}
onSubmit={() => void submit()}
onAbort={() => void abortSelected()}
onScroll={(atBottom, scrollTop) => {
dispatch({
type: "scrollChanged",
runtimeId: selected.summary.runtimeId,
atBottom,
scrollTop,
});
if (atBottom)
requestAnimationFrame(() =>
dispatch({
type: "markRead",
runtimeId: selected.summary.runtimeId,
}),
);
}}
onModel={(model: Model) =>
agentCommand("set_model", {
provider: model.provider,
modelId: model.id,
})
}
onThinking={(level) => agentCommand("set_thinking_level", { level })}
isModelUpdating={modelCommandPending}
locked={isSessionLocked}
lockReason={isRenewing ? "renewing" : "restarting"}
status={{
label: sessionStatusLabel,
detail: displayedStatus,
phase: sessionStatusPhase,
bridge: state.bridge.status,
extensions: Object.values(selected.extensionStatuses),
}}
isSubmitting={isSubmitting}
isAborting={isAborting}
onCommand={(command) => {
if (command.input?.length)
setCommandForms((current) => ({
...current,
[selected.summary.runtimeId]: command,
}));
else void runRpcCommand(command, {}, selected);
}}
/>
) : (
<section className="empty-workspace">
<h2>No open sessions</h2>
<p>Start a session in this directory, or add another directory.</p>
{state.selectedDirectoryPath && (
<button onClick={() => void createRuntime()}>New session</button>
)}
</section>
)}
</section>
</div>
{sessions && (
<SessionPicker
sessions={sessions}
onOpen={(session) => void openSession(session)}
onClose={closePicker}
/>
)}
<CommandFormDialog
command={visibleCommandForm}
pending={commandFormPending}
commandError={visibleCommandNotice?.message}
onCancel={() => {
if (!selectedRuntimeId) return;
setCommandForms((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
onSubmit={(input) =>
visibleCommandForm &&
void runRpcCommand(visibleCommandForm, input, selected)
}
/>
<ExtensionDialog
extension={selected?.extensions[0]}
onRespond={(response) => void respond(response)}
/>
{isStartingSession && (
<div
className="startup-blocker"
role="dialog"
aria-modal="true"
aria-labelledby="startup-blocker-title"
tabIndex={-1}
autoFocus
>
<div className="startup-blocker-card">
<span className="working-bars startup-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong id="startup-blocker-title">Starting new session</strong>
<p>{state.newSessionLaunch?.detail ?? "Creating a fresh Pi runtime…"}</p>
<small>Pi is loading its session and extensions. Please wait.</small>
</div>
</div>
</div>
)}
</main>
);
}