Files
pi-gui/ui/src/components/ExtensionDialog.tsx
T

138 lines
3.8 KiB
TypeScript

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"])',
) ?? []),
];
}
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.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"
>
<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>
);
}