feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
@@ -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 &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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
@@ -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()];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
]),
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user