5efeae4ece
* feat: allow multiline custom answers in clarification overlay Swap the single-line custom-answer input for an auto-growing textarea so users can type multi-line replies to ask_user_question. Enter submits, Shift+Enter inserts a newline; the box grows to ~6 lines then scrolls. Adds unit and e2e coverage. * fix: address review feedback on multiline clarification input - Always preventDefault on desktop plain Enter so an empty draft can't leak a stray newline before the trim guard (Claude/CodeRabbit). - On coarse-pointer devices let Enter insert a newline and submit via the existing Submit button; hide the keyboard hints on touch (mobile parity). - Preserve newlines in the resolved clarification history message via whitespace-pre-wrap + items-start. - Strengthen e2e to guard against fused lines; add Ctrl+Enter and touch tests. * fix: touch submit button and IME guard for clarification input - Guard against submitting on the Enter that confirms an IME candidate (e.nativeEvent.isComposing) on CJK keyboards. - Add an inline Send button on coarse-pointer devices so single-question custom answers (which have no overlay Submit button) have a touch-reachable send path. Fixes the regression from switching touch Enter to newline. * refactor: extract clarification custom-input controls - Pull the Enter/⇧↵ hints and touch Send button into a CustomInputControls subcomponent to shrink ClarificationCustomInput. - Compute the trimmed draft once and reuse it across the keydown handler and the Send button (disabled state, click, styling) so the conditions can't drift. - Add a test asserting the touch Send button is disabled while isSubmitting. * fix: ignore auto-repeat Enter in clarification custom input A held Enter key emits auto-repeat keydown events; guard the handler with e.repeat so it can't fire onSubmit more than once. Adds a regression test. * fix: suppress newline on repeat Enter and add mobile clarification e2e - Repeat Enter on desktop now calls preventDefault before bailing, so a held key can't leak newlines into the current or next question's textarea after the carousel advances; it still never re-fires onSubmit/finalize. - Add a mobile-chrome (Pixel 5) e2e covering the touch path: Enter inserts a newline and the inline Send button submits the multiline answer. * test: consolidate clarification e2e seed helper Extract the shared seed → navigate → waitForLoad flow into e2e/helpers/clarification.ts and delegate from the clarification, resize, and mobile specs instead of duplicating it in each.
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import ReactMarkdown from "react-markdown";
|
|
import { IconMessageQuestion, IconCheck, IconX } from "@tabler/icons-react";
|
|
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
|
import type { Message, ClarificationRequestMetadata } from "@/lib/types/http";
|
|
|
|
type ClarificationRequestMessageProps = {
|
|
comment: Message;
|
|
};
|
|
|
|
/**
|
|
* Displays a resolved clarification request in the chat history.
|
|
* Pending clarifications are shown in the input area instead.
|
|
*/
|
|
export function ClarificationRequestMessage({ comment }: ClarificationRequestMessageProps) {
|
|
const metadata = comment.metadata as ClarificationRequestMetadata | undefined;
|
|
|
|
if (!metadata?.question) {
|
|
return null;
|
|
}
|
|
|
|
const question = metadata.question;
|
|
const status = metadata.status;
|
|
const isAnswered = status === "answered";
|
|
const isSkipped = status === "rejected";
|
|
const isExpired = status === "expired";
|
|
const isCancelled = status === "cancelled";
|
|
|
|
const getStatusIndicator = () => {
|
|
if (isAnswered) {
|
|
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
|
|
}
|
|
if (isSkipped || isCancelled) {
|
|
return <IconX className="h-3.5 w-3.5 text-muted-foreground" />;
|
|
}
|
|
if (isExpired) {
|
|
return <IconX className="h-3.5 w-3.5 text-orange-500" />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Get the answer summary for display
|
|
const getAnswerSummary = () => {
|
|
const response = metadata.response;
|
|
if (!response) return "No selection";
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Get selected option labels
|
|
if (response.selected_options?.length) {
|
|
for (const optionId of response.selected_options) {
|
|
const option = question.options.find((o) => o.option_id === optionId);
|
|
if (option) {
|
|
parts.push(option.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add custom text
|
|
if (response.custom_text) {
|
|
parts.push(`"${response.custom_text}"`);
|
|
}
|
|
|
|
return parts.length > 0 ? parts.join(", ") : "No selection";
|
|
};
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="flex items-start gap-3 w-full">
|
|
{/* Icon */}
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<IconMessageQuestion className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Question */}
|
|
<div className="markdown-body max-w-none text-xs text-muted-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
|
{question.prompt}
|
|
</ReactMarkdown>
|
|
</div>
|
|
|
|
{/* Answer - indented below question */}
|
|
{isAnswered && (
|
|
<div className="mt-1 ml-3 flex items-start gap-1.5 text-xs text-foreground/80">
|
|
{getStatusIndicator()}
|
|
{/* pre-wrap preserves newlines from multiline custom answers. */}
|
|
<span className="whitespace-pre-wrap">{getAnswerSummary()}</span>
|
|
{metadata.agent_disconnected && (
|
|
<span className="text-muted-foreground">· sent as new message</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isSkipped && (
|
|
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{getStatusIndicator()}
|
|
Skipped
|
|
</div>
|
|
)}
|
|
{isCancelled && (
|
|
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{getStatusIndicator()}
|
|
Cancelled
|
|
</div>
|
|
)}
|
|
{isExpired && (
|
|
<div
|
|
data-testid="clarification-expired-notice"
|
|
className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-orange-500/80"
|
|
>
|
|
{getStatusIndicator()}
|
|
Timed out (agent moved on)
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|