'use client'; /** * The question form — `ask_user` taking over the composer. * * The agent hit a decision the user must own, called `ask_user`, and the run * ENDED on it. There is nothing to type into until that question is answered, so * the composer stops being a text box and becomes the question: same frame, same * place, swapped in position. (The previous shape floated a panel ABOVE the * composer, which asked the user to read one thing and act in another; a * questionnaire that owns the input area has one focus and one verb.) * * Shape, per envelope (`questionFormRows`): * * - With options: numbered full-width rows, radio or checkbox, plus the * always-last "other…" row whose inline underline box is the free-text channel. * That row is why no sentence points at "the box below" any more — this IS the * box below. * - Without options: the title and one multi-line box. Nothing else to draw. * * The keyboard is the point (rules and their tests live in * `question-form-state.ts`): 1-9 confirm a row, ↑↓ move the highlight, Enter * confirms it, Esc gives the composer back. Every one of those is suspended while * the caret is in a text box, except the two that are always an escape hatch * (Esc, ⌘/Ctrl+Enter). * * Dismissing is not "cancel the question": the transcript card stays live and * clickable, and the host remembers only that this question's form was waved off * (see `WorkbenchChat`). Nothing about the question changes — it just stops * holding the composer hostage. */ import { useEffect, useRef } from 'react'; import { Check } from 'lucide-react'; import type { ChatNode } from '@/lib/workbench/session-store'; import { defaultWorkbenchTranslator, type WorkbenchTranslator } from '@/lib/i18n/workbench'; import { wbStyles as styles } from './chat-styles'; import { editingKindFor, questionFormKeyAction, useQuestionForm } from './question-form-state'; /** The keyboard legend, decoration only — the keys work whether or not it is read. */ export const QUESTION_FORM_KEY_HINT = defaultWorkbenchTranslator('workbench.question.keyHint'); export function QuestionForm({ node, onAnswer, onDismiss, t = defaultWorkbenchTranslator, }: { readonly node: ChatNode; /** Same contract as the card's: false means the send failed and the form revives. */ readonly onAnswer?: (text: string) => Promise; /** Give the composer back. The question stays open; only the form steps aside. */ readonly onDismiss: () => void; readonly t?: WorkbenchTranslator; }) { const form = useQuestionForm(node, onAnswer, t); const { mode, rows, locked } = form; const rowRefs = useRef<(HTMLButtonElement | null)[]>([]); const otherRef = useRef(null); const openRef = useRef(null); const submitRef = useRef(null); const otherIndex = rows.length - 1; const otherPicked = form.picks.otherPicked; // An open question has one place a keystroke can go, so focus starts there. useEffect(() => { if (mode === 'open') openRef.current?.focus(); }, [mode]); /** * Focus follows the highlight — that is what makes ↑↓ navigation real rather * than a coloured border, and what puts the first option under the keyboard the * moment the form appears. The one exception is the selected "other" row, * where the thing to type into is the box, not the row. */ useEffect(() => { if (mode === 'open') return; if (form.highlight >= rows.length) { submitRef.current?.focus(); return; } if (form.highlight === otherIndex && otherPicked) { otherRef.current?.focus(); return; } rowRefs.current[form.highlight]?.focus(); }, [mode, form.highlight, rows.length, otherIndex, otherPicked]); return (
{ const action = questionFormKeyAction({ key: event.key, editing: editingKindFor((event.target as HTMLElement).tagName), modifier: event.metaKey || event.ctrlKey, composing: event.nativeEvent.isComposing, highlight: form.highlight, rowCount: rows.length, }); if (action.type === 'none') return; event.preventDefault(); event.stopPropagation(); if (action.type === 'move') form.setHighlight(action.index); else if (action.type === 'activate') form.activate(action.index); else if (action.type === 'submit') form.submit(); else onDismiss(); }} >

{node.text}

{mode === 'open' ? (