"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RefreshCcw } from "lucide-react"; import { DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { usePageActive } from "@/hooks/usePageActive"; import { answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost, getOnboardingApiV1BoardsBoardIdOnboardingGet, startOnboardingApiV1BoardsBoardIdOnboardingStartPost, } from "@/api/generated/board-onboarding/board-onboarding"; import type { BoardOnboardingAgentComplete, BoardOnboardingRead, BoardOnboardingReadMessages, BoardRead, } from "@/api/generated/model"; type NormalizedMessage = { role: string; content: string; }; /** * Normalize backend onboarding messages into a strict `{role, content}` list. * * The server stores messages as untyped JSON; this protects the UI from partial * or malformed entries. */ const normalizeMessages = ( value?: BoardOnboardingReadMessages, ): NormalizedMessage[] | null => { if (!value) return null; if (!Array.isArray(value)) return null; const items: NormalizedMessage[] = []; for (const entry of value) { if (!entry || typeof entry !== "object") continue; const raw = entry as Record; const role = typeof raw.role === "string" ? raw.role : null; const content = typeof raw.content === "string" ? raw.content : null; if (!role || !content) continue; items.push({ role, content }); } return items.length ? items : null; }; type QuestionOption = { id: string; label: string }; type Question = { question: string; options: QuestionOption[]; }; const FREE_TEXT_OPTION_RE = /(i'?ll type|i will type|type it|type my|other|custom|free\\s*text)/i; const isFreeTextOption = (label: string) => FREE_TEXT_OPTION_RE.test(label); /** * Best-effort parser for assistant-produced question payloads. * * During onboarding, the assistant can respond with either: * - raw JSON (ideal) * - a fenced ```json block * - slightly-structured objects * * This function validates shape and normalizes option ids/labels. */ const normalizeQuestion = (value: unknown): Question | null => { if (!value || typeof value !== "object") return null; const data = value as { question?: unknown; options?: unknown }; if (typeof data.question !== "string" || !Array.isArray(data.options)) return null; const options: QuestionOption[] = data.options .map((option, index) => { if (typeof option === "string") { return { id: String(index + 1), label: option }; } if (option && typeof option === "object") { const raw = option as { id?: unknown; label?: unknown }; const label = typeof raw.label === "string" ? raw.label : typeof raw.id === "string" ? raw.id : null; if (!label) return null; return { id: typeof raw.id === "string" ? raw.id : String(index + 1), label, }; } return null; }) .filter((option): option is QuestionOption => Boolean(option)); if (!options.length) return null; return { question: data.question, options }; }; /** * Extract the most recent assistant question from the transcript. * * We intentionally only inspect the last assistant message: the user may have * typed arbitrary text between questions. */ const parseQuestion = (messages?: NormalizedMessage[] | null) => { if (!messages?.length) return null; const lastAssistant = [...messages] .reverse() .find((msg) => msg.role === "assistant"); if (!lastAssistant?.content) return null; try { return normalizeQuestion(JSON.parse(lastAssistant.content)); } catch { const match = lastAssistant.content.match(/```(?:json)?\s*([\s\S]*?)```/); if (match) { try { return normalizeQuestion(JSON.parse(match[1])); } catch { return null; } } } return null; }; export function BoardOnboardingChat({ boardId, onConfirmed, }: { boardId: string; onConfirmed: (board: BoardRead) => void; }) { const isPageActive = usePageActive(); const [session, setSession] = useState(null); const [loading, setLoading] = useState(false); const [awaitingAssistantFingerprint, setAwaitingAssistantFingerprint] = useState(null); const [awaitingKind, setAwaitingKind] = useState< "answer" | "extra_context" | null >(null); const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState( null, ); const [otherText, setOtherText] = useState(""); const [extraContext, setExtraContext] = useState(""); const [extraContextOpen, setExtraContextOpen] = useState(false); const [error, setError] = useState(null); const [selectedOptions, setSelectedOptions] = useState([]); const freeTextRef = useRef(null); const extraContextRef = useRef(null); const normalizedMessages = useMemo( () => normalizeMessages(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; 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( () => parseQuestion(normalizedMessages), [normalizedMessages], ); const draft: BoardOnboardingAgentComplete | null = session?.draft_goal ?? null; const isAwaitingAgent = useMemo(() => { if (!awaitingAssistantFingerprint) return false; return lastAssistantFingerprint === awaitingAssistantFingerprint; }, [awaitingAssistantFingerprint, lastAssistantFingerprint]); const wantsFreeText = useMemo( () => selectedOptions.some((label) => isFreeTextOption(label)), [selectedOptions], ); useEffect(() => { if (!wantsFreeText) return; freeTextRef.current?.focus(); }, [wantsFreeText]); useEffect(() => { if (!extraContextOpen) return; extraContextRef.current?.focus(); }, [extraContextOpen]); useEffect(() => { setSelectedOptions([]); setOtherText(""); }, [question?.question]); useEffect(() => { if (!wantsFreeText) setOtherText(""); }, [wantsFreeText]); const startSession = useCallback(async () => { setLoading(true); setError(null); try { const result = await startOnboardingApiV1BoardsBoardIdOnboardingStartPost( boardId, {}, ); if (result.status !== 200) throw new Error("Unable to start onboarding."); setSession(result.data); } catch (err) { setError( err instanceof Error ? err.message : "Failed to start onboarding.", ); } finally { setLoading(false); } }, [boardId]); const refreshSession = useCallback(async () => { try { const result = await getOnboardingApiV1BoardsBoardIdOnboardingGet(boardId); if (result.status !== 200) return; setSession(result.data); } catch { // ignore } }, [boardId]); useEffect(() => { void startSession(); }, [startSession]); useEffect(() => { if (!isPageActive) return; void refreshSession(); const interval = setInterval(refreshSession, 2000); return () => clearInterval(interval); }, [isPageActive, refreshSession]); const handleAnswer = useCallback( async (value: string, freeText?: string) => { const fingerprintBefore = lastAssistantFingerprint; setLoading(true); setError(null); setAwaitingAssistantFingerprint(null); setAwaitingKind(null); setLastSubmittedAnswer(null); try { const result = await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost( boardId, { answer: value, other_text: freeText ?? null, }, ); if (result.status !== 200) throw new Error("Unable to submit answer."); setSession(result.data); setOtherText(""); setSelectedOptions([]); setAwaitingAssistantFingerprint(fingerprintBefore); setAwaitingKind("answer"); setLastSubmittedAnswer(freeText ? `${value}: ${freeText}` : value); } catch (err) { setError( err instanceof Error ? err.message : "Failed to submit answer.", ); } finally { setLoading(false); } }, [boardId, lastAssistantFingerprint], ); const toggleOption = useCallback((label: string) => { setSelectedOptions((prev) => prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label], ); }, []); const submitExtraContext = useCallback(async () => { const trimmed = extraContext.trim(); if (!trimmed) return; const fingerprintBefore = lastAssistantFingerprint; setLoading(true); setError(null); setAwaitingAssistantFingerprint(null); setAwaitingKind(null); setLastSubmittedAnswer(null); try { const result = await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, { answer: "Additional context", other_text: trimmed, }); if (result.status !== 200) throw new Error("Unable to submit extra context."); setSession(result.data); setExtraContext(""); setExtraContextOpen(false); setAwaitingAssistantFingerprint(fingerprintBefore); setAwaitingKind("extra_context"); setLastSubmittedAnswer("Additional context"); } catch (err) { setError( err instanceof Error ? err.message : "Failed to submit extra context.", ); } finally { setLoading(false); } }, [boardId, extraContext, lastAssistantFingerprint]); const submitAnswer = useCallback(() => { const trimmedOther = otherText.trim(); if (selectedOptions.length === 0) return; if (wantsFreeText && !trimmedOther) return; const answer = selectedOptions.join(", "); void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined); }, [handleAnswer, otherText, selectedOptions, wantsFreeText]); useEffect(() => { if (!awaitingAssistantFingerprint) return; if (lastAssistantFingerprint !== awaitingAssistantFingerprint) { setAwaitingAssistantFingerprint(null); setAwaitingKind(null); setLastSubmittedAnswer(null); } }, [awaitingAssistantFingerprint, lastAssistantFingerprint]); const confirmGoal = async () => { if (!draft) return; setLoading(true); setError(null); try { const result = await confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost( boardId, { board_type: draft.board_type ?? "goal", objective: draft.objective ?? null, success_metrics: draft.success_metrics ?? null, target_date: draft.target_date ?? null, }, ); if (result.status !== 200) throw new Error("Unable to confirm board goal."); onConfirmed(result.data); } catch (err) { setError( err instanceof Error ? err.message : "Failed to confirm board goal.", ); } finally { setLoading(false); } }; return (
Board onboarding {error ? (
{error}
) : null} {draft ? (

Review the lead agent draft and confirm.

{isAwaitingAgent ? (
{awaitingKind === "extra_context" ? "Updating the draft…" : "Waiting for the agent…"}
{lastSubmittedAnswer ? (

Sent:{" "} {lastSubmittedAnswer}

) : null}

This usually takes a few seconds.

) : null}

Objective

{draft.objective || "—"}

Success metrics

              {JSON.stringify(draft.success_metrics ?? {}, null, 2)}
            

Target date

{draft.target_date || "—"}

Board type

{draft.board_type || "goal"}

{draft.user_profile ? ( <>

User profile

Preferred name: {" "} {draft.user_profile.preferred_name || "—"}

Pronouns:{" "} {draft.user_profile.pronouns || "—"}

Timezone:{" "} {draft.user_profile.timezone || "—"}

) : null} {draft.lead_agent ? ( <>

Lead agent preferences

Name:{" "} {draft.lead_agent.name || "—"}

Role:{" "} {draft.lead_agent.identity_profile?.role || "—"}

Communication: {" "} {draft.lead_agent.identity_profile?.communication_style || "—"}

Emoji:{" "} {draft.lead_agent.identity_profile?.emoji || "—"}

) : null}

Extra context (optional)

{extraContextOpen ? (