diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 249ce9286e5e5..bb834e7667ed3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -214,6 +214,7 @@ import { buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, + findFirstUnansweredPendingUserInputQuestionIndex, projectPendingUserInputAnswer, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -10455,11 +10456,20 @@ export default function ChatView(props: ChatViewProps) { if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + return; } + // Submitting with gaps shows the first question still waiting for an answer. + setActivePendingUserInputQuestionIndex( + findFirstUnansweredPendingUserInputQuestionIndex( + activePendingUserInput.questions, + activePendingDraftAnswers, + ), + ); return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ + activePendingDraftAnswers, activePendingProgress, activePendingResolvedAnswers, activePendingUserInput, @@ -11880,6 +11890,9 @@ export default function ChatView(props: ChatViewProps) { onPreviousActivePendingUserInputQuestion={ onPreviousActivePendingUserInputQuestion } + onSelectActivePendingUserInputQuestion={ + setActivePendingUserInputQuestionIndex + } onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 64514530b29fb..059a94845fbbb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1371,6 +1371,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( canAdvance: boolean; isResponding: boolean; isComplete: boolean; + unansweredCount: number; } | null; isRunning: boolean; canInterrupt: boolean; @@ -1579,6 +1580,7 @@ export interface ChatComposerProps { questionIndex: number; isLastQuestion: boolean; canAdvance: boolean; + answeredQuestionCount: number; customAnswer: string; activeQuestion: { id: string; @@ -1682,6 +1684,7 @@ export interface ChatComposerProps { onAdvanceActivePendingUserInput: () => void; onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void; onPreviousActivePendingUserInputQuestion: () => void; + onSelectActivePendingUserInputQuestion: (questionIndex: number) => void; onChangeActivePendingUserInputCustomAnswer: ( questionId: string, value: string, @@ -1801,6 +1804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onAdvanceActivePendingUserInput, onDismissActivePendingUserInput, onPreviousActivePendingUserInputQuestion, + onSelectActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, onOpenProviderSetup, @@ -3043,11 +3047,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !(questionPreparations[attachmentTargetKey] ?? 0), isResponding: activePendingIsResponding, isComplete: Boolean(activePendingResolvedAnswers), + unansweredCount: + (pendingUserInputs[0]?.questions.length ?? 0) - + activePendingProgress.answeredQuestionCount, } : null, [ activePendingIsResponding, activePendingProgress, + pendingUserInputs, activePendingResolvedAnswers, attachmentBlockReason, questionPreparations, @@ -6879,6 +6887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) questionIndex={activePendingQuestionIndex} onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} + onSelectQuestion={onSelectActivePendingUserInputQuestion} onDismiss={onDismissActivePendingUserInput} /> ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? ( @@ -6900,6 +6909,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) questionIndex={activePendingQuestionIndex} onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} + onSelectQuestion={onSelectActivePendingUserInputQuestion} onDismiss={onDismissActivePendingUserInput} /> {!isChoiceOnlyPendingQuestion || diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx index c414934fa0da6..562031f91d26b 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx @@ -49,6 +49,7 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) { answers={answers} questionIndex={questionIndex} onAdvance={advance} + onSelectQuestion={setQuestionIndex} onToggleOption={(questionId, value) => { const question = prompt.questions.find((entry) => entry.id === questionId)!; setAnswers((existing) => ({ @@ -87,8 +88,10 @@ async function chooseOption() { await act(async () => { renderer!.root .findAllByType("button") - .find((button) => - button.findAllByType("span").some((span) => span.children.includes("Incremental")), + .find( + (button) => + button.props.role !== "tab" && + button.findAllByType("span").some((span) => span.children.includes("Incremental")), )! .props.onClick(); }); @@ -142,3 +145,26 @@ it("still auto-advances once after the answer draft rerenders the same question" await act(async () => vi.advanceTimersByTime(200)); expect(activeIndex()).toBe("1"); }); + +it("does not submit when the last question is answered", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("2"); +}); + +it("jumps to a question from its tab, which shows the chosen answer", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + const tabs = () => + renderer!.root.findAll((node) => node.props.role === "tab" && node.type === "button"); + expect(tabs()[0]!.props["aria-label"]).toBe("Question 1: Incremental"); + expect(tabs()[2]!.props["aria-label"]).toBe("Question 3"); + await act(async () => tabs()[2]!.props.onClick()); + expect(activeIndex()).toBe("2"); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx index f81ed67026000..0036b04c9a345 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx @@ -14,7 +14,7 @@ vi.mock("../ui/collapsible", () => { import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; -it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => { +it("cancels an answer's pending auto-advance on revocation and resumes after a new grant", async () => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", { @@ -38,6 +38,13 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne options: [{ label: "Incremental", description: "One module at a time" }], multiSelect: false, }, + { + id: "scope", + header: "Scope", + question: "How much?", + options: [{ label: "All of it", description: "Every module" }], + multiSelect: false, + }, ], }; const panel = (disabled: boolean) => ( @@ -49,6 +56,7 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne questionIndex={0} onToggleOption={onToggleOption} onAdvance={onAdvance} + onSelectQuestion={() => {}} onDismiss={onDismiss} /> ); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx index 34f9467e42c4b..93bb20b675f7f 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -33,6 +33,7 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) { questionIndex={0} onToggleOption={() => {}} onAdvance={() => {}} + onSelectQuestion={() => {}} onDismiss={() => {}} />, ); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 1e8caefac2d40..36ede9a0eb843 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, + summarizePendingUserInputAnswer, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; import { CheckIcon } from "lucide-react"; @@ -17,7 +18,9 @@ interface PendingUserInputPanelProps { answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; + /** Moves past an answered question; never called on the last one. */ onAdvance: () => void; + onSelectQuestion: (questionIndex: number) => void; onDismiss: (requestId: RuntimeRequestId) => void; } @@ -29,6 +32,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex, onToggleOption, onAdvance, + onSelectQuestion, onDismiss, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; @@ -45,6 +49,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex={questionIndex} onToggleOption={onToggleOption} onAdvance={onAdvance} + onSelectQuestion={onSelectQuestion} onDismiss={onDismiss} /> ); @@ -58,6 +63,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex, onToggleOption, onAdvance, + onSelectQuestion, onDismiss, }: { prompt: PendingUserInput; @@ -67,6 +73,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; + onSelectQuestion: (questionIndex: number) => void; onDismiss: (requestId: RuntimeRequestId) => void; }) { // Message-mode requests remain answerable after their provider turn ends. @@ -126,6 +133,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return clearAutoAdvance; }, [activeQuestion?.id, disabled]); + // Choosing an option moves on to the next question, but never submits: the + // last question waits for Submit so a click can't send answers by accident. const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => { if (disabled || isResponding) return; @@ -137,19 +146,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onToggleOption(questionId, optionValue); if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; } + if (progress.isLastQuestion) return; autoAdvanceTimerRef.current = window.setTimeout(() => { autoAdvanceTimerRef.current = null; onAdvanceRef.current(); }, 200); }, - [activeQuestion, disabled, isResponding, onToggleOption], + [activeQuestion, disabled, isResponding, onToggleOption, progress.isLastQuestion], ); - // Keyboard shortcut: number keys 1-9 select corresponding options when focus is - // outside editable fields. Multi-select prompts toggle options in place; single- - // select prompts keep the existing auto-advance behavior. Collapsed prompts opt - // out, since the numbers they refer to are not on screen. + // Keyboard shortcuts when focus is outside editable fields: number keys 1-9 + // select the corresponding option, and left/right arrows switch questions. + // Collapsed prompts opt out, since what the keys refer to is not on screen. useEffect(() => { if (!activeQuestion || responseDisabled || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { @@ -164,6 +174,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) { return; } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + const nextIndex = progress.questionIndex + (event.key === "ArrowLeft" ? -1 : 1); + if (nextIndex < 0 || nextIndex >= prompt.questions.length) return; + event.preventDefault(); + onSelectQuestion(nextIndex); + return; + } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; const optionIndex = digit - 1; @@ -175,7 +192,15 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, handleOptionSelection, isCollapsed, responseDisabled]); + }, [ + activeQuestion, + handleOptionSelection, + isCollapsed, + onSelectQuestion, + progress.questionIndex, + prompt.questions.length, + responseDisabled, + ]); if (!activeQuestion) { return null; @@ -200,7 +225,9 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( - {activeQuestion.header} + {isCollapsed || prompt.questions.length === 1 + ? activeQuestion.header + : `${progress.answeredQuestionCount} of ${prompt.questions.length} answered`} {isCollapsed ? ( @@ -209,7 +236,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : null} - {prompt.questions.length > 1 ? ( + {isCollapsed && prompt.questions.length > 1 ? ( {questionIndex + 1}/{prompt.questions.length} @@ -240,6 +267,45 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( + {prompt.questions.length > 1 ? ( + +
+ {prompt.questions.map((question, index) => { + const summary = summarizePendingUserInputAnswer(question, answers[question.id]); + const isActive = index === progress.questionIndex; + return ( + + ); + })} +
+
+ ) : null}

{activeQuestion.question}

diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index d951f1e21fe36..2a95e585c2ce2 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -28,6 +28,7 @@ function renderPendingActions(isRunning: boolean) { canAdvance: true, isResponding: false, isComplete: true, + unansweredCount: 0, }, isRunning, canInterrupt: isRunning, diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fc811118aa124..558b6b7da6303 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -23,6 +23,7 @@ interface PendingActionState { canAdvance: boolean; isResponding: boolean; isComplete: boolean; + unansweredCount: number; } interface ComposerPrimaryActionsProps { @@ -63,10 +64,15 @@ const formatPendingPrimaryActionLabel = (input: { isLastQuestion: boolean; isResponding: boolean; questionIndex: number; + unansweredCount: number; }) => { if (input.isResponding) { return "Submitting..."; } + // Submitting with gaps jumps to the first unanswered question instead. + if (input.isLastQuestion && input.unansweredCount > 0) { + return `Submit (${input.unansweredCount} left)`; + } if (input.compact) { return input.isLastQuestion ? "Submit" : "Next"; } @@ -194,7 +200,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ !canOperateThread || isEnvironmentUnavailable || pendingAction.isResponding || - (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) + !pendingAction.canAdvance } > {formatPendingPrimaryActionLabel({ @@ -202,6 +208,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isLastQuestion: pendingAction.isLastQuestion, isResponding: pendingAction.isResponding, questionIndex: pendingAction.questionIndex, + unansweredCount: pendingAction.unansweredCount, })} diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index d6d14b0c4cacb..274945beefa62 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -12,6 +12,7 @@ import { projectPendingUserInputAnswer, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, + summarizePendingUserInputAnswer, togglePendingUserInputOptionSelection, } from "./pendingUserInput"; @@ -406,6 +407,44 @@ it("accepts attachment-only answers after every upload finishes", () => { ).toBeNull(); }); +describe("summarizePendingUserInputAnswer", () => { + const question = { + id: "hosting", + header: "Images", + question: "Where should images go?", + options: [ + { label: "GitHub attachments", value: "gh", description: "Upload with gh" }, + { label: "Drop", description: "Upload to Drop" }, + ], + multiSelect: false, + }; + + it("is null while the question is unanswered", () => { + expect(summarizePendingUserInputAnswer(question, undefined)).toBeNull(); + }); + + it("shows the option label rather than its value", () => { + expect(summarizePendingUserInputAnswer(question, { selectedOptionValues: ["gh"] })).toBe( + "GitHub attachments", + ); + }); + + it("joins multi-select labels", () => { + expect( + summarizePendingUserInputAnswer( + { ...question, multiSelect: true }, + { selectedOptionValues: ["Drop", "gh"] }, + ), + ).toBe("Drop, GitHub attachments"); + }); + + it("collapses a typed answer onto one line", () => { + expect( + summarizePendingUserInputAnswer(question, { customAnswer: " Use S3\n with a CDN " }), + ).toBe("Use S3 with a CDN"); + }); +}); + describe("carryDisplacedCustomAnswerIntoPrompt", () => { it("keeps the thread draft when nothing was typed into the answer", () => { expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 9b5abf195c7a2..ba8eeed85de92 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -86,6 +86,20 @@ export function resolvePendingUserInputAnswer( ); } +/** Short text for a question's answer, as shown on its tab. Null while unanswered. */ +export function summarizePendingUserInputAnswer( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): string | null { + const answer = resolvePendingUserInputAnswer(question, draft); + if (answer === null) return null; + const labelFor = (value: string) => + question.options.find((option) => (option.value ?? option.label) === value)?.label ?? value; + if (Array.isArray(answer)) return answer.map(labelFor).join(", "); + if (answer.trim().length === 0) return "Attachment"; + return labelFor(answer).replace(/\s+/g, " ").trim(); +} + export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string,