Files
kandev/apps/web/components/task/chat/messages/clarification-request-message.tsx
T

213 lines
7.3 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import { IconMessageQuestion, IconCheck, IconX, IconMessagePlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type {
ClarificationAnswer,
ClarificationQuestion,
Message,
ClarificationRequestMetadata,
} from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { ClarificationMarkdown } from "../clarification-markdown";
import { ClarificationInputOverlay } from "../clarification-input-overlay";
import type {
LateClarificationSnapshot,
LateClarificationState,
} from "@/hooks/use-late-clarification-message";
import type { MessageAdmissionOutcome } from "@/hooks/use-message-handler";
type ClarificationRequestMessageProps = {
comment: Message;
messages?: readonly Message[];
onLateAnswer?: (snapshot: LateClarificationSnapshot) => Promise<MessageAdmissionOutcome>;
lateAnswerSnapshot?: LateClarificationSnapshot | null;
lateAnswerState?: LateClarificationState;
onResetLateAnswer?: () => void;
isCurrentTurn?: boolean;
};
function AnswerSummary({
question,
response,
}: {
question: ClarificationQuestion;
response: ClarificationAnswer | undefined;
}) {
const { t } = useTranslation();
if (!response) return <span>{t("task:clarificationNoSelection")}</span>;
const selectedOptions = (response.selected_options ?? [])
.map((optionId) => question.options.find((option) => option.option_id === optionId))
.filter((option) => option !== undefined);
const hasCustomText = Boolean(response.custom_text);
if (selectedOptions.length === 0 && !hasCustomText) {
return <span>{t("task:clarificationNoSelection")}</span>;
}
return (
<span className="min-w-0">
{selectedOptions.map((option, index) => (
<span key={option.option_id}>
{index > 0 ? ", " : null}
<ClarificationMarkdown variant="inline">{option.label}</ClarificationMarkdown>
</span>
))}
{response.custom_text && (
<span className="whitespace-pre-wrap">
{selectedOptions.length > 0 ? ", " : null}
{`"${response.custom_text}"`}
</span>
)}
</span>
);
}
/**
* Displays a resolved or superseded clarification request in the chat history.
* The active pending clarification is shown in the input area instead.
*/
// eslint-disable-next-line max-lines-per-function, complexity -- the transcript row owns question status and late-answer controls.
export function ClarificationRequestMessage({
comment,
messages = [comment],
onLateAnswer,
lateAnswerSnapshot,
lateAnswerState,
onResetLateAnswer,
isCurrentTurn = false,
}: ClarificationRequestMessageProps) {
const { t } = useTranslation();
const [isAnswering, setIsAnswering] = useState(false);
const answerButtonRef = useRef<HTMLButtonElement>(null);
const answerScopeRef = useRef<HTMLDivElement>(null);
const closeAnswerForm = useCallback(() => {
setIsAnswering(false);
requestAnimationFrame(() => answerButtonRef.current?.focus());
}, []);
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 canAnswerAsNewMessage =
!metadata.response && (status === "expired" || (status === "pending" && !isCurrentTurn));
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;
};
return (
<div className="w-full" data-testid="clarification-request-message">
<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 */}
{question.title && (
<ClarificationMarkdown
variant="inline"
className="mb-0.5 text-xs font-medium text-foreground/80"
>
{question.title}
</ClarificationMarkdown>
)}
<ClarificationMarkdown
variant="block"
className="max-w-none text-xs text-muted-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
>
{question.prompt}
</ClarificationMarkdown>
{/* Answer - indented below question */}
{isAnswered && (
<div className="mt-1 ml-3 flex items-start gap-1.5 text-xs text-foreground/80">
{getStatusIndicator()}
<AnswerSummary question={question} response={metadata.response} />
{metadata.agent_disconnected && (
<span className="text-muted-foreground">{t("task:sentAsNewMessage")}</span>
)}
</div>
)}
{isSkipped && (
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
{getStatusIndicator()}
{t("task:skipped")}
</div>
)}
{isCancelled && (
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
{getStatusIndicator()}
{t("task: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()}
{t("task:timedOutAgentMovedOn")}
</div>
)}
{canAnswerAsNewMessage && !isAnswering && (
<Button
ref={answerButtonRef}
type="button"
variant="outline"
size="sm"
className="mt-2 min-h-11 cursor-pointer gap-1.5 md:min-h-0"
onClick={() => {
if (lateAnswerState?.status === "sent" || lateAnswerState?.status === "queued") {
onResetLateAnswer?.();
}
setIsAnswering(true);
}}
data-testid="clarification-answer-as-new-message"
>
<IconMessagePlus className="h-3.5 w-3.5" />
{t("task:answerAsNewMessage")}
</Button>
)}
{canAnswerAsNewMessage && isAnswering && (
<div ref={answerScopeRef} className="mt-3 -ml-3 border-l border-border/60 pl-3">
<ClarificationInputOverlay
messages={messages}
mode="late"
onLateAnswer={onLateAnswer}
initialAnswers={lateAnswerState?.snapshot?.answers ?? lateAnswerSnapshot?.answers}
lateAnswerState={lateAnswerState}
onResolved={() => {}}
shortcutScopeRef={answerScopeRef}
onDismiss={closeAnswerForm}
/>
</div>
)}
</div>
</div>
</div>
);
}