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

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+174 -25
View File
@@ -1,27 +1,176 @@
import { useEffect, useRef } from "react";
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
import type { DirectorySession } from "../workspace/types";
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>
);
}