'use client' import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react' import Link from 'next/link' import { Send, Square, RotateCw, BookmarkCheck, BookmarkX, Check, Brain, Copy, ThumbsUp, ThumbsDown, ArrowDown, } from 'lucide-react' import dynamic from 'next/dynamic' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' import ApprovalCard from './ApprovalCard' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import type { StoredStagedOperation } from '@/types' import type { AgentStatusEvent } from './agent-status' import { sendFeedback, type FeedbackSentiment } from './feedback-client' import { Skeleton } from '@/components/ui/skeleton' // New messages arrive one at a time, so they enter on the short bubble curve. // The whole loaded history must NOT: `.animate-slide-up` is the 500ms // once-per-navigation page-entry animation, so resuming a 20-message thread // used to fire 20 simultaneous 500ms slides. const MESSAGE_ENTER_CLASS = 'animate-in fade-in-0 slide-in-from-bottom-2 duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]' // Markdown parser loads separately from the chat surface: react-markdown + // remark-gfm pull in the whole unified/remark tree. // // It used to render `null` while the chunk loaded. That is invisible while a // reply streams (nobody reads that fast) but very visible on RESUME: every // assistant bubble in a hydrated conversation was an empty bordered card until // the chunk landed, then all the text appeared at once and reflowed the thread. // Two changes: the chunk is prefetched as soon as any chat surface mounts, and // until it resolves the raw text renders in place of nothing, so a bubble is // never blank. const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), { ssr: false, loading: () => null, }) // Module-scoped so the chunk is fetched once per page load and every later // chat surface (sheet, /chat, a resumed conversation) renders markdown on its // first frame instead of falling back to plain text again. let markdownReady = false let markdownPromise: Promise | null = null /** Start the markdown chunk before anything needs to render with it. */ function prefetchMarkdown(): Promise { if (!markdownPromise) { markdownPromise = import('./MarkdownMessage') .then((mod) => { markdownReady = true return mod }) .catch(() => { // A chunk can 404 after a deploy, or the network can blip. Clear the // cached promise so a later surface retries, instead of every bubble // for the rest of the session being stuck on the plain-text fallback, // and swallow the rejection so it is not an unhandled one. markdownPromise = null return null }) } return markdownPromise } /** True once the markdown chunk is usable; triggers the fetch if it isn't. */ function useMarkdownReady(): boolean { const [ready, setReady] = useState(markdownReady) useEffect(() => { if (ready) return let alive = true void prefetchMarkdown().then(() => { if (alive) setReady(true) }) return () => { alive = false } }, [ready]) return ready } // Reusable chat surface: used both inside the right-hand AgentSheet and on // the full-page /chat route. Owns: // * Message state (rendered list) // * NDJSON stream consumer for /api/agent/invoke // * Markdown rendering + tool-call badges + approval cards // * Input form // // What it does NOT own: // * Sheet chrome (title bar, close button): wrapper's job // * Page layout / sidebar: wrapper's job // // Two modes: // * Fresh start (initialMessages empty, initialConversationId null): // mount fires the first POST /api/agent/invoke with intent_args, which // creates a new conversation_id and streams the intent's templated first // turn back. // * Resume (initialMessages + initialConversationId supplied): hydrate from // DB rows, skip the first-turn template, just await user input. export interface ChatMessage { role: 'user' | 'assistant' text: string // Extended-thinking reasoning, streamed token-by-token via reasoning_delta. // Shown in a collapsible "Tänkte…" block. Stream-time only: not hydrated. reasoning?: string // Tool-use chips. `completed` flips true when the matching `tool_result` // event arrives so the UI can swap the pulsing dot for a static check // instead of yanking the chip out from under the user. Hydrated messages // are always completed (they would not have been persisted otherwise). toolCalls?: { tool_use_id: string; name: string; completed?: boolean }[] staged?: StagedOperation[] memoryEvents?: MemoryEvent[] // Set when the user pressed Stop mid-stream: the partial text stays, with a // marker so a truncated answer is never mistaken for a complete one. interrupted?: boolean } // Emitted by run-turn.ts after a successful remember_fact / forget_fact call // so the chat surface can render a quiet "Sparat som minne: …" chip below the // assistant message. Stream-time only: not hydrated on /chat resume. interface MemoryEvent { tool_use_id: string action: 'remembered' | 'forgotten' memory_id: string memory_kind?: 'fact' | 'preference' | 'pattern' | 'correction' content?: string } interface StagedOperation { tool_use_id: string operation_id?: string risk_level: 'low' | 'medium' | 'high' message: string // The originating tool name (e.g. 'gnubok_categorize_transaction'). Lets // ApprovalCard pick the right structured-preview renderer. tool_name?: string // The structured operation preview from the staged envelope. Shape varies // by tool; ApprovalCard's renderers do the type-narrowing. preview?: unknown // Period state at the operation's effective date. Surfaced as a small // badge: open|locked|closed. period_status?: { period_id?: string | null status: 'open' | 'locked' | 'closed' lock_date?: string | null } } export interface AgentChatProps { intentId: string intentArgs?: Record contextRef?: string initialMessages?: ChatMessage[] initialConversationId?: string | null onConversationIdChange?: (id: string) => void // Fires after the first turn_complete in a fresh-start session: used by // bootstrap starters (ChatNewStarter, ChatIntakeStarter) to defer the URL // swap until streaming is done. Swapping on the early `conversation` // event unmounts the component mid-stream and the assistant reply is // never persisted before /chat/[id] hydrates. onFirstTurnComplete?: (id: string) => void // Optional vertical padding override: defaults to py-6 inside the // scroller. The full-page chat uses py-8 for breathing room. scrollerClassName?: string // Publishes turn boundaries and the current tool to the shared status // channel. Optional: /chat is its own surface and has nothing to notify. onStatus?: (event: AgentStatusEvent) => void // Pre-baked first user message. When set, the mount effect fires the first // turn with this verbatim (skipping the intent's promptTemplate path) AND // renders it as a user-side message in the timeline. Used by /chat empty // state suggestion chips. seedUserMessage?: string } export default function AgentChat({ intentId, intentArgs, contextRef, initialMessages, initialConversationId, onConversationIdChange, onFirstTurnComplete, scrollerClassName, seedUserMessage, onStatus, }: AgentChatProps) { const [conversationId, setConversationId] = useState(initialConversationId ?? null) // Track whether the first-turn callback has fired so the bootstrap // starters get exactly one notification even if a turn fires before // the conversation_id event (defensive: order shouldn't matter). const firstTurnFiredRef = useRef(false) const conversationIdRef = useRef(initialConversationId ?? null) const [messages, setMessages] = useState(initialMessages ?? []) // How many messages were already on screen when this thread mounted. Anything // at or past this index is new and animates in; the resumed history does not. const historyBaselineRef = useRef((initialMessages ?? []).length) // Read by the announcement effect, which must not re-run on every token: a // `messages` dependency would fire it hundreds of times per turn. Written in // an effect rather than during render: React may replay a render, and a // render-phase ref write can therefore leave the announcement reading a // snapshot the user never saw. Declared BEFORE the announcement effect so it // is already current when that one runs for the same commit. const messagesRef = useRef(messages) useEffect(() => { messagesRef.current = messages }, [messages]) // Where the current turn's messages start. Without it the announcement // searches the whole thread, so a turn that produces no text of its own (a // tool-only turn, an error) finds the PREVIOUS answer and reads it out as // though it were the new one. const turnStartRef = useRef(0) const hasAi = useCapability(CAPABILITY.ai) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) // Turn boundaries for the status channel are derived from the streaming flag // rather than published at each call site: a turn can end by completing, // erroring, aborting or being stopped, and a channel that misses one of // those leaves the trigger claiming the agent is still working forever. const turnOpenRef = useRef(false) // Screen-reader announcement for the turn. Deliberately NOT the streaming // text: a live region over token deltas re-announces on every delta and // renders the chat unusable with a screen reader. Announce the two states // that matter instead, and the finished answer once, when it is finished. const [announcement, setAnnouncement] = useState('') useEffect(() => { if (streaming) { turnOpenRef.current = true turnStartRef.current = messagesRef.current.length onStatus?.({ type: 'turn_start' }) setAnnouncement('Assistenten skriver ett svar.') } else if (turnOpenRef.current) { turnOpenRef.current = false onStatus?.({ type: 'turn_end' }) setAnnouncement(announceableAnswer(messagesRef.current.slice(turnStartRef.current))) } }, [streaming, onStatus]) const [errorMessage, setErrorMessage] = useState(null) const scrollerRef = useRef(null) const textareaRef = useRef(null) // Active turn's controller, kept in a ref (not state) so the stop button // can read it without re-renders churning the AbortController identity. const activeControllerRef = useRef(null) // Set when a tool call runs; consumed by the NEXT text_delta to insert a // single paragraph break so post-tool narration starts on its own line. // A ref (not state) because it must be read/cleared synchronously inside // the streaming loop without triggering re-renders, and because the // break must fire exactly once per resume, not on every delta. const breakBeforeNextTextRef = useRef(false) // Fresh-start vs. resume: only kick off the first turn when we have neither // a hydrated conversation nor pre-existing messages. React 19 Strict Mode // runs effects twice in dev; the first call's cleanup aborts its fetch, the // second completes. The invoke endpoint is idempotent on first-turn when // no conversation_id is supplied (it creates a fresh row each time, so a // transient duplicate just orphans the first conversation: harmless). useEffect(() => { // Only bootstrap a first turn on a genuine fresh start: i.e. NO // conversation id. A present id means the conversation already exists // (or is mid-creation elsewhere), so we must not fire an invoke. // // Why id-alone, not id+messages: the intake flow fires an invoke with // no conversation_id, then swaps the URL to /chat/[id] the moment the // `conversation` event lands: which can beat the greeting being // persisted. /chat/[id] then hydrates with 0 messages. If we keyed the // guard on messages.length we'd auto-fire a SECOND invoke against the // same conversation and render two greetings. Keying on id presence // alone closes that race. const hasResumeState = !!initialConversationId if (hasResumeState) return // Paywall: never auto-fire the first invoke without the ai capability; // the composer is already replaced by the upgrade note. if (!hasAi) return // Seed-message path: render the user's pre-baked starter in the timeline // and send it as the first turn's user_message (skips intent.capture + // promptTemplate). Empty seed runs the normal capture-driven flow. if (seedUserMessage && seedUserMessage.trim().length > 0) { setMessages([{ role: 'user', text: seedUserMessage.trim() }]) void startTurn({ conversationId: initialConversationId ?? null, userMessage: seedUserMessage.trim(), }) } else { void startTurn({ conversationId: initialConversationId ?? null, userMessage: '', }) } return () => { activeControllerRef.current?.abort() activeControllerRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Autoscroll on new content, but only if the user was already pinned to the // bottom. Scrolling up to re-read a long answer should NOT yank the user // back on every streaming token. Threshold accounts for sub-pixel rounding. const wasAtBottomRef = useRef(true) // True when the user has scrolled up AND new content has landed below them. // Without this, reading back through a long answer while the next one streams // silently buries the reply: no yank (that would be worse), but a way back. const [hasUnseenBelow, setHasUnseenBelow] = useState(false) useEffect(() => { const el = scrollerRef.current if (!el) return const onScroll = () => { const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) wasAtBottomRef.current = distance < 64 if (wasAtBottomRef.current) setHasUnseenBelow(false) } el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll) }, []) useEffect(() => { const el = scrollerRef.current if (!el) return if (wasAtBottomRef.current) { el.scrollTop = el.scrollHeight setHasUnseenBelow(false) } else { setHasUnseenBelow(true) } }, [messages]) function jumpToLatest() { const el = scrollerRef.current if (!el) return el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) wasAtBottomRef.current = true setHasUnseenBelow(false) } async function startTurn(body: { conversationId: string | null userMessage: string // When true, the user_message is persisted for agent context but flagged // hidden so it never renders as a user bubble (e.g. a rejection correction // fed back into the chat). The caller also skips adding a visible bubble. hidden?: boolean // Resolves false when the turn never reached the server (network error or // a non-2xx), so the caller can hand the user's text back to the composer // instead of stranding a bubble that was never persisted. }): Promise { // Abort any in-flight turn before starting a new one: guards against // racing two turns when handleSend is triggered twice fast. activeControllerRef.current?.abort() const controller = new AbortController() activeControllerRef.current = controller const signal = controller.signal // Reset the post-tool paragraph-break ref at the start of every turn so a // prior turn that ended on tool_use can't leak a leading "\n\n" into the // next turn's first text delta. breakBeforeNextTextRef.current = false setStreaming(true) setErrorMessage(null) let response: Response try { response = await fetch('/api/agent/invoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ intent_id: intentId, intent_args: intentArgs, context_ref: contextRef, conversation_id: body.conversationId, user_message: body.userMessage, user_message_hidden: body.hidden ?? false, }), signal, }) } catch (err) { if (signal.aborted) return false setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte nå assistenten.') setStreaming(false) activeControllerRef.current = null return false } if (!response.ok || !response.body) { // Surface the server's friendly Swedish message (rate-limit sentence, // "ingen aktiv firma", etc.) rather than a raw "HTTP 429". let msg = 'Kunde inte nå assistenten. Försök igen om en stund.' try { const errBody = await response.json() if (errBody && typeof errBody.error === 'string' && errBody.error.trim()) { msg = errBody.error } } catch { // non-JSON / empty body: keep the generic message } setErrorMessage(msg) setStreaming(false) activeControllerRef.current = null return false } // Assistant bubble is appended LAZILY: only when the first event that // produces user-visible content arrives. Eagerly appending here would // leave an empty bubble dangling if the stream errors or yields zero // events (e.g. proxy hiccup) before any content. let assistantBubbleAppended = false const ensureAssistantBubble = () => { if (assistantBubbleAppended) return assistantBubbleAppended = true setMessages((prev) => [...prev, { role: 'assistant', text: '' }]) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let nl: number while ((nl = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, nl).trim() buffer = buffer.slice(nl + 1) if (!line) continue // Guard JSON.parse per line: a malformed line (proxy split, // partial buffer flush) must NOT abort the entire stream. Skip and // continue; the next well-formed line will be handled normally. let parsed: unknown try { parsed = JSON.parse(line) } catch { continue } // First user-visible event lazily mounts the bubble. `conversation` // is a metadata event with no visible payload so it does not. const ev = parsed as { kind?: string } | null if ( ev && typeof ev.kind === 'string' && ev.kind !== 'conversation' && ev.kind !== 'turn_complete' ) { ensureAssistantBubble() } handleEvent(parsed) } } } catch (err) { if (!signal.aborted) { setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Streamen avbröts.') } } finally { try { reader.releaseLock() } catch { // already released } // Guard against an aborted prior turn clobbering the new turn's // streaming flag: only the active controller may reset the state. if (activeControllerRef.current === controller) { setStreaming(false) activeControllerRef.current = null } } // The request reached the server. A mid-stream failure is reported through // errorMessage and leaves whatever streamed on screen, so it does not count // as "never sent". return true } function handleStop() { activeControllerRef.current?.abort() activeControllerRef.current = null setStreaming(false) // Keep whatever streamed, but mark it: a half-finished answer that looks // finished is worse than no answer, especially when it stopped mid-figure. setMessages((prev) => { const last = prev[prev.length - 1] if (!last || last.role !== 'assistant' || last.interrupted) return prev return [...prev.slice(0, -1), { ...last, interrupted: true }] }) } function handleRegenerate() { if (!hasAi) return // Re-run the last user message and let the agent produce a fresh // response. UI truncates back to the last user message; DB rows are // append-only, so the previous assistant turn stays in agent_messages // (audit trail intact). The new turn is appended on top. let lastUserIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') { lastUserIdx = i break } } if (lastUserIdx === -1) return const userMsg = messages[lastUserIdx] // Anything the discarded turn staged has to be withdrawn BEFORE the // replacement runs. Otherwise the operation stays pending server-side while // the regenerated turn stages a second proposal for the same booking: two // live proposals for one action, each with its own 30-day expiry. Rejecting // is the same path the Avslå button uses, so the audit trail records why it // went away. const abandoned = messages .slice(lastUserIdx + 1) .flatMap((m) => m.staged ?? []) .map((s) => s.operation_id) .filter((id): id is string => typeof id === 'string') void (async () => { const withdrawn = await Promise.all( abandoned.map(async (operationId) => { try { const res = await fetch(`/api/pending-operations/${operationId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rejection_category: 'other', rejection_reason: 'Ersatt: användaren begärde ett nytt svar.', }), }) // 409 means someone already resolved it (approved in Granskning, or // a parallel client): it is no longer pending either way, which is // all we need. return res.ok || res.status === 409 } catch { return false } }), ) if (withdrawn.some((ok) => !ok)) { // Leave the turn on screen: hiding a card whose operation is still // pending is the failure mode this whole change exists to remove. setErrorMessage( 'Kunde inte dra tillbaka det tidigare förslaget, så svaret behölls. Försök igen.', ) return } setMessages((prev) => prev.slice(0, lastUserIdx + 1)) void startTurn({ conversationId, userMessage: userMsg.text }) })() } // Fired after the user rejects a proposal with a reason. The rejection is // already recorded server-side; here we feed the correction back as a HIDDEN // user turn so the agent re-proposes inline: no synthetic user bubble (we // don't add a user row, and the turn is persisted hidden). function handleCorrection(correctionMessage: string) { if (!hasAi) return void startTurn({ conversationId, userMessage: correctionMessage, hidden: true }) } function handleEvent(event: unknown) { if (typeof event !== 'object' || event === null) return const ev = event as { kind: string } & Record switch (ev.kind) { case 'conversation': { const id = ev.conversation_id as string setConversationId(id) conversationIdRef.current = id onConversationIdChange?.(id) break } case 'reasoning_delta': // Extended-thinking tokens. Accumulate onto the active assistant // message; the ReasoningBlock renders them live, then collapses. setMessages((prev) => updateLastAssistant(prev, (m) => ({ ...m, reasoning: (m.reasoning ?? '') + (ev.delta as string), })), ) break case 'text_delta': // Insert a paragraph break ONCE when text resumes after a tool // call, so post-tool narration starts on its own line instead of // gluing onto the previous sentence ("kategoriseras.Inget historik"). // breakBeforeNextTextRef is set by tool_use/tool_result and consumed // here on the first delta. Critically, the break is applied to the // delta exactly once: NOT re-evaluated per delta, which previously // split mid-word ("minnes\n\nno\n\nterna") because streaming deltas // arrive in sub-word chunks. setMessages((prev) => updateLastAssistant(prev, (m) => { let delta = ev.delta as string if (breakBeforeNextTextRef.current) { breakBeforeNextTextRef.current = false // Only add the break if the buffer has content and doesn't // already end with whitespace, and the delta isn't itself // starting with a newline. if (m.text.length > 0 && !/\s$/.test(m.text) && !/^\s/.test(delta)) { delta = '\n\n' + delta } } return { ...m, text: m.text + delta } }), ) break case 'tool_use': // Next text_delta should open a fresh paragraph. breakBeforeNextTextRef.current = true // Same label the in-thread chip shows, so a hidden panel and a visible // one describe the step identically. onStatus?.({ type: 'step', label: prettyToolName(ev.name as string) }) setMessages((prev) => updateLastAssistant(prev, (m) => ({ ...m, toolCalls: [ ...(m.toolCalls ?? []), { tool_use_id: ev.tool_use_id as string, name: ev.name as string }, ], })), ) break case 'tool_result': // Mark the matching chip as completed instead of removing it. Tools // run in 100-500 ms so yanking the chip the moment it finishes makes // the indicator feel like a flicker rather than a record of what // happened. Leaving the chip in place (with a static check dot, // no pulse) gives the user a stable trace of which calls ran. setMessages((prev) => updateLastAssistant(prev, (m) => ({ ...m, toolCalls: m.toolCalls?.map((tc) => tc.tool_use_id === (ev.tool_use_id as string) ? { ...tc, completed: true } : tc, ), })), ) break case 'memory_captured': { const evt: MemoryEvent = { tool_use_id: ev.tool_use_id as string, action: (ev.action as 'remembered' | 'forgotten') ?? 'remembered', memory_id: ev.memory_id as string, memory_kind: ev.memory_kind as MemoryEvent['memory_kind'], content: ev.content as string | undefined, } setMessages((prev) => updateLastAssistant(prev, (m) => ({ ...m, memoryEvents: [...(m.memoryEvents ?? []), evt], // Drop the matching tool_use chip: the richer memory chip // replaces it and they convey the same event. toolCalls: m.toolCalls?.filter((tc) => tc.tool_use_id !== evt.tool_use_id), })), ) break } case 'staged_operation': { const stagedRaw = ev.staged as { operation_id?: string risk_level: 'low' | 'medium' | 'high' message: string preview?: unknown period_status?: { period_id?: string | null status: 'open' | 'locked' | 'closed' lock_date?: string | null } } setMessages((prev) => updateLastAssistant(prev, (m) => ({ ...m, staged: [ ...(m.staged ?? []), { tool_use_id: ev.tool_use_id as string, tool_name: (ev.tool_name as string | undefined) ?? undefined, operation_id: stagedRaw.operation_id, risk_level: stagedRaw.risk_level, message: stagedRaw.message, preview: stagedRaw.preview, period_status: stagedRaw.period_status, }, ], })), ) break } case 'error': setErrorMessage(ev.message as string) break case 'turn_complete': { if (!firstTurnFiredRef.current && conversationIdRef.current) { firstTurnFiredRef.current = true onFirstTurnComplete?.(conversationIdRef.current) } break } } } async function handleSend() { const text = input.trim() if (!text || streaming) return setInput('') setMessages((prev) => [...prev, { role: 'user', text }]) const ok = await startTurn({ conversationId, userMessage: text }) if (!ok) { // The turn never reached the server, so nothing was persisted and the // dangling user bubble would vanish on reload. Put the text back in the // composer instead of making the user retype it, and drop the bubble so // what is on screen matches what was actually sent. setMessages((prev) => { const last = prev[prev.length - 1] return last?.role === 'user' && last.text === text ? prev.slice(0, -1) : prev }) setInput((current) => (current.length > 0 ? current : text)) } } // Auto-resize the textarea as the user types. Capped at 8rem (~128px) so // the input bar never devours the message list. Shrinks back when the // user clears or backspaces. useLayoutEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' const max = 128 el.style.height = `${Math.min(el.scrollHeight, max)}px` }, [input]) // Index of the last assistant bubble: used to gate the Regenerate // affordance so it only appears on the latest response. let lastAssistantIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'assistant') { lastAssistantIdx = i break } } // A vote needs the thread it belongs to. Without a conversation id there is // nothing to attach the report to, so the buttons stay inert rather than // posting a vote the backlog cannot trace to an answer. const handleVote = useCallback( async (sentiment: FeedbackSentiment) => { const id = conversationIdRef.current if (!id) return false return sendFeedback({ conversationId: id, sentiment }) }, [], ) return (
{/* The chat had no live region at all, so a screen-reader user got no signal that the assistant had answered: the reply simply appeared for people who could see it. role="status" is the polite variant, which waits for a pause rather than interrupting. */}
{announcement}
{/* The pill is positioned against THIS box, not the whole component: the composer below grows as the user types, and a fixed offset from the bottom would slide the pill under it. */}
{messages.length === 0 && streaming && } {messages.map((m, i) => (
= historyBaselineRef.current ? MESSAGE_ENTER_CLASS : undefined}> 0 } onRegenerate={handleRegenerate} onCorrection={handleCorrection} onVote={handleVote} />
))} {errorMessage && (
{errorMessage}
)}
{hasUnseenBelow && ( )}
{/* Paywall: /api/agent/invoke 403s without the ai capability. Replace the composer with an upsell so an already-open conversation (or a deep link to /chat/*) never offers an input that can't send. */} {!hasAi ? (
AI-assistenten kräver ett abonnemang.
) : (
{ e.preventDefault() void handleSend() }} >