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