import { Fragment, useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { errorMessage } from '@/api/client'; import { approveAssistant, askAssistant, assistantAvailable, type AssistantAnswer, } from '@/api/assistant'; import { useAssistantScope } from './assistantScope'; import { matchAssistantRoute } from './assistantContext'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; import { DEFAULT_WIDTH, expandedWidth, MAX_WIDTH, MIN_WIDTH, useAssistantWidth, } from './assistantWidth'; /** One question and what came back. `answer` and `error` are exclusive. */ interface Exchange { question: string; answer?: AssistantAnswer; error?: string; /** * What became of a change Buddy proposed. * * `undefined` means the card is still on screen waiting. Once decided the * card is replaced by its outcome and cannot be pressed again — a card that * stayed live after approval is a second write waiting to happen. */ decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string }; } /** Replaces the last entry, which is always the one in flight. */ function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] { if (thread.length === 0) return thread; return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry)); } /** * What the composer says when it cannot be used, and why. * * Three different states with three different sentences. "Not connected yet" * for everything would be true and useless: a person whose deployment has no * model can do nothing, but a person on the Inventory page can move to Sales * and get an answer today. */ export function composerHint( available: boolean | null, hasAgent: boolean, isAsking: boolean, ): string { if (isAsking) return 'Thinking…'; if (available === null) return 'Checking…'; if (available === false) return 'Not connected yet'; if (!hasAgent) return 'No assistant for this page yet'; return 'Ask about this page'; } /** * The line under the composer, or nothing. * * This used to be one hardcoded sentence — "Not connected yet — there is no * assistant service behind this panel." — written before the panel had an API * behind it and never removed once it did. It rendered on every page in every * state, so the panel told everybody Buddy was off, permanently, including on a * deployment where Buddy was answering questions. It also flatly contradicted * the placeholder three lines above it, which was the only part telling the * truth. * * The lesson worth keeping: a status message with no condition attached is not * a status message. Each branch here reads the same two facts the composer * itself is disabled by, so the two cannot drift apart again. */ export function composerNote(available: boolean | null, hasAgent: boolean): string { // Still asking. A sentence that appears for 200ms and is replaced reads as a // flicker, so the space stays empty until there is something true to put in it. if (available === null) return ''; if (available === false) return 'Not connected yet — there is no assistant service behind this panel.'; if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.'; // Buddy is working. The caveat that matters then is not about connection: it // is that an answer is a model reading live rows, and the rows are the thing // to act on. return 'Buddy reads your live data. Check anything you act on.'; } /** * A change waiting on the person. * * Framed and set apart from the reply on purpose. The sentence above it was * written by a model; this was resolved by the server against the database, and * the two must not read as one thing. What is shown here — the ids, the names, * the quantity — is what the button actually agrees to. * * Once decided the buttons are gone, replaced by what happened. A card that * stayed pressable after approval is a second write waiting for a double-click. */ function ApprovalCard({ proposal, decision, onDecide, }: { proposal: NonNullable; decision: Exchange['decision']; onDecide: (approve: boolean) => void; }) { const busy = decision?.state === 'approving'; const outcomeColour = decision?.state === 'failed' ? 'var(--color-error, #d64545)' : decision?.state === 'done' ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-3)'; return (

{proposal.summary}

{proposal.details?.length ? (
{proposal.details.map((detail) => (
{detail.label}
{detail.value}
))}
) : null} {proposal.warning ? (

{proposal.warning}

) : null} {decision === undefined ? (
{/* "Not now", not "Reject". Nothing is refused and nothing is recorded — the card is simply left alone, and it expires unused. */}
) : (

{busy ? 'Making the change…' : decision.state === 'dismissed' ? 'Left alone. Nothing was changed.' : (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}

)}
); } /** * One question and its answer. * * The answer shows what it ran, not just what it concluded. Buddy states things * with the confidence of a sentence, and the only honest way to present that is * beside the tools it used and a link to the page holding the same rows — so a * person can disagree with it. */ function Exchange({ entry, isLast, isAsking, onDecide, }: { entry: Exchange; isLast: boolean; isAsking: boolean; onDecide: (approve: boolean) => void; }) { const waiting = isLast && isAsking && !entry.answer && !entry.error; return (

{entry.question}

{waiting ? (

Looking…

) : null} {entry.error ? (

{entry.error}

) : null} {entry.answer ? (

{entry.answer.reply}

{/* Said plainly, not as a subtle grey hint. A partial answer that looks complete is the failure this flag exists to prevent. */} {entry.answer.incomplete ? (

This answer is partial — Buddy ran out of room before finishing.

) : null} {entry.answer.used?.length ? (

{entry.answer.used .map((step) => step.outcome === 'ok' ? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}` : `${step.tool} · refused`, ) .join(' ')}

) : null} {entry.answer.awaiting ? ( ) : null} {entry.answer.sources?.length ? (

{entry.answer.sources.map((source) => ( See the rows ))}

) : null}
) : null}
); } /** * Typed against `CONTEXT`'s own shape so the two cannot drift. * * Without this the fallback has no `agent` key at all, and `routeContext` — * which is one or the other — loses the field entirely. A page that fell * through to the fallback would then be a compile error rather than simply a * page with no assistant, which is what it is. */ /** * Nearle Buddy, as a layout column. * * Not an overlay and not a floating bubble: the panel is part of the page on * every supported route, so the page reflows beside it rather than being * covered. It opens by stating the most useful thing about the page it is next * to, says what it read, and offers prompts scoped to that page. * * 380px by default, sticky under the 56px header, and its own height is the * viewport minus 104px so it never pushes the page taller than the screen. */ export function AssistantPanel({ onClose, isStacked = false, }: { onClose: () => void; /** Below md the panel sits under the page rather than beside it. */ isStacked?: boolean; }) { const { pathname } = useLocation(); const [draft, setDraft] = useState(''); const [isFocused, setIsFocused] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const textareaRef = useRef(null); const threadRef = useRef(null); const { width, setWidth, reset } = useAssistantWidth(); const [isDragging, setDragging] = useState(false); const { key, context: routeContext } = matchAssistantRoute(pathname); /** * The heading follows the branch in view. * * A panel headed "Across your branches" sitting above a board showing one * shop is a promise about scope that the page has already broken. Only the * title moves; everything else about Buddy is untouched. */ const scopeLabel = useAssistantScope(); const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext; /* * Whether Buddy can answer here at all, and why not when it cannot. * * Two separate reasons, kept apart because they are two different facts and * the person can act on one of them. `available === false` means this * deployment has no model configured — nothing to be done from the browser. * `agent === undefined` means this page has no assistant yet, which is about * the page and not the deployment. * * `null` is "not asked yet": the composer stays disabled during the check, so * it is never briefly live against a server that turns out to have no model. */ const [available, setAvailable] = useState(null); const [thread, setThread] = useState([]); const [isAsking, setAsking] = useState(false); const agent = routeContext.agent; const canAsk = available === true && Boolean(agent) && !isAsking; useEffect(() => { let live = true; void assistantAvailable().then((ok) => { if (live) setAvailable(ok); }); // Cancelled on unmount so a slow answer cannot set state on a closed panel. return () => { live = false; }; }, []); /* * The thread is per-page and deliberately not persisted. * * An answer about Sales sitting above the Inventory page is worse than no * answer: it reads as being about what is on screen. Clearing on navigation * costs a person their history, which is the smaller loss. */ useEffect(() => { setThread([]); }, [key]); /* * Follow the conversation down, unless the person has scrolled away from it. * * A chat that does not follow leaves the newest answer below the fold, which * reads as nothing having happened. One that follows unconditionally yanks * somebody out of an earlier answer they were still reading the moment a * reply lands — and a reply can land a while after the question, because a * tool call and a model round trip are seconds, not milliseconds. * * So: only when they were already at the bottom. The 40px allowance covers * fractional scroll heights, which browsers disagree about by a pixel or two * at non-integer zoom levels — without it this silently stops following for * anybody not at 100%. */ useEffect(() => { const el = threadRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (distanceFromBottom > 40) return; el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); }, [thread]); /* * Approving is its own call, with no question in it. * * Indexed rather than acting on the last exchange: a person can scroll up and * approve an earlier card, and nothing about a card ties it to being the most * recent thing said. */ const decide = async (index: number, approve: boolean) => { const entry = thread[index]; const card = entry?.answer?.awaiting?.card; if (!card || !agent || entry.decision) return; if (!approve) { // Dismissing is purely local: the server was never told, because there is // nothing to undo. The card simply expires unused. setThread((current) => current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)), ); return; } setThread((current) => current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)), ); try { const done = await approveAssistant(agent, card); setThread((current) => current.map((e, i) => i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e, ), ); } catch (error) { // "Somebody already approved that" arrives here, and it is an answer // rather than a fault — shown on the card, which stays decided so the // button cannot be pressed again into the same refusal. setThread((current) => current.map((e, i) => i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e, ), ); } }; const send = async (text: string) => { const question = text.trim(); if (!question || !agent || !canAsk) return; setDraft(''); setAsking(true); setThread((current) => [...current, { question }]); try { const answer = await askAssistant(agent, question); setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer }))); } catch (error) { // Shown in the thread rather than as a toast: the question is still on // screen, and the failure belongs next to it. setThread((current) => replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })), ); } finally { setAsking(false); } }; /** * Three widths, in priority order: stacked (the phone layout owns it), * expanded (the one-click half-screen), then whatever the operator dragged * it to. The inline width beats the media queries in `index.css`, which is * the point — those are the default, not the rule. * * Height is deliberately NOT set here. It was, briefly — measured off the * page so a short page got a short panel — but that put a strip of page * background under the panel on every screen that was not full. The column * is full height, always, and `.assistant` owns that. */ const sizeStyle: React.CSSProperties = isStacked ? {} : isExpanded ? { width: expandedWidth(window.innerWidth), maxWidth: 'none' } : { width, maxWidth: 'none' }; return ( /* No `position` here. `.assistant` is `position: sticky` from md up, and an inline `relative` silently beat it — which is what made the panel scroll away with the page. Sticky is itself a containing block, so the resize handle's `absolute` still anchors to it. */