Files
kandev/apps/web/components/task/chat/clarification-input-overlay.tsx
T
2026-04-16 15:58:01 +01:00

274 lines
8.6 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { IconX, IconMessageQuestion, IconInfoCircle } from "@tabler/icons-react";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import { getBackendConfig } from "@/lib/config";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import type {
Message,
ClarificationRequestMetadata,
ClarificationAnswer,
ClarificationQuestion,
} from "@/lib/types/http";
const RESULT_EXPIRED = "expired";
type ClarificationInputOverlayProps = {
message: Message;
onResolved: () => void;
};
/**
* Post a response to the clarification endpoint. Returns true on success.
*/
async function postClarificationResponse(
pendingId: string,
body: Record<string, unknown>,
): Promise<"ok" | "expired" | "error"> {
const { apiBaseUrl } = getBackendConfig();
const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${pendingId}/respond`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.ok) return "ok";
if (response.status === 410) {
const data = await response.json().catch(() => ({}));
console.warn("Clarification expired:", data);
return RESULT_EXPIRED;
}
console.error("Clarification request failed:", response.status, response.statusText);
return "error";
}
function handleClarificationResult(result: "ok" | "expired" | "error", onResolved: () => void) {
if (result === "ok" || result === RESULT_EXPIRED) onResolved();
}
type ClarificationOptionsProps = {
question: ClarificationQuestion;
isSubmitting: boolean;
onSelectOption: (optionId: string) => void;
};
function ClarificationOptions({
question,
isSubmitting,
onSelectOption,
}: ClarificationOptionsProps) {
return (
<div className="space-y-0.5 mb-1.5 ml-6">
{question.options.map((option) => (
<button
key={option.option_id}
type="button"
onClick={() => onSelectOption(option.option_id)}
disabled={isSubmitting}
data-testid="clarification-option"
className={cn(
"flex items-start gap-2 w-full text-left text-xs rounded px-1.5 py-1 -ml-1.5 transition-colors",
"hover:bg-blue-500/15 hover:text-blue-600 dark:hover:text-blue-400",
isSubmitting ? "opacity-50 cursor-not-allowed" : "text-foreground/80 cursor-pointer",
)}
>
<span className="text-muted-foreground flex-shrink-0 leading-5">•</span>
<span className="flex-1 min-w-0">
<span data-testid="clarification-option-label" className="block leading-5">
{option.label}
</span>
{option.description && (
<span
data-testid="clarification-option-description"
className="block text-muted-foreground/70 mt-0.5 leading-snug"
>
{option.description}
</span>
)}
</span>
</button>
))}
</div>
);
}
type ClarificationCustomInputProps = {
customText: string;
isSubmitting: boolean;
onChange: (text: string) => void;
onSubmit: () => void;
};
function ClarificationCustomInput({
customText,
isSubmitting,
onChange,
onSubmit,
}: ClarificationCustomInputProps) {
return (
<div className="flex items-center gap-2 ml-6">
<span className="text-muted-foreground flex-shrink-0">•</span>
<input
type="text"
placeholder="Type something..."
value={customText}
onChange={(e) => onChange(e.target.value)}
disabled={isSubmitting}
data-testid="clarification-input"
className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground focus:outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && customText.trim()) {
e.preventDefault();
onSubmit();
}
}}
/>
</div>
);
}
type UseClarificationHandlersParams = {
metadata: ClarificationRequestMetadata | undefined;
isSubmitting: boolean;
setIsSubmitting: React.Dispatch<React.SetStateAction<boolean>>;
customText: string;
onResolved: () => void;
};
function useClarificationHandlers({
metadata,
isSubmitting,
setIsSubmitting,
customText,
onResolved,
}: UseClarificationHandlersParams) {
const handleSubmitOption = useCallback(
async (optionId: string) => {
if (!metadata?.pending_id || isSubmitting) return;
setIsSubmitting(true);
try {
const answer: ClarificationAnswer = {
question_id: metadata.question.id,
selected_options: [optionId],
};
const result = await postClarificationResponse(metadata.pending_id, {
answers: [answer],
rejected: false,
});
handleClarificationResult(result, onResolved);
} catch (error) {
console.error("Failed to submit clarification response:", error);
} finally {
setIsSubmitting(false);
}
},
[metadata, isSubmitting, onResolved, setIsSubmitting],
);
const handleSubmitCustom = useCallback(async () => {
if (!metadata?.pending_id || isSubmitting || !customText.trim()) return;
setIsSubmitting(true);
try {
const answer: ClarificationAnswer = {
question_id: metadata.question.id,
selected_options: [],
custom_text: customText.trim(),
};
const result = await postClarificationResponse(metadata.pending_id, {
answers: [answer],
rejected: false,
});
handleClarificationResult(result, onResolved);
} catch (error) {
console.error("Failed to submit clarification response:", error);
} finally {
setIsSubmitting(false);
}
}, [metadata, isSubmitting, customText, onResolved, setIsSubmitting]);
const handleSkip = useCallback(async () => {
if (!metadata?.pending_id || isSubmitting) return;
setIsSubmitting(true);
try {
const result = await postClarificationResponse(metadata.pending_id, {
answers: [],
rejected: true,
reject_reason: "User skipped",
});
handleClarificationResult(result, onResolved);
} catch (error) {
console.error("Failed to skip clarification:", error);
} finally {
setIsSubmitting(false);
}
}, [metadata, isSubmitting, onResolved, setIsSubmitting]);
return { handleSubmitOption, handleSubmitCustom, handleSkip };
}
/**
* Inline clarification UI - simple numbered text options like Conductor.
*/
export function ClarificationInputOverlay({ message, onResolved }: ClarificationInputOverlayProps) {
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
const [customText, setCustomText] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { handleSubmitOption, handleSubmitCustom, handleSkip } = useClarificationHandlers({
metadata,
isSubmitting,
setIsSubmitting,
customText,
onResolved,
});
if (!metadata?.question) return null;
const question = metadata.question;
return (
<div className="relative px-3 py-2" data-testid="clarification-overlay">
<button
type="button"
onClick={handleSkip}
disabled={isSubmitting}
className="absolute top-2 right-3 text-muted-foreground hover:text-foreground z-10 cursor-pointer"
data-testid="clarification-skip"
>
<IconX className="h-4 w-4" />
</button>
<div className="pr-6">
<div className="flex items-start gap-2 mb-1">
<IconMessageQuestion className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="markdown-body max-w-none text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{question.prompt}
</ReactMarkdown>
</div>
</div>
<ClarificationOptions
question={question}
isSubmitting={isSubmitting}
onSelectOption={handleSubmitOption}
/>
{metadata.agent_disconnected && (
<div
data-testid="clarification-deferred-notice"
className="ml-6 mb-1 text-xs text-amber-500/80 flex items-center gap-1.5"
>
<IconInfoCircle className="h-3.5 w-3.5 flex-shrink-0" />
The agent has moved on. Your response will be sent as a new message.
</div>
)}
<ClarificationCustomInput
customText={customText}
isSubmitting={isSubmitting}
onChange={setCustomText}
onSubmit={handleSubmitCustom}
/>
</div>
</div>
);
}