feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
@@ -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 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]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
@@ -13,6 +15,39 @@ export function messageText(message: RuntimeView["messages"][number]) {
|
||||
.filter(Boolean)
|
||||
.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"]) {
|
||||
const order: Record<TodoTask["status"], number> = {
|
||||
in_progress: 0,
|
||||
@@ -41,6 +76,8 @@ export function ConversationWorkspace({
|
||||
onModel,
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
isAborting = false,
|
||||
onCommand,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
onDraft: (value: string) => void;
|
||||
@@ -50,13 +87,16 @@ export function ConversationWorkspace({
|
||||
onModel: (model: Model) => void;
|
||||
onThinking: (level: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
isAborting?: boolean;
|
||||
onCommand: (command: RpcCommand) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const runtimeIsWorking =
|
||||
runtime.summary.state === "streaming" ||
|
||||
runtime.progress.phase === "working";
|
||||
const isWorking =
|
||||
isSubmitting ||
|
||||
runtime.progress.phase === "working" ||
|
||||
runtime.progress.phase === "recovering";
|
||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
@@ -108,13 +148,7 @@ export function ConversationWorkspace({
|
||||
<p className="muted">No messages yet.</p>
|
||||
)}
|
||||
{runtime.messages.map((message, index) => (
|
||||
<article
|
||||
className={`message ${message.role ?? "system"}`}
|
||||
key={index}
|
||||
>
|
||||
<strong>{message.role ?? "message"}</strong>
|
||||
<pre>{messageText(message)}</pre>
|
||||
</article>
|
||||
<TranscriptMessage key={index} message={message} />
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
@@ -122,12 +156,7 @@ export function ConversationWorkspace({
|
||||
key={pending.id}
|
||||
>
|
||||
<strong>
|
||||
You{" "}
|
||||
<span>
|
||||
{pending.phase === "sending"
|
||||
? "Sending…"
|
||||
: "Sent · waiting for Pi"}
|
||||
</span>
|
||||
You <span>{pendingStatus(pending)}</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
@@ -215,6 +244,11 @@ export function ConversationWorkspace({
|
||||
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<CommandPalette
|
||||
draft={runtime.draft}
|
||||
commands={rpcCommandCatalog}
|
||||
onSelect={onCommand}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
@@ -229,9 +263,14 @@ export function ConversationWorkspace({
|
||||
placeholder="Ask Pi…"
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
{runtime.state.isStreaming && (
|
||||
<button type="button" className="quiet" onClick={onAbort}>
|
||||
Abort
|
||||
{runtimeIsWorking && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
disabled={isAborting}
|
||||
onClick={onAbort}
|
||||
>
|
||||
{isAborting ? "Aborting…" : "Abort"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -2,57 +2,136 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { Extension } from "../workspace/types";
|
||||
|
||||
function controls(container: HTMLElement | null) {
|
||||
return [...(container?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])];
|
||||
return [
|
||||
...(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 }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const respond = useRef(onRespond);
|
||||
const [value, setValue] = useState("");
|
||||
respond.current = onRespond;
|
||||
export function ExtensionDialog({
|
||||
extension,
|
||||
onRespond,
|
||||
}: {
|
||||
extension?: Extension;
|
||||
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(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(extension.initialValue ?? extension.text ?? "");
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
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 () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(
|
||||
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
||||
);
|
||||
const frame = requestAnimationFrame(() =>
|
||||
controls(ref.current)[0]?.focus(),
|
||||
);
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
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 () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
|
||||
if (!extension) return null;
|
||||
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
|
||||
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
|
||||
{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 })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</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>;
|
||||
if (!extension) return null;
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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";
|
||||
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])];
|
||||
controls()[0]?.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(); };
|
||||
}, [onClose]);
|
||||
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>
|
||||
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}>
|
||||
<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>}
|
||||
</section></div>;
|
||||
|
||||
const focusableSelector =
|
||||
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function sessionTitle(session: DirectorySession) {
|
||||
return session.name ?? session.firstMessage ?? "Untitled session";
|
||||
}
|
||||
|
||||
function messageCountLabel(count: number) {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function matchesQuery(session: DirectorySession, query: string) {
|
||||
return [session.name, session.firstMessage, session.id].some((value) =>
|
||||
value?.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
onOpen,
|
||||
}: {
|
||||
session: DirectorySession;
|
||||
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 { SessionTabs } from "./SessionTabs";
|
||||
import { SessionPicker } from "./SessionPicker";
|
||||
import { ConversationWorkspace } from "./ConversationWorkspace";
|
||||
import { ExtensionDialog } from "./ExtensionDialog";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
runtimeView,
|
||||
} from "../workspace/reducer";
|
||||
import type { Workspace } from "../workspace/types";
|
||||
import type { DirectorySession, Workspace } from "../workspace/types";
|
||||
const summary = (id: string, state = "idle") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: "/repo",
|
||||
@@ -96,41 +98,172 @@ describe("multi-session navigation", () => {
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
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 opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionPicker
|
||||
sessions={[
|
||||
{
|
||||
path: "/s",
|
||||
id: "s",
|
||||
cwd: "/repo",
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
},
|
||||
]}
|
||||
onOpen={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
||||
);
|
||||
const search = screen.getByRole("searchbox", {
|
||||
name: "Search saved sessions",
|
||||
});
|
||||
const close = screen.getByRole("button", { name: "Close" });
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Untitled session/ }),
|
||||
).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(opener).toHaveFocus();
|
||||
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 () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user