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

148 lines
4.6 KiB
TypeScript

"use client";
import { IconMessageQuestion, IconCheck, IconX } from "@tabler/icons-react";
import type {
ClarificationAnswer,
ClarificationQuestion,
Message,
ClarificationRequestMetadata,
} from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { ClarificationMarkdown } from "../clarification-markdown";
type ClarificationRequestMessageProps = {
comment: Message;
};
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.
*/
export function ClarificationRequestMessage({ comment }: ClarificationRequestMessageProps) {
const { t } = useTranslation();
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;
};
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>
)}
</div>
</div>
</div>
);
}