feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+35 -6
View File
@@ -20,6 +20,7 @@ const DEFAULT_EVENT_LIMIT = 1_000;
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000; const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
const RESTORE_CONCURRENCY = 2; const RESTORE_CONCURRENCY = 2;
const RESOURCE_WARNING_COUNT = 6; const RESOURCE_WARNING_COUNT = 6;
const INITIAL_LAUNCH_GET_STATE_TIMEOUT_MS = 90_000;
export class UnknownAgentError extends Error { export class UnknownAgentError extends Error {
constructor(agentId) { constructor(agentId) {
@@ -229,6 +230,8 @@ function routeCommand(adapter, operation, payload = {}, runtimeState) {
return adapter.send({ type: "set_thinking_level", level: payload.level }); return adapter.send({ type: "set_thinking_level", level: payload.level });
case "set_session_name": case "set_session_name":
return adapter.send({ type: "set_session_name", name: payload.name }); return adapter.send({ type: "set_session_name", name: payload.name });
case "pi_rpc_command":
return adapter.send({ type: payload.command, ...payload.input });
case "extension_response": case "extension_response":
adapter.respondToExtension(payload.requestId, payload.response); adapter.respondToExtension(payload.requestId, payload.response);
return Promise.resolve({ return Promise.resolve({
@@ -451,11 +454,16 @@ export function createAgentRegistry({
let resolved; let resolved;
if (typeof nextPath === "string" && path.isAbsolute(nextPath)) { if (typeof nextPath === "string" && path.isAbsolute(nextPath)) {
try { try {
resolved = await ownedSessionPath(runtime.sessionDir, nextPath, { const candidate = await ownedSessionPath(runtime.sessionDir, nextPath, {
allowMissing: true, allowMissing: true,
}); });
} catch { await stat(candidate);
throw new Error("Pi reported a session outside its managed directory"); resolved = candidate;
} catch (error) {
if (error?.code !== "ENOENT")
throw new Error(
"Pi reported a session outside its managed directory",
);
} }
} }
if (expectedSessionPath && resolved !== expectedSessionPath) if (expectedSessionPath && resolved !== expectedSessionPath)
@@ -627,8 +635,19 @@ export function createAgentRegistry({
}); });
} }
}, },
onError: (error) => { onError: (error, { terminal = true } = {}) => {
if (runtime.adapter !== adapter || runtime.stopped) return; if (runtime.adapter !== adapter || runtime.stopped) return;
if (!terminal) {
const diagnostic = {
code: error.code ?? "unknown",
message: error.message,
};
publishAgent(runtime, "diagnostic", { error: diagnostic });
publishWorkspace("runtime_diagnostic", runtime, {
error: diagnostic,
});
return;
}
runtime.state = "error"; runtime.state = "error";
runtime.stateVersion += 1; runtime.stateVersion += 1;
runtime.error = { runtime.error = {
@@ -673,7 +692,10 @@ export function createAgentRegistry({
}); });
runtime.adapter = adapter; runtime.adapter = adapter;
try { try {
const state = await adapter.send({ type: "get_state" }); const state = await adapter.send(
{ type: "get_state" },
{ timeoutMs: INITIAL_LAUNCH_GET_STATE_TIMEOUT_MS },
);
runtime.state = state?.data?.isStreaming ? "streaming" : "idle"; runtime.state = state?.data?.isStreaming ? "streaming" : "idle";
runtime.stateVersion += 1; runtime.stateVersion += 1;
await updateRuntimeIdentity(runtime, state, { await updateRuntimeIdentity(runtime, state, {
@@ -1390,6 +1412,7 @@ export function createAgentRegistry({
}, },
async route(agentId, operation, payload = {}) { async route(agentId, operation, payload = {}) {
const runtime = getAgent(agentId); const runtime = getAgent(agentId);
if (operation === "abort") return runtime.adapter.send({ type: "abort" });
return enqueue(runtime, async () => { return enqueue(runtime, async () => {
if (operation === "switch_session") { if (operation === "switch_session") {
if (runtime.state === "streaming") if (runtime.state === "streaming")
@@ -1416,6 +1439,12 @@ export function createAgentRegistry({
} }
const previousState = runtime.state; const previousState = runtime.state;
const previousStateVersion = runtime.stateVersion; const previousStateVersion = runtime.stateVersion;
const delivery =
operation === "submit_prompt"
? previousState === "streaming"
? "follow_up"
: "prompt"
: undefined;
const startsWork = const startsWork =
operation === "prompt" || operation === "prompt" ||
(operation === "submit_prompt" && previousState !== "streaming"); (operation === "submit_prompt" && previousState !== "streaming");
@@ -1447,7 +1476,7 @@ export function createAgentRegistry({
runtime.attention = runtime.attention =
runtime.errorAttention || runtime.extensions.size > 0; runtime.errorAttention || runtime.extensions.size > 0;
} }
return response; return delivery ? { ...response, delivery } : response;
} catch (error) { } catch (error) {
if ( if (
startsWork && startsWork &&
+55 -36
View File
@@ -2,28 +2,13 @@ import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { StringDecoder } from "node:string_decoder"; import { StringDecoder } from "node:string_decoder";
import { PI_RPC_COMMANDS } from "./pi-rpc-command-spec.js";
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000; export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000;
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024; export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
const supportedCommands = new Set([ const supportedCommands = new Set(PI_RPC_COMMANDS);
"prompt",
"steer",
"follow_up",
"abort",
"get_state",
"get_session_stats",
"new_session",
"switch_session",
"get_messages",
"get_available_models",
"get_commands",
"set_session_name",
"compact",
"set_model",
"set_thinking_level",
]);
export class PiRpcError extends Error { export class PiRpcError extends Error {
constructor(code, message) { constructor(code, message) {
@@ -43,9 +28,9 @@ function assertAbsolutePath(value, field) {
} }
} }
function emitSafely(callback, value) { function emitSafely(callback, ...values) {
try { try {
callback(value); callback(...values);
} catch { } catch {
// Adapter observers must not interrupt the RPC reader. // Adapter observers must not interrupt the RPC reader.
} }
@@ -161,6 +146,7 @@ export function startPiRpcAdapter({
let sequence = 0; let sequence = 0;
let stdoutBuffer = ""; let stdoutBuffer = "";
let discardingOversizedFrame = false;
let closed = false; let closed = false;
let intentionalStop = false; let intentionalStop = false;
const decoder = new StringDecoder("utf8"); const decoder = new StringDecoder("utf8");
@@ -170,7 +156,7 @@ export function startPiRpcAdapter({
resolveExit = resolve; resolveExit = resolve;
}); });
const reportError = (error) => emitSafely(onError, error); const reportError = (error, metadata) => emitSafely(onError, error, metadata);
const rejectPending = (error) => { const rejectPending = (error) => {
for (const entry of pending.values()) { for (const entry of pending.values()) {
clearTimeout(entry.timeout); clearTimeout(entry.timeout);
@@ -237,37 +223,58 @@ export function startPiRpcAdapter({
if (frame.type === "response") handleResponse(frame); if (frame.type === "response") handleResponse(frame);
else handleEvent(frame); else handleEvent(frame);
}; };
const reportOversizedFrame = () =>
child.stdout.on("data", (chunk) => { reportError(
if (closed) return; new PiRpcError(
stdoutBuffer += decoder.write(chunk); "frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
{ terminal: false },
);
const consumeStdout = (text) => {
let remaining = text;
if (discardingOversizedFrame) {
const newlineIndex = remaining.indexOf("\n");
if (newlineIndex === -1) return;
discardingOversizedFrame = false;
remaining = remaining.slice(newlineIndex + 1);
}
stdoutBuffer += remaining;
let newlineIndex; let newlineIndex;
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) { while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex); const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (Buffer.byteLength(line, "utf8") > maxFrameBytes) {
reportOversizedFrame();
continue;
}
handleLine(line); handleLine(line);
} }
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) { if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
reportError( reportOversizedFrame();
new PiRpcError(
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
);
stdoutBuffer = ""; stdoutBuffer = "";
discardingOversizedFrame = true;
} }
};
child.stdout.on("data", (chunk) => {
if (!closed) consumeStdout(decoder.write(chunk));
}); });
child.stdout.on("end", () => { child.stdout.on("end", () => {
if (closed) return; if (closed) return;
const tail = stdoutBuffer + decoder.end(); consumeStdout(decoder.end());
stdoutBuffer = ""; if (discardingOversizedFrame) {
if (tail.length > 0) discardingOversizedFrame = false;
return;
}
if (stdoutBuffer.length > 0)
reportError( reportError(
new PiRpcError( new PiRpcError(
"unterminated_frame", "unterminated_frame",
"Pi RPC stdout ended without an LF-terminated frame", "Pi RPC stdout ended without an LF-terminated frame",
), ),
); );
stdoutBuffer = "";
}); });
child.stderr.on("data", () => {}); child.stderr.on("data", () => {});
child.stderr.on("error", (error) => child.stderr.on("error", (error) =>
@@ -293,7 +300,7 @@ export function startPiRpcAdapter({
get sequence() { get sequence() {
return sequence; return sequence;
}, },
send(commandInput) { send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") { if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject( return Promise.reject(
new PiRpcError( new PiRpcError(
@@ -318,12 +325,24 @@ export function startPiRpcAdapter({
), ),
); );
} }
if (
requestedTimeoutMs !== undefined &&
(!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0)
) {
return Promise.reject(
new PiRpcError(
"invalid_timeout",
"Pi RPC command timeout must be a positive safe integer",
),
);
}
const id = `bridge-${randomUUID()}`; const id = `bridge-${randomUUID()}`;
const timeoutMs = const timeoutMs =
commandInput.type === "compact" requestedTimeoutMs ??
(commandInput.type === "compact"
? compactCommandTimeoutMs ? compactCommandTimeoutMs
: commandTimeoutMs; : commandTimeoutMs);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
pending.delete(id); pending.delete(id);
+48
View File
@@ -0,0 +1,48 @@
const fields = {
prompt: ["message", "images", "streamingBehavior"],
steer: ["message", "images"],
follow_up: ["message", "images"],
abort: [],
new_session: ["parentSession"],
get_state: [],
get_messages: [],
set_model: ["provider", "modelId"],
cycle_model: [],
get_available_models: [],
set_thinking_level: ["level"],
cycle_thinking_level: [],
set_steering_mode: ["mode"],
set_follow_up_mode: ["mode"],
compact: ["customInstructions"],
set_auto_compaction: ["enabled"],
set_auto_retry: ["enabled"],
abort_retry: [],
bash: ["command"],
abort_bash: [],
get_session_stats: [],
export_html: ["outputPath"],
switch_session: ["sessionPath"],
fork: ["entryId"],
clone: [],
get_fork_messages: [],
get_entries: ["since"],
get_tree: [],
get_last_assistant_text: [],
set_session_name: ["name"],
get_commands: [],
};
export const PI_RPC_COMMANDS = Object.freeze(Object.keys(fields));
export function validatePiRpcCommand(command, input) {
if (!Object.hasOwn(fields, command))
throw new TypeError(`unsupported Pi RPC command: ${command}`);
if (input === undefined) return {};
if (input === null || typeof input !== "object" || Array.isArray(input))
throw new TypeError("Pi RPC command input must be an object");
for (const key of Object.keys(input)) {
if (!fields[command].includes(key))
throw new TypeError(`unsupported Pi RPC field: ${command}.${key}`);
}
return input;
}
+13
View File
@@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import { validatePiRpcCommand } from "../bridge/pi-rpc-command-spec.js";
export const PROTOCOL_VERSION = "v1"; export const PROTOCOL_VERSION = "v1";
export const MAX_FRAME_BYTES = 64 * 1024; export const MAX_FRAME_BYTES = 64 * 1024;
@@ -37,6 +38,7 @@ const requestOperations = new Map([
["compact", { agent: true, payload: "compact" }], ["compact", { agent: true, payload: "compact" }],
["set_model", { agent: true, payload: "model" }], ["set_model", { agent: true, payload: "model" }],
["set_thinking_level", { agent: true, payload: "thinking" }], ["set_thinking_level", { agent: true, payload: "thinking" }],
["pi_rpc_command", { agent: true, payload: "piRpcCommand" }],
]); ]);
const eventTypes = new Set([ const eventTypes = new Set([
@@ -275,6 +277,17 @@ function validatePayload(kind, value) {
); );
return { level }; return { level };
} }
case "piRpcCommand": {
assertAllowedKeys(payload, new Set(["command", "input"]), "payload");
const command = assertString(payload.command, "payload.command", {
maxLength: 64,
});
try {
return { command, input: validatePiRpcCommand(command, payload.input) };
} catch (error) {
throw new ProtocolError("invalid_message", error.message);
}
}
default: default:
throw new ProtocolError("invalid_message", "unsupported payload shape"); throw new ProtocolError("invalid_message", "unsupported payload shape");
} }
+64 -4
View File
@@ -20,10 +20,12 @@ function createAdapterFactory() {
startAdapter: (options) => { startAdapter: (options) => {
const adapter = { const adapter = {
sent: [], sent: [],
sentWithOptions: [],
extensionResponses: [], extensionResponses: [],
stopped: false, stopped: false,
send(command) { send(command, options) {
this.sent.push(command); this.sent.push(command);
this.sentWithOptions.push({ command, options });
return Promise.resolve({ return Promise.resolve({
type: "response", type: "response",
command: command.type, command: command.type,
@@ -53,6 +55,10 @@ test("starts only the home agent and creates other worktree agents on explicit s
}); });
const home = await registry.start(); const home = await registry.start();
assert.deepEqual(fixture.calls[0].adapter.sentWithOptions[0], {
command: { type: "get_state" },
options: { timeoutMs: 90_000 },
});
assert.equal(registry.listAgents().length, 1); assert.equal(registry.listAgents().length, 1);
assert.equal(home.worktreePath, worktrees.home); assert.equal(home.worktreePath, worktrees.home);
@@ -72,6 +78,58 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped)); assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
}); });
test("sends abort without waiting behind a blocked prompt", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
const agent = await registry.start();
const adapter = fixture.calls[0].adapter;
const send = adapter.send.bind(adapter);
let releasePrompt;
adapter.send = (command) => {
if (command.type !== "prompt") return send(command);
adapter.sent.push(command);
return new Promise((resolve) => {
releasePrompt = () =>
resolve({ type: "response", command: "prompt", success: true });
});
};
const prompt = registry.route(agent.id, "prompt", { message: "Work" });
while (!releasePrompt) await new Promise((resolve) => setImmediate(resolve));
const abort = registry.route(agent.id, "abort");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(adapter.sent.at(-1).type, "abort");
releasePrompt();
await Promise.all([prompt, abort]);
await registry.stop();
});
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
fixture.calls[0].options.onError(
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
{ terminal: false },
);
const runtime = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(runtime.state, "idle");
assert.equal(runtime.attention, false);
assert.equal(runtime.error, undefined);
await registry.stop();
});
test("stops agents created during shutdown and rejects new selections", async () => { test("stops agents created during shutdown and rejects new selections", async () => {
const worktrees = await createWorktrees(); const worktrees = await createWorktrees();
const fixture = createAdapterFactory(); const fixture = createAdapterFactory();
@@ -270,8 +328,9 @@ test("coordinates forgetting with directory creation and active commands", async
/runtimes are open/, /runtimes are open/,
); );
const closing = registry.closeSessionRuntime( const closing = registry.closeSessionRuntime(
registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature) registry
.runtimeId, .listAgents()
.find((agent) => agent.worktreePath === worktrees.feature).runtimeId,
); );
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
releaseStop(); releaseStop();
@@ -483,9 +542,10 @@ test("routes commands by explicit agent ID and replays only events after the cur
type: "agent_state", type: "agent_state",
data: { state: "streaming" }, data: { state: "streaming" },
}); });
await registry.route(agent.id, "submit_prompt", { const followUp = await registry.route(agent.id, "submit_prompt", {
message: "After this turn", message: "After this turn",
}); });
assert.equal(followUp.delivery, "follow_up");
assert.deepEqual(adapter.sent.at(-1), { assert.deepEqual(adapter.sent.at(-1), {
type: "follow_up", type: "follow_up",
message: "After this turn", message: "After this turn",
+39
View File
@@ -483,6 +483,45 @@ test("persists a first-prompt session when the runtime settles and awaits refres
assert.match(manifestText, new RegExp(createdPath.replaceAll("/", "\\/"))); assert.match(manifestText, new RegExp(createdPath.replaceAll("/", "\\/")));
}); });
test("does not persist an uncreated Pi session file across a restart", async () => {
const paths = await fixture();
const futurePath = join(
sessionDirectoryPath(paths.sessionRoot, paths.home),
"future.jsonl",
);
const adapters = adapterFactory({
stateFor: () => ({ sessionFile: futurePath, sessionId: "future-id" }),
});
const firstRegistry = createAgentRegistry({
homeWorktree: paths.home,
sessionRoot: paths.sessionRoot,
startAdapter: adapters.startAdapter,
});
await firstRegistry.start();
assert.equal(
firstRegistry.getWorkspace().directories[0].runtimes[0].sessionPath,
undefined,
);
await firstRegistry.stop();
const manifest = await readJson(
join(paths.sessionRoot, "bridge-workspace-v2.json"),
);
assert.equal(manifest.runtimes[0].sessionPath, undefined);
const restoredRegistry = createAgentRegistry({
homeWorktree: paths.home,
sessionRoot: paths.sessionRoot,
startAdapter: adapters.startAdapter,
});
await restoredRegistry.start();
const restored = restoredRegistry.getWorkspace().directories[0].runtimes[0];
assert.equal(restored.state, "idle");
assert.equal(restored.sessionPath, undefined);
assert.equal(adapters.calls[1].options.sessionPath, undefined);
await restoredRegistry.stop();
});
test("extension responses do not clear unrelated error attention", async () => { test("extension responses do not clear unrelated error attention", async () => {
const paths = await fixture(); const paths = await fixture();
const adapters = adapterFactory(); const adapters = adapterFactory();
+110
View File
@@ -96,6 +96,32 @@ test("starts Pi in RPC mode and correlates a command response", async () => {
await adapter.stop(); await adapter.stop();
}); });
test("allows a per-command timeout override without serializing it to Pi", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
commandTimeoutMs: 10,
});
const initial = adapter.send({ type: "get_state" }, { timeoutMs: 50 });
const [initialCommand] = fixture.sent();
await new Promise((resolve) => setTimeout(resolve, 20));
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: initialCommand.id, command: "get_state", success: true })}\n`,
);
await initial;
assert.deepEqual(fixture.sent()[0], {
type: "get_state",
id: initialCommand.id,
});
await assert.rejects(
adapter.send({ type: "get_session_stats" }),
/timed out/,
);
await adapter.stop();
});
test("requests Pi session statistics for context and token status", async () => { test("requests Pi session statistics for context and token status", async () => {
const fixture = createFixture(); const fixture = createFixture();
const adapter = startPiRpcAdapter({ const adapter = startPiRpcAdapter({
@@ -182,6 +208,90 @@ test("forwards extension responses without replacing Pi's request ID", async ()
await adapter.stop(); await adapter.stop();
}); });
test("discards a fragmented oversized frame before processing the next frame", async () => {
const fixture = createFixture();
const errors = [];
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
onEvent: (event) => events.push(event),
});
const oversized = JSON.stringify({
type: "message_update",
delta: "x".repeat(80),
});
fixture.child.stdout.write(oversized.slice(0, 40));
fixture.child.stdout.write(
`${oversized.slice(40)}\n${JSON.stringify({ type: "agent_settled" })}\n`,
);
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
assert.deepEqual(events, [
{
seq: 1,
type: "agent_state",
data: { state: "idle", event: { type: "agent_settled" } },
},
]);
await adapter.stop();
});
test("discards an oversized complete frame before processing the next frame", async () => {
const fixture = createFixture();
const errors = [];
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
onEvent: (event) => events.push(event),
});
const oversized = JSON.stringify({
type: "message_update",
delta: "x".repeat(80),
});
fixture.child.stdout.write(
`${oversized}\n${JSON.stringify({ type: "agent_settled" })}\n`,
);
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
assert.equal(events.length, 1);
assert.equal(events[0].data.state, "idle");
await adapter.stop();
});
test("does not report an unterminated frame after discarding an oversized frame", async () => {
const fixture = createFixture();
const errors = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
});
fixture.child.stdout.write("x".repeat(40));
fixture.child.stdout.end();
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
await adapter.stop();
});
test("reports invalid child output and rejects pending commands on child exit", async () => { test("reports invalid child output and rejects pending commands on child exit", async () => {
const fixture = createFixture(); const fixture = createFixture();
const errors = []; const errors = [];
+1 -2
View File
@@ -375,7 +375,7 @@ pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, Stri
})) }))
} }
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<(), String> { pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<Value, String> {
request( request(
socket_path, socket_path,
"submit_prompt", "submit_prompt",
@@ -383,7 +383,6 @@ pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) ->
Some(json!({ "message": message })), Some(json!({ "message": message })),
) )
.await .await
.map(|_| ())
} }
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> { pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
+80 -3
View File
@@ -1,8 +1,8 @@
mod bridge; mod bridge;
mod ui_state; mod ui_state;
use serde_json::Value; use serde_json::{json, Value};
use std::sync::{Arc, Mutex}; use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State}; use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>); struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
@@ -45,6 +45,58 @@ fn socket_path() -> Result<String, String> {
bridge::default_socket_path() bridge::default_socket_path()
} }
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
args.iter()
.position(|arg| arg == "--worktree")
.and_then(|index| args.get(index + 1))
.cloned()
}
fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option<String> {
if !args.iter().any(|arg| arg == "--new") {
return None;
}
let saved_default = app
.path()
.app_data_dir()
.ok()
.and_then(|directory| ui_state::load_from(&directory).ok())
.and_then(|state| state.default_new_session_worktree);
let worktree = requested_new_worktree_argument(args)
.or(saved_default)
.or_else(|| env::var("PI_STATUS_DEFAULT_WORKTREE").ok())?;
Path::new(&worktree).is_absolute().then_some(worktree)
}
fn launch_new_session(app: AppHandle, args: &[String]) {
let Some(worktree_path) = requested_new_worktree(&app, args) else { return };
tauri::async_runtime::spawn(async move {
// Give a newly-created webview time to register its frontend listeners.
tokio::time::sleep(Duration::from_millis(350)).await;
let _ = app.emit("workspace-new-session", json!({
"phase": "starting",
"detail": "Starting a new Pi session…"
}));
match socket_path().map(|socket| (socket, worktree_path)) {
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await {
Ok(result) => { let _ = app.emit("workspace-new-session", json!({
"phase": "ready",
"runtimeId": result.runtime.runtime_id,
"detail": "New session ready"
})); }
Err(error) => { let _ = app.emit("workspace-new-session", json!({
"phase": "error",
"detail": format!("Could not start session: {error}")
})); }
},
Err(error) => { let _ = app.emit("workspace-new-session", json!({
"phase": "error",
"detail": error
})); }
}
});
}
#[tauri::command] #[tauri::command]
async fn get_workspace() -> Result<bridge::Workspace, String> { async fn get_workspace() -> Result<bridge::Workspace, String> {
bridge::get_workspace(&socket_path()?).await bridge::get_workspace(&socket_path()?).await
@@ -146,7 +198,7 @@ async fn new_session(agent_id: String) -> Result<Value, String> {
} }
#[tauri::command] #[tauri::command]
async fn submit_prompt(agent_id: String, message: String) -> Result<(), String> { async fn submit_prompt(agent_id: String, message: String) -> Result<Value, String> {
bridge::submit_prompt(&socket_path()?, &agent_id, &message).await bridge::submit_prompt(&socket_path()?, &agent_id, &message).await
} }
@@ -180,6 +232,16 @@ async fn set_session_name(agent_id: String, name: String) -> Result<(), String>
bridge::set_session_name(&socket_path()?, &agent_id, &name).await bridge::set_session_name(&socket_path()?, &agent_id, &name).await
} }
#[tauri::command]
async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Result<Value, String> {
bridge::request(
&socket_path()?,
"pi_rpc_command",
Some(&agent_id),
Some(serde_json::json!({ "command": command, "input": input })),
).await
}
#[tauri::command] #[tauri::command]
async fn compact(agent_id: String, custom_instructions: Option<String>) -> Result<(), String> { async fn compact(agent_id: String, custom_instructions: Option<String>) -> Result<(), String> {
bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await
@@ -281,6 +343,7 @@ fn unsubscribe_workspace(subscription: State<'_, WorkspaceSubscription>) -> Resu
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let launch_args = env::args().collect::<Vec<_>>();
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(LegacySubscription(Mutex::new(None))) .manage(LegacySubscription(Mutex::new(None)))
@@ -289,6 +352,7 @@ pub fn run() {
task: Mutex::new(None), task: Mutex::new(None),
}) })
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) { match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => { WindowAction::Hide => {
@@ -301,6 +365,10 @@ pub fn run() {
} }
} }
})) }))
.setup(move |app| {
launch_new_session(app.handle().clone(), &launch_args);
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_workspace, get_workspace,
get_workspace_summary, get_workspace_summary,
@@ -329,6 +397,7 @@ pub fn run() {
set_thinking_level, set_thinking_level,
set_session_name, set_session_name,
compact, compact,
pi_rpc_command,
respond_to_extension, respond_to_extension,
subscribe_agent subscribe_agent
]); ]);
@@ -357,6 +426,14 @@ mod tests {
assert_eq!(*tasks.lock().unwrap(), Some(2)); assert_eq!(*tasks.lock().unwrap(), Some(2));
} }
#[test]
fn new_session_uses_an_explicit_absolute_worktree() {
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()];
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into()));
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()];
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
}
#[test] #[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() { fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()]; let toggle = vec!["--toggle".to_owned()];
+10
View File
@@ -58,6 +58,8 @@ pub struct UiStateV1 {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub interface_scale: Option<f64>, pub interface_scale: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub default_new_session_worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>, pub dismissed_collision_warning: Option<bool>,
} }
@@ -73,6 +75,7 @@ impl Default for UiStateV1 {
last_seen: BTreeMap::new(), last_seen: BTreeMap::new(),
workspace_cursor: None, workspace_cursor: None,
interface_scale: None, interface_scale: None,
default_new_session_worktree: None,
dismissed_collision_warning: None, dismissed_collision_warning: None,
} }
} }
@@ -127,6 +130,13 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
bounded(runtime_id, "selected runtime id")?; bounded(runtime_id, "selected runtime id")?;
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len()); total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
} }
if let Some(worktree) = &state.default_new_session_worktree {
bounded(worktree, "default new-session worktree")?;
if !Path::new(worktree).is_absolute() {
return Err("Default new-session worktree must be absolute".to_owned());
}
total_string_bytes = total_string_bytes.saturating_add(worktree.len());
}
let mut total_draft_bytes = 0usize; let mut total_draft_bytes = 0usize;
for (runtime_id, draft) in &state.drafts { for (runtime_id, draft) in &state.drafts {
bounded(runtime_id, "draft runtime id")?; bounded(runtime_id, "draft runtime id")?;
+247 -5
View File
@@ -448,8 +448,18 @@ select:focus-visible {
} }
.message.assistant { .message.assistant {
margin-right: 12px; margin-right: 12px;
border-color: #505050; border-color: #496983;
background: #353535; background: #253544;
}
.message.tool,
.message.toolResult {
margin-right: 12px;
border-color: #2d8372;
background: #193936;
}
.message.error {
border-color: #bf6570;
background: #44282d;
} }
.message strong { .message strong {
display: block; display: block;
@@ -461,6 +471,16 @@ select:focus-visible {
.message.user strong { .message.user strong {
color: #ffc170; color: #ffc170;
} }
.message.assistant strong {
color: #b9d9ff;
}
.message.tool strong,
.message.toolResult strong {
color: #a9f2df;
}
.message.error strong {
color: #ffd7db;
}
.message pre { .message pre {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
@@ -469,6 +489,30 @@ select:focus-visible {
font-size: 13px; font-size: 13px;
line-height: 1.32; line-height: 1.32;
} }
.tool-result summary {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
list-style: none;
color: #a9f2df;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.tool-result summary::-webkit-details-marker {
display: none;
}
.tool-result summary::after {
content: "▾";
font-size: 14px;
}
.tool-result[open] summary::after {
content: "▴";
}
.tool-result[open] summary {
margin-bottom: 6px;
}
.pending-message { .pending-message {
border-style: dashed; border-style: dashed;
} }
@@ -529,6 +573,34 @@ select:focus-visible {
align-items: center; align-items: center;
font-size: 10px; font-size: 10px;
} }
.pi-controls select {
min-width: 0;
appearance: none;
color-scheme: dark;
padding: 5px 25px 5px 6px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 7px center;
color: #f2ece4;
}
.pi-controls select:hover {
border-color: #d88735;
background-color: #383129;
}
.pi-controls select:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.pi-controls select option {
background: #2c2a27;
color: #f2ece4;
}
.pi-controls select option:checked {
background: #4b3420;
color: #fff2df;
}
.composer-progress { .composer-progress {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -582,12 +654,94 @@ select:focus-visible {
display: flex; display: flex;
gap: 7px; gap: 7px;
} }
.default-worktree-control {
display: grid;
gap: 4px;
margin: 8px 0;
color: #ddd8d0;
font-size: 11px;
font-weight: 700;
}
.default-worktree-control select {
min-width: 0;
appearance: none;
color-scheme: dark;
padding: 5px 28px 5px 7px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 8px center;
color: #f2ece4;
}
.default-worktree-control select:hover {
border-color: #d88735;
background-color: #383129;
}
.default-worktree-control select:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.default-worktree-control option {
background: #2c2a27;
color: #f2ece4;
}
.default-worktree-control option:checked {
background: #4b3420;
color: #fff2df;
}
.default-worktree-control small {
color: #b9afa4;
font-weight: 400;
}
.extension-widget { .extension-widget {
padding: 5px 8px; padding: 5px 8px;
border-left: 3px solid #f0a347; border-left: 3px solid #f0a347;
background: #302a24; background: #302a24;
font-size: 12px; font-size: 12px;
} }
.startup-blocker {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(20, 18, 16, 0.82);
backdrop-filter: blur(3px);
outline: 0;
}
.startup-blocker-card {
display: flex;
align-items: center;
gap: 12px;
width: min(380px, 90vw);
padding: 16px;
border: 1px solid #f0a347;
border-radius: 8px;
background: #33271d;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);
color: #ffd9aa;
}
.startup-blocker-card > div {
display: grid;
gap: 3px;
}
.startup-blocker-card strong {
color: #fff2df;
font-size: 14px;
}
.startup-blocker-card p {
margin: 0;
font-weight: 700;
}
.startup-blocker-card small {
color: #d8b892;
}
.startup-bars {
flex: 0 0 auto;
transform: scale(1.35);
}
.modal-backdrop, .modal-backdrop,
.extension-backdrop { .extension-backdrop {
position: absolute; position: absolute;
@@ -611,6 +765,9 @@ select:focus-visible {
border-radius: 10px; border-radius: 10px;
background: #2d2e31; background: #2d2e31;
} }
.session-picker {
overflow: hidden;
}
.picker-heading { .picker-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -619,20 +776,105 @@ select:focus-visible {
.extension h2 { .extension h2 {
margin: 0; margin: 0;
} }
.session-search input {
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
border: 1px solid #5f6570;
border-radius: 6px;
background: #202125;
color: #f1f3f4;
}
.session-result-count {
margin: 0;
color: #8d97a6;
font-size: 0.85rem;
}
.session-list { .session-list {
display: grid; display: grid;
gap: 5px; gap: 6px;
max-height: min(44vh, 430px);
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
} }
.session-list button { .session-row {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
gap: 12px;
padding: 10px;
border: 1px solid #474b54;
border-radius: 7px;
background: #25262a;
text-align: left; text-align: left;
} }
.session-list small { .session-row:hover {
border-color: #b76f26;
background: #303136;
}
.session-row-content {
display: grid;
min-width: 0;
gap: 3px;
}
.session-preview,
.session-metadata {
overflow: hidden;
color: #a7afbc;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-metadata {
display: flex;
gap: 8px;
color: #8d97a6; color: #8d97a6;
} }
.session-open {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: #234a33;
color: #a5e3bc;
font-size: 0.75rem;
font-weight: 700;
}
.extension-eyebrow,
.extension-request-label,
.extension-request-action,
.extension-request-details {
margin: 0;
}
.extension-eyebrow,
.extension-request-label {
color: #b9afa4;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.extension-request {
display: grid;
gap: 6px;
padding: 12px;
border-left: 3px solid #f0a347;
border-radius: 4px;
background: #25262a;
}
.extension-request-action {
color: #fff3df;
font-size: 1.05rem;
font-weight: 750;
}
.extension-request-details {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.extension-options { .extension-options {
display: flex; display: flex;
flex-wrap: wrap;
gap: 7px; gap: 7px;
} }
.extension textarea { .extension textarea {
+144 -20
View File
@@ -6,6 +6,8 @@ import "./App.css";
import { ConversationWorkspace } from "./components/ConversationWorkspace"; import { ConversationWorkspace } from "./components/ConversationWorkspace";
import { DirectorySidebar } from "./components/DirectorySidebar"; import { DirectorySidebar } from "./components/DirectorySidebar";
import { ExtensionDialog } from "./components/ExtensionDialog"; import { ExtensionDialog } from "./components/ExtensionDialog";
import { CommandFormDialog } from "./components/CommandFormDialog";
import type { RpcCommand } from "./commands/rpc";
import { SessionPicker } from "./components/SessionPicker"; import { SessionPicker } from "./components/SessionPicker";
import { SessionTabs } from "./components/SessionTabs"; import { SessionTabs } from "./components/SessionTabs";
import type { import type {
@@ -37,6 +39,7 @@ type LocalOperation = {
export default function App() { export default function App() {
const { state, dispatch, refresh, loadSnapshot } = useWorkspace(); const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [commandForm, setCommandForm] = useState<RpcCommand | undefined>();
const [view, setView] = useState<"conversation" | "settings">("conversation"); const [view, setView] = useState<"conversation" | "settings">("conversation");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [folderPath, setFolderPath] = useState(""); const [folderPath, setFolderPath] = useState("");
@@ -64,6 +67,7 @@ export default function App() {
const mountedRef = useRef(true); const mountedRef = useRef(true);
const operationSequence = useRef(0); const operationSequence = useRef(0);
const [operation, setOperation] = useState<LocalOperation | undefined>(); const [operation, setOperation] = useState<LocalOperation | undefined>();
const [abortingRuntimeId, setAbortingRuntimeId] = useState<string>();
const [restoreTabFocus, setRestoreTabFocus] = useState(false); const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const startOperation = ( const startOperation = (
kind: LocalOperation["kind"], kind: LocalOperation["kind"],
@@ -99,6 +103,14 @@ export default function App() {
); );
}, [selected, dispatch, operation]); }, [selected, dispatch, operation]);
useEffect(() => {
if (
abortingRuntimeId &&
state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming"
)
setAbortingRuntimeId(undefined);
}, [abortingRuntimeId, state.runtimesById]);
async function createRuntime(path = state.selectedDirectoryPath) { async function createRuntime(path = state.selectedDirectoryPath) {
if (!path || !mountedRef.current) return; if (!path || !mountedRef.current) return;
const operationId = startOperation( const operationId = startOperation(
@@ -292,9 +304,13 @@ export default function App() {
} }
const runtimeId = selected.summary.runtimeId, const runtimeId = selected.summary.runtimeId,
id = ++submissionId, id = ++submissionId,
initialDelivery =
selected.summary.state === "streaming" ? "follow_up" : "prompt",
operationId = startOperation( operationId = startOperation(
"submitting", "submitting",
"Sending prompt to Pi…", initialDelivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt to Pi…",
selected.summary.runtimeId, selected.summary.runtimeId,
); );
dispatch({ dispatch({
@@ -307,24 +323,49 @@ export default function App() {
(message) => message.role === "user", (message) => message.role === "user",
).length, ).length,
phase: "sending", phase: "sending",
delivery: initialDelivery,
}, },
}); });
dispatch({ type: "draftChanged", runtimeId, draft: "" }); dispatch({ type: "draftChanged", runtimeId, draft: "" });
const sent = await agentCommand( try {
"submit_prompt", const result = await invoke<{ delivery?: "prompt" | "follow_up" }>(
{ message: text }, "submit_prompt",
false, { agentId: selected.summary.agentId, message: text },
selected, );
false, const delivery = result.delivery ?? initialDelivery;
); dispatch({ type: "submissionSent", runtimeId, id, delivery });
if (sent) { setStatus(
dispatch({ type: "submissionSent", runtimeId, id }); delivery === "follow_up"
setStatus("Prompt accepted · waiting for Pi…"); ? "Follow-up queued · waiting for Pi…"
} else { : "Prompt accepted · waiting for Pi…",
);
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
dispatch({ type: "submissionRemoved", runtimeId, id }); dispatch({ type: "submissionRemoved", runtimeId, id });
dispatch({ type: "draftChanged", runtimeId, draft: text }); dispatch({ type: "draftChanged", runtimeId, draft: text });
} finally {
finishOperation(operationId);
} }
finishOperation(operationId); }
async function abortSelected() {
const target = selected;
if (!target?.summary.agentId) return;
setAbortingRuntimeId(target.summary.runtimeId);
setStatus("Aborting Pi…");
try {
await invoke("abort", { agentId: target.summary.agentId });
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
setAbortingRuntimeId(undefined);
}
}
async function runRpcCommand(
command: RpcCommand,
input: Record<string, unknown> = {},
) {
if (!selected) return;
await agentCommand("pi_rpc_command", { command: command.command, input });
setCommandForm(undefined);
} }
async function respond(response: Record<string, unknown>) { async function respond(response: Record<string, unknown>) {
const target = selected; const target = selected;
@@ -342,10 +383,15 @@ export default function App() {
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1]; const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
const displayedStatus = const displayedStatus =
operation?.detail ?? operation?.detail ??
state.newSessionLaunch?.detail ??
(pendingSubmission (pendingSubmission
? pendingSubmission.phase === "sending" ? pendingSubmission.phase === "sending"
? "Sending prompt to Pi…" ? pendingSubmission.delivery === "follow_up"
: "Prompt accepted · waiting for Pi…" ? "Queueing follow-up…"
: "Sending prompt to Pi…"
: pendingSubmission.delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…"
: undefined) ?? : undefined) ??
(selected?.progress.phase === "working" || (selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering" selected?.progress.phase === "recovering"
@@ -353,11 +399,19 @@ export default function App() {
: undefined) ?? : undefined) ??
status; status;
const isWorking = const isWorking =
state.newSessionLaunch?.phase === "starting" ||
selected?.progress.phase === "working" || selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering"; selected?.progress.phase === "recovering";
const isStartingSession =
state.newSessionLaunch?.phase === "starting" ||
operation?.kind === "creating";
const sameDirectoryCollision = directory && directory.openCount > 1; const sameDirectoryCollision = directory && directory.openCount > 1;
return ( return (
<main className="app-shell" style={{ zoom: state.interfaceScale }}> <main
className="app-shell"
style={{ zoom: state.interfaceScale }}
aria-busy={isStartingSession}
>
<header <header
className="workflow-header" className="workflow-header"
onMouseDown={(event) => { onMouseDown={(event) => {
@@ -379,15 +433,21 @@ export default function App() {
{isWorking && ( {isWorking && (
<span className="working-pulse" aria-hidden="true" /> <span className="working-pulse" aria-hidden="true" />
)} )}
{isWorking ? "Working" : selected.summary.state} {state.newSessionLaunch?.phase === "starting"
? "Starting"
: isWorking
? "Working"
: selected.summary.state}
</span> </span>
)} )}
</div> </div>
<div className="status-row"> <div className="status-row">
{isWorking && ( {isWorking && (
<span className="working-banner" role="status"> <span className="working-banner" role="status">
<span className="working-pulse" aria-hidden="true" /> Pi is <span className="working-pulse" aria-hidden="true" />{" "}
working {state.newSessionLaunch?.phase === "starting"
? "Starting new session"
: "Pi is working"}
</span> </span>
)} )}
<span role="status" aria-live="polite"> <span role="status" aria-live="polite">
@@ -503,6 +563,29 @@ export default function App() {
{view === "settings" ? ( {view === "settings" ? (
<section className="settings"> <section className="settings">
<h2>Session controls</h2> <h2>Session controls</h2>
<label className="default-worktree-control">
Default new-session directory
<select
value={state.defaultNewSessionWorktree ?? ""}
onChange={(event) =>
dispatch({
type: "setDefaultNewSessionWorktree",
worktreePath: event.currentTarget.value || undefined,
})
}
>
<option value="">Use system default</option>
{state.directoryOrder.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</select>
<small>
Used by Ctrl+Super+Space. Only currently open directories are
listed.
</small>
</label>
{selected ? ( {selected ? (
<> <>
<p>{selected.summary.worktreePath}</p> <p>{selected.summary.worktreePath}</p>
@@ -553,7 +636,7 @@ export default function App() {
}) })
} }
onSubmit={() => void submit()} onSubmit={() => void submit()}
onAbort={() => void agentCommand("abort")} onAbort={() => void abortSelected()}
onScroll={(atBottom, scrollTop) => { onScroll={(atBottom, scrollTop) => {
dispatch({ dispatch({
type: "scrollChanged", type: "scrollChanged",
@@ -582,6 +665,12 @@ export default function App() {
operation?.kind === "submitting" && operation?.kind === "submitting" &&
operation.runtimeId === selected.summary.runtimeId operation.runtimeId === selected.summary.runtimeId
} }
isAborting={abortingRuntimeId === selected.summary.runtimeId}
onCommand={(command) =>
command.input?.length
? setCommandForm(command)
: void runRpcCommand(command)
}
/> />
) : ( ) : (
<section className="empty-workspace"> <section className="empty-workspace">
@@ -605,10 +694,45 @@ export default function App() {
onClose={closePicker} onClose={closePicker}
/> />
)} )}
<CommandFormDialog
command={commandForm}
onCancel={() => setCommandForm(undefined)}
onSubmit={(input) =>
commandForm && void runRpcCommand(commandForm, input)
}
/>
<ExtensionDialog <ExtensionDialog
extension={selected?.extensions[0]} extension={selected?.extensions[0]}
onRespond={(response) => void respond(response)} onRespond={(response) => void respond(response)}
/> />
{isStartingSession && (
<div
className="startup-blocker"
role="dialog"
aria-modal="true"
aria-labelledby="startup-blocker-title"
tabIndex={-1}
autoFocus
>
<div className="startup-blocker-card">
<span className="working-bars startup-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong id="startup-blocker-title">Starting new session</strong>
<p>
{state.newSessionLaunch?.detail ??
"Creating a fresh Pi runtime…"}
</p>
<small>
Pi is loading its session and extensions. Please wait.
</small>
</div>
</div>
</div>
)}
</main> </main>
); );
} }
+141
View File
@@ -0,0 +1,141 @@
export type RpcField = {
name: string;
label: string;
kind: "text" | "path" | "boolean" | "enum" | "json";
required?: boolean;
options?: string[];
};
export type RpcCommand = {
command: string;
category:
| "Prompting"
| "Session"
| "Model & Thinking"
| "Queue"
| "Context & Retry"
| "Shell"
| "Inspection & Export";
description: string;
input?: RpcField[];
};
const text = (name: string, required = true): RpcField => ({
name,
label: name,
kind: "text",
required,
});
const path = (name: string, required = true): RpcField => ({
name,
label: name,
kind: "path",
required,
});
const boolean = (name: string): RpcField => ({
name,
label: name,
kind: "boolean",
required: true,
});
const enumField = (name: string, options: string[]): RpcField => ({
name,
label: name,
kind: "enum",
options,
required: true,
});
const json = (name: string): RpcField => ({ name, label: name, kind: "json" });
const define = (
category: RpcCommand["category"],
commands: Array<[string, string, RpcField[]?]>,
) =>
commands.map(([command, description, input]) => ({
command,
category,
description,
...(input ? { input } : {}),
}));
export const rpcCommandCatalog: RpcCommand[] = [
...define("Prompting", [
[
"prompt",
"Send a prompt",
[
text("message"),
json("images"),
enumField("streamingBehavior", ["steer", "followUp"]),
],
],
["steer", "Queue a steering message", [text("message"), json("images")]],
[
"follow_up",
"Queue a follow-up message",
[text("message"), json("images")],
],
["abort", "Abort the agent"],
]),
...define("Session", [
["new_session", "Start a new session", [path("parentSession", false)]],
["switch_session", "Switch session", [path("sessionPath")]],
["fork", "Fork from a message", [text("entryId")]],
["clone", "Clone the active branch"],
["set_session_name", "Set session name", [text("name")]],
["get_fork_messages", "List forkable messages"],
["get_entries", "Read session entries", [text("since", false)]],
["get_tree", "Read session tree"],
["get_state", "Read session state"],
["get_messages", "Read messages"],
["get_last_assistant_text", "Read last assistant text"],
]),
...define("Model & Thinking", [
["get_available_models", "List models"],
["set_model", "Select model", [text("provider"), text("modelId")]],
["cycle_model", "Cycle model"],
[
"set_thinking_level",
"Set thinking level",
[
enumField("level", [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]),
],
],
["cycle_thinking_level", "Cycle thinking level"],
]),
...define("Queue", [
[
"set_steering_mode",
"Set steering delivery",
[enumField("mode", ["all", "one-at-a-time"])],
],
[
"set_follow_up_mode",
"Set follow-up delivery",
[enumField("mode", ["all", "one-at-a-time"])],
],
]),
...define("Context & Retry", [
["compact", "Compact context", [text("customInstructions", false)]],
["set_auto_compaction", "Set automatic compaction", [boolean("enabled")]],
["set_auto_retry", "Set automatic retry", [boolean("enabled")]],
["abort_retry", "Abort retry"],
]),
...define("Shell", [
["bash", "Run shell command", [text("command")]],
["abort_bash", "Abort shell command"],
]),
...define("Inspection & Export", [
["get_session_stats", "Read session statistics"],
["export_html", "Export session HTML", [path("outputPath", false)]],
["get_commands", "List Pi commands"],
]),
];
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import type { RpcCommand, RpcField } from "../commands/rpc";
const controls = (container: HTMLElement | null) =>
Array.from(
container?.querySelectorAll<HTMLElement>(
"button, input, select, textarea",
) ?? [],
).filter((node) => !node.hasAttribute("disabled"));
function valueFor(field: RpcField, value: string | boolean): unknown {
if (field.kind === "boolean") return value === true;
if (field.kind === "json")
return value ? JSON.parse(value as string) : undefined;
return value || undefined;
}
export function CommandFormDialog({
command,
onSubmit,
onCancel,
}: {
command?: RpcCommand;
onSubmit: (input: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const [values, setValues] = useState<Record<string, string | boolean>>({});
const [error, setError] = useState<string>();
useEffect(() => {
if (!command) return;
opener.current = document.activeElement as HTMLElement | null;
setValues({});
setError(undefined);
const frame = requestAnimationFrame(() =>
controls(ref.current)[0]?.focus(),
);
return () => {
cancelAnimationFrame(frame);
opener.current?.focus();
};
}, [command]);
if (!command) return null;
const fields = command.input ?? [];
const submit = () => {
try {
const input: Record<string, unknown> = {};
for (const field of fields) {
const value = valueFor(
field,
values[field.name] ?? (field.kind === "boolean" ? false : ""),
);
if (field.required && (value === undefined || value === ""))
throw new Error(`${field.label} is required`);
if (value !== undefined) input[field.name] = value;
}
onSubmit(input);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid input");
}
};
return (
<div className="extension-backdrop">
<section
ref={ref}
className="extension"
role="dialog"
aria-modal="true"
aria-labelledby="command-form-title"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
if (event.key === "Tab") {
const items = controls(ref.current),
first = items[0],
last = items[items.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();
}
}
}}
>
<p className="extension-eyebrow">Pi RPC command</p>
<h2 id="command-form-title">/{command.command}</h2>
<p>{command.description}</p>
{fields.map((field) => (
<label key={field.name}>
{field.label}
{field.kind === "boolean" ? (
<input
aria-label={field.label}
type="checkbox"
checked={values[field.name] === true}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.checked,
})
}
/>
) : field.kind === "enum" ? (
<select
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
>
<option value="">Select…</option>
{field.options?.map((option) => (
<option key={option}>{option}</option>
))}
</select>
) : field.kind === "json" ? (
<textarea
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
) : (
<input
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
)}
</label>
))}
{error && <p role="alert">{error}</p>}
<div className="extension-options">
<button onClick={submit}>Run command</button>
<button className="quiet" onClick={onCancel}>
Cancel
</button>
</div>
</section>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import type { RpcCommand } from "../commands/rpc";
export function CommandPalette({
draft,
commands,
onSelect,
}: {
draft: string;
commands: RpcCommand[];
onSelect: (command: RpcCommand) => void;
}) {
const [active, setActive] = useState(0);
const query = draft.startsWith("/") ? draft.slice(1).toLowerCase() : "";
const matches = draft.startsWith("/")
? commands.filter((command) =>
`${command.command} ${command.category} ${command.description}`
.toLowerCase()
.includes(query),
)
: [];
useEffect(() => setActive(0), [draft]);
useEffect(() => {
const keydown = (event: KeyboardEvent) => {
if (!matches.length) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((current) => (current + 1) % matches.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActive((current) => (current - 1 + matches.length) % matches.length);
}
if (
event.key === "Enter" &&
document.activeElement?.getAttribute("aria-label") === "Prompt"
) {
event.preventDefault();
onSelect(matches[active]);
}
};
window.addEventListener("keydown", keydown);
return () => window.removeEventListener("keydown", keydown);
}, [active, matches, onSelect]);
if (!draft.startsWith("/")) return null;
return (
<section className="command-palette" aria-label="Pi RPC commands">
{matches.map((command, index) => (
<button
key={command.command}
className={index === active ? "active" : ""}
onClick={() => onSelect(command)}
onMouseEnter={() => setActive(index)}
>
<strong>/{command.command}</strong>
<span>
{command.category} · {command.description}
</span>
</button>
))}
{matches.length === 0 && <p className="muted">No RPC commands match.</p>}
</section>
);
}
+58 -19
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types"; import type { Model, RuntimeView, TodoTask } from "../workspace/types";
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette";
export function messageText(message: RuntimeView["messages"][number]) { export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content; if (typeof message.content === "string") return message.content;
@@ -13,6 +15,39 @@ export function messageText(message: RuntimeView["messages"][number]) {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
} }
function TranscriptMessage({
message,
}: {
message: RuntimeView["messages"][number];
}) {
const role = message.role ?? "system";
if (role === "toolResult")
return (
<article className={`message ${role}`}>
<details className="tool-result">
<summary>Tool result · {message.toolName ?? "tool"}</summary>
<pre>{messageText(message)}</pre>
</details>
</article>
);
return (
<article className={`message ${role}`}>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
);
}
function pendingStatus(pending: RuntimeView["pendingSubmissions"][number]) {
if (pending.phase === "sending")
return pending.delivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt…";
return pending.delivery === "follow_up"
? "Follow-up queued · waiting for Pi"
: "Prompt accepted · waiting for Pi";
}
function todos(messages: RuntimeView["messages"]) { function todos(messages: RuntimeView["messages"]) {
const order: Record<TodoTask["status"], number> = { const order: Record<TodoTask["status"], number> = {
in_progress: 0, in_progress: 0,
@@ -41,6 +76,8 @@ export function ConversationWorkspace({
onModel, onModel,
onThinking, onThinking,
isSubmitting = false, isSubmitting = false,
isAborting = false,
onCommand,
}: { }: {
runtime: RuntimeView; runtime: RuntimeView;
onDraft: (value: string) => void; onDraft: (value: string) => void;
@@ -50,13 +87,16 @@ export function ConversationWorkspace({
onModel: (model: Model) => void; onModel: (model: Model) => void;
onThinking: (level: string) => void; onThinking: (level: string) => void;
isSubmitting?: boolean; isSubmitting?: boolean;
isAborting?: boolean;
onCommand: (command: RpcCommand) => void;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]); const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking =
runtime.summary.state === "streaming" ||
runtime.progress.phase === "working";
const isWorking = const isWorking =
isSubmitting || isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
runtime.progress.phase === "working" ||
runtime.progress.phase === "recovering";
useEffect(() => { useEffect(() => {
if (runtime.scroll.atBottom && ref.current) if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight; ref.current.scrollTop = ref.current.scrollHeight;
@@ -108,13 +148,7 @@ export function ConversationWorkspace({
<p className="muted">No messages yet.</p> <p className="muted">No messages yet.</p>
)} )}
{runtime.messages.map((message, index) => ( {runtime.messages.map((message, index) => (
<article <TranscriptMessage key={index} message={message} />
className={`message ${message.role ?? "system"}`}
key={index}
>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
))} ))}
{runtime.pendingSubmissions.map((pending) => ( {runtime.pendingSubmissions.map((pending) => (
<article <article
@@ -122,12 +156,7 @@ export function ConversationWorkspace({
key={pending.id} key={pending.id}
> >
<strong> <strong>
You{" "} You <span>{pendingStatus(pending)}</span>
<span>
{pending.phase === "sending"
? "Sending…"
: "Sent · waiting for Pi"}
</span>
</strong> </strong>
<pre>{pending.text}</pre> <pre>{pending.text}</pre>
</article> </article>
@@ -215,6 +244,11 @@ export function ConversationWorkspace({
{runtime.progress.tool && <small>{runtime.progress.tool}</small>} {runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div> </div>
<div className="composer"> <div className="composer">
<CommandPalette
draft={runtime.draft}
commands={rpcCommandCatalog}
onSelect={onCommand}
/>
<textarea <textarea
aria-label="Prompt" aria-label="Prompt"
value={runtime.draft} value={runtime.draft}
@@ -229,9 +263,14 @@ export function ConversationWorkspace({
placeholder="Ask Pi…" placeholder="Ask Pi…"
/> />
<div className="composer-actions"> <div className="composer-actions">
{runtime.state.isStreaming && ( {runtimeIsWorking && (
<button type="button" className="quiet" onClick={onAbort}> <button
Abort type="button"
className="quiet"
disabled={isAborting}
onClick={onAbort}
>
{isAborting ? "Aborting…" : "Abort"}
</button> </button>
)} )}
<button <button
+128 -49
View File
@@ -2,57 +2,136 @@ import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types"; import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) { function controls(container: HTMLElement | null) {
return [...(container?.querySelectorAll<HTMLElement>( return [
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])', ...(container?.querySelectorAll<HTMLElement>(
) ?? [])]; 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? []),
];
} }
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) { export function ExtensionDialog({
const ref = useRef<HTMLElement>(null); extension,
const opener = useRef<HTMLElement | null>(null); onRespond,
const respond = useRef(onRespond); }: {
const [value, setValue] = useState(""); extension?: Extension;
respond.current = onRespond; onRespond: (response: Record<string, unknown>) => void;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
useEffect(() => { useEffect(() => {
if (!extension) return; if (!extension) return;
opener.current = document.activeElement as HTMLElement | null; opener.current = document.activeElement as HTMLElement | null;
setValue(extension.initialValue ?? extension.text ?? ""); setValue(
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus()); extension.prefill ?? extension.initialValue ?? extension.text ?? "",
const keydown = (event: KeyboardEvent) => { );
if (event.key === "Escape") { const frame = requestAnimationFrame(() =>
event.preventDefault(); controls(ref.current)[0]?.focus(),
respond.current({ cancelled: true }); );
return; const keydown = (event: KeyboardEvent) => {
} if (event.key === "Escape") {
if (event.key !== "Tab") return; event.preventDefault();
const focusable = controls(ref.current); respond.current({ cancelled: true });
const first = focusable[0]; return;
const last = focusable[focusable.length - 1]; }
if (!first || !last) return; if (event.key !== "Tab") return;
if (event.shiftKey && document.activeElement === first) { const focusable = controls(ref.current);
event.preventDefault(); const first = focusable[0];
last.focus(); const last = focusable[focusable.length - 1];
} else if (!event.shiftKey && document.activeElement === last) { if (!first || !last) return;
event.preventDefault(); if (event.shiftKey && document.activeElement === first) {
first.focus(); event.preventDefault();
} last.focus();
}; } else if (!event.shiftKey && document.activeElement === last) {
window.addEventListener("keydown", keydown); event.preventDefault();
return () => { first.focus();
cancelAnimationFrame(frame); }
window.removeEventListener("keydown", keydown); };
opener.current?.focus(); window.addEventListener("keydown", keydown);
}; return () => {
}, [extension]); cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
if (!extension) return null; if (!extension) return null;
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title"> return (
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>} <div className="extension-backdrop">
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>} <section
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>} ref={ref}
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>} className="extension"
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>} role="dialog"
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button> aria-modal="true"
</section></div>; aria-labelledby="extension-title"
>
<p className="extension-eyebrow">Pi agent request</p>
<h2 id="extension-title">Review request</h2>
<div className="extension-request">
<p className="extension-request-label">Requested action</p>
<p className="extension-request-action">
{extension.title ?? "Pi needs input"}
</p>
{extension.message && (
<>
<p className="extension-request-label">Request details</p>
<p className="extension-request-details">{extension.message}</p>
</>
)}
</div>
{extension.method === "select" && (
<div className="extension-options">
{extension.options?.map((option) => (
<button key={option} onClick={() => onRespond({ value: option })}>
{option}
</button>
))}
</div>
)}
{extension.method === "confirm" && (
<div className="extension-options">
<button onClick={() => onRespond({ confirmed: true })}>
Allow request
</button>
<button
className="quiet"
onClick={() => onRespond({ confirmed: false })}
>
Decline request
</button>
</div>
)}
{extension.method === "input" && (
<>
<input
aria-label="Extension value"
value={value}
placeholder={extension.placeholder}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
{extension.method === "editor" && (
<>
<textarea
aria-label="Extension value"
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
<button
className="quiet"
onClick={() => onRespond({ cancelled: true })}
>
Cancel
</button>
</section>
</div>
);
} }
+174 -25
View File
@@ -1,27 +1,176 @@
import { useEffect, useRef } from "react"; import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
import type { DirectorySession } from "../workspace/types"; import type { DirectorySession } from "../workspace/types";
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
const dialogRef = useRef<HTMLElement>(null); const focusableSelector =
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null); 'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
useEffect(() => {
const dialog = dialogRef.current; function sessionTitle(session: DirectorySession) {
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])]; return session.name ?? session.firstMessage ?? "Untitled session";
controls()[0]?.focus(); }
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); return; } function messageCountLabel(count: number) {
if (event.key !== "Tab") return; return `${count} ${count === 1 ? "message" : "messages"}`;
const focusable = controls(); const first = focusable[0]; const last = focusable[focusable.length - 1]; }
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } function matchesQuery(session: DirectorySession, query: string) {
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } return [session.name, session.firstMessage, session.id].some((value) =>
}; value?.toLocaleLowerCase().includes(query),
window.addEventListener("keydown", keydown); );
return () => { window.removeEventListener("keydown", keydown); openerRef.current?.focus(); }; }
}, [onClose]);
return <div className="modal-backdrop"><section ref={dialogRef} className="session-picker" role="dialog" aria-modal="true" aria-labelledby="session-picker-title"> function SessionRow({
<div className="picker-heading"><h2 id="session-picker-title">Saved sessions</h2><button onClick={onClose}>Close</button></div> session,
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}> onOpen,
<span>{session.name ?? session.firstMessage ?? "Untitled session"}</span><small>{session.runtimeId ? "Already open" : `${session.messageCount} messages · ${new Date(session.modified).toLocaleDateString()}`}</small> }: {
</button>)}</div> : <p className="muted">No saved sessions for this directory.</p>} session: DirectorySession;
</section></div>; onOpen: (session: DirectorySession) => void;
}) {
const showPreview = Boolean(
session.name &&
session.firstMessage &&
session.firstMessage !== session.name,
);
return (
<li>
<button className="session-row" onClick={() => onOpen(session)}>
<span className="session-row-content">
<strong>{sessionTitle(session)}</strong>
{showPreview ? (
<span className="session-preview">{session.firstMessage}</span>
) : null}
<small className="session-metadata">
<span>{messageCountLabel(session.messageCount)}</span>
<time dateTime={session.modified}>
Modified {new Date(session.modified).toLocaleDateString()}
</time>
</small>
</span>
{session.runtimeId ? <span className="session-open">Open</span> : null}
</button>
</li>
);
}
function SessionResults({
sessions,
query,
onOpen,
}: {
sessions: DirectorySession[];
query: string;
onOpen: (session: DirectorySession) => void;
}) {
if (sessions.length === 0)
return <p className="muted">No saved sessions match “{query}”.</p>;
return (
<ul id="session-results" className="session-list">
{sessions.map((session) => (
<SessionRow key={session.path} session={session} onOpen={onOpen} />
))}
</ul>
);
}
function useDialogFocus(
dialogRef: RefObject<HTMLElement | null>,
searchRef: RefObject<HTMLInputElement | null>,
onClose: () => void,
) {
const openerRef = useRef<HTMLElement | null>(
document.activeElement as HTMLElement | null,
);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
];
searchRef.current?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
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", keydown);
return () => {
window.removeEventListener("keydown", keydown);
openerRef.current?.focus();
};
}, [dialogRef, onClose, searchRef]);
}
export function SessionPicker({
sessions,
onOpen,
onClose,
}: {
sessions: DirectorySession[];
onOpen: (session: DirectorySession) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const dialogRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const normalizedQuery = query.trim().toLocaleLowerCase();
const filteredSessions = useMemo(
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
[normalizedQuery, sessions],
);
useDialogFocus(dialogRef, searchRef, onClose);
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
return (
<div className="modal-backdrop">
<section
ref={dialogRef}
className="session-picker"
role="dialog"
aria-modal="true"
aria-labelledby="session-picker-title"
>
<div className="picker-heading">
<h2 id="session-picker-title">Saved sessions</h2>
<button onClick={onClose}>Close</button>
</div>
<div className="session-search">
<label className="sr-only" htmlFor="session-search">
Search saved sessions
</label>
<input
ref={searchRef}
id="session-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, message, or ID"
aria-controls="session-results"
/>
</div>
<p className="session-result-count" role="status" aria-live="polite">
{sessions.length ? resultLabel : "No saved sessions"}
</p>
{sessions.length === 0 ? (
<p className="muted">No saved sessions for this directory.</p>
) : (
<SessionResults
sessions={filteredSessions}
query={query}
onOpen={onOpen}
/>
)}
</section>
</div>
);
} }
+154 -21
View File
@@ -4,12 +4,14 @@ import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar"; import { DirectorySidebar } from "./DirectorySidebar";
import { SessionTabs } from "./SessionTabs"; import { SessionTabs } from "./SessionTabs";
import { SessionPicker } from "./SessionPicker"; import { SessionPicker } from "./SessionPicker";
import { ConversationWorkspace } from "./ConversationWorkspace";
import { ExtensionDialog } from "./ExtensionDialog";
import { import {
initialWorkspaceState, initialWorkspaceState,
reconcileWorkspace, reconcileWorkspace,
runtimeView, runtimeView,
} from "../workspace/reducer"; } from "../workspace/reducer";
import type { Workspace } from "../workspace/types"; import type { DirectorySession, Workspace } from "../workspace/types";
const summary = (id: string, state = "idle") => ({ const summary = (id: string, state = "idle") => ({
runtimeId: id, runtimeId: id,
worktreePath: "/repo", worktreePath: "/repo",
@@ -96,41 +98,172 @@ describe("multi-session navigation", () => {
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus(); expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled(); expect(onFocusRestored).toHaveBeenCalled();
}); });
it("traps and restores focus for the saved-session picker", async () => { it("collapses a tool result until its summary is expanded", async () => {
const user = userEvent.setup();
const runtime = runtimeView(summary("tool-result"));
runtime.messages = [
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
];
const { container } = render(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn()}
onThinking={vi.fn()}
onCommand={vi.fn()}
/>,
);
const details = container.querySelector("details");
expect(details).not.toHaveAttribute("open");
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
it("shows an aborting control and labels an accepted follow-up", () => {
const runtime = runtimeView(summary("working", "streaming"));
runtime.progress = {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
};
runtime.pendingSubmissions = [
{
id: 1,
text: "Continue with the plan",
userMessageCount: 0,
phase: "sent",
delivery: "follow_up",
},
];
render(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn()}
onThinking={vi.fn()}
onCommand={vi.fn()}
isAborting
/>,
);
expect(screen.getByRole("button", { name: "Aborting…" })).toBeDisabled();
expect(
screen.getByText("Follow-up queued · waiting for Pi"),
).toBeInTheDocument();
});
it("searches saved sessions and displays useful metadata", async () => {
const user = userEvent.setup();
const onOpen = vi.fn();
const sessions: DirectorySession[] = [
{
path: "/planning",
id: "plan-id",
cwd: "/repo",
name: "Planning",
firstMessage: "Sketch the release plan",
modified: "2026-08-15T10:00:00.000Z",
messageCount: 1,
runtimeId: "already-open",
},
{
path: "/incident",
id: "incident-42",
cwd: "/repo",
firstMessage: "Investigate search crash",
modified: "2026-08-14T10:00:00.000Z",
messageCount: 2,
},
];
render(
<SessionPicker sessions={sessions} onOpen={onOpen} onClose={vi.fn()} />,
);
const search = screen.getByRole("searchbox", {
name: "Search saved sessions",
});
expect(screen.getByRole("status")).toHaveTextContent("2 saved sessions");
expect(screen.getByText("Sketch the release plan")).toBeInTheDocument();
expect(screen.getByText("1 message")).toBeInTheDocument();
expect(screen.getByText("Open")).toBeInTheDocument();
await user.type(search, "release");
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
expect(
screen.getByRole("button", { name: /Planning/ }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
await user.clear(search);
await user.type(search, "INCIDENT-42");
await user.click(screen.getByRole("button", { name: /Investigate/ }));
expect(onOpen).toHaveBeenCalledWith(sessions[1]);
await user.clear(search);
await user.type(search, "missing");
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
});
it("focuses search, traps focus, and restores the opener", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const opener = document.createElement("button"); const opener = document.createElement("button");
document.body.append(opener); document.body.append(opener);
opener.focus(); opener.focus();
const onClose = vi.fn(); const onClose = vi.fn();
const { unmount } = render( const { unmount } = render(
<SessionPicker <SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
sessions={[
{
path: "/s",
id: "s",
cwd: "/repo",
modified: new Date().toISOString(),
messageCount: 0,
},
]}
onOpen={vi.fn()}
onClose={onClose}
/>,
); );
const search = screen.getByRole("searchbox", {
name: "Search saved sessions",
});
const close = screen.getByRole("button", { name: "Close" }); const close = screen.getByRole("button", { name: "Close" });
expect(search).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(close).toHaveFocus(); expect(close).toHaveFocus();
await user.keyboard("{Tab}"); await user.keyboard("{Shift>}{Tab}{/Shift}");
expect( expect(search).toHaveFocus();
screen.getByRole("button", { name: /Untitled session/ }),
).toHaveFocus();
await user.keyboard("{Tab}");
expect(close).toHaveFocus();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
unmount(); unmount();
expect(opener).toHaveFocus(); expect(opener).toHaveFocus();
document.body.removeChild(opener); document.body.removeChild(opener);
}); });
it("labels permission requests with the action and exact details", async () => {
const user = userEvent.setup();
const onRespond = vi.fn();
render(
<ExtensionDialog
extension={{
method: "confirm",
title: "Run a shell command",
message:
"rm -rf ./build\nThis removes the generated build directory.",
}}
onRespond={onRespond}
/>,
);
expect(screen.getByText("Pi agent request")).toBeInTheDocument();
expect(screen.getByText("Requested action")).toBeInTheDocument();
expect(screen.getByText("Run a shell command")).toBeInTheDocument();
expect(screen.getByText("Request details")).toBeInTheDocument();
expect(screen.getByText(/rm -rf .\/build/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Allow request" }));
expect(onRespond).toHaveBeenCalledWith({ confirmed: true });
});
it("uses Pi editor prefill content", () => {
render(
<ExtensionDialog
extension={{
method: "editor",
title: "Edit request",
prefill: "Existing request",
}}
onRespond={vi.fn()}
/>,
);
expect(screen.getByLabelText("Extension value")).toHaveValue(
"Existing request",
);
});
it("routes new, saved-open, and close controls through callbacks", async () => { it("routes new, saved-open, and close controls through callbacks", async () => {
const onNew = vi.fn(), const onNew = vi.fn(),
onResume = vi.fn(), onResume = vi.fn(),
+69 -12
View File
@@ -1,16 +1,73 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { legacyPreferences, removeLegacyPreferences, presentationState } from "./persistence"; import {
import { initialWorkspaceState, reconcileWorkspace, workspaceReducer } from "./reducer"; legacyPreferences,
removeLegacyPreferences,
presentationState,
} from "./persistence";
import {
initialWorkspaceState,
reconcileWorkspace,
workspaceReducer,
} from "./reducer";
import type { Workspace } from "./types"; import type { Workspace } from "./types";
const workspace: Workspace = { bridgeInstanceId: "e", latestSeq: 7, directories: [{ worktreePath: "/repo", isHome: true, openCount: 1, workingCount: 0, attentionCount: 0, recoveringCount: 0, errorCount: 0, runtimes: [{ runtimeId: "r", worktreePath: "/repo", state: "idle", label: "r", attention: false, queueCount: 0, lastActivity: "now", openedAt: "now", agentId: "ephemeral" }] }] }; const workspace: Workspace = {
bridgeInstanceId: "e",
latestSeq: 7,
directories: [
{
worktreePath: "/repo",
isHome: true,
openCount: 1,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
runtimes: [
{
runtimeId: "r",
worktreePath: "/repo",
state: "idle",
label: "r",
attention: false,
queueCount: 0,
lastActivity: "now",
openedAt: "now",
agentId: "ephemeral",
},
],
},
],
};
describe("presentation persistence", () => { describe("presentation persistence", () => {
it("persists only allowlisted presentation state, never agent or session payloads", () => { it("persists only allowlisted presentation state, never agent or session payloads", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "r", draft: "safe draft" }); let state = reconcileWorkspace(initialWorkspaceState, workspace);
const persisted = presentationState(state); const encoded = JSON.stringify(persisted); state = workspaceReducer(state, {
expect(persisted.drafts.r).toBe("safe draft"); expect(encoded).not.toContain("ephemeral"); expect(encoded).not.toContain("messages"); expect(encoded).not.toContain("extensions"); type: "draftChanged",
}); runtimeId: "r",
it("reads and removes legacy worktree/scale only after caller saves", () => { draft: "safe draft",
const getItem = vi.fn((key: string) => key.includes("selected") ? "/repo" : "1.2"); expect(legacyPreferences({ getItem })).toEqual({ path: "/repo", scale: 1.2 }); });
const removeItem = vi.fn(); removeLegacyPreferences({ removeItem }); expect(removeItem).toHaveBeenCalledTimes(2); state = workspaceReducer(state, {
}); type: "setDefaultNewSessionWorktree",
worktreePath: "/repo",
});
const persisted = presentationState(state);
const encoded = JSON.stringify(persisted);
expect(persisted.drafts.r).toBe("safe draft");
expect(persisted.defaultNewSessionWorktree).toBe("/repo");
expect(encoded).not.toContain("ephemeral");
expect(encoded).not.toContain("messages");
expect(encoded).not.toContain("extensions");
});
it("reads and removes legacy worktree/scale only after caller saves", () => {
const getItem = vi.fn((key: string) =>
key.includes("selected") ? "/repo" : "1.2",
);
expect(legacyPreferences({ getItem })).toEqual({
path: "/repo",
scale: 1.2,
});
const removeItem = vi.fn();
removeLegacyPreferences({ removeItem });
expect(removeItem).toHaveBeenCalledTimes(2);
});
}); });
+1
View File
@@ -33,6 +33,7 @@ export function presentationState(state: WorkspaceState): UiState {
} }
: undefined, : undefined,
interfaceScale: state.interfaceScale, interfaceScale: state.interfaceScale,
defaultNewSessionWorktree: state.defaultNewSessionWorktree,
dismissedCollisionWarning: state.dismissedCollisionWarning, dismissedCollisionWarning: state.dismissedCollisionWarning,
}; };
} }
+64
View File
@@ -112,6 +112,70 @@ describe("workspace reducer", () => {
}); });
expect(state.runtimesById.a.pendingSubmissions).toEqual([]); expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
}); });
it("reconciles duplicate pending submissions one message at a time and honors Pi queue totals", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
for (const id of [1, 2])
state = workspaceReducer(state, {
type: "submissionAdded",
runtimeId: "a",
submission: {
id,
text: "Continue",
userMessageCount: 0,
phase: "sent",
},
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
transcript: {
data: { messages: [{ role: "user", content: "Continue" }] },
},
extensions: [],
},
});
expect(state.runtimesById.a.pendingSubmissions).toHaveLength(1);
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_event",
data: {
runtimeId: "a",
eventType: "queue",
eventData: {
event: {
pendingMessageCount: 3,
followUp: ["one"],
steering: ["two"],
},
},
},
});
expect(state.runtimesById.a.summary.queueCount).toBe(3);
});
it("keeps a hotkey-triggered new-session startup visible until completion", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "newSessionLaunch",
launch: { phase: "starting", detail: "Starting a new Pi session…" },
});
expect(state.newSessionLaunch?.phase).toBe("starting");
state = workspaceReducer(state, {
type: "newSessionLaunch",
launch: undefined,
});
expect(state.newSessionLaunch).toBeUndefined();
});
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => { it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace); let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, { state = workspaceReducer(state, {
+48 -10
View File
@@ -24,6 +24,28 @@ function messageText(message: RuntimeView["messages"][number]) {
.join(""); .join("");
} }
function reconcilePendingSubmissions(
pendingSubmissions: RuntimeView["pendingSubmissions"],
messages: RuntimeView["messages"],
) {
const userMessages = messages
.filter((message) => message.role === "user")
.map(messageText);
const consumed = new Set<number>();
return pendingSubmissions.filter((pending) => {
for (
let index = pending.userMessageCount;
index < userMessages.length;
index++
) {
if (consumed.has(index) || userMessages[index] !== pending.text) continue;
consumed.add(index);
return false;
}
return true;
});
}
export function runtimeView( export function runtimeView(
summary: RuntimeSummary, summary: RuntimeSummary,
previous?: RuntimeView, previous?: RuntimeView,
@@ -84,6 +106,7 @@ export const initialWorkspaceState: WorkspaceState = {
runtimeOrderByDirectory: {}, runtimeOrderByDirectory: {},
windowActive: true, windowActive: true,
resourceWarning: false, resourceWarning: false,
defaultNewSessionWorktree: undefined,
dismissedCollisionWarning: false, dismissedCollisionWarning: false,
interfaceScale: 1, interfaceScale: 1,
}; };
@@ -169,6 +192,8 @@ export function reconcileWorkspace(
selectedDirectoryPath, selectedDirectoryPath,
dismissedCollisionWarning: dismissedCollisionWarning:
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning, persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
defaultNewSessionWorktree:
persisted?.defaultNewSessionWorktree ?? state.defaultNewSessionWorktree,
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale, interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
}; };
} }
@@ -319,9 +344,11 @@ export function applyWorkspaceEvent(
(String(payload.type ?? "").endsWith("_start") ? 1 : 0), (String(payload.type ?? "").endsWith("_start") ? 1 : 0),
}; };
} else if (type === "queue") { } else if (type === "queue") {
const queueCount = Array.isArray(payload.followUp) const queueCount =
? payload.followUp.length typeof payload.pendingMessageCount === "number"
: view.summary.queueCount; ? payload.pendingMessageCount
: (Array.isArray(payload.followUp) ? payload.followUp.length : 0) +
(Array.isArray(payload.steering) ? payload.steering.length : 0);
view.summary = { ...view.summary, queueCount }; view.summary = { ...view.summary, queueCount };
view.progress = { view.progress = {
...view.progress, ...view.progress,
@@ -372,13 +399,20 @@ export type WorkspaceAction =
| { type: "setWindowActive"; active: boolean } | { type: "setWindowActive"; active: boolean }
| { type: "setScale"; scale: number } | { type: "setScale"; scale: number }
| { type: "setResourceWarning"; value: boolean } | { type: "setResourceWarning"; value: boolean }
| { type: "setDefaultNewSessionWorktree"; worktreePath?: string }
| { type: "newSessionLaunch"; launch?: WorkspaceState["newSessionLaunch"] }
| { type: "dismissCollisionWarning" } | { type: "dismissCollisionWarning" }
| { | {
type: "submissionAdded"; type: "submissionAdded";
runtimeId: string; runtimeId: string;
submission: RuntimeView["pendingSubmissions"][number]; submission: RuntimeView["pendingSubmissions"][number];
} }
| { type: "submissionSent"; runtimeId: string; id: number } | {
type: "submissionSent";
runtimeId: string;
id: number;
delivery: "prompt" | "follow_up";
}
| { type: "submissionRemoved"; runtimeId: string; id: number }; | { type: "submissionRemoved"; runtimeId: string; id: number };
export function workspaceReducer( export function workspaceReducer(
@@ -438,6 +472,10 @@ export function workspaceReducer(
return { ...state, interfaceScale: action.scale }; return { ...state, interfaceScale: action.scale };
if (action.type === "setResourceWarning") if (action.type === "setResourceWarning")
return { ...state, resourceWarning: action.value }; return { ...state, resourceWarning: action.value };
if (action.type === "setDefaultNewSessionWorktree")
return { ...state, defaultNewSessionWorktree: action.worktreePath };
if (action.type === "newSessionLaunch")
return { ...state, newSessionLaunch: action.launch };
if (action.type === "dismissCollisionWarning") if (action.type === "dismissCollisionWarning")
return { ...state, dismissedCollisionWarning: true }; return { ...state, dismissedCollisionWarning: true };
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined; const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
@@ -463,11 +501,9 @@ export function workspaceReducer(
view.state = action.snapshot.state?.data ?? {}; view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {}; view.stats = action.snapshot.stats?.data ?? {};
view.messages = action.snapshot.transcript?.data?.messages ?? []; view.messages = action.snapshot.transcript?.data?.messages ?? [];
const receivedUserMessages = view.messages view.pendingSubmissions = reconcilePendingSubmissions(
.filter((message) => message.role === "user") view.pendingSubmissions,
.map(messageText); view.messages,
view.pendingSubmissions = view.pendingSubmissions.filter(
(pending) => !receivedUserMessages.includes(pending.text),
); );
view.commands = action.snapshot.commands?.data?.commands ?? []; view.commands = action.snapshot.commands?.data?.commands ?? [];
view.models = action.snapshot.models?.data?.models ?? []; view.models = action.snapshot.models?.data?.models ?? [];
@@ -494,7 +530,9 @@ export function workspaceReducer(
view.pendingSubmissions = [...view.pendingSubmissions, action.submission]; view.pendingSubmissions = [...view.pendingSubmissions, action.submission];
else if (action.type === "submissionSent") else if (action.type === "submissionSent")
view.pendingSubmissions = view.pendingSubmissions.map((item) => view.pendingSubmissions = view.pendingSubmissions.map((item) =>
item.id === action.id ? { ...item, phase: "sent" } : item, item.id === action.id
? { ...item, phase: "sent", delivery: action.delivery }
: item,
); );
else if (action.type === "submissionRemoved") else if (action.type === "submissionRemoved")
view.pendingSubmissions = view.pendingSubmissions.filter( view.pendingSubmissions = view.pendingSubmissions.filter(
+5
View File
@@ -97,6 +97,7 @@ export type Extension = {
message?: string; message?: string;
options?: string[]; options?: string[];
placeholder?: string; placeholder?: string;
prefill?: string;
initialValue?: string; initialValue?: string;
text?: string; text?: string;
notifyType?: "info" | "warning" | "error"; notifyType?: "info" | "warning" | "error";
@@ -118,6 +119,7 @@ export type PendingSubmission = {
text: string; text: string;
userMessageCount: number; userMessageCount: number;
phase: "sending" | "sent"; phase: "sending" | "sent";
delivery?: "prompt" | "follow_up";
}; };
export type RuntimeSnapshot = { export type RuntimeSnapshot = {
bridgeInstanceId: string; bridgeInstanceId: string;
@@ -211,6 +213,8 @@ export type WorkspaceState = {
selectedRuntimeId?: string; selectedRuntimeId?: string;
windowActive: boolean; windowActive: boolean;
resourceWarning: boolean; resourceWarning: boolean;
newSessionLaunch?: { phase: "starting" | "error"; detail: string };
defaultNewSessionWorktree?: string;
dismissedCollisionWarning: boolean; dismissedCollisionWarning: boolean;
interfaceScale: number; interfaceScale: number;
}; };
@@ -228,5 +232,6 @@ export type UiState = {
>; >;
workspaceCursor?: { bridgeInstanceId: string; seq: number }; workspaceCursor?: { bridgeInstanceId: string; seq: number };
interfaceScale?: number; interfaceScale?: number;
defaultNewSessionWorktree?: string;
dismissedCollisionWarning?: boolean; dismissedCollisionWarning?: boolean;
}; };
+258 -132
View File
@@ -3,145 +3,271 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useReducer, useRef } from "react"; import { useCallback, useEffect, useReducer, useRef } from "react";
import { initialWorkspaceState, workspaceReducer } from "./reducer"; import { initialWorkspaceState, workspaceReducer } from "./reducer";
import { legacyPreferences, loadPresentation, presentationState, removeLegacyPreferences, savePresentation } from "./persistence"; import {
import type { HostEvent, RuntimeSnapshot, UiState, Workspace, WorkspaceSummary } from "./types"; legacyPreferences,
loadPresentation,
presentationState,
removeLegacyPreferences,
savePresentation,
} from "./persistence";
import type {
HostEvent,
RuntimeSnapshot,
UiState,
Workspace,
WorkspaceSummary,
} from "./types";
export function useWorkspace() { export function useWorkspace() {
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState); const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
const stateRef = useRef(state); stateRef.current = state; const stateRef = useRef(state);
const loadGeneration = useRef<Record<string, number>>({}); stateRef.current = state;
const timers = useRef<Record<string, number>>({}); const loadGeneration = useRef<Record<string, number>>({});
const initialized = useRef(false); const timers = useRef<Record<string, number>>({});
const legacyMigrationPending = useRef(false); const initialized = useRef(false);
const disposed = useRef(true); const legacyMigrationPending = useRef(false);
const refreshGeneration = useRef(0); const disposed = useRef(true);
const refreshGeneration = useRef(0);
const loadSnapshot = useCallback(async (runtimeId: string) => { const loadSnapshot = useCallback(async (runtimeId: string) => {
if (disposed.current) return; if (disposed.current) return;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1; const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation; loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation }); dispatch({ type: "snapshotRequested", runtimeId, generation });
try { try {
const snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", { runtimeId }); const snapshot = await invoke<RuntimeSnapshot>(
if (!disposed.current) "get_session_runtime_snapshot",
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot }); { runtimeId },
} catch (error) { );
if (!disposed.current) if (!disposed.current)
dispatch({ type: "snapshotFailed", runtimeId, generation, error: String(error) }); dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
} } catch (error) {
}, []); if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
}
}, []);
const scheduleSnapshot = useCallback((runtimeId: string) => { const scheduleSnapshot = useCallback(
if (timers.current[runtimeId]) return; (runtimeId: string) => {
timers.current[runtimeId] = window.setTimeout(() => { if (timers.current[runtimeId]) return;
delete timers.current[runtimeId]; void loadSnapshot(runtimeId); timers.current[runtimeId] = window.setTimeout(() => {
}, 120); delete timers.current[runtimeId];
}, [loadSnapshot]); void loadSnapshot(runtimeId);
}, 120);
},
[loadSnapshot],
);
const refresh = useCallback(async (initialUiState?: UiState, reloadAllSnapshots = false) => { const refresh = useCallback(
const generation = ++refreshGeneration.current; async (initialUiState?: UiState, reloadAllSnapshots = false) => {
const cancelled = () => disposed.current || generation !== refreshGeneration.current; const generation = ++refreshGeneration.current;
const workspace = await invoke<Workspace>("get_workspace"); const cancelled = () =>
if (cancelled()) return; disposed.current || generation !== refreshGeneration.current;
let nextPersisted = initialUiState; const workspace = await invoke<Workspace>("get_workspace");
if (nextPersisted) { if (cancelled()) return;
const legacy = legacyPreferences(localStorage); let nextPersisted = initialUiState;
const candidate = nextPersisted.selectedRuntimeId if (nextPersisted) {
? undefined const legacy = legacyPreferences(localStorage);
: workspace.directories.find((directory) => directory.worktreePath === legacy.path)?.runtimes[0]; const candidate = nextPersisted.selectedRuntimeId
nextPersisted = { ? undefined
...nextPersisted, : workspace.directories.find(
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}), (directory) => directory.worktreePath === legacy.path,
...(nextPersisted.interfaceScale === undefined && legacy.scale !== undefined )?.runtimes[0];
? { interfaceScale: legacy.scale } nextPersisted = {
: {}), ...nextPersisted,
}; ...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
legacyMigrationPending.current = Boolean( ...(nextPersisted.interfaceScale === undefined &&
(!initialUiState?.selectedRuntimeId && candidate) || legacy.scale !== undefined
(initialUiState?.interfaceScale === undefined && legacy.scale !== undefined), ? { interfaceScale: legacy.scale }
); : {}),
} };
dispatch({ type: "workspaceLoaded", workspace, persisted: nextPersisted }); legacyMigrationPending.current = Boolean(
const summary = await invoke<WorkspaceSummary>("get_workspace_summary"); (!initialUiState?.selectedRuntimeId && candidate) ||
if (cancelled()) return; (initialUiState?.interfaceScale === undefined &&
dispatch({ type: "setResourceWarning", value: summary.resourceWarning }); legacy.scale !== undefined),
const selected = nextPersisted?.selectedRuntimeId && workspace.directories.some((directory) => directory.runtimes.some((runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId)) );
? nextPersisted.selectedRuntimeId : workspace.directories[0]?.runtimes[0]?.runtimeId; }
const runtimeIds = workspace.directories.flatMap((directory) => directory.runtimes.map((runtime) => runtime.runtimeId)); dispatch({
for (const runtimeId of reloadAllSnapshots ? runtimeIds : selected ? [selected] : []) { type: "workspaceLoaded",
if (cancelled()) return; workspace,
void loadSnapshot(runtimeId); persisted: nextPersisted,
} });
if (cancelled()) return; const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
await invoke("subscribe_workspace", { cursor: { bridgeInstanceId: workspace.bridgeInstanceId, seq: workspace.latestSeq } }); if (cancelled()) return;
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {}); dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
}, [loadSnapshot]); const selected =
nextPersisted?.selectedRuntimeId &&
workspace.directories.some((directory) =>
directory.runtimes.some(
(runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId,
),
)
? nextPersisted.selectedRuntimeId
: workspace.directories[0]?.runtimes[0]?.runtimeId;
const runtimeIds = workspace.directories.flatMap((directory) =>
directory.runtimes.map((runtime) => runtime.runtimeId),
);
for (const runtimeId of reloadAllSnapshots
? runtimeIds
: selected
? [selected]
: []) {
if (cancelled()) return;
void loadSnapshot(runtimeId);
}
if (cancelled()) return;
await invoke("subscribe_workspace", {
cursor: {
bridgeInstanceId: workspace.bridgeInstanceId,
seq: workspace.latestSeq,
},
});
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
},
[loadSnapshot],
);
useEffect(() => { useEffect(() => {
disposed.current = false; disposed.current = false;
let stop: (() => void) | undefined; let stop: (() => void) | undefined;
void listen<HostEvent>("workspace-bridge", (message) => { let stopNewRuntime: (() => void) | undefined;
if (disposed.current) return; void listen<{
const host = message.payload; phase: "starting" | "ready" | "error";
dispatch({ type: "hostEvent", host }); runtimeId?: string;
if (host.kind === "resetRequired") void refresh(undefined, true); detail: string;
else if (host.kind === "event") { }>("workspace-new-session", (message) => {
const runtimeId = host.event.data?.runtimeId; const launch = message.payload;
const eventType = host.event.type; if (launch.phase === "starting") {
const piType = String(host.event.data?.eventData?.event?.type ?? ""); dispatch({
if (["runtime_opened", "runtime_closed", "runtime_recovered", "runtime_restore_failed"].includes(eventType ?? "")) { type: "newSessionLaunch",
void refresh(); launch: { phase: "starting", detail: launch.detail },
} else if (runtimeId && (host.event.data?.eventType === "transcript" || piType === "agent_settled" || piType === "compaction_end")) { });
scheduleSnapshot(runtimeId); } else if (launch.phase === "ready" && launch.runtimeId) {
} dispatch({ type: "newSessionLaunch", launch: undefined });
} void refresh().then(() => {
}).then((unlisten) => { if (disposed.current) unlisten(); else stop = unlisten; }); if (!disposed.current)
void (async () => { dispatch({ type: "selectRuntime", runtimeId: launch.runtimeId! });
try { });
const loaded = await loadPresentation(); } else if (launch.phase === "error") {
if (disposed.current) return; dispatch({
await refresh(loaded); type: "newSessionLaunch",
if (disposed.current) return; launch: { phase: "error", detail: launch.detail },
initialized.current = true; });
} catch (error) { }
if (!disposed.current) }).then((unlisten) => {
dispatch({ type: "hostEvent", host: { kind: "disconnected", message: String(error), retryInMs: 0 } }); if (disposed.current) unlisten();
} else stopNewRuntime = unlisten;
})(); });
const appWindow = getCurrentWindow(); void listen<HostEvent>("workspace-bridge", (message) => {
const visibility = () => dispatch({ type: "setWindowActive", active: document.visibilityState === "visible" && document.hasFocus() }); if (disposed.current) return;
window.addEventListener("focus", visibility); window.addEventListener("blur", visibility); document.addEventListener("visibilitychange", visibility); const host = message.payload;
return () => { dispatch({ type: "hostEvent", host });
disposed.current = true; if (host.kind === "resetRequired") void refresh(undefined, true);
refreshGeneration.current += 1; else if (host.kind === "event") {
stop?.(); const runtimeId = host.event.data?.runtimeId;
void invoke("unsubscribe_workspace").catch(() => {}); const eventType = host.event.type;
window.removeEventListener("focus", visibility); const piType = String(host.event.data?.eventData?.event?.type ?? "");
window.removeEventListener("blur", visibility); if (
document.removeEventListener("visibilitychange", visibility); [
Object.values(timers.current).forEach(clearTimeout); "runtime_opened",
void appWindow; "runtime_closed",
}; "runtime_recovered",
}, [refresh, scheduleSnapshot]); "runtime_restore_failed",
].includes(eventType ?? "")
) {
void refresh();
} else if (
runtimeId &&
(host.event.data?.eventType === "transcript" ||
piType === "agent_settled" ||
piType === "compaction_end")
) {
scheduleSnapshot(runtimeId);
}
}
}).then((unlisten) => {
if (disposed.current) unlisten();
else stop = unlisten;
});
void (async () => {
try {
const loaded = await loadPresentation();
if (disposed.current) return;
await refresh(loaded);
if (disposed.current) return;
initialized.current = true;
} catch (error) {
if (!disposed.current)
dispatch({
type: "hostEvent",
host: {
kind: "disconnected",
message: String(error),
retryInMs: 0,
},
});
}
})();
const appWindow = getCurrentWindow();
const visibility = () =>
dispatch({
type: "setWindowActive",
active: document.visibilityState === "visible" && document.hasFocus(),
});
window.addEventListener("focus", visibility);
window.addEventListener("blur", visibility);
document.addEventListener("visibilitychange", visibility);
return () => {
disposed.current = true;
refreshGeneration.current += 1;
stop?.();
stopNewRuntime?.();
void invoke("unsubscribe_workspace").catch(() => {});
window.removeEventListener("focus", visibility);
window.removeEventListener("blur", visibility);
document.removeEventListener("visibilitychange", visibility);
Object.values(timers.current).forEach(clearTimeout);
void appWindow;
};
}, [refresh, scheduleSnapshot]);
useEffect(() => { useEffect(() => {
if (!initialized.current) return; if (!initialized.current) return;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
void savePresentation(presentationState(state)).then(() => { void savePresentation(presentationState(state))
if (legacyMigrationPending.current) { .then(() => {
removeLegacyPreferences(localStorage); if (legacyMigrationPending.current) {
legacyMigrationPending.current = false; removeLegacyPreferences(localStorage);
} legacyMigrationPending.current = false;
}).catch(() => {}); }
}, 300); })
return () => clearTimeout(timer); .catch(() => {});
}, [state.directoryOrder, state.runtimeOrderByDirectory, state.selectedRuntimeId, state.interfaceScale, state.bridge.latestSeq, state.runtimesById]); }, 300);
return () => clearTimeout(timer);
}, [
state.directoryOrder,
state.runtimeOrderByDirectory,
state.selectedRuntimeId,
state.defaultNewSessionWorktree,
state.interfaceScale,
state.bridge.latestSeq,
state.runtimesById,
]);
useEffect(() => { useEffect(() => {
const id = state.selectedRuntimeId; if (!id) return; const id = state.selectedRuntimeId;
if (!state.runtimesById[id]?.loading && !state.runtimesById[id]?.messages.length) void loadSnapshot(id); if (!id) return;
}, [state.selectedRuntimeId, loadSnapshot]); if (
!state.runtimesById[id]?.loading &&
!state.runtimesById[id]?.messages.length
)
void loadSnapshot(id);
}, [state.selectedRuntimeId, loadSnapshot]);
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot }; return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
} }