import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Loader2 } from "lucide-react"; import { useAssistantService, type AssistantDestinationLink, } from "../../services/AssistantService.tsx"; import { useAuthServiceContext } from "../../services"; import { Input } from "../ui/input.tsx"; import { SquareArrowRightEnter } from "../ui/square-arrow-right-enter.tsx"; import { useSetupNudge } from "./useSetupNudge.ts"; import "./AssistantPanel.css"; import { SupportTicketForm } from "./SupportTicketForm.tsx"; import { PatientSupportTicketForm } from "./PatientSupportTicketForm.tsx"; import { isPatientExperience } from "../../utils/mountContext.ts"; import { assistantSubject, useAssistantConversation, type AssistantConversation, type Turn } from "./useAssistantConversation.ts"; /** * The assistant's conversation, rendered inside the setup & help widget., slices 4 and 5. * * IT IS THE WIDGET'S `children` AND NOTHING ELSE, which is what built the container for: * *"the same surface later becomes the in-product assistant... it renders a MODE and a HEADLINE handed to * it."* No new chrome, no second floating thing, and the phone/collapse/z-order rules already settled * there are inherited rather than re-decided. * * WHAT THIS COMPONENT MAY AND MAY NOT DO * * **It never composes an answer.** The prose and the destinations both come from the server, which * grounded them against what this caller can reach. A client that appended "you might also try..." would * be adding an ungrounded suggestion to a grounded answer, and the reader cannot tell them apart. * **It renders links only from `destinations`.** The server discards any completion whose prose * contains a path, so there is never a URL in the text to linkify - and building one here would be * putting back exactly what the server threw away. * **A destination may carry a visit the server resolved**, and this composes the route for it - route * shapes are the client's to know. The model never wrote that id: it chose a numbered entry from facts * the server had already fetched, so there is nothing here it could have invented. */ /** Kept small on purpose: this is a help widget, not a chat product. */ const MAX_TURNS = 6; /** * Where a destination goes, including the one visit the server may have resolved. * * Only the record destination takes a visit. A visitId arriving on anything else is ignored rather than * appended, because `/dashboard/my-appointments/visit/x` is not a route and would land on nothing. */ export const destinationHref = (destination: AssistantDestinationLink): string => { const visitId = destination.params?.visitId; return visitId && destination.id === 'patient-record' ? `${destination.path}/visit/${encodeURIComponent(visitId)}` : destination.path; }; export interface AssistantPanelProps { /** * Opens the support-ticket form. Optional on purpose: the escalation link renders only when this * is supplied, so the panel can never offer an action it cannot carry out. */ onSubmitTicket?: () => void; /** * What this assistant is helping the reader USE, in their own words - "the patient portal", "the * front desk", "your admin workspace". Only the empty state reads it. * * Defaulted rather than required so no caller is broken by it, and the default is decided by the * MOUNT rather than by a role: a patient is never told they are in a "workspace", which is a word * that means nothing on their side of the product. */ workspaceLabel?: string; /** Held by the mount so the conversation outlives a closed panel; alone, the panel keeps its own. */ store?: AssistantConversation; /** A page's help request and prefill reference; consumed once and never over an open draft. */ ticketRequest?: { reference: string; opening: string }; /** The viewer and practice the request was made for; a reference filled in for one is dropped when it changes. */ requestOwner?: string; /** Reports a filled form or preserved draft so the mount stops holding the request. */ onTicketRequestTaken?: (outcome: "filled" | "existing-draft", opening: string) => void; } export const AssistantPanel = ({ onSubmitTicket, store, ticketRequest, requestOwner, onTicketRequestTaken, workspaceLabel = isPatientExperience() ? "the patient portal" : "this workspace", }: AssistantPanelProps = {}) => { const { ask } = useAssistantService(); const navigate = useNavigate(); const location = useLocation(); const inputRef = useRef(null); const transcriptRef = useRef(null); /* * WHICH VIEW THE PANEL IS SHOWING. Spencer, 2026-09-08: "when you click 'or submit a support * ticket' it just displays the support ticket form in the ai assistant overlay." * * State, not a route: the panel is a layer over whatever page the reader was on, and navigating * would take away the screen they are trying to get help with. Going back restores the * conversation because the transcript was never unmounted. */ const { nudge, probe: probeSetup } = useSetupNudge(); // A conversation belongs to its page; the mount holds it so closing the panel keeps it. const local = useAssistantConversation(assistantSubject(location)); const { conversation, update, isLive } = store ?? local; const { turns, draft, asking, showingTicket } = conversation; const setDraft = (value: string) => update(() => ({ draft: value })); const setShowingTicket = (value: boolean) => update(() => ({ showingTicket: value })); // An open draft may hold unsent words, so a request never replaces it; either outcome consumes the request. const [recovery, setRecovery] = useState<{ reference: string; owner?: string } | undefined>(); const recoveryReference = recovery?.reference; const requestedOpening = ticketRequest?.opening; useEffect(() => { if (!ticketRequest) return; const filled = !showingTicket; if (filled) { setRecovery({ reference: ticketRequest.reference, owner: requestOwner }); setShowingTicket(true); } onTicketRequestTaken?.(filled ? "filled" : "existing-draft", ticketRequest.opening); // eslint-disable-next-line react-hooks/exhaustive-deps -- one take per opening, read as it arrives }, [requestedOpening]); // A filled-in reference belongs to the viewer and practice it was asked for: on a change it goes, with its form. useEffect(() => { if (!recovery || recovery.owner === requestOwner) return; setRecovery(undefined); setShowingTicket(false); // eslint-disable-next-line react-hooks/exhaustive-deps -- runs on a change of owner only }, [requestOwner]); const { currentOrganization } = useAuthServiceContext(); const practiceName = currentOrganization?.name?.trim() ?? ""; // The crumb row already names the current page; read it once the route has rendered. const [pageTitle, setPageTitle] = useState(""); useEffect(() => { const crumbRow = document.querySelector("[data-akashic-crumb-actions]")?.parentElement; setPageTitle(crumbRow?.querySelector("[data-slot=breadcrumb-page]")?.textContent?.trim() ?? ""); }, [location.pathname, location.hash]); /* * THE NEWEST EXCHANGE IS THE ONE YOU CAME BACK FOR, so the transcript holds its bottom rather than * its top. It runs on `asking` as well as on the turn count: an answer arriving replaces * "Thinking..." with prose several lines longer, which pushes the bottom down after the turn * itself was added. */ useEffect(() => { const transcript = transcriptRef.current; if (transcript) transcript.scrollTop = transcript.scrollHeight; }, [turns, asking]); const submit = async (event: React.FormEvent) => { event.preventDefault(); const question = draft.trim(); if (!question || asking) return; // The question is shown immediately, before the answer exists - otherwise the widget looks frozen // for the length of a model call and people press the button again. const askedFor = conversation.id; update((current) => ({ draft: "", asking: true, turns: [...current.turns, { question }].slice(-MAX_TURNS) })); try { const reply = await ask({ question, page: routeOf(location.pathname + location.hash) }); if (!isLive(askedFor)) return; update((current) => ({ turns: replaceLast(current.turns, { question, reply }) }), askedFor); // AFTER THE ANSWER, AND ONLY AFTER ONE ARRIVED. Nothing about somebody's setup is worth saying to // a person still waiting to hear whether we could answer them at all - and a nudge under a failed // request reads as a change of subject away from the thing that just broke. void probeSetup(); } catch { if (!isLive(askedFor)) return; // The server already turns every failure it can see into a refusal, so reaching here means the // request itself did not complete. Saying so plainly beats a spinner that never resolves. update((current) => ({ turns: replaceLast(current.turns, { question, failed: true }) }), askedFor); } finally { if (isLive(askedFor)) { update(() => ({ asking: false }), askedFor); inputRef.current?.focus(); } } }; const openDestination = (destination: AssistantDestinationLink) => { navigate(destinationHref(destination)); }; if (showingTicket) { /* Two forms on two sets of routes, chosen here rather than by a flag inside one of them - a patient's ticket is keyed on their account and a practice's on the practice, and this branch is what keeps one component from ever holding both. The question the assistant just failed to answer is the one support needs, so neither form makes the reader retype it. */ const ticketProps = { onBack: () => { setRecovery(undefined); setShowingTicket(false); }, workspace: workspaceLabel, route: location.pathname + location.hash, lastQuestion: turns[turns.length - 1]?.question, }; return isPatientExperience() ? ( ) : ( ); } return (
{(practiceName || pageTitle) && (

{practiceName} {practiceName && pageTitle && <> · } {pageTitle}

)}
{turns.length === 0 && (

Ask where something is in {workspaceLabel} or how to do it.

)} {/* One transcript block per exchange, as the assistant deck draws it: the question, then the answer. */} {turns.map((turn, index) => (

You asked: {turn.question}

Assistant:{" "} {turn.failed && "I could not reach the assistant just now. Please try again in a moment."} {!turn.failed && !turn.reply && ( )} {turn.reply && ( <> {turn.reply.answer} {/* A refusal carries no destinations, so nothing reads as a suggestion under one. */} {turn.reply.destinations.length > 0 && (
    {turn.reply.destinations.map((destination) => (
  • ))}
)} )}
))} {/* The setup nudge is chosen from a closed set, never composed, and follows an answer. */} {nudge && turns.some((turn) => turn.reply) && (

{nudge.text}{" "}

)}
setDraft(event.target.value)} placeholder="What do you need help with?" aria-label="Ask the assistant" />
); }; /** * The current route, as a plain path. * * THE HASH IS STRIPPED BACK TO A ROUTE AND NOTHING ELSE TRAVELS. The dashboard is hash-routed, so * `location.pathname` alone is the WordPress page rather than the screen the reader is looking at - but * a raw hash can also carry a query string, and a query string is where a search term, an email address * or a patient name ends up. Take the path, drop everything after it. * The server refuses anything that is not route-shaped, so this is the client half of one rule rather * than the only enforcement of it. */ export const routeOf = (href: string): string | undefined => { const hash = href.indexOf("#"); const raw = hash === -1 ? href : href.slice(hash + 1); const path = raw.split(/[?&]/)[0]; return path.startsWith("/") ? path : undefined; }; const replaceLast = (turns: Turn[], turn: Turn): Turn[] => [...turns.slice(0, -1), turn]; export default AssistantPanel;