feat(chat): enhance agent interaction feedback and loading states
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user