diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index 539f32d..1734277 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -51,8 +51,7 @@ async function readSessionReference(sessionDir) { ); return { sessionPath: - typeof value?.sessionPath === "string" && - path.isAbsolute(value.sessionPath) + typeof value?.sessionPath === "string" && path.isAbsolute(value.sessionPath) ? value.sessionPath : undefined, worktreePath: @@ -110,11 +109,7 @@ async function readSessionSummary(sessionPath, currentPath) { firstMessage = sessionPreview(entry.message.content); } } - if ( - !header || - typeof header.id !== "string" || - typeof header.cwd !== "string" - ) + if (!header || typeof header.id !== "string" || typeof header.cwd !== "string") return undefined; return { path: sessionPath, @@ -380,8 +375,7 @@ export function createAgentRegistry({ } function enqueue(runtime, operation) { - if (stopping) - return Promise.reject(new Error("agent registry is stopping")); + if (stopping) return Promise.reject(new Error("agent registry is stopping")); if ( runtime.state === "closing" || runtime.state === "stopped" || @@ -420,10 +414,7 @@ export function createAgentRegistry({ } catch (error) { if (error?.code !== "ENOENT" || !allowMissing) throw error; const resolvedParent = await realpath(path.dirname(sessionPath)); - resolvedSessionPath = path.join( - resolvedParent, - path.basename(sessionPath), - ); + resolvedSessionPath = path.join(resolvedParent, path.basename(sessionPath)); } const relative = path.relative(resolvedSessionDir, resolvedSessionPath); if ( @@ -461,9 +452,7 @@ export function createAgentRegistry({ resolved = candidate; } catch (error) { if (error?.code !== "ENOENT") - throw new Error( - "Pi reported a session outside its managed directory", - ); + throw new Error("Pi reported a session outside its managed directory"); } } if (expectedSessionPath && resolved !== expectedSessionPath) @@ -483,8 +472,7 @@ export function createAgentRegistry({ throw new Error("session runtime is closing"); if (typeof state.sessionName === "string") runtime.sessionName = state.sessionName; - if (typeof state.sessionId === "string") - runtime.sessionId = state.sessionId; + if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId; if (resolved) { if ( runtime.sessionPath && @@ -532,12 +520,8 @@ export function createAgentRegistry({ } } if (type === "queue") { - const followUp = Array.isArray(event.followUp) - ? event.followUp.length - : 0; - const steering = Array.isArray(event.steering) - ? event.steering.length - : 0; + const followUp = Array.isArray(event.followUp) ? event.followUp.length : 0; + const steering = Array.isArray(event.steering) ? event.steering.length : 0; runtime.queueCount = typeof event.pendingMessageCount === "number" ? event.pendingMessageCount @@ -553,8 +537,7 @@ export function createAgentRegistry({ const method = event.method; if (["select", "confirm", "input", "editor"].includes(method)) { runtime.attention = true; - if (typeof event.id === "string") - runtime.extensions.set(event.id, event); + if (typeof event.id === "string") runtime.extensions.set(event.id, event); } } const message = event.message; @@ -624,8 +607,7 @@ export function createAgentRegistry({ const state = await adapter.send({ type: "get_state" }); await updateRuntimeIdentity(runtime, state, { adapter }); }).catch((error) => { - if (runtime.stopped || runtime.state === "closing" || stopping) - return; + if (runtime.stopped || runtime.state === "closing" || stopping) return; runtime.error = { code: "identity_refresh", message: error.message, @@ -829,8 +811,7 @@ export function createAgentRegistry({ } function openRuntime(record, options) { - if (stopping) - return Promise.reject(new Error("agent registry is stopping")); + if (stopping) return Promise.reject(new Error("agent registry is stopping")); const operation = openRuntimeInternal(record, options); pendingOpenOperations.add(operation); void operation @@ -867,14 +848,8 @@ export function createAgentRegistry({ let canonicalSession; if (record.sessionPath) { const canonicalWorktree = await realpath(record.worktreePath); - const sessionDir = sessionDirectoryFor( - sessionRoot, - canonicalWorktree, - ); - canonicalSession = await ownedSessionPath( - sessionDir, - record.sessionPath, - ); + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalWorktree); + canonicalSession = await ownedSessionPath(sessionDir, record.sessionPath); } prepared.push({ record, canonicalSession }); } catch (error) { @@ -1057,6 +1032,13 @@ export function createAgentRegistry({ runtime.sessionId = undefined; runtime.sessionName = undefined; runtime.firstMessage = undefined; + runtime.extensions.clear(); + runtime.queueCount = 0; + runtime.activeTool = undefined; + runtime.error = undefined; + runtime.errorAttention = false; + runtime.attention = false; + runtime.lastActivity = new Date().toISOString(); if (runtime.legacyDefault) await persistSessionReference( runtime.sessionDir, @@ -1066,6 +1048,12 @@ export function createAgentRegistry({ await persistWorkspace(); const state = await runtime.adapter.send({ type: "get_state" }); await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter }); + runtime.state = state?.data?.isStreaming ? "streaming" : "idle"; + runtime.stateVersion += 1; + publishAgent(runtime, "agent_state", { state: runtime.state }); + publishWorkspace("runtime_renewed", runtime, { + runtime: publicRuntime(runtime), + }); return response; } @@ -1105,17 +1093,14 @@ export function createAgentRegistry({ workingCount: runtimes.filter((entry) => entry.state === "streaming") .length, attentionCount: runtimes.filter((entry) => entry.attention).length, - recoveringCount: runtimes.filter( - (entry) => entry.state === "recovering", - ).length, + recoveringCount: runtimes.filter((entry) => entry.state === "recovering") + .length, errorCount: runtimes.filter((entry) => ["error", "failed"].includes(entry.state), ).length, runtimes, })) - .sort((left, right) => - left.worktreePath.localeCompare(right.worktreePath), - ), + .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)), ...(manifestIssue ? { issue: manifestIssue } : {}), }; } @@ -1130,10 +1115,7 @@ export function createAgentRegistry({ const loaded = await workspaceStore.load(); manifestIssue = loaded.issue; if (loaded.migrating) { - const sessionDir = sessionDirectoryFor( - sessionRoot, - canonicalHomeWorktree, - ); + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalHomeWorktree); await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE, @@ -1143,9 +1125,7 @@ export function createAgentRegistry({ runtimeId: randomUUID(), worktreePath: canonicalHomeWorktree, legacyDefault: true, - ...(reference.sessionPath - ? { sessionPath: reference.sessionPath } - : {}), + ...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}), }; if (reference.sessionPath) await restoreRecords([migrationRecord]); else await openRuntime(migrationRecord); @@ -1179,8 +1159,7 @@ export function createAgentRegistry({ ...(runtimesByWorktreePath.get(canonicalPath) ?? []), ].filter((runtime) => runtime.agentId); const existing = - liveRuntimes.find((runtime) => runtime.legacyDefault) ?? - liveRuntimes[0]; + liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes[0]; if (existing) { if (!existing.legacyDefault) { await setLegacyDefault(existing); @@ -1197,9 +1176,7 @@ export function createAgentRegistry({ const opened = await openRuntime({ worktreePath: canonicalPath, legacyDefault: true, - ...(reference.sessionPath - ? { sessionPath: reference.sessionPath } - : {}), + ...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}), }); return publicAgent(getRuntime(opened.runtimeId)); }, @@ -1213,9 +1190,7 @@ export function createAgentRegistry({ listAgents() { return [...runtimesByAgentId.values()] .map(publicAgent) - .sort((left, right) => - left.worktreePath.localeCompare(right.worktreePath), - ); + .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)); }, async listDirectories() { let entries = []; @@ -1240,9 +1215,9 @@ export function createAgentRegistry({ ]); return [...paths] .map((worktreePath) => { - const runtime = [ - ...(runtimesByWorktreePath.get(worktreePath) ?? []), - ].find((entry) => entry.agentId); + const runtime = [...(runtimesByWorktreePath.get(worktreePath) ?? [])].find( + (entry) => entry.agentId, + ); return { worktreePath, state: runtime?.state ?? "inactive", @@ -1250,9 +1225,7 @@ export function createAgentRegistry({ ...(runtime ? { agentId: runtime.agentId } : {}), }; }) - .sort((left, right) => - left.worktreePath.localeCompare(right.worktreePath), - ); + .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)); }, async forgetDirectory(worktreePath) { let canonicalPath = path.normalize(worktreePath); @@ -1297,9 +1270,8 @@ export function createAgentRegistry({ bridgeInstanceId, latestSeq: workspaceSequence, openCount: runtimes.length, - workingCount: runtimes.filter( - (runtime) => runtime.state === "streaming", - ).length, + workingCount: runtimes.filter((runtime) => runtime.state === "streaming") + .length, attentionCount: runtimes.filter((runtime) => runtime.attention).length, recoveringCount: runtimes.filter( (runtime) => runtime.state === "recovering", @@ -1323,11 +1295,11 @@ export function createAgentRegistry({ }; return enqueue(runtime, async () => { const adapter = runtime.adapter; - const safe = async (command, fallback) => { + const safe = async (command, fallback, { includeError = false } = {}) => { try { return await adapter.send({ type: command }); - } catch { - return fallback; + } catch (error) { + return includeError ? { ...fallback, error: String(error) } : fallback; } }; const state = await safe("get_state", { data: {} }); @@ -1336,9 +1308,11 @@ export function createAgentRegistry({ data: { messages: [] }, }); const commands = await safe("get_commands", { data: { commands: [] } }); - const models = await safe("get_available_models", { - data: { models: [] }, - }); + const models = await safe( + "get_available_models", + { data: { models: [] } }, + { includeError: true }, + ); await updateRuntimeIdentity(runtime, state, { adapter }); return { bridgeInstanceId, @@ -1392,10 +1366,23 @@ export function createAgentRegistry({ return publicAgent(runtime); }); }, + async renewSession(agentId) { + const runtime = getAgent(agentId); + return enqueue(runtime, async () => { + if (!runtime.adapter) throw new Error("Pi is not currently running"); + if (runtime.state === "streaming" || runtime.queueCount > 0) + await runtime.adapter.send({ type: "abort" }); + const response = await beginLegacyNewSession(runtime); + if (response?.data?.cancelled) + throw new Error("Pi cancelled the session renewal"); + return publicAgent(runtime); + }); + }, async restart(agentId) { const runtime = getAgent(agentId); return enqueue(runtime, async () => { const replacedAdapter = runtime.adapter; + if (!replacedAdapter) throw new Error("Pi is not currently running"); // Detach the old child before stopping it so late events cannot mutate // presentation state that belongs to the replacement generation. runtime.adapter = undefined; @@ -1406,7 +1393,29 @@ export function createAgentRegistry({ runtime.attention = runtime.errorAttention; runtime.supervisor.markHealthy(); runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit(); - await runtime.recoveryPromise; + const nextAdapter = await runtime.recoveryPromise; + if (!nextAdapter) { + runtime.state = "failed"; + runtime.stateVersion += 1; + runtime.error = { + code: "restart_failed", + message: "Pi did not restart after the recovery attempts were exhausted", + }; + runtime.errorAttention = true; + runtime.attention = true; + publishAgent(runtime, "agent_state", { + state: runtime.state, + error: runtime.error, + }); + throw new Error(runtime.error.message); + } + runtime.error = undefined; + runtime.errorAttention = false; + runtime.attention = runtime.extensions.size > 0; + publishAgent(runtime, "agent_state", { state: runtime.state }); + publishWorkspace("runtime_recovered", runtime, { + runtime: publicRuntime(runtime), + }); return publicAgent(runtime); }); }, @@ -1458,9 +1467,7 @@ export function createAgentRegistry({ operation === "extension_response" && !runtime.extensions.has(payload.requestId) ) - throw new Error( - "extension request is not actionable for this runtime", - ); + throw new Error("extension request is not actionable for this runtime"); const response = await routeCommand( runtime.adapter, operation, @@ -1473,8 +1480,7 @@ export function createAgentRegistry({ } if (operation === "extension_response") { runtime.extensions.delete(payload.requestId); - runtime.attention = - runtime.errorAttention || runtime.extensions.size > 0; + runtime.attention = runtime.errorAttention || runtime.extensions.size > 0; } return delivery ? { ...response, delivery } : response; } catch (error) { diff --git a/src/bridge/service.js b/src/bridge/service.js index f41eaaa..6b61f60 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -76,6 +76,8 @@ export async function startBridgeService({ return { agent: await registry.retry(request.agentId) }; case "restart": return { agent: await registry.restart(request.agentId) }; + case "renew_session": + return { agent: await registry.renewSession(request.agentId) }; case "list_sessions": return { sessions: await registry.listSessions(request.agentId) }; default: diff --git a/src/protocol/index.js b/src/protocol/index.js index 3151bda..56d360b 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -32,6 +32,7 @@ const requestOperations = new Map([ ["extension_response", { agent: true, payload: "extensionResponse" }], ["retry", { agent: true, payload: "none" }], ["restart", { agent: true, payload: "none" }], + ["renew_session", { agent: true, payload: "none" }], ["get_available_models", { agent: true, payload: "none" }], ["get_commands", { agent: true, payload: "none" }], ["set_session_name", { agent: true, payload: "sessionName" }], @@ -102,10 +103,7 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) { ); } if (pattern && !pattern.test(value)) - throw new ProtocolError( - "invalid_message", - `${field} has an invalid format`, - ); + throw new ProtocolError("invalid_message", `${field} has an invalid format`); return value; } diff --git a/test/multi-session-registry.test.js b/test/multi-session-registry.test.js index 316abf7..33d542f 100644 --- a/test/multi-session-registry.test.js +++ b/test/multi-session-registry.test.js @@ -224,12 +224,10 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves startAdapter, }); const starting = restored.start(); - while (gates.length < 2) - await new Promise((resolve) => setImmediate(resolve)); + while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve)); assert.equal(gates.length, 2); gates.splice(0).forEach((resolve) => resolve()); - while (gates.length < 1) - await new Promise((resolve) => setImmediate(resolve)); + while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve)); assert.equal(gates.length, 1); gates.splice(0).forEach((resolve) => resolve()); await starting; @@ -290,11 +288,7 @@ test("serializes close behind an in-flight command and aborts the working runtim test("forces a child kill and retains its lease until exit is confirmed", async () => { const paths = await fixture(); - const saved = await sessionFile( - paths.sessionRoot, - paths.home, - "forced-close", - ); + const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close"); let killed; let releaseStop; let blockedSavedAdapter = false; @@ -445,6 +439,68 @@ test("legacy new session releases old identity and makes its history reopenable" await registry.stop(); }); +test("renews one runtime in place, aborts work, and clears its active identity", async () => { + const paths = await fixture(); + const saved = await sessionFile( + paths.sessionRoot, + paths.home, + "renew-me", + "renew-me-id", + ); + let currentState = { + sessionFile: saved, + sessionId: "renew-me-id", + sessionName: "Renew me", + }; + const adapters = adapterFactory({ stateFor: () => currentState }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const original = await registry.start(); + const adapter = adapters.calls[0].adapter; + const originalSend = adapter.send.bind(adapter); + adapter.send = async (command) => { + if (command.type === "new_session") currentState = {}; + return originalSend(command); + }; + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "stale-renew-request", method: "confirm" } }, + }); + adapters.calls[0].options.onEvent({ + type: "agent_state", + data: { state: "streaming" }, + }); + + const renewed = await registry.renewSession(original.id); + const active = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(renewed.id, original.id); + assert.equal(renewed.runtimeId, original.runtimeId); + assert.equal(active.worktreePath, paths.home); + assert.equal(active.sessionPath, undefined); + assert.equal(active.sessionId, undefined); + assert.equal(active.label, "New session"); + assert.equal(active.state, "idle"); + assert.equal(active.attention, false); + assert.deepEqual( + adapter.sent.slice(-3).map((command) => command.type), + ["abort", "new_session", "get_state"], + ); + assert.equal( + registry + .workspaceEventsAfter(0) + .events.some((event) => event.type === "runtime_renewed"), + true, + ); + + currentState = { sessionFile: saved, sessionId: "renew-me-id" }; + const reopened = await registry.openSessionRuntime(paths.home, saved); + assert.notEqual(reopened.runtimeId, original.runtimeId); + await registry.stop(); +}); + test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => { const paths = await fixture(); const createdPath = await sessionFile( @@ -611,6 +667,28 @@ test("non-default runtime snapshots do not replace the legacy session pointer", await registry.stop(); }); +test("reports available-model RPC failures in runtime snapshots", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const runtime = await registry.start(); + const originalSend = adapters.calls[0].adapter.send; + adapters.calls[0].adapter.send = async function (command) { + if (command.type === "get_available_models") + throw new Error("model registry unavailable"); + return originalSend.call(this, command); + }; + + const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId); + assert.deepEqual(snapshot.models.data.models, []); + assert.match(snapshot.models.error, /model registry unavailable/); + await registry.stop(); +}); + test("retains a corrupt workspace manifest and reports its issue", async () => { const paths = await fixture(); await mkdir(paths.sessionRoot, { recursive: true }); @@ -622,10 +700,7 @@ test("retains a corrupt workspace manifest and reports its issue", async () => { startAdapter: adapterFactory().startAdapter, }); await registry.start(); - assert.equal( - registry.getWorkspace().issue.code, - "invalid_workspace_manifest", - ); + assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest"); assert.equal(await readFile(manifestPath, "utf8"), "{broken"); await registry.stop(); }); @@ -1025,6 +1100,35 @@ test("manual restart ignores late extension dialogs from the replaced child", as await registry.stop(); }); +test("manual restart rejects exhausted recovery and marks the runtime failed", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + let starts = 0; + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter(options) { + starts += 1; + if (starts > 1) throw new Error("replacement failed to start"); + return adapters.startAdapter(options); + }, + maxRecoveryAttempts: 1, + recoveryDelayForAttempt: () => 0, + sleep: async () => {}, + }); + const runtime = await registry.start(); + + await assert.rejects( + registry.restart(runtime.id), + /Pi did not restart after the recovery attempts were exhausted/, + ); + assert.equal( + registry.getWorkspace().directories[0].runtimes[0].state, + "failed", + ); + await registry.stop(); +}); + test("child exit clears extension dialogs that recovery cannot answer", async () => { const paths = await fixture(); const adapters = adapterFactory(); diff --git a/test/protocol.test.js b/test/protocol.test.js index 2fc2a83..2a5aa92 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -79,6 +79,15 @@ test("accepts bridge-managed directory and session operations", () => { agentId: "agent-main", }, ); + assert.deepEqual( + parseRequestFrame(request({ op: "renew_session", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "renew_session", + agentId: "agent-main", + }, + ); assert.deepEqual( parseRequestFrame( request({ diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js index b91c836..872ebb6 100644 --- a/test/tauri-ui.test.js +++ b/test/tauri-ui.test.js @@ -18,11 +18,12 @@ test("wires the desktop to stable runtime workspace APIs", async () => { "close_session_runtime", "list_directory_sessions", "get_session_runtime_snapshot", + "renew_session", "subscribe_workspace", ]) assert.match(`${app}\n${hook}`, new RegExp(operation)); assert.doesNotMatch(app, /switch_session/); - assert.doesNotMatch(app, /new_session/); + assert.doesNotMatch(app, /["']new_session["']/); assert.match(reducer, /WorkspaceState\["runtimesById"\]/); assert.match(reducer, /requestGeneration/); assert.match(reducer, /loadedThroughSeq/); @@ -54,6 +55,10 @@ test("renders directory sidebar, accessible session tabs, warnings, and reduced assert.match(app, /Many Pi sessions are open/); assert.match(css, /prefers-reduced-motion/); assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/); + assert.match( + css, + /\.session-row-content\s*>\s*strong\s*\{[^}]*color:\s*#f1f3f4/s, + ); }); test("preserves native session naming, compaction, composer, and extension responses", async () => { diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index dbd7730..91c7123 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -217,6 +217,11 @@ async fn restart(agent_id: String) -> Result<(), String> { bridge::command(&socket_path()?, &agent_id, "restart").await } +#[tauri::command] +async fn renew_session(agent_id: String) -> Result<(), String> { + bridge::command(&socket_path()?, &agent_id, "renew_session").await +} + #[tauri::command] async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> { bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await @@ -393,6 +398,7 @@ pub fn run() { abort, retry, restart, + renew_session, set_model, set_thinking_level, set_session_name, diff --git a/ui/src/App.css b/ui/src/App.css index 2d82bc3..c301fda 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -158,6 +158,18 @@ select:focus-visible { flex: 0 0 auto; padding: 4px 8px; } +.command-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + border: 1px solid #a65b67; + border-radius: 5px; + background: #3d252b; + color: #ffd7db; + font-size: 13px; +} .workspace-layout { flex: 1; min-height: 0; @@ -255,35 +267,46 @@ select:focus-visible { } .session-tabs { min-width: 0; - flex: 1; + flex: 0 1 auto; display: flex; overflow-x: auto; - gap: 2px; + gap: 4px; padding-left: 4px; } .session-tab { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) 28px 28px; + align-items: stretch; flex: 0 0 auto; + overflow: hidden; + border: 1px solid #555; + border-bottom: 0; + border-radius: 7px 7px 0 0; + background: #303136; +} +.session-tab.selected { + border-color: #d88735; + background: #3b3935; + box-shadow: inset 0 2px #f0a347; } .session-tab > button[role="tab"] { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; - min-width: 124px; - max-width: 190px; - padding: 6px 8px 5px; - border: 1px solid #555; - border-bottom: 0; - border-radius: 8px 8px 0 0; - background: #303136; + min-width: 112px; + max-width: 176px; + padding: 6px 9px 5px; + border: 0; + border-radius: 0; + background: transparent; color: #d5d7dc; text-align: left; } +.session-tab > button[role="tab"]:hover { + background: #383a40; +} .session-tab.selected > button[role="tab"] { - border-color: #d88735; - background: #3b3935; color: #fff5e7; - box-shadow: inset 0 2px #f0a347; } .session-tab > button span { overflow: hidden; @@ -299,16 +322,71 @@ select:focus-visible { .session-tab small.recovering { color: #ffc170; } +.renew-session, .close-session { + display: grid; + place-items: center; + width: 28px; + min-width: 0; align-self: stretch; - border: 1px solid #505257; - border-bottom: 0; - border-left: 0; - border-radius: 0 8px 0 0; - padding: 4px 8px; - background: #303136; + border: 0; + border-radius: 0; + padding: 0; + background: transparent; color: #b8bbc1; } +.renew-session { + color: #a9f2df; +} +.renew-session-icon { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.tab-action-spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(169, 242, 223, 0.35); + border-top-color: #a9f2df; + border-radius: 50%; + animation: tab-action-spin 0.8s linear infinite; +} +.close-session { + font-size: 16px; +} +.session-tab.selected .renew-session, +.session-tab.selected .close-session { + color: #fff5e7; +} +.renew-session:hover { + background: #294139; + color: #c8ffef; +} +.close-session:hover { + background: #4a3131; + color: #ffd7db; +} +.new-session-tab { + align-self: stretch; + flex: 0 0 32px; + margin-left: 4px; + padding: 0; + border: 1px solid #d88735; + border-bottom: 0; + border-radius: 8px 8px 0 0; + background: #4a3827; + color: #ffc170; + font-size: 18px; + line-height: 1; +} +.new-session-tab:hover { + background: #5a432d; + color: #ffe0b6; +} .unread-dot { width: 7px; height: 7px; @@ -321,7 +399,8 @@ select:focus-visible { .session-tab-actions { display: flex; gap: 3px; - padding: 0 0 2px 5px; + margin-left: auto; + padding: 0 0 2px 8px; } .session-tab-actions button { white-space: nowrap; @@ -336,11 +415,6 @@ select:focus-visible { background: #4a4540; color: #ffc170; } -.session-tab-actions .new-session { - border-color: #d88735; - background: #4a3827; - color: #ffc170; -} .session-tab-actions span { margin-right: 5px; font-size: 15px; @@ -356,18 +430,68 @@ select:focus-visible { padding: 7px; } .workflow { + position: relative; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 7px; } +.session-lock { + position: absolute; + z-index: 18; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(20, 18, 16, 0.86); + backdrop-filter: blur(3px); +} +.session-lock-card { + display: flex; + align-items: center; + gap: 12px; + width: min(420px, 92%); + padding: 15px 17px; + border: 1px solid #f0a347; + border-radius: 8px; + background: #33271d; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48); + color: #ffd9aa; +} +.session-lock-card > div { + display: grid; + gap: 3px; +} +.session-lock-card strong { + color: #fff2df; + font-size: 14px; +} +.session-lock-card p { + margin: 0; + font-weight: 700; +} +.session-lock-card small { + color: #d8b892; +} +.session-lock-bars { + flex: 0 0 auto; + transform: scale(1.3); +} .workflow-main { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 5px; } +.transcript-container { + position: relative; + min-width: 0; + min-height: 0; +} +.transcript { + height: 100%; +} .transcript, .todos-pane, .settings, @@ -385,6 +509,33 @@ select:focus-visible { inset 3px 0 #f0a347, 0 0 16px rgba(240, 163, 71, 0.25); } +.transcript:focus-visible { + outline: 3px solid #f0a347; + outline-offset: -3px; +} +.scroll-to-latest { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + z-index: 2; + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid #f0a347; + border-radius: 50%; + background: #3d2d1d; + box-shadow: 0 5px 14px rgba(0, 0, 0, 0.4); + color: #ffd39a; + font-size: 20px; + line-height: 1; +} +.scroll-to-latest:hover { + background: #4b3420; + color: #fff0da; +} .agent-working { display: flex; align-items: center; @@ -489,30 +640,62 @@ select:focus-visible { font-size: 13px; line-height: 1.32; } -.tool-result summary { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - list-style: none; +.tool-result-visible > strong, +.tool-result-label { color: #a9f2df; font-size: 10px; font-weight: 700; text-transform: uppercase; } +.tool-result summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 8px; + cursor: pointer; + list-style: none; +} .tool-result summary::-webkit-details-marker { display: none; } .tool-result summary::after { + grid-column: 2; + grid-row: 1; content: "▾"; + color: #a9f2df; font-size: 14px; } +.tool-result-preview { + grid-column: 1 / -1; + display: block; + padding: 5px 6px; + border-left: 2px solid #547d72; + background: #222b29; + color: #c9d8d4; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 400; + line-height: 1.32; + text-transform: none; + white-space: pre-wrap; +} +.tool-result-hidden-hint { + grid-column: 1 / -1; + color: #91a69f; + font-size: 10px; + font-weight: 600; + text-transform: none; +} .tool-result[open] summary::after { content: "▴"; } .tool-result[open] summary { margin-bottom: 6px; } +.tool-result[open] .tool-result-preview, +.tool-result[open] .tool-result-hidden-hint { + display: none; +} .pending-message { border-style: dashed; } @@ -566,14 +749,16 @@ select:focus-visible { border-radius: 5px; background: #303030; } -.pi-controls label { +.pi-controls label, +.model-control { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto minmax(0, 1fr); gap: 6px; align-items: center; font-size: 10px; } -.pi-controls select { +.pi-controls select, +.model-trigger { min-width: 0; appearance: none; color-scheme: dark; @@ -585,11 +770,13 @@ select:focus-visible { no-repeat right 7px center; color: #f2ece4; } -.pi-controls select:hover { +.pi-controls select:hover, +.model-trigger:hover { border-color: #d88735; background-color: #383129; } -.pi-controls select:focus { +.pi-controls select:focus, +.model-trigger:focus { border-color: #f0a347; box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22); } @@ -601,6 +788,101 @@ select:focus-visible { background: #4b3420; color: #fff2df; } +.model-selector { + position: relative; + min-width: 0; +} +.model-trigger { + width: 100%; + overflow: hidden; + text-align: left; + font-weight: 650; +} +.model-trigger > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.model-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 30; + display: grid; + width: min(400px, calc(100vw - 40px)); + max-height: min(420px, calc(100vh - 80px)); + gap: 6px; + padding: 8px; + border: 1px solid #70522f; + border-radius: 7px; + background: #292724; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); +} +.model-menu input { + width: 100%; + padding: 7px 9px; + border: 1px solid #5b5146; + border-radius: 4px; + background: #1f1e1c; + color: #f2ece4; +} +.model-result-count { + margin: 0; + color: #aaa39a; + font-size: 10px; +} +.model-results { + display: grid; + gap: 7px; + min-height: 0; + overflow-y: auto; +} +.model-provider-group { + display: grid; + gap: 2px; +} +.model-provider-heading { + position: sticky; + top: 0; + z-index: 1; + padding: 4px 5px 3px; + background: #292724; + color: #f0a347; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.model-option { + display: grid; + gap: 1px; + width: 100%; + padding: 6px 7px; + border: 1px solid transparent; + background: #35322e; + color: #f2ece4; + text-align: left; +} +.model-option:hover { + border-color: #8c6338; + background: #44382d; +} +.model-option[aria-selected="true"] { + border-color: #f0a347; + background: #4b3420; + color: #fff2df; +} +.model-option small { + overflow-wrap: anywhere; + color: #aaa39a; + font-size: 9px; + font-weight: 500; +} +.model-empty { + margin: 5px 0; + text-align: center; +} .composer-progress { display: flex; align-items: center; @@ -654,6 +936,25 @@ select:focus-visible { display: flex; gap: 7px; } +.restart-progress { + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + margin-top: 8px; + padding: 7px 9px; + border: 1px solid #b76f26; + border-radius: 5px; + background: #41301f; + color: #ffd39a; +} +.restart-progress > span:last-child { + display: grid; + gap: 1px; +} +.restart-progress small { + color: #d9b987; +} .default-worktree-control { display: grid; gap: 4px; @@ -820,6 +1121,9 @@ select:focus-visible { min-width: 0; gap: 3px; } +.session-row-content > strong { + color: #f1f3f4; +} .session-preview, .session-metadata { overflow: hidden; @@ -883,6 +1187,11 @@ select:focus-visible { .notification { padding: 6px; } +@keyframes tab-action-spin { + to { + transform: rotate(360deg); + } +} @keyframes work-pulse { 70% { box-shadow: 0 0 0 7px rgba(240, 163, 71, 0); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 0000000..b9563a9 --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,372 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeSummary, Workspace } from "./workspace/types"; +import { + initialWorkspaceState, + reconcileWorkspace, + workspaceReducer, +} from "./workspace/reducer"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + loadSnapshot: vi.fn(), + refresh: vi.fn(), + dispatch: vi.fn(), + useWorkspace: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ startDragging: vi.fn() }), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); +vi.mock("./workspace/useWorkspace", () => ({ + useWorkspace: mocks.useWorkspace, +})); + +import App from "./App"; + +const summary: RuntimeSummary = { + runtimeId: "runtime-a", + agentId: "agent-a", + worktreePath: "/repo", + state: "idle", + label: "Session A", + attention: false, + queueCount: 0, + lastActivity: "2026-08-24T20:00:00.000Z", + openedAt: "2026-08-24T20:00:00.000Z", +}; +const otherSummary: RuntimeSummary = { + ...summary, + runtimeId: "runtime-b", + agentId: "agent-b", + label: "Session B", +}; + +function workspaceState() { + const workspace: Workspace = { + bridgeInstanceId: "epoch", + latestSeq: 0, + directories: [ + { + worktreePath: "/repo", + isHome: true, + openCount: 2, + workingCount: 0, + attentionCount: 0, + recoveringCount: 0, + errorCount: 0, + runtimes: [summary, otherSummary], + }, + ], + }; + const state = reconcileWorkspace(initialWorkspaceState, workspace); + state.runtimesById[summary.runtimeId].models = [ + { provider: "openai", id: "gpt-5", name: "GPT 5" }, + ]; + return state; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("App command progress and errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.refresh.mockResolvedValue(undefined); + mocks.loadSnapshot.mockResolvedValue(true); + mocks.useWorkspace.mockReturnValue({ + state: workspaceState(), + dispatch: mocks.dispatch, + refresh: mocks.refresh, + loadSnapshot: mocks.loadSnapshot, + }); + }); + + it("shows restart progress until the replacement snapshot is restored", async () => { + const user = userEvent.setup(); + const restart = deferred(); + const snapshot = deferred(); + mocks.invoke.mockImplementation((command: string) => + command === "restart" ? restart.promise : Promise.resolve(undefined), + ); + mocks.loadSnapshot.mockReturnValue(snapshot.promise); + render(); + await user.click(screen.getByRole("button", { name: "Controls" })); + await user.click(screen.getByRole("button", { name: "Restart Pi" })); + + const restarting = screen.getByRole("button", { name: "Restarting Pi…" }); + expect(restarting).toBeDisabled(); + expect( + screen.getByText("Restoring this session and its extensions…"), + ).toBeInTheDocument(); + expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true"); + await user.click(restarting); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + + await act(async () => restart.resolve(undefined)); + expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled(); + expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", { + throwOnError: true, + }); + + await act(async () => snapshot.resolve(true)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Restart Pi" })).toBeEnabled(), + ); + expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "false"); + }); + + it("renews a background session from its tab without changing selection", async () => { + const user = userEvent.setup(); + const renewal = deferred(); + const snapshot = deferred(); + mocks.invoke.mockImplementation((command: string) => + command === "renew_session" ? renewal.promise : Promise.resolve(undefined), + ); + mocks.loadSnapshot.mockReturnValue(snapshot.promise); + render(); + + await user.click(screen.getByRole("button", { name: "Renew Session B" })); + expect(mocks.invoke).toHaveBeenCalledWith("renew_session", { + agentId: "agent-b", + }); + expect( + screen.getByRole("button", { name: "Renewing Session B" }), + ).toBeDisabled(); + expect(screen.getByRole("tab", { name: /Session A/ })).toHaveAttribute( + "aria-selected", + "true", + ); + + await act(async () => renewal.resolve(undefined)); + expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-b", { + throwOnError: true, + }); + await act(async () => snapshot.resolve(true)); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Renew Session B" }), + ).toBeEnabled(), + ); + }); + + it("keeps parallel prompt submission state attached to its session", async () => { + const user = userEvent.setup(); + const submissionA = deferred<{ delivery: "prompt" }>(); + const submissionB = deferred<{ delivery: "prompt" }>(); + let state = workspaceState(); + state = workspaceReducer(state, { + type: "draftChanged", + runtimeId: "runtime-a", + draft: "Prompt A", + }); + mocks.dispatch.mockImplementation((action) => { + state = workspaceReducer(state, action); + }); + mocks.useWorkspace.mockImplementation(() => ({ + state, + dispatch: mocks.dispatch, + refresh: mocks.refresh, + loadSnapshot: mocks.loadSnapshot, + })); + mocks.invoke.mockImplementation( + (command: string, args?: { agentId?: string }) => { + if (command !== "submit_prompt") return Promise.resolve(undefined); + return args?.agentId === "agent-a" + ? submissionA.promise + : submissionB.promise; + }, + ); + const rendered = render(); + + await user.click(screen.getByRole("button", { name: "Send" })); + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-b", + }); + state = workspaceReducer(state, { + type: "draftChanged", + runtimeId: "runtime-b", + draft: "Prompt B", + }); + rendered.rerender(); + await user.click(screen.getByRole("button", { name: "Send" })); + + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-a", + }); + rendered.rerender(); + + expect(screen.getByLabelText("Prompt")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled(); + expect(screen.getByText("Sending your prompt…")).toBeInTheDocument(); + expect(screen.getByText("Working", { selector: ".state-pill" })).toHaveClass( + "working", + ); + + await act(async () => { + submissionA.resolve({ delivery: "prompt" }); + submissionB.resolve({ delivery: "prompt" }); + }); + }); + + it("keeps prompt failures attached to the session that submitted them", async () => { + const user = userEvent.setup(); + const submission = deferred<{ delivery: "prompt" }>(); + let state = workspaceState(); + state = workspaceReducer(state, { + type: "draftChanged", + runtimeId: "runtime-a", + draft: "Fail in A", + }); + mocks.dispatch.mockImplementation((action) => { + state = workspaceReducer(state, action); + }); + mocks.useWorkspace.mockImplementation(() => ({ + state, + dispatch: mocks.dispatch, + refresh: mocks.refresh, + loadSnapshot: mocks.loadSnapshot, + })); + mocks.invoke.mockReturnValue(submission.promise); + const rendered = render(); + + await user.click(screen.getByRole("button", { name: "Send" })); + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-b", + }); + rendered.rerender(); + await act(async () => submission.reject(new Error("session A failed"))); + + expect( + screen.queryByText("Pi command failed: Error: session A failed"), + ).toBeNull(); + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-a", + }); + rendered.rerender(); + expect( + screen.getByText("Pi command failed: Error: session A failed"), + ).toBeInTheDocument(); + }); + + it("keeps command forms bound to the session that opened them", async () => { + const user = userEvent.setup(); + let state = workspaceState(); + state = workspaceReducer(state, { + type: "draftChanged", + runtimeId: "runtime-a", + draft: "/bash", + }); + mocks.dispatch.mockImplementation((action) => { + state = workspaceReducer(state, action); + }); + mocks.useWorkspace.mockImplementation(() => ({ + state, + dispatch: mocks.dispatch, + refresh: mocks.refresh, + loadSnapshot: mocks.loadSnapshot, + })); + mocks.invoke.mockResolvedValue(undefined); + const rendered = render(); + + await user.click(screen.getByRole("button", { name: /\/bash/ })); + expect(screen.getByRole("dialog", { name: "/bash" })).toBeInTheDocument(); + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-b", + }); + rendered.rerender(); + expect(screen.queryByRole("dialog", { name: "/bash" })).toBeNull(); + + state = workspaceReducer(state, { + type: "selectRuntime", + runtimeId: "runtime-a", + }); + rendered.rerender(); + await user.type(screen.getByLabelText("command"), "echo session-a"); + await user.click(screen.getByRole("button", { name: "Run command" })); + + expect(mocks.invoke).toHaveBeenCalledWith("pi_rpc_command", { + agentId: "agent-a", + command: "bash", + input: { command: "echo session-a" }, + }); + }); + + it("locks selected-session input and output throughout renewal", async () => { + const user = userEvent.setup(); + const renewal = deferred(); + const snapshot = deferred(); + mocks.invoke.mockImplementation((command: string) => + command === "renew_session" ? renewal.promise : Promise.resolve(undefined), + ); + mocks.loadSnapshot.mockReturnValue(snapshot.promise); + render(); + const prompt = screen.getByLabelText("Prompt"); + expect(prompt).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Renew Session A" })); + expect(prompt).toBeDisabled(); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-busy", "true"); + expect( + screen.getByText("Input and output are temporarily locked."), + ).toBeInTheDocument(); + expect( + screen + .getByRole("region", { name: "Conversation" }) + .closest(".workflow-main"), + ).toHaveAttribute("inert"); + expect( + screen.getByRole("button", { name: /Model Current model/i }), + ).toBeDisabled(); + + await act(async () => renewal.resolve(undefined)); + expect(prompt).toBeDisabled(); + expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", { + throwOnError: true, + }); + await act(async () => snapshot.resolve(true)); + await waitFor(() => expect(prompt).toBeEnabled()); + expect( + screen.queryByText("Input and output are temporarily locked."), + ).toBeNull(); + }); + + it("keeps the model picker open and shows a persistent alert on RPC failure", async () => { + const user = userEvent.setup(); + mocks.invoke.mockRejectedValue(new Error("provider rejected the model")); + render(); + await user.click( + screen.getByRole("button", { name: /Model Current model/i }), + ); + await user.click(screen.getByRole("option", { name: /GPT 5/ })); + + expect(mocks.invoke).toHaveBeenCalledWith("set_model", { + agentId: "agent-a", + provider: "openai", + modelId: "gpt-5", + }); + const modelError = await screen.findByText( + "Pi model command failed: Error: provider rejected the model", + ); + expect(modelError.closest('[role="alert"]')).not.toBeNull(); + expect( + screen.getByRole("dialog", { name: "Choose model" }), + ).toBeInTheDocument(); + expect(mocks.loadSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e68fa76..40ee4b7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -31,15 +31,57 @@ type LocalOperation = { | "closing" | "listing" | "submitting" - | "command"; + | "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) { + return command === "pi_rpc_command" && typeof args.command === "string" + ? args.command + : command; +} + +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 [commandForm, setCommandForm] = useState(); + const [runtimeStatuses, setRuntimeStatuses] = useState>( + {}, + ); + const [commandForms, setCommandForms] = useState>( + {}, + ); + const [commandNotices, setCommandNotices] = useState< + Record + >({}); const [view, setView] = useState<"conversation" | "settings">("conversation"); const [adding, setAdding] = useState(false); const [folderPath, setFolderPath] = useState(""); @@ -63,24 +105,50 @@ export default function App() { state.runtimesById, ], ); - const previousSelected = useRef(undefined); const mountedRef = useRef(true); const operationSequence = useRef(0); - const [operation, setOperation] = useState(); - const [abortingRuntimeId, setAbortingRuntimeId] = useState(); + const activeAgentCommands = useRef(new Set()); + const [operations, setOperations] = useState>( + {}, + ); + const [abortingRuntimeIds, setAbortingRuntimeIds] = useState>( + () => new Set(), + ); const [restoreTabFocus, setRestoreTabFocus] = useState(false); + 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 id = ++operationSequence.current; - setOperation({ id, kind, detail, runtimeId }); - setStatus(detail); - return id; + 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) => - setOperation((current) => (current?.id === id ? undefined : current)); + setOperations((current) => { + if (!(id in current)) return current; + const next = { ...current }; + delete next[id]; + return next; + }); useEffect(() => { mountedRef.current = true; @@ -89,34 +157,29 @@ export default function App() { }; }, []); + const selectedRuntimeId = selected?.summary.runtimeId; useEffect(() => { - if (!selected || operation) return; - setStatus( - selected.summary.state === "streaming" - ? "Pi working" - : (selected.error ?? "Pi ready"), - ); - if (previousSelected.current !== selected.summary.runtimeId) - previousSelected.current = selected.summary.runtimeId; + if (!selectedRuntimeId) return; requestAnimationFrame(() => - dispatch({ type: "markRead", runtimeId: selected.summary.runtimeId }), + dispatch({ type: "markRead", runtimeId: selectedRuntimeId }), ); - }, [selected, dispatch, operation]); + }, [selectedRuntimeId, dispatch]); useEffect(() => { - if ( - abortingRuntimeId && - state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming" - ) - setAbortingRuntimeId(undefined); - }, [abortingRuntimeId, state.runtimesById]); + 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 createRuntime(path = state.selectedDirectoryPath) { if (!path || !mountedRef.current) return; - const operationId = startOperation( - "creating", - "Starting a new Pi session…", - ); + const operationId = startOperation("creating", "Starting a new Pi session…"); try { const result = await invoke<{ runtime: RuntimeView["summary"] }>( "create_session_runtime", @@ -157,7 +220,10 @@ export default function App() { if (mountedRef.current) setRestoreTabFocus(true); } catch (error) { if (mountedRef.current) - setStatus(`Could not close session: ${String(error)}`); + setRuntimeStatus( + runtime.summary.runtimeId, + `Could not close session: ${String(error)}`, + ); } finally { if (mountedRef.current) finishOperation(operationId); } @@ -237,20 +303,50 @@ export default function App() { const runtimeId = target?.summary.runtimeId, agentId = target?.summary.agentId; if (!runtimeId || !agentId) { - setStatus("This session is not currently available"); + 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("command", "Updating Pi session…", runtimeId) + ? startOperation( + renewing ? "renewing" : restarting ? "restarting" : "command", + commandProgress(operationCommand), + runtimeId, + operationCommand, + ) : undefined; try { await invoke(command, { agentId, ...args }); - if (reload) await loadSnapshot(runtimeId); + if (reload) await loadSnapshot(runtimeId, { throwOnError: true }); return true; } catch (error) { - setStatus(`Pi command failed: ${String(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); } } @@ -279,7 +375,10 @@ export default function App() { const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim(); if (text === "/name" || name) { if (!name) { - setStatus("Enter a session name after /name"); + setRuntimeStatus( + selected.summary.runtimeId, + "Enter a session name after /name", + ); return; } await agentCommand("set_session_name", { name }); @@ -334,13 +433,9 @@ export default function App() { ); const delivery = result.delivery ?? initialDelivery; dispatch({ type: "submissionSent", runtimeId, id, delivery }); - setStatus( - delivery === "follow_up" - ? "Follow-up queued · waiting for Pi…" - : "Prompt accepted · waiting for Pi…", - ); + clearRuntimeStatus(runtimeId); } catch (error) { - setStatus(`Pi command failed: ${String(error)}`); + setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`); dispatch({ type: "submissionRemoved", runtimeId, id }); dispatch({ type: "draftChanged", runtimeId, draft: text }); } finally { @@ -350,22 +445,39 @@ export default function App() { async function abortSelected() { const target = selected; if (!target?.summary.agentId) return; - setAbortingRuntimeId(target.summary.runtimeId); - setStatus("Aborting Pi…"); + const runtimeId = target.summary.runtimeId; + setAbortingRuntimeIds((current) => new Set(current).add(runtimeId)); + clearRuntimeStatus(runtimeId); try { await invoke("abort", { agentId: target.summary.agentId }); } catch (error) { - setStatus(`Pi command failed: ${String(error)}`); - setAbortingRuntimeId(undefined); + 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 = {}, + target = selected, ) { - if (!selected) return; - await agentCommand("pi_rpc_command", { command: command.command, input }); - setCommandForm(undefined); + 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) { const target = selected; @@ -379,11 +491,54 @@ export default function App() { ); } const closePicker = useCallback(() => setSessions(undefined), []); + const activeOperations = Object.values(operations); + const globalOperation = activeOperations + .filter((entry) => !entry.runtimeId) + .reduce( + (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 = - operation?.detail ?? state.newSessionLaunch?.detail ?? + globalOperation?.detail ?? + (isAborting ? "Aborting Pi…" : undefined) ?? + selectedOperation?.detail ?? (pendingSubmission ? pendingSubmission.phase === "sending" ? pendingSubmission.delivery === "follow_up" @@ -393,33 +548,54 @@ export default function App() { ? "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 isStartingSession = state.newSessionLaunch?.phase === "starting" || - operation?.kind === "creating"; + 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; return (
{ if ( event.button === 0 && - !(event.target as HTMLElement).closest( - "button,input,select,textarea", - ) + !(event.target as HTMLElement).closest("button,input,select,textarea") ) void getCurrentWindow().startDragging(); }} @@ -429,15 +605,19 @@ export default function App() { PI

{selected?.summary.label ?? "Pi workspace"}

{selected && ( - - {isWorking && ( -