"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconX, IconMessageQuestion, IconInfoCircle, IconCheck } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import ReactMarkdown from "react-markdown"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import type { Message, ClarificationRequestMetadata, ClarificationAnswer, ClarificationQuestion, } from "@/lib/types/http"; import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group"; import { ClarificationCarouselNav, ClarificationCustomInput, ClarificationOptions, ClarificationStepper, } from "./clarification-overlay-parts"; type ClarificationInputOverlayProps = { messages: readonly Message[] | null | undefined; onResolved: () => void; }; type SingleQuestionMeta = { message: Message; metadata: ClarificationRequestMetadata; question: ClarificationQuestion; questionId: string; }; function readSingleQuestionMeta(message: Message | null | undefined): SingleQuestionMeta | null { if (!message) return null; const metadata = message.metadata as ClarificationRequestMetadata | undefined; if (!metadata?.question) return null; const questionId = metadata.question_id ?? metadata.question.id; if (!questionId) return null; return { message, metadata, question: metadata.question, questionId }; } function resolveQuestionMessages(messages: readonly Message[] | null | undefined): Message[] { if (messages && messages.length > 0) return [...messages]; return []; } function sortMessagesByQuestionIndex(messages: Message[]): Message[] { return messages.slice().sort((a, b) => { const ai = (a.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; const bi = (b.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; return ai - bi; }); } type CardProps = { meta: SingleQuestionMeta; index: number; total: number; selectedOption: string | null; customCommittedText: string | null; customDraft: string; customActive: boolean; isSubmitting: boolean; showAgentDisconnected: boolean; onSelectOption: (optionId: string) => void; onCustomDraftChange: (text: string) => void; onSubmitCustom: (text: string) => void; onRequestFinalSubmit: () => void; }; function ClarificationCard(props: CardProps) { const { meta, index, total, selectedOption, customCommittedText, customDraft, customActive, isSubmitting, showAgentDisconnected, onSelectOption, onCustomDraftChange, onSubmitCustom, onRequestFinalSubmit, } = props; const { question, metadata } = meta; return (
{(total > 1 || metadata.question.title) && (
{total > 1 && ( Question {index + 1} of {total} )} {metadata.question.title && ( {total > 1 ? "· " : ""} {metadata.question.title} )}
)}
{question.prompt}
{showAgentDisconnected && (
The agent has moved on. Your response will be sent as a new message.
)}
); } function useResolveCallback( submitState: ReturnType["submitState"], onResolved: () => void, ) { const last = useRef(submitState); useEffect(() => { if (last.current !== submitState && submitState === "ok") { onResolved(); } last.current = submitState; }, [submitState, onResolved]); } type CarouselShortcutArgs = { meta: SingleQuestionMeta; activeIndex: number; total: number; canSubmit: boolean; onPick: (index: number) => void; onPrev: () => void; onNext: () => void; onSkip: () => void; onSubmit: () => void; }; // shouldIgnoreShortcut filters out events that the overlay must not handle: // keystrokes inside an input/textarea (the user is typing) and any modifier // combo (so we don't hijack browser shortcuts like Cmd/Ctrl+1..9 for tab // switching or Alt+ArrowLeft for back-navigation). function shouldIgnoreShortcut(e: KeyboardEvent): boolean { if ( e.target instanceof HTMLElement && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") ) { return true; } return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; } // tryHandleMetaEnter returns true when the event was Cmd/Ctrl+Enter, so the // caller can short-circuit. When focus is inside the custom-text input it // returns true *without* invoking onSubmit — the input's own keydown handler // owns that path and is responsible for committing the draft + final submit. function tryHandleMetaEnter(e: KeyboardEvent, canSubmit: boolean, onSubmit: () => void): boolean { if (e.key !== "Enter" || e.shiftKey || e.altKey) return false; if (!e.metaKey && !e.ctrlKey) return false; const inEditable = e.target instanceof HTMLElement && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA"); if (inEditable) return true; e.preventDefault(); if (canSubmit) onSubmit(); return true; } function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) { const optionsCount = args.meta.question.options.length; const isLast = args.activeIndex === args.total - 1; const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (tryHandleMetaEnter(e, canSubmit, onSubmit)) return; if (shouldIgnoreShortcut(e)) return; if (e.key === "Escape") { e.preventDefault(); onSkip(); return; } if (e.key === "ArrowLeft") { e.preventDefault(); onPrev(); return; } if (e.key === "ArrowRight") { e.preventDefault(); if (isLast && canSubmit) onSubmit(); else onNext(); return; } const num = Number.parseInt(e.key, 10); if (Number.isFinite(num) && num >= 1 && num <= optionsCount) { e.preventDefault(); onPick(num - 1); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]); return null; } type CarouselBodyProps = { sortedMessages: Message[]; group: ReturnType; activeIndex: number; setActiveIndex: (idx: number) => void; customDrafts: Record; setCustomDrafts: React.Dispatch>>; allAnswered: boolean; isSubmitting: boolean; onSubmit: () => void; }; type QuestionHandlerCtx = { meta: SingleQuestionMeta; group: ReturnType; isSingleQuestion: boolean; activeIndex: number; total: number; setActiveIndex: (idx: number) => void; setCustomDrafts: React.Dispatch>>; }; type QuestionHandlers = { onSelectOption: (optionId: string) => void; onCustomDraftChange: (value: string) => void; onSubmitCustom: (text: string) => void; }; type SelectionState = { selectedOption: string | null; customCommittedText: string | null; draft: string; customActive: boolean; }; function deriveSelectionState( meta: SingleQuestionMeta, answers: Record, customDrafts: Record, ): SelectionState { const stored = answers[meta.questionId]; const selected = stored?.selected_options ?? []; const selectedOption = selected.length > 0 ? selected[0] : null; const customCommittedText = stored?.custom_text ?? null; const draft = customDrafts[meta.questionId] ?? ""; const hasCustomText = draft.trim().length > 0 || (customCommittedText?.length ?? 0) > 0; const customActive = !selectedOption && hasCustomText; return { selectedOption, customCommittedText, draft, customActive }; } function buildQuestionHandlers(ctx: QuestionHandlerCtx): QuestionHandlers { const { meta, group, isSingleQuestion, activeIndex, total, setActiveIndex, setCustomDrafts } = ctx; // Records the answer, then auto-submits (single-question — uses the override // path because setState is async) or auto-advances to the next step. const commitAnswer = (answer: ClarificationAnswer) => { group.recordAnswer(meta.questionId, answer); if (isSingleQuestion) { void group.submitCollected({ [meta.questionId]: answer }); return; } if (activeIndex < total - 1) setActiveIndex(activeIndex + 1); }; return { onSelectOption(optionId) { // Picking an option wipes any in-flight draft so the answer state and // the visible input agree (custom_text and selected_options are mutually // exclusive at commit time). setCustomDrafts((prev) => { if (!prev[meta.questionId]) return prev; return { ...prev, [meta.questionId]: "" }; }); commitAnswer({ question_id: meta.questionId, selected_options: [optionId] }); }, // Live-record the draft so the stepper updates and the custom input lights // up the moment the user types. Emptying the draft clears the answer so // allAnswered reverts to false. Enter/Cmd+Enter still drives advance/submit. onCustomDraftChange(value) { setCustomDrafts((prev) => ({ ...prev, [meta.questionId]: value })); const trimmed = value.trim(); if (trimmed.length === 0) { group.clearAnswer(meta.questionId); return; } group.recordAnswer(meta.questionId, { question_id: meta.questionId, selected_options: [], custom_text: trimmed, }); }, onSubmitCustom(text) { const trimmed = text.trim(); if (!trimmed) return; commitAnswer({ question_id: meta.questionId, selected_options: [], custom_text: trimmed, }); }, }; } function ClarificationCarouselBody({ sortedMessages, group, activeIndex, setActiveIndex, customDrafts, setCustomDrafts, allAnswered, isSubmitting, onSubmit, }: CarouselBodyProps) { const total = sortedMessages.length; const activeMessage = sortedMessages[Math.min(activeIndex, total - 1)] ?? null; const meta = activeMessage ? readSingleQuestionMeta(activeMessage) : null; const showAgentDisconnectedAtTop = sortedMessages.some( (m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true, ); const isSingleQuestion = total === 1; if (!meta) return null; const { selectedOption, customCommittedText, draft, customActive } = deriveSelectionState( meta, group.answers, customDrafts, ); const { onSelectOption, onCustomDraftChange, onSubmitCustom } = buildQuestionHandlers({ meta, group, isSingleQuestion, activeIndex, total, setActiveIndex, setCustomDrafts, }); return ( <> {!isSingleQuestion && ( setActiveIndex(Math.max(0, activeIndex - 1))} onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} /> )} onSelectOption(meta.question.options[idx].option_id)} onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))} onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} onSkip={() => void group.skipAll("User skipped")} onSubmit={onSubmit} /> ); } export function ClarificationInputOverlay({ messages, onResolved, }: ClarificationInputOverlayProps) { const sortedMessages = useMemo( () => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)), [messages], ); const group = useClarificationGroup(sortedMessages); const [customDrafts, setCustomDrafts] = useState>({}); const [rawActiveIndex, setActiveIndex] = useState(0); // Clamp the active index to the current bundle size so late-arriving // messages or shrunk bundles never put us out of range. const total = sortedMessages.length; const activeIndex = total === 0 ? 0 : Math.min(rawActiveIndex, total - 1); useResolveCallback(group.submitState, onResolved); // group is a fresh object every render, but its submitCollected callback is // memoised by the hook — depend on the function only so this useCallback // doesn't churn on every keystroke (via the live-record path). const submitCollected = group.submitCollected; const allAnswered = sortedMessages.length > 0 && sortedMessages.every((m) => { const id = readSingleQuestionMeta(m)?.questionId; return id ? Boolean(group.answers[id]) : false; }); const handleSubmit = useCallback(() => { if (allAnswered) void submitCollected(); }, [allAnswered, submitCollected]); if (sortedMessages.length === 0) return null; const isSubmitting = group.submitState === "submitting"; const isAnsweredAt = (index: number) => { const m = sortedMessages[index]; if (!m) return false; const id = readSingleQuestionMeta(m)?.questionId; return id ? Boolean(group.answers[id]) : false; }; return (
{total > 1 && ( )} {total > 1 && ( {group.answeredCount} of {group.total} answered )}
{total > 1 && ( )}
); } export type { ClarificationAnswer };