'use client' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import Link from 'next/link' import { Send, Square, RotateCw, BookmarkCheck, BookmarkX, Check, Brain, } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import ApprovalCard from './ApprovalCard' // 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[] } // 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 // 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, }: 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 ?? []) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) 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 // 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) useEffect(() => { const el = scrollerRef.current if (!el) return const onScroll = () => { const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) wasAtBottomRef.current = distance < 64 } 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 } }, [messages]) 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 }): 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 setErrorMessage(err instanceof Error ? err.message : 'Kunde inte nå assistenten.') setStreaming(false) activeControllerRef.current = null return } 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 } // 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 ? err.message : '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 } } } function handleStop() { activeControllerRef.current?.abort() activeControllerRef.current = null setStreaming(false) } function handleRegenerate() { // 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] setMessages(messages.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) { 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 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 }]) await startTurn({ conversationId, userMessage: 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 } } return (
{messages.length === 0 && streaming && } {messages.map((m, i) => (
0 } onRegenerate={handleRegenerate} onCorrection={handleCorrection} />
))} {errorMessage && (
{errorMessage}
)}
{ e.preventDefault() void handleSend() }} >