import { useLayoutEffect, useRef, useState } from "react"; import { LuArrowDown, LuArrowUp, LuCheck, LuChevronDown, LuChevronUp, LuMessageCircleQuestion } from "react-icons/lu"; import { api } from "../api"; import { t } from "../i18n"; import type { Item } from "../transcript"; import { codeAreaCls, inputCls, primarySmCls } from "./SettingsUi"; /** A question of the agent's ask_question tool, as the server settled it (see server/src/pi/ask-question.ts). */ export interface AgentQuestion { id: string; question: string; kind: "single" | "multiple" | "text" | "ranking"; options: { label: string; description?: string }[]; other: boolean; optional: boolean; } /** What the person has put in for one question. Kept apart from the answer sent, which is made of it. */ interface Draft { /** single: the pick; text: the text. */ one: string; /** multiple: the picks. */ many: string[]; /** ranking: the options in the order the person has put them. */ order: string[]; /** ranking: the person moved something or kept the order, so it is theirs and not the agent's own. */ ranked: boolean; /** The "other" choice is ticked, and what was typed for it. */ otherOn: boolean; other: string; } const emptyDraft = (q: AgentQuestion): Draft => ({ one: "", many: [], order: q.options.map((o) => o.label), ranked: false, otherOn: false, other: "" }); /** The answer a draft stands for, or undefined while there is none. A ranking is one once the person has moved or kept the order: the order it starts in is the agent's. */ function answerOf(q: AgentQuestion, d: Draft): string | string[] | undefined { const typed = d.otherOn ? d.other.trim() : ""; if (q.kind === "text") return d.one.trim() || undefined; if (q.kind === "ranking") return d.ranked ? d.order : undefined; if (q.kind === "single") return (d.otherOn ? typed : d.one) || undefined; const picks = [...d.many, ...(typed ? [typed] : [])]; return picks.length ? picks : undefined; } /** * The agent asking the person several things at once: choose one, choose several, type, or put options in order. * Drawn in the chat above the message box, not over the page: the rest of the page stays as it is, and Esc * closes nothing. A big card covers the chat behind it, so it can be minimized to its heading to keep reading, * and the draft is kept while it is away. The server keeps the question until it ends, so it is back after a * reload. It answers the same request route as an extension's menu (see ExtensionDialog), with the answers by * question. */ export function QuestionCard({ sessionId, requestId, title, questions, onDone, }: { sessionId: string; requestId: string; title?: string; questions: AgentQuestion[]; onDone: () => void; }) { const [drafts, setDrafts] = useState>(() => Object.fromEntries(questions.map((q) => [q.id, emptyDraft(q)]))); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [minimized, setMinimized] = useState(false); const card = useRef(null); const heading = title || t("Questions from the agent"); const change = (id: string, patch: Partial) => setDrafts((all) => ({ ...all, [id]: { ...all[id], ...patch } })); const answers = Object.fromEntries(questions.flatMap((q) => { const a = answerOf(q, drafts[q.id]); return a === undefined ? [] : [[q.id, a]]; })); const complete = questions.every((q) => q.optional || Object.hasOwn(answers, q.id)); const respond = async (payload: { value?: unknown; cancelled?: boolean }) => { if (busy) return; if (payload.cancelled && error) { onDone(); return; } setBusy(true); setError(""); try { const result = await api.respondUi(sessionId, requestId, payload); if (!result.ok) { setError(result.note || t("This question has expired. Your answer was not delivered.")); return; } onDone(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }; // A button that is moved in the page loses the focus, and one that has just become disabled cannot have it: // so the row that was moved gets it back on the arrow still there to press, once the new order is drawn. const arrows = useRef(new Map()); const refocus = useRef(null); useLayoutEffect(() => { const key = refocus.current; refocus.current = null; const arrow = key ? arrows.current.get(key) : undefined; if (arrow && !arrow.disabled) arrow.focus(); else if (key) card.current?.focus(); }); const move = (q: AgentQuestion, from: number, by: number) => { const order = [...drafts[q.id].order]; const to = from + by; const label = order[from]; [order[from], order[to]] = [order[to], order[from]]; const next = by < 0 ? (to === 0 ? "down" : "up") : to === order.length - 1 ? "up" : "down"; refocus.current = `${q.id}\n${label}\n${next}`; change(q.id, { order, ranked: true }); }; // "Keep this order" goes away once pressed, and the focus with it: it goes to the first row's arrow, or to the card when that row cannot move. const keep = (q: AgentQuestion) => { refocus.current = `${q.id}\n${drafts[q.id].order[0]}\ndown`; change(q.id, { ranked: true }); }; const arrow = (q: AgentQuestion, label: string, dir: "up" | "down") => (el: HTMLButtonElement | null) => { const key = `${q.id}\n${label}\n${dir}`; if (el) arrows.current.set(key, el); else arrows.current.delete(key); }; return (

{heading}

{minimized ? null : ( <> {error &&

{error}

}
{questions.map((q) => { const d = drafts[q.id]; const legend = `${q.question}${q.optional ? ` (${t("optional")})` : ""}`; return (
{legend} {(q.kind === "single" || q.kind === "multiple") && (
{q.options.map((o) => ( ))} {q.other && (
change(q.id, { otherOn: q.kind === "single" ? true : e.target.checked })} className="accent-current" /> change(q.id, { other: e.target.value, otherOn: true })} aria-label={t("Type your own answer")} placeholder={t("Another answer")} className={`${inputCls} py-1.5 text-fg`} />
)}
)} {q.kind === "text" && (