Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ import {
buildPendingUserInputAnswers,
carryDisplacedCustomAnswerIntoPrompt,
derivePendingUserInputProgress,
findFirstUnansweredPendingUserInputQuestionIndex,
projectPendingUserInputAnswer,
setPendingUserInputCustomAnswer,
togglePendingUserInputOptionSelection,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -11880,6 +11890,9 @@ export default function ChatView(props: ChatViewProps) {
onPreviousActivePendingUserInputQuestion={
onPreviousActivePendingUserInputQuestion
}
onSelectActivePendingUserInputQuestion={
setActivePendingUserInputQuestionIndex
}
onChangeActivePendingUserInputCustomAnswer={
onChangeActivePendingUserInputCustomAnswer
}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
canAdvance: boolean;
isResponding: boolean;
isComplete: boolean;
unansweredCount: number;
} | null;
isRunning: boolean;
canInterrupt: boolean;
Expand Down Expand Up @@ -1579,6 +1580,7 @@ export interface ChatComposerProps {
questionIndex: number;
isLastQuestion: boolean;
canAdvance: boolean;
answeredQuestionCount: number;
customAnswer: string;
activeQuestion: {
id: string;
Expand Down Expand Up @@ -1682,6 +1684,7 @@ export interface ChatComposerProps {
onAdvanceActivePendingUserInput: () => void;
onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void;
onPreviousActivePendingUserInputQuestion: () => void;
onSelectActivePendingUserInputQuestion: (questionIndex: number) => void;
onChangeActivePendingUserInputCustomAnswer: (
questionId: string,
value: string,
Expand Down Expand Up @@ -1801,6 +1804,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onAdvanceActivePendingUserInput,
onDismissActivePendingUserInput,
onPreviousActivePendingUserInputQuestion,
onSelectActivePendingUserInputQuestion,
onChangeActivePendingUserInputCustomAnswer,
onProviderModelSelect,
onOpenProviderSetup,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ? (
Expand All @@ -6900,6 +6909,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
questionIndex={activePendingQuestionIndex}
onToggleOption={onSelectActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
onSelectQuestion={onSelectActivePendingUserInputQuestion}
onDismiss={onDismissActivePendingUserInput}
/>
{!isChoiceOnlyPendingQuestion ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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(<Questions initialIndex={2} />);
});
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(<Questions />);
});
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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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) => (
Expand All @@ -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}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function renderPanel(pendingUserInput: PendingUserInput = prompt) {
questionIndex={0}
onToggleOption={() => {}}
onAdvance={() => {}}
onSelectQuestion={() => {}}
onDismiss={() => {}}
/>,
);
Expand Down
82 changes: 74 additions & 8 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,7 +18,9 @@ interface PendingUserInputPanelProps {
answers: Record<string, PendingUserInputDraftAnswer>;
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;
}

Expand All @@ -29,6 +32,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
questionIndex,
onToggleOption,
onAdvance,
onSelectQuestion,
onDismiss,
}: PendingUserInputPanelProps) {
if (pendingUserInputs.length === 0) return null;
Expand All @@ -45,6 +49,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
questionIndex={questionIndex}
onToggleOption={onToggleOption}
onAdvance={onAdvance}
onSelectQuestion={onSelectQuestion}
onDismiss={onDismiss}
/>
);
Expand All @@ -58,6 +63,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
questionIndex,
onToggleOption,
onAdvance,
onSelectQuestion,
onDismiss,
}: {
prompt: PendingUserInput;
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -200,7 +225,9 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
<ComposerBanner.Icon />
<ComposerBanner.Content>
<span className="shrink-0 font-medium text-muted-foreground">
{activeQuestion.header}
{isCollapsed || prompt.questions.length === 1
? activeQuestion.header
: `${progress.answeredQuestionCount} of ${prompt.questions.length} answered`}
</span>
{isCollapsed ? (
<span className="min-w-0 flex-1 truncate text-secondary-label">
Expand All @@ -209,7 +236,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
) : null}
</ComposerBanner.Content>
<ComposerBanner.Actions>
{prompt.questions.length > 1 ? (
{isCollapsed && prompt.questions.length > 1 ? (
<span className="text-3xs font-medium text-muted-foreground tabular-nums">
{questionIndex + 1}/{prompt.questions.length}
</span>
Expand Down Expand Up @@ -240,6 +267,45 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
</ComposerBanner.Actions>
</CollapsibleTrigger>
<CollapsiblePanel>
{prompt.questions.length > 1 ? (
<ComposerBanner.Body className="pe-1 pb-2">
<div
role="tablist"
aria-label="Questions"
className="flex gap-1 overflow-x-auto [scrollbar-width:none]"
>
{prompt.questions.map((question, index) => {
const summary = summarizePendingUserInputAnswer(question, answers[question.id]);
const isActive = index === progress.questionIndex;
return (
<button
key={question.id}
type="button"
role="tab"
aria-selected={isActive}
aria-label={summary ? `${question.header}: ${summary}` : question.header}
data-pending-user-input-tab={summary === null ? "unanswered" : "answered"}
onClick={() => onSelectQuestion(index)}
className={cn(
"flex max-w-48 min-w-0 shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-2xs outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
isActive
? "bg-muted/70 text-foreground"
: "text-muted-foreground hover:bg-muted/30 hover:text-foreground/85",
)}
>
<span className="shrink-0 font-medium">{question.header}</span>
{summary !== null ? (
<>
<CheckIcon className="size-3 shrink-0 text-primary" />
<span className="min-w-0 truncate text-secondary-label">{summary}</span>
</>
) : null}
</button>
);
})}
</div>
</ComposerBanner.Body>
) : null}
<ComposerBanner.Scroll>
<ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">
<p className="text-sm text-foreground/85">{activeQuestion.question}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function renderPendingActions(isRunning: boolean) {
canAdvance: true,
isResponding: false,
isComplete: true,
unansweredCount: 0,
},
isRunning,
canInterrupt: isRunning,
Expand Down
Loading
Loading