feat(status-ui): improve bridge recovery and desktop controls

This commit is contained in:
2026-07-28 21:15:25 +02:00
parent 7cc15cca92
commit a792c577ef
23 changed files with 1336 additions and 204 deletions
+12 -6
View File
@@ -16,19 +16,25 @@ From the repository root:
```bash
npm --prefix ui ci
node src/bridge/cli.js --worktree "$HOME"
npm --prefix ui run tauri dev
```
The second command starts the bridge; run it in a separate terminal. The Tauri app connects to it on startup.
The UI needs a running bridge. For day-to-day use, keep it in the background with the root project's `npm run bridge:service:install` command. For foreground development, run the bridge in a separate terminal:
```bash
node src/bridge/cli.js --worktree "$HOME"
```
The Tauri app connects to the bridge on startup. See the [root README](../README.md#keep-the-bridge-running-in-the-background-recommended) for start, restart, status, and log commands.
## Commands
```bash
npm --prefix ui run dev # Vite frontend only
npm --prefix ui run build # typecheck and build frontend assets
npm --prefix ui run tauri dev # run the desktop client
npm --prefix ui run tauri build # package a desktop bundle
npm --prefix ui run dev # Vite frontend only
npm --prefix ui run build # typecheck and build frontend assets
npm --prefix ui run tauri dev # run the desktop client in development
npm --prefix ui run tauri:release # build the production executable used by Noctalia/Mod+Space
npm --prefix ui run tauri build # package installers (requires platform bundle tooling)
```
## Boundaries
+2 -1
View File
@@ -13,7 +13,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"tauri:release": "tauri build --no-bundle"
},
"dependencies": {
"react": "^19.1.0",
+1
View File
@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-changed=../dist");
tauri_build::build()
}
+10
View File
@@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
.await
}
pub async fn forget_directory(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
request(
socket_path,
"forget_directory",
None,
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
request(socket_path, "list_sessions", Some(agent_id), None).await
}
+46 -3
View File
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct Subscription(Mutex<Option<JoinHandle<()>>>);
#[derive(Debug, PartialEq, Eq)]
enum WindowAction {
Hide,
ShowAndFocus,
}
fn window_action(args: &[String], is_visible: bool) -> WindowAction {
if args.iter().any(|arg| arg == "--hide")
|| (args.iter().any(|arg| arg == "--toggle") && is_visible)
{
WindowAction::Hide
} else {
WindowAction::ShowAndFocus
}
}
fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
@@ -25,6 +41,11 @@ async fn select_worktree(worktree_path: String) -> Result<Value, String> {
bridge::select_worktree(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn forget_directory(worktree_path: String) -> Result<Value, String> {
bridge::forget_directory(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await
@@ -121,16 +142,24 @@ fn subscribe_agent(
pub fn run() {
let builder = tauri::Builder::default()
.manage(Subscription(Mutex::new(None)))
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
}
}
}))
.invoke_handler(tauri::generate_handler![
list_agents,
list_directories,
select_worktree,
forget_directory,
load_agent,
list_sessions,
switch_session,
@@ -149,3 +178,17 @@ pub fn run() {
eprintln!("Pi Status UI exited: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()];
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus);
}
}
+2 -2
View File
@@ -13,8 +13,8 @@
"windows": [
{
"title": "Pi Status UI",
"width": 760,
"height": 620,
"width": 900,
"height": 720,
"minWidth": 480,
"minHeight": 420,
"center": true,
+123 -25
View File
@@ -9,9 +9,22 @@
color: #ebebe8;
background: #101111;
}
* {
*,
*::before,
*::after {
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
body {
margin: 0;
min-width: 480px;
@@ -37,6 +50,13 @@ button {
button:hover {
background: #f0ffe0;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid #c4ed8b;
outline-offset: 2px;
}
.app-shell {
position: relative;
@@ -70,8 +90,7 @@ button:hover {
.workspace-heading,
.status-row,
.composer-actions,
.settings-actions,
.folder-form {
.settings-actions {
display: flex;
align-items: center;
gap: 8px;
@@ -148,14 +167,20 @@ h1 {
padding: 7px 9px;
}
.agents {
.directory-tabs,
.directory-tab-list {
display: flex;
align-items: center;
gap: 6px;
min-height: 30px;
min-height: 32px;
}
.directory-tabs {
overflow-x: auto;
padding: 1px 0 3px;
}
.directory-tab-list {
gap: 3px;
}
.agent-caption {
flex: 0 0 auto;
color: #70776f;
@@ -164,34 +189,93 @@ h1 {
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent {
.directory-tab {
display: flex;
flex: 0 0 auto;
align-items: stretch;
}
.directory-tab-button {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 180px;
padding: 5px 8px;
border: 1px solid #303530;
border-right: 0;
border-radius: 7px 0 0 7px;
padding: 5px 8px;
background: #1b1e1b;
color: #aeb4ac;
font-size: 11px;
font-weight: 650;
}
.agent > span {
.directory-tab-button > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent small {
.directory-tab-button small {
color: #70776f;
font-size: 10px;
font-weight: 600;
}
.agent.selected {
.directory-tab-button.selected {
border-color: #7da856;
background: #222b1d;
color: #ecf7df;
}
.directory-tab-button.selected + .directory-close {
border-color: #7da856;
background: #222b1d;
}
.directory-tab-button:only-child {
border-right: 1px solid #303530;
border-radius: 7px;
}
.directory-tab-button.selected:only-child {
border-color: #7da856;
}
.directory-close {
min-width: 24px;
border: 1px solid #303530;
border-radius: 0 7px 7px 0;
padding: 3px 6px;
background: #1b1e1b;
color: #aeb4ac;
font-size: 15px;
line-height: 1;
}
.directory-tab:focus-within .directory-tab-button,
.directory-tab:focus-within .directory-close {
border-color: #c4ed8b;
}
.directory-close:hover {
background: #3a2222;
color: #ffc0c0;
}
.directory-close:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.directory-add-toggle {
flex: 0 0 auto;
padding: 5px 8px;
white-space: nowrap;
}
.directory-add {
display: flex;
flex: 0 0 auto;
align-items: stretch;
gap: 5px;
}
.directory-add input {
width: 220px;
padding: 5px 7px;
font-size: 11px;
}
.directory-add button {
padding: 5px 8px;
}
.notifications {
display: grid;
@@ -500,9 +584,9 @@ textarea {
line-height: 1.35;
}
.composer textarea:focus {
outline: none;
border-color: #6f8b56;
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28);
outline: 2px solid #c4ed8b;
outline-offset: -2px;
}
.composer-actions {
justify-content: flex-end;
@@ -657,15 +741,6 @@ h2 {
margin: 0;
font-size: 14px;
}
.folder-form {
align-items: stretch;
}
.remembered {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.settings-actions {
justify-content: flex-start;
}
@@ -684,6 +759,15 @@ h2 {
.session-panel-heading h2 {
margin-bottom: 3px;
}
.session-actions {
display: flex;
flex: 0 0 auto;
gap: 5px;
}
.session-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.session-panel-heading .muted {
max-width: 460px;
overflow: hidden;
@@ -727,9 +811,18 @@ h2 {
opacity: 0.5;
cursor: not-allowed;
}
.extension {
.extension-backdrop {
position: absolute;
inset: auto 14px 14px;
z-index: 10;
inset: 0;
display: flex;
align-items: end;
justify-content: center;
padding: 14px;
background: rgba(0, 0, 0, 0.5);
}
.extension {
width: min(100%, 680px);
max-height: min(70vh, 460px);
overflow: auto;
border: 1px solid #9cc76d;
@@ -746,8 +839,13 @@ h2 {
}
@media (prefers-reduced-motion: reduce) {
.transcript.working {
animation: none;
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+391 -127
View File
@@ -1,4 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
type KeyboardEvent as ReactKeyboardEvent,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -8,6 +15,7 @@ type Agent = { id: string; worktreePath: string; state: string };
type Directory = {
worktreePath: string;
state: string;
isHome: boolean;
agentId?: string;
};
type Session = {
@@ -122,7 +130,6 @@ const thinkingLevels = [
"xhigh",
"max",
];
const rememberedKey = "pi-status-ui.remembered-worktrees";
const selectedPathKey = "pi-status-ui.selected-worktree";
const controlCommands: Command[] = [
{
@@ -198,12 +205,12 @@ function currentTodos(messages: Message[]) {
return [];
}
function rememberedPaths() {
try {
return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[];
} catch {
return [];
}
function focusableExtensionControls(container: HTMLElement) {
return [
...container.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
),
];
}
function App() {
@@ -221,6 +228,7 @@ function App() {
const [commands, setCommands] = useState<Command[]>([]);
const [message, setMessage] = useState("");
const [folderPath, setFolderPath] = useState("");
const [addingDirectory, setAddingDirectory] = useState(false);
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
@@ -237,8 +245,13 @@ function App() {
Record<string, ExtensionWidget>
>({});
const transcriptRef = useRef<HTMLElement>(null);
const extensionRef = useRef<HTMLElement>(null);
const directoryTabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const pendingSubmissionId = useRef(0);
const loadGeneration = useRef(0);
const refreshGeneration = useRef(0);
const bridgeRetryTimer = useRef<number | undefined>(undefined);
const bridgeRetryAttempt = useRef(0);
const lastEventSequence = useRef<Record<string, number>>({});
const [workProgress, setWorkProgress] = useState<WorkProgress>({
phase: "idle",
@@ -247,6 +260,13 @@ function App() {
});
const selected = agents.find((agent) => agent.id === selectedId);
const selectedDirectoryIndex = directories.findIndex(
(directory) => directory.agentId === selectedId,
);
const selectedDirectoryTabId =
selectedDirectoryIndex >= 0
? `directory-tab-${selectedDirectoryIndex}`
: undefined;
const filteredCommands = useMemo(() => {
if (!message.startsWith("/")) return [];
const uniqueCommands = [...controlCommands, ...commands].filter(
@@ -282,6 +302,16 @@ function App() {
const loadedCommands = unpack(snapshot.commands);
const loadedModels = unpack(snapshot.models);
setState(loadedState as AgentState);
setDirectories((current) =>
current.map((directory) =>
directory.agentId === agentId
? {
...directory,
state: loadedState.isStreaming ? "streaming" : "idle",
}
: directory,
),
);
setStats(loadedStats as SessionStats);
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
@@ -304,17 +334,36 @@ function App() {
await invoke("subscribe_agent", { agentId, cursor: 0 });
} catch (error) {
setStatus(String(error));
scheduleBridgeReconnect();
}
}
function scheduleBridgeReconnect(preferredPath?: string) {
if (bridgeRetryTimer.current !== undefined) return;
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
bridgeRetryAttempt.current += 1;
setStatus(`Bridge unavailable; retrying in ${delay}ms…`);
bridgeRetryTimer.current = window.setTimeout(() => {
bridgeRetryTimer.current = undefined;
void refreshAgents(preferredPath);
}, delay);
}
async function refreshAgents(
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
) {
const generation = ++refreshGeneration.current;
try {
const [agentResponse, directoryResponse] = await Promise.all([
invoke<{ agents: Agent[] }>("list_agents"),
invoke<{ directories: Directory[] }>("list_directories"),
]);
if (generation !== refreshGeneration.current) return;
if (bridgeRetryTimer.current !== undefined) {
window.clearTimeout(bridgeRetryTimer.current);
bridgeRetryTimer.current = undefined;
}
bridgeRetryAttempt.current = 0;
setAgents(agentResponse.agents);
setDirectories(directoryResponse.directories);
const next =
@@ -329,7 +378,8 @@ function App() {
await loadAgent(next.id, true);
} else setStatus("No Pi agents available");
} catch (error) {
setStatus(String(error));
if (generation !== refreshGeneration.current) return;
scheduleBridgeReconnect(preferredPath);
}
}
@@ -346,6 +396,10 @@ function App() {
useEffect(() => {
void refreshAgents();
return () => {
if (bridgeRetryTimer.current !== undefined)
window.clearTimeout(bridgeRetryTimer.current);
};
}, []);
useEffect(() => {
@@ -541,6 +595,18 @@ function App() {
if (bridgeEvent.type === "extension_ui_request") {
handleExtension(bridgeEvent.data?.event as Extension | undefined);
} else {
if (
bridgeEvent.type === "agent_state" &&
typeof bridgeEvent.data?.state === "string"
) {
setDirectories((current) =>
current.map((directory) =>
directory.agentId === bridgeEvent.agentId
? { ...directory, state: bridgeEvent.data?.state as string }
: directory,
),
);
}
updateWorkProgress(bridgeEvent);
if (selectedId) void loadAgent(selectedId);
}
@@ -551,7 +617,7 @@ function App() {
setStatus(
event.payload.message ?? "Bridge connection lost; reconnecting…",
);
window.setTimeout(() => void refreshAgents(), 500);
scheduleBridgeReconnect();
}).then((stop) => {
unlistenError = stop;
});
@@ -580,7 +646,58 @@ function App() {
else await activateWorktree(directory.worktreePath);
}
async function startNewSession() {
function handleDirectoryTabKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
index: number,
) {
let nextIndex: number | undefined;
if (event.key === "ArrowRight")
nextIndex = (index + 1) % directories.length;
else if (event.key === "ArrowLeft")
nextIndex = (index - 1 + directories.length) % directories.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = directories.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
const nextDirectory = directories[nextIndex];
directoryTabRefs.current[nextDirectory.worktreePath]?.focus();
}
async function forgetDirectory(directory: Directory) {
if (directory.isHome) return;
if (directory.state === "streaming") {
setStatus("Wait for Pi to finish before forgetting this directory");
return;
}
if (
!window.confirm(
`Forget ${directory.worktreePath}? Its saved sessions will remain available when you add it again.`,
)
)
return;
try {
const closingIndex = directories.findIndex(
(candidate) => candidate.worktreePath === directory.worktreePath,
);
const nextFocusPath =
directories[closingIndex + 1]?.worktreePath ??
directories[closingIndex - 1]?.worktreePath;
await invoke("forget_directory", {
worktreePath: directory.worktreePath,
});
const wasSelected = directory.agentId === selectedId;
if (wasSelected) localStorage.removeItem(selectedPathKey);
await refreshAgents(wasSelected ? "" : selected?.worktreePath);
window.requestAnimationFrame(() => {
if (nextFocusPath) directoryTabRefs.current[nextFocusPath]?.focus();
});
setStatus(`Forgot ${worktreeLabel(directory.worktreePath)}`);
} catch (error) {
setStatus(String(error));
}
}
async function startNewSession(successMessage = "Started a new Pi session") {
if (!selectedId) return;
try {
const response = await invoke<{ data?: { cancelled?: boolean } }>(
@@ -594,12 +711,34 @@ function App() {
await loadAgent(selectedId, true);
await loadSessions(selectedId);
setCommandFollowUp(undefined);
setStatus("Started a new Pi session");
setStatus(successMessage);
} catch (error) {
setStatus(String(error));
}
}
async function closeCurrentSession() {
if (
!window.confirm(
"Close the current session and start a new one? The current history remains available through /resume.",
)
)
return;
await startNewSession(
"Closed the current session. Use /resume to reopen it.",
);
}
async function restartPi() {
if (
!window.confirm(
"Restart Pi for the selected directory? Any in-progress response will be interrupted.",
)
)
return;
await invokeAgent("restart");
}
async function switchSession(session: Session) {
if (!selectedId || session.isCurrent) return;
try {
@@ -649,10 +788,9 @@ function App() {
const result = await invoke<{ agent: Agent }>("select_worktree", {
worktreePath,
});
const paths = [...new Set([...rememberedPaths(), worktreePath])];
localStorage.setItem(rememberedKey, JSON.stringify(paths));
localStorage.setItem(selectedPathKey, worktreePath);
setFolderPath("");
setAddingDirectory(false);
setSelectedId(result.agent.id);
await refreshAgents(worktreePath);
setView("conversation");
@@ -724,17 +862,25 @@ function App() {
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
void getCurrentWindow()
.hide()
.catch((error) => setStatus(String(error)));
if (event.key !== "Escape") return;
if (pendingExtension) return;
event.preventDefault();
event.stopPropagation();
if (commandFollowUp) {
setCommandFollowUp(undefined);
return;
}
if (addingDirectory) {
setAddingDirectory(false);
return;
}
void getCurrentWindow()
.hide()
.catch((error) => setStatus(String(error)));
};
window.addEventListener("keydown", handleKeydown, true);
return () => window.removeEventListener("keydown", handleKeydown, true);
}, []);
}, [addingDirectory, commandFollowUp, pendingExtension]);
async function respondToExtension(response: Record<string, unknown>) {
if (!selectedId || !pendingExtension?.id) return;
@@ -751,6 +897,37 @@ function App() {
}
}
useEffect(() => {
if (!pendingExtension) return;
const dialog = extensionRef.current;
if (!dialog) return;
const controls = focusableExtensionControls(dialog);
const initial =
dialog.querySelector<HTMLElement>("[data-initial-focus]") ?? controls[0];
initial?.focus();
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
void respondToExtension({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = focusableExtensionControls(dialog);
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", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [pendingExtension]);
const extensionMethod = pendingExtension?.method ?? "";
const workLabel =
workProgress.phase === "recovering"
@@ -789,10 +966,13 @@ function App() {
{workLabel}
</span>
</div>
<div className="status-row" role="status">
<span title={status}>{status}</span>
<div className="status-row">
<span role="status" aria-atomic="true" title={status}>
{status}
</span>
{contextWindow && (
<span
aria-hidden="true"
className="status-metric"
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
>
@@ -802,6 +982,7 @@ function App() {
)}
{stats.tokens?.total !== undefined && (
<span
aria-hidden="true"
className="status-metric"
title={`Session tokens — input: ${formatTokens(stats.tokens.input)}, output: ${formatTokens(stats.tokens.output)}, cache read: ${formatTokens(stats.tokens.cacheRead)}, cache write: ${formatTokens(stats.tokens.cacheWrite)}`}
>
@@ -819,6 +1000,8 @@ function App() {
</div>
</div>
<button
aria-controls="directory-workspace"
aria-expanded={view === "settings"}
className="quiet compact-button"
onClick={() =>
setView(view === "conversation" ? "settings" : "conversation")
@@ -828,23 +1011,86 @@ function App() {
</button>
</header>
<section className="agents" aria-label="Managed directories">
<span className="agent-caption">Directories</span>
{directories.map((directory) => (
<button
className={
directory.agentId === selectedId ? "agent selected" : "agent"
}
key={directory.worktreePath}
title={directory.worktreePath}
onClick={() => void chooseDirectory(directory)}
<section className="directory-tabs" aria-label="Managed directories">
<div
aria-label="Managed directories"
className="directory-tab-list"
role="tablist"
>
<span className="agent-caption">Directories</span>
{directories.map((directory, index) => {
const isSelected = directory.agentId === selectedId;
return (
<div
className="directory-tab"
key={directory.worktreePath}
role="presentation"
>
<button
aria-controls="directory-workspace"
aria-label={`${worktreeLabel(directory.worktreePath) ?? directory.worktreePath}, ${directory.state}${directory.isHome ? ", home directory" : ""}`}
aria-selected={directory.agentId === selectedId}
className={
isSelected
? "directory-tab-button selected"
: "directory-tab-button"
}
id={`directory-tab-${index}`}
onClick={() => void chooseDirectory(directory)}
onKeyDown={(event) => handleDirectoryTabKeyDown(event, index)}
ref={(element) => {
directoryTabRefs.current[directory.worktreePath] = element;
}}
role="tab"
tabIndex={isSelected || (!selectedId && index === 0) ? 0 : -1}
title={directory.worktreePath}
>
<span>
{worktreeLabel(directory.worktreePath) ??
directory.worktreePath}
</span>
<small>{directory.state}</small>
</button>
{!directory.isHome && (
<button
aria-label={`Forget ${directory.worktreePath}`}
className="directory-close"
disabled={directory.state === "streaming"}
title={`Forget ${directory.worktreePath}`}
onClick={() => void forgetDirectory(directory)}
>
×
</button>
)}
</div>
);
})}
</div>
<button
aria-expanded={addingDirectory}
className="quiet directory-add-toggle"
onClick={() => setAddingDirectory((current) => !current)}
>
{addingDirectory ? "Cancel" : "Add directory"}
</button>
{addingDirectory && (
<form
className="directory-add"
onSubmit={(event) => {
event.preventDefault();
void addFolder();
}}
>
<span>
{worktreeLabel(directory.worktreePath) ?? directory.worktreePath}
</span>
<small>{directory.state}</small>
</button>
))}
<input
autoFocus
value={folderPath}
onChange={(event) => setFolderPath(event.currentTarget.value)}
placeholder="/absolute/path/to/project"
aria-label="Directory path"
/>
<button type="submit">Add</button>
</form>
)}
</section>
{extensionNotifications.length > 0 && (
@@ -853,6 +1099,7 @@ function App() {
<div
className={`notification ${notice.notifyType ?? "info"}`}
key={`${notice.id ?? "notice"}-${index}`}
role={notice.notifyType === "error" ? "alert" : "status"}
>
{notice.message ?? "Extension notification"}
</div>
@@ -861,32 +1108,13 @@ function App() {
)}
{view === "settings" ? (
<section className="settings">
<h2>Agents and session</h2>
<div className="folder-form">
<input
value={folderPath}
onChange={(event) => setFolderPath(event.currentTarget.value)}
placeholder="/absolute/path/to/project"
/>
<button onClick={() => void addFolder()}>Add folder</button>
</div>
<div className="remembered">
<p className="muted">Remembered folders</p>
{rememberedPaths().length ? (
rememberedPaths().map((path) => (
<button
className="quiet"
key={path}
onClick={() => void activateWorktree(path)}
>
{path}
</button>
))
) : (
<p className="muted">Home only</p>
)}
</div>
<section
aria-labelledby={selectedDirectoryTabId}
className="settings"
id="directory-workspace"
role="tabpanel"
>
<h2>Session controls</h2>
<p className="muted">
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
{state.messageCount ?? 0} messages ·{" "}
@@ -904,17 +1132,29 @@ function App() {
"Open a directory to view its sessions"}
</p>
</div>
<button
disabled={!selectedId || state.isStreaming}
onClick={() => void startNewSession()}
>
New session
</button>
<div className="session-actions">
<button
aria-label="Close current session and start a new session"
className="quiet"
disabled={!selectedId || state.isStreaming}
onClick={() => void closeCurrentSession()}
>
Close current
</button>
<button
disabled={!selectedId || state.isStreaming}
onClick={() => void startNewSession()}
>
New session
</button>
</div>
</div>
{sessions.length ? (
<div className="session-list">
{sessions.map((session) => (
<button
aria-current={session.isCurrent ? "page" : undefined}
aria-label={`${session.name ?? session.firstMessage ?? "Untitled session"}${session.isCurrent ? ", current session" : `, ${session.messageCount} messages`}`}
className={
session.isCurrent ? "session current" : "session quiet"
}
@@ -944,8 +1184,9 @@ function App() {
Retry
</button>
<button
aria-label="Restart Pi for the selected directory"
className="quiet danger"
onClick={() => void invokeAgent("restart")}
onClick={() => void restartPi()}
>
Restart Pi
</button>
@@ -953,7 +1194,12 @@ function App() {
</section>
) : (
<>
<div className="workflow">
<div
aria-labelledby={selectedDirectoryTabId}
className="workflow"
id="directory-workspace"
role="tabpanel"
>
<div className="workflow-main">
<section
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
@@ -1227,61 +1473,79 @@ function App() {
)}
{pendingExtension && (
<section className="extension" aria-label="Pi extension request">
<h2>{pendingExtension.title ?? "Pi extension request"}</h2>
<p>{pendingExtension.message ?? "Choose a response."}</p>
{extensionMethod === "select" &&
pendingExtension.options?.map((option) => (
<button
key={option}
onClick={() => void respondToExtension({ value: option })}
>
{option}
</button>
))}
{extensionMethod === "confirm" && (
<div className="composer-actions">
<button
onClick={() => void respondToExtension({ confirmed: true })}
>
Confirm
</button>
<button
className="quiet"
onClick={() => void respondToExtension({ confirmed: false })}
>
Decline
</button>
</div>
)}
{(extensionMethod === "input" || extensionMethod === "editor") && (
<>
<textarea
value={extensionValue}
onChange={(event) =>
setExtensionValue(event.currentTarget.value)
}
placeholder={
pendingExtension.placeholder ?? "Extension response"
}
rows={4}
/>
<button
onClick={() =>
void respondToExtension({ value: extensionValue })
}
>
Submit
</button>
</>
)}
<button
className="quiet"
onClick={() => void respondToExtension({ cancelled: true })}
<div className="extension-backdrop">
<section
className="extension"
ref={extensionRef}
role="dialog"
aria-modal="true"
aria-labelledby="extension-title"
aria-describedby="extension-description"
>
Cancel request
</button>
</section>
<h2 id="extension-title">
{pendingExtension.title ?? "Pi extension request"}
</h2>
<p id="extension-description">
{pendingExtension.message ?? "Choose a response."}
</p>
{extensionMethod === "select" &&
pendingExtension.options?.map((option) => (
<button
key={option}
onClick={() => void respondToExtension({ value: option })}
>
{option}
</button>
))}
{extensionMethod === "confirm" && (
<div className="composer-actions">
<button
onClick={() => void respondToExtension({ confirmed: true })}
>
Confirm
</button>
<button
className="quiet"
onClick={() => void respondToExtension({ confirmed: false })}
>
Decline
</button>
</div>
)}
{(extensionMethod === "input" || extensionMethod === "editor") && (
<>
<label className="sr-only" htmlFor="extension-value">
Extension response
</label>
<textarea
id="extension-value"
value={extensionValue}
onChange={(event) =>
setExtensionValue(event.currentTarget.value)
}
placeholder={
pendingExtension.placeholder ?? "Extension response"
}
rows={4}
/>
<button
onClick={() =>
void respondToExtension({ value: extensionValue })
}
>
Submit
</button>
</>
)}
<button
data-initial-focus
className="quiet"
onClick={() => void respondToExtension({ cancelled: true })}
>
Cancel request
</button>
</section>
</div>
)}
</main>
);