'use client' import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useState, } from 'react' import dynamic from 'next/dynamic' import { INITIAL_AGENT_STATUS, reduceAgentStatus, type AgentStatus, type AgentStatusEvent, } from './agent-status' import { Skeleton } from '@/components/ui/skeleton' // The sheet is a lazy chunk, and it used to have no loading state at all: a // click on the launcher produced NOTHING until the chunk arrived, then the // whole panel appeared at once. Two fixes: a skeleton in the same geometry so // the surface is there on the first frame, and a prefetch once the page is // idle so the chunk is usually already loaded before anyone clicks. const AgentSheet = dynamic(() => import('./AgentSheet'), { loading: () => , }) function AgentSheetSkeleton() { return ( ) } /** * Serialize intent args into the sheet's remount key. * * The key used to be intent + contextRef + seed only, but some callers pass a * CONSTANT contextRef with varying args: bulk-book always uses 'inbox:bulk' and * carries the selected item ids. Selecting A+B, collapsing, then selecting C+D * produced the same key, so the sheet did not remount and the earlier * conversation reopened while the user believed C+D were being booked. * * Key order so the same selection reached by different routes stays one session. */ // Fallback identity for args that cannot be serialized (cycles, non-JSON // values). A timestamp would be wrong twice over: two different objects created // in the same millisecond would collide, and the same object would get a new // key on every render tick, remounting the sheet under the user mid-session. // A WeakMap gives each object one stable id for as long as it exists. const argsFallbackIds = new WeakMap() let argsFallbackSeq = 0 function stableArgsKey(args?: Record): string { if (!args) return '' try { const keys = Object.keys(args).sort() return JSON.stringify(keys.map((k) => [k, args[k]])) } catch { let id = argsFallbackIds.get(args) if (!id) { id = `unserializable:${++argsFallbackSeq}` argsFallbackIds.set(args, id) } return id } } /** Warm the sheet chunk when the browser is idle, never on the critical path. */ function useSheetPrefetch() { useEffect(() => { const warm = () => { // Swallow a failed prefetch: the real import on click will surface any // genuine problem, and warming must never produce an unhandled rejection. void import('./AgentSheet').catch(() => {}) } const w = window as Window & { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number cancelIdleCallback?: (id: number) => void } if (typeof w.requestIdleCallback === 'function') { // A busy page can stay non-idle indefinitely, so cap the wait: the point // is to have the chunk ready before the first click, not to hold out for // a quiet moment that may never arrive. const id = w.requestIdleCallback(warm, { timeout: 2000 }) return () => w.cancelIdleCallback?.(id) } const t = setTimeout(warm, 2000) return () => clearTimeout(t) }, []) } export interface AgentIdentity { displayName: string | null avatarId: string | null // True only after the user has completed Phase B verification in // /onboarding/agent. Consumers (AgentTrigger, page-level Sparkle // buttons) should hide themselves when this is false so the FAB // doesn't pop up before the agent build flow has run. isVerified: boolean } // Provider exposes a single imperative function: openAgentSheet({...}). Any // client component (top-nav button, transaction row "Fråga om" button, etc.) // calls it to bring the sheet up with a specific intent + capture args. // // The sheet itself manages its own message list, streaming state, and // dismissal. The provider just owns "what is open" and re-opens or replaces // the panel when called again. export interface OpenAgentSheetArgs { intentId: string // Intent-specific args passed to the server's intent.capture(), e.g. // { transaction_id: '...' } for transaction.categorization. intentArgs?: Record // Optional ref persisted on agent_conversations.context_ref so the UI can // surface a back-pointer ("om transaktion 12 mar / 1 240 kr") later. contextRef?: string // Pre-populated first user message. When set, the chat skips the intent's // promptTemplate and sends this verbatim instead. Used by /chat empty-state // suggestion chips to give the user a one-click starting prompt. seedUserMessage?: string } interface AgentSheetContextValue { openAgentSheet: (args: OpenAgentSheetArgs) => void closeAgentSheet: () => void // Collapse hides the sheet WITHOUT unmounting it, so the in-memory // conversation (messages, streaming, pending approval cards) survives: the // floating trigger re-expands the same session. Distinct from close, which // ends the session entirely. collapseAgentSheet: () => void expandAgentSheet: () => void // Discard the current thread and start a fresh conversation on the same // intent (the header "Ny konversation" control). Implemented by remounting // the sheet via a nonce in its key. restartAgentSheet: () => void // True while a session exists (open or collapsed). isOpen: boolean // True while a session exists but is minimized off-screen. collapsed: boolean // What the assistant is doing, for surfaces outside the panel (today the // floating trigger). See agent-status.ts: one channel, so a durable // background run can publish to it later without a second one. status: AgentStatus publishAgentStatus: (event: AgentStatusEvent) => void // Width in px the docked panel is claiming from the page, or null when it is // overlaying instead. Set by the panel, read by the frame layout. setDockWidth: (px: number | null) => void // Agent name + avatar: set once from the server-loaded agent_profile // and exposed through context so the trigger / chat headers can render // them without their own fetches. Null when the user hasn't verified a // profile yet (free tier or pre-onboarding). identity: AgentIdentity } const AgentSheetContext = createContext(null) interface AgentSheetProviderProps { children: React.ReactNode identity?: AgentIdentity } export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) { useSheetPrefetch() const [activeArgs, setActiveArgs] = useState(null) // Collapsed = session alive but hidden. Kept separate from activeArgs so // collapsing never unmounts AgentChat (which would wipe the conversation). const [collapsed, setCollapsed] = useState(false) // Bumped by restartAgentSheet to force a fresh AgentChat mount (a new thread) // on the same intent, without closing the sheet. const [restartNonce, setRestartNonce] = useState(0) const [status, publishAgentStatus] = useReducer(reduceAgentStatus, INITIAL_AGENT_STATUS) const [dockWidth, setDockWidth] = useState(null) // Visibility drives whether a finished turn is news or not, so it is derived // from the session state rather than reported by the panel: closing counts as // hidden just like collapsing, and a panel that never opened is neither. const panelVisible = activeArgs !== null && !collapsed useEffect(() => { publishAgentStatus({ type: 'visibility', visible: panelVisible }) }, [panelVisible]) // Dock the panel into the frame instead of over it: the page panel gives up // its right margin so the content the user is asking about stays readable // beside the answer. Written as a CSS variable rather than a class on
// because the frame layout is a server component; globals.css seeds the // default so the first paint is not a jump. useEffect(() => { const root = document.documentElement if (dockWidth === null) { root.style.removeProperty('--agent-dock-w') return } root.style.setProperty('--agent-dock-w', `${dockWidth}px`) return () => { root.style.removeProperty('--agent-dock-w') } }, [dockWidth]) const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => { setActiveArgs(args) setCollapsed(false) }, []) const closeAgentSheet = useCallback(() => { setActiveArgs(null) setCollapsed(false) setDockWidth(null) publishAgentStatus({ type: 'reset' }) }, []) const collapseAgentSheet = useCallback(() => setCollapsed(true), []) const expandAgentSheet = useCallback(() => setCollapsed(false), []) const restartAgentSheet = useCallback(() => { setRestartNonce((n) => n + 1) setCollapsed(false) }, []) const resolvedIdentity = useMemo( () => identity ?? { displayName: null, avatarId: null, isVerified: false }, [identity], ) const value = useMemo( () => ({ openAgentSheet, closeAgentSheet, collapseAgentSheet, expandAgentSheet, restartAgentSheet, isOpen: activeArgs !== null, collapsed, status, publishAgentStatus, setDockWidth, identity: resolvedIdentity, }), [ openAgentSheet, closeAgentSheet, collapseAgentSheet, expandAgentSheet, restartAgentSheet, activeArgs, collapsed, status, resolvedIdentity, ], ) return ( {children} {activeArgs && ( )} ) } export function useAgentSheet(): AgentSheetContextValue { const ctx = useContext(AgentSheetContext) if (!ctx) { throw new Error('useAgentSheet must be used inside ') } return ctx }