138 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|