feat(chat): enhance agent interaction feedback and loading states

This commit is contained in:
Abhimanyu Saharan
2026-02-07 16:57:18 +05:30
parent 117048d637
commit 92a3124cba

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { import {
DialogFooter, DialogFooter,
@@ -120,16 +121,40 @@ export function BoardOnboardingChat({
const isPageActive = usePageActive(); const isPageActive = usePageActive();
const [session, setSession] = useState<BoardOnboardingRead | null>(null); const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [awaitingAssistantFingerprint, setAwaitingAssistantFingerprint] =
useState<string | null>(null);
const [awaitingKind, setAwaitingKind] = useState<
"answer" | "extra_context" | null
>(null);
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState<string | null>(
null,
);
const [otherText, setOtherText] = useState(""); const [otherText, setOtherText] = useState("");
const [extraContext, setExtraContext] = useState(""); const [extraContext, setExtraContext] = useState("");
const [extraContextOpen, setExtraContextOpen] = useState(false); const [extraContextOpen, setExtraContextOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]); const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const freeTextRef = useRef<HTMLTextAreaElement | null>(null);
const extraContextRef = useRef<HTMLTextAreaElement | null>(null);
const normalizedMessages = useMemo( const normalizedMessages = useMemo(
() => normalizeMessages(session?.messages), () => normalizeMessages(session?.messages),
[session?.messages], [session?.messages],
); );
const lastAssistantFingerprint = useMemo(() => {
const rawMessages = session?.messages;
if (!rawMessages || !Array.isArray(rawMessages)) return "";
for (let idx = rawMessages.length - 1; idx >= 0; idx -= 1) {
const entry = rawMessages[idx];
if (!entry || typeof entry !== "object") continue;
const raw = entry as Record<string, unknown>;
if (raw.role !== "assistant") continue;
const content = typeof raw.content === "string" ? raw.content : "";
const timestamp = typeof raw.timestamp === "string" ? raw.timestamp : "";
return `${timestamp}|${content}`;
}
return "";
}, [session?.messages]);
const question = useMemo( const question = useMemo(
() => parseQuestion(normalizedMessages), () => parseQuestion(normalizedMessages),
[normalizedMessages], [normalizedMessages],
@@ -137,11 +162,26 @@ export function BoardOnboardingChat({
const draft: BoardOnboardingAgentComplete | null = const draft: BoardOnboardingAgentComplete | null =
session?.draft_goal ?? null; session?.draft_goal ?? null;
const isAwaitingAgent = useMemo(() => {
if (!awaitingAssistantFingerprint) return false;
return lastAssistantFingerprint === awaitingAssistantFingerprint;
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const wantsFreeText = useMemo( const wantsFreeText = useMemo(
() => selectedOptions.some((label) => isFreeTextOption(label)), () => selectedOptions.some((label) => isFreeTextOption(label)),
[selectedOptions], [selectedOptions],
); );
useEffect(() => {
if (!wantsFreeText) return;
freeTextRef.current?.focus();
}, [wantsFreeText]);
useEffect(() => {
if (!extraContextOpen) return;
extraContextRef.current?.focus();
}, [extraContextOpen]);
useEffect(() => { useEffect(() => {
setSelectedOptions([]); setSelectedOptions([]);
setOtherText(""); setOtherText("");
@@ -194,8 +234,12 @@ export function BoardOnboardingChat({
const handleAnswer = useCallback( const handleAnswer = useCallback(
async (value: string, freeText?: string) => { async (value: string, freeText?: string) => {
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try { try {
const result = const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost( await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
@@ -208,6 +252,12 @@ export function BoardOnboardingChat({
if (result.status !== 200) throw new Error("Unable to submit answer."); if (result.status !== 200) throw new Error("Unable to submit answer.");
setSession(result.data); setSession(result.data);
setOtherText(""); setOtherText("");
setSelectedOptions([]);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("answer");
setLastSubmittedAnswer(
freeText ? `${value}: ${freeText}` : value,
);
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error ? err.message : "Failed to submit answer.", err instanceof Error ? err.message : "Failed to submit answer.",
@@ -216,7 +266,7 @@ export function BoardOnboardingChat({
setLoading(false); setLoading(false);
} }
}, },
[boardId], [boardId, lastAssistantFingerprint],
); );
const toggleOption = useCallback((label: string) => { const toggleOption = useCallback((label: string) => {
@@ -230,8 +280,12 @@ export function BoardOnboardingChat({
const submitExtraContext = useCallback(async () => { const submitExtraContext = useCallback(async () => {
const trimmed = extraContext.trim(); const trimmed = extraContext.trim();
if (!trimmed) return; if (!trimmed) return;
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try { try {
const result = const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, { await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
@@ -242,6 +296,10 @@ export function BoardOnboardingChat({
throw new Error("Unable to submit extra context."); throw new Error("Unable to submit extra context.");
setSession(result.data); setSession(result.data);
setExtraContext(""); setExtraContext("");
setExtraContextOpen(false);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("extra_context");
setLastSubmittedAnswer("Additional context");
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error ? err.message : "Failed to submit extra context.", err instanceof Error ? err.message : "Failed to submit extra context.",
@@ -249,7 +307,7 @@ export function BoardOnboardingChat({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [boardId, extraContext]); }, [boardId, extraContext, lastAssistantFingerprint]);
const submitAnswer = useCallback(() => { const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim(); const trimmedOther = otherText.trim();
@@ -259,6 +317,15 @@ export function BoardOnboardingChat({
void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined); void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined);
}, [handleAnswer, otherText, selectedOptions, wantsFreeText]); }, [handleAnswer, otherText, selectedOptions, wantsFreeText]);
useEffect(() => {
if (!awaitingAssistantFingerprint) return;
if (lastAssistantFingerprint !== awaitingAssistantFingerprint) {
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
}
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const confirmGoal = async () => { const confirmGoal = async () => {
if (!draft) return; if (!draft) return;
setLoading(true); setLoading(true);
@@ -303,6 +370,29 @@ export function BoardOnboardingChat({
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Review the lead agent draft and confirm. Review the lead agent draft and confirm.
</p> </p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the agent…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm"> <div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-semibold text-slate-900">Objective</p> <p className="font-semibold text-slate-900">Objective</p>
<p className="text-slate-700">{draft.objective || "—"}</p> <p className="text-slate-700">{draft.objective || "—"}</p>
@@ -402,26 +492,28 @@ export function BoardOnboardingChat({
size="sm" size="sm"
type="button" type="button"
onClick={() => setExtraContextOpen((prev) => !prev)} onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading} disabled={loading || isAwaitingAgent}
> >
{extraContextOpen ? "Hide" : "Add"} {extraContextOpen ? "Hide" : "Add"}
</Button> </Button>
</div> </div>
{extraContextOpen ? ( {extraContextOpen ? (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<Textarea <Textarea
className="min-h-[84px]" ref={extraContextRef}
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)" className="min-h-[84px]"
value={extraContext} placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
onChange={(event) => setExtraContext(event.target.value)} value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return; if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return; if (event.shiftKey) return;
event.preventDefault(); event.preventDefault();
if (loading) return; if (loading || isAwaitingAgent) return;
void submitExtraContext(); void submitExtraContext();
}} }}
disabled={loading || isAwaitingAgent}
/> />
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button <Button
@@ -429,9 +521,9 @@ export function BoardOnboardingChat({
size="sm" size="sm"
type="button" type="button"
onClick={() => void submitExtraContext()} onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()} disabled={loading || isAwaitingAgent || !extraContext.trim()}
> >
{loading ? "Sending..." : "Send context"} {loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Send context"}
</Button> </Button>
</div> </div>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
@@ -446,7 +538,7 @@ export function BoardOnboardingChat({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={confirmGoal} disabled={loading}> <Button onClick={confirmGoal} disabled={loading || isAwaitingAgent} type="button">
Confirm goal Confirm goal
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -456,6 +548,29 @@ export function BoardOnboardingChat({
<p className="text-sm font-medium text-slate-900"> <p className="text-sm font-medium text-slate-900">
{question.question} {question.question}
</p> </p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the next question…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="space-y-2"> <div className="space-y-2">
{question.options.map((option) => { {question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label); const isSelected = selectedOptions.includes(option.label);
@@ -465,7 +580,8 @@ export function BoardOnboardingChat({
variant={isSelected ? "primary" : "secondary"} variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start" className="w-full justify-start"
onClick={() => toggleOption(option.label)} onClick={() => toggleOption(option.label)}
disabled={loading} disabled={loading || isAwaitingAgent}
type="button"
> >
{option.label} {option.label}
</Button> </Button>
@@ -475,6 +591,7 @@ export function BoardOnboardingChat({
{wantsFreeText ? ( {wantsFreeText ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
ref={freeTextRef}
className="min-h-[84px]" className="min-h-[84px]"
placeholder="Type your answer..." placeholder="Type your answer..."
value={otherText} value={otherText}
@@ -484,9 +601,10 @@ export function BoardOnboardingChat({
if (event.nativeEvent.isComposing) return; if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return; if (event.shiftKey) return;
event.preventDefault(); event.preventDefault();
if (loading) return; if (loading || isAwaitingAgent) return;
submitAnswer(); submitAnswer();
}} }}
disabled={loading || isAwaitingAgent}
/> />
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Tip: press Enter to send. Shift+Enter for a newline. Tip: press Enter to send. Shift+Enter for a newline.
@@ -497,16 +615,22 @@ export function BoardOnboardingChat({
<Button <Button
variant="outline" variant="outline"
onClick={submitAnswer} onClick={submitAnswer}
type="button"
disabled={ disabled={
loading || loading ||
isAwaitingAgent ||
selectedOptions.length === 0 || selectedOptions.length === 0 ||
(wantsFreeText && !otherText.trim()) (wantsFreeText && !otherText.trim())
} }
> >
{loading ? "Sending..." : "Next"} {loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Next"}
</Button> </Button>
{loading ? ( {loading ? (
<p className="text-xs text-slate-500">Sending your answer</p> <p className="text-xs text-slate-500">Sending your answer</p>
) : isAwaitingAgent ? (
<p className="text-xs text-slate-500">
Waiting for the agent to respond
</p>
) : null} ) : null}
</div> </div>
</div> </div>