feat(status-ui): improve bridge recovery and desktop controls
This commit is contained in:
+12
-6
@@ -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
@@ -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,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../dist");
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user