'use client' import { useEffect, useRef, useState } from 'react' import { X, Expand, Shrink, PanelRight, PanelRightClose, PictureInPicture2, Eraser, History, ChevronLeft, Loader2, } from 'lucide-react' import AgentChat, { attachStagedOperations, normalizeStoredMessages, type ChatMessage, } from './AgentChat' import type { AgentPanelFloatRect, StoredStagedOperation } from '@/types' import { DOCK_GUTTER, DOCK_WIDTH_MAX, DOCK_WIDTH_MIN, clampDockWidth, clampFloatRect, defaultFloatRect, expandedDockWidth, resizeFloatRect, type ResizeEdges, } from '@/lib/agent-panel/geometry' import type { AgentStatusEvent } from './agent-status' import ContextChip from './ContextChip' import { intentLabel } from './conversation-display' import AgentAvatar from './AgentAvatar' import AgentSessionList from './AgentSessionList' import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' import { useCompanyOptional } from '@/contexts/CompanyContext' import { cn } from '@/lib/utils' // Undimmed non-modal side sheet: sits above the page on a hairline border + // shadow, but the page underneath stays fully interactive. Plan §3b. // // The sheet is a thin wrapper around AgentChat: it owns the title bar, close // button, and "expand to /chat/[id]" affordance. All message rendering and // streaming live in AgentChat so the full-page chat view can reuse them. interface Props { intentId: string intentArgs?: Record contextRef?: string seedUserMessage?: string // Hidden (display:none) but still mounted so the conversation survives. The // provider keeps rendering this component; we just visually remove it. collapsed: boolean // Publishes what the agent is doing to the one status channel, so the // floating trigger can say "arbetar" / "är klar" while the panel is hidden. onStatus?: (event: AgentStatusEvent) => void // How much width the panel is claiming from the page, or null while it // overlays. Docking is what makes the panel a second column instead of a // curtain over the thing being discussed. onDockWidthChange?: (px: number | null) => void onCollapse: () => void onRestart: () => void onClose: () => void } // Geometry (docked width, expanded width, floating rect) lives in // lib/agent-panel/geometry and is persisted per user via the provider // (ui_state.agent_panel). The drag paths below write styles imperatively and // commit ONE preference update on release, so a long conversation never // re-renders at pointer-move frequency while the user drags. function useViewportSize() { const [size, setSize] = useState(() => typeof window === 'undefined' ? { w: 1440, h: 900 } : { w: window.innerWidth, h: window.innerHeight }, ) useEffect(() => { let frame = 0 const onResize = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(() => setSize({ w: window.innerWidth, h: window.innerHeight }), ) } window.addEventListener('resize', onResize) return () => { cancelAnimationFrame(frame) window.removeEventListener('resize', onResize) } }, []) return size } /** Matches Tailwind's md breakpoint: floating mode exists on desktop only. */ function useMinWidthMd() { const [md, setMd] = useState( () => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches, ) useEffect(() => { const mq = window.matchMedia('(min-width: 768px)') const onChange = () => setMd(mq.matches) mq.addEventListener('change', onChange) return () => mq.removeEventListener('change', onChange) }, []) return md } /** Current sidebar column width (--nav-w, set inline on #dash-shell). */ function readNavWidth(): number { if (typeof document === 'undefined') return 248 const shell = document.getElementById('dash-shell') const px = shell ? parseInt(getComputedStyle(shell).getPropertyValue('--nav-w'), 10) : NaN return Number.isFinite(px) ? px : 248 } /** * Reactive sidebar width: the nav toggle rewrites #dash-shell's inline * --nav-w, which fires no resize event, so observe the style attribute * instead of forcing a computed-style read on every render. */ function useNavWidth(): number { const [w, setW] = useState(readNavWidth) useEffect(() => { const shell = document.getElementById('dash-shell') if (!shell) return const update = () => setW(readNavWidth()) update() const mo = new MutationObserver(update) mo.observe(shell, { attributes: true, attributeFilter: ['style'] }) return () => mo.disconnect() }, []) return w } /** * Minimal pointer drag: capture on the handle, report cursor deltas, call * onEnd exactly once on release or cancel. Deliberately not a library: * three call sites, no gesture semantics beyond delta tracking. */ function startPointerDrag( e: React.PointerEvent, onMove: (dx: number, dy: number) => void, onEnd: () => void, ) { if (e.button !== 0) return e.preventDefault() const target = e.currentTarget as HTMLElement const startX = e.clientX const startY = e.clientY try { target.setPointerCapture(e.pointerId) } catch { // Capture is best-effort: without it the drag still works while the // cursor stays over the handle. } // Listeners live on window, NOT on the handle: if capture fails (or the // handle unmounts mid-drag), a pointerup outside the 8px strip would never // reach the handle and onEnd would never run, leaving the drag's global // side effects (transition suppression, data-agent-resizing) stuck for the // rest of the session. lostpointercapture covers the mid-drag-unmount case. // Window listeners see every active pointer, so a second touch or a pen // must not move this drag or end it early: only the initiating pointer id // counts. lostpointercapture carries no useful pointerId in all engines, // so it stays unfiltered; it can only fire for the captured pointer anyway. const pointerId = e.pointerId const move = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return onMove(ev.clientX - startX, ev.clientY - startY) } const end = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', up) window.removeEventListener('pointercancel', up) target.removeEventListener('lostpointercapture', end) onEnd() } const up = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return end() } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) window.addEventListener('pointercancel', up) target.addEventListener('lostpointercapture', end) } interface LoadedConversation { id: string intentId: string contextRef: string | null title: string | null messages: ChatMessage[] } export default function AgentSheet({ intentId, intentArgs, contextRef, seedUserMessage, collapsed, onStatus, onDockWidthChange, onCollapse, onRestart, onClose, }: Props) { // Live conversation id from the active AgentChat (fresh sessions report it via // onConversationIdChange; resumed ones we set directly on select). // Drops the enter class once the slide has played, so re-expanding a // collapsed session is instant rather than sliding in again. const [entering, setEntering] = useState(true) useEffect(() => { const t = setTimeout(() => setEntering(false), 320) return () => clearTimeout(t) }, []) const [conversationId, setConversationId] = useState(null) // 'chat' shows the conversation; 'list' shows the session picker. const [view, setView] = useState<'chat' | 'list'>('chat') // A past conversation the user picked from the list, hydrated for resume. When // set, it replaces the intent-driven fresh chat. const [loaded, setLoaded] = useState(null) const [loadingConversation, setLoadingConversation] = useState(false) const [loadError, setLoadError] = useState(null) // Enlarge the panel IN PLACE (no navigation): the user stays on the current // page (e.g. /bookkeeping) with a wider reading/verifying surface. Transient // focus mode, deliberately not persisted (unlike the dock width below). const [expanded, setExpanded] = useState(false) const { identity, panelPrefs, updatePanelPrefs } = useAgentSheet() const viewport = useViewportSize() const isDesktop = useMinWidthMd() const navW = useNavWidth() // Resolved geometry for this render. Floating exists on desktop only: below // md the sheet stays the full-screen mobile surface whatever the persisted // mode says. Everything re-clamps against the live viewport, so preferences // saved on another screen can never strand the panel off-screen. const floating = panelPrefs.mode === 'floating' && isDesktop const dockW = clampDockWidth(panelPrefs.dockWidth, viewport.w, navW) const expandedW = expandedDockWidth(viewport.w, navW) const floatRect = floating ? clampFloatRect( panelPrefs.float ?? defaultFloatRect(viewport.w, viewport.h), viewport.w, viewport.h, ) : null // Reserve page margin while docked, compact AND expanded: both reflow the // page beside the panel instead of covering it (the original complaint). // Floating and collapsed claim nothing; below md the margin variable is // inert (the frame layout gates it on md:). const reservedWidth = collapsed || floating ? null : (expanded ? expandedW : dockW) + DOCK_GUTTER useEffect(() => { onDockWidthChange?.(reservedWidth) }, [reservedWidth, onDockWidthChange]) useEffect(() => () => onDockWidthChange?.(null), [onDockWidthChange]) const companyCtx = useCompanyOptional() const isSandbox = companyCtx?.isSandbox ?? false const agentName = identity.displayName?.trim() || null const sheetTitle = intentLabel(intentId, agentName) const displayTitle = loaded ? (loaded.title ?? intentLabel(loaded.intentId, agentName)) : sheetTitle const activeConversationId = loaded?.id ?? conversationId // A resumed thread's stored ref wins: it says what THAT conversation was // about, which is the whole reason to show this. Falls back to the ref the // panel was opened with for a thread that has not been persisted yet. const activeContextRef = loaded ? loaded.contextRef : (contextRef ?? null) const sheetRef = useRef(null) // Monotonic counter so a slow conversation fetch can't clobber a newer pick. const selectSeqRef = useRef(0) // Esc: back out of the session list first, otherwise close. Never while // collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere). // // The sheet is deliberately non-modal, so this listener sits on window while // the rest of the page stays interactive: it must therefore only claim the // key when nothing nearer the user wants it. Closing the sheet discards the // whole in-memory conversation, so an Esc meant for a dropdown inside an // approval card, the command palette, or any dialog used to destroy the // session outright. Three guards, cheapest first: // - defaultPrevented: a Radix popover/dialog that handled Esc marks it. // - an open overlay anywhere on the page (Radix marks these on the body // and on the overlay elements themselves) means the key isn't ours. // - focus sitting outside the sheet means the user is working elsewhere. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (collapsed || e.key !== 'Escape') return if (e.defaultPrevented) return if (typeof document !== 'undefined') { // Match on data-state="open", not on the popper wrapper itself: a // force-mounted popper stays in the DOM while closed, and keying off // the wrapper alone would then block Escape for the rest of the session. const overlayOpen = document.querySelector( '[data-radix-popper-content-wrapper] [data-state="open"], [role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="listbox"][data-state="open"], [data-radix-menu-content][data-state="open"], [data-radix-select-content][data-state="open"]', ) if (overlayOpen) return const active = document.activeElement if (active && sheetRef.current && !sheetRef.current.contains(active)) return } if (view === 'list') setView('chat') else onClose() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onClose, collapsed, view]) // Move focus off the sheet before hiding it, so it never sits on a // display:none node (accessibility). const handleCollapse = () => { if (typeof document !== 'undefined') { ;(document.activeElement as HTMLElement | null)?.blur() } onCollapse() } // ── Docked width drag ───────────────────────────────────────────────── // Live frames write the sheet's max-width and the page-margin variable // directly; data-agent-resizing suppresses the 300ms margin transition // (globals.css) so the page reflow tracks the cursor 1:1. One preference // commit on release. const pendingDockW = useRef(null) function onDockResizeStart(e: React.PointerEvent) { const el = sheetRef.current if (!el) return const base = expanded ? expandedW : dockW document.documentElement.setAttribute('data-agent-resizing', '') el.style.transition = 'none' startPointerDrag( e, (dx) => { // The handle sits on the panel's left edge: dragging left widens. // navW from the closure: the sidebar cannot change mid-drag, and this // avoids a computed-style read on every pointer frame. const w = clampDockWidth(base - dx, window.innerWidth, navW) pendingDockW.current = w el.style.maxWidth = `${w}px` document.documentElement.style.setProperty('--agent-dock-w', `${w + DOCK_GUTTER}px`) }, () => { document.documentElement.removeAttribute('data-agent-resizing') el.style.transition = '' if (pendingDockW.current !== null) { // Keep the final width as the inline override too: if the committed // value equals the previous preference, React sees identical style // props and writes nothing, so the DOM must already be correct. el.style.maxWidth = `${pendingDockW.current}px` // Dragging from focus mode lands on a custom width: that IS leaving // focus mode, so fold the result back into the normal dock. setExpanded(false) updatePanelPrefs({ dockWidth: pendingDockW.current }) pendingDockW.current = null } }, ) } function onDockResizeKey(e: React.KeyboardEvent) { const step = 24 // Step from the width the user actually sees: in focus mode that is // expandedW, and the first keypress folds it into a custom dock width. const base = expanded ? expandedW : dockW let next: number | null = null if (e.key === 'ArrowLeft') next = base + step if (e.key === 'ArrowRight') next = base - step if (next === null) return e.preventDefault() setExpanded(false) updatePanelPrefs({ dockWidth: clampDockWidth(next, viewport.w, navW) }) } // ── Floating move / resize ──────────────────────────────────────────── const pendingFloat = useRef(null) const commitFloatRect = () => { if (pendingFloat.current !== null) { updatePanelPrefs({ float: pendingFloat.current }) pendingFloat.current = null } } function onFloatMoveStart(e: React.PointerEvent) { const el = sheetRef.current if (!el || !floatRect) return // Header buttons keep their clicks; only bare header surface drags. if ((e.target as Element).closest('button, a, input, textarea, select, [role="button"]')) { return } const base = floatRect startPointerDrag( e, (dx, dy) => { const r = clampFloatRect( { ...base, x: base.x + dx, y: base.y + dy }, window.innerWidth, window.innerHeight, ) pendingFloat.current = r el.style.left = `${r.x}px` el.style.top = `${r.y}px` }, commitFloatRect, ) } function onFloatResizeStart(e: React.PointerEvent, edges: ResizeEdges) { const el = sheetRef.current if (!el || !floatRect) return const base = floatRect startPointerDrag( e, (dx, dy) => { const r = resizeFloatRect(base, dx, dy, edges, window.innerWidth, window.innerHeight) pendingFloat.current = r el.style.left = `${r.x}px` el.style.top = `${r.y}px` el.style.width = `${r.w}px` el.style.height = `${r.h}px` }, commitFloatRect, ) } function toggleFloating() { if (floating) { updatePanelPrefs({ mode: 'docked' }) } else { updatePanelPrefs({ mode: 'floating', float: panelPrefs.float ?? defaultFloatRect(viewport.w, viewport.h), }) } } // Resume a past conversation inline: fetch its messages, hydrate, and swap the // sheet back to the chat view. Picking the one already open just closes the // list (keeps its live in-memory state instead of re-hydrating it). async function handleSelectConversation(id: string) { if (id === activeConversationId) { setView('chat') return } setView('chat') setLoaded(null) setLoadingConversation(true) setLoadError(null) // Sequence token: picking A (slow) then B (fast) used to end with A's // response overwriting B, leaving the user typing into a conversation they // did not choose. Only the newest selection may write state. const seq = ++selectSeqRef.current try { const res = await fetch(`/api/agent/conversations/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = (await res.json()) as { data?: { conversation: { id: string intent_id: string context_ref: string | null title: string | null } messages: { role: string; content: unknown; hidden?: boolean | null }[] staged_operations?: StoredStagedOperation[] } } const data = json.data if (!data) throw new Error('missing data') if (seq !== selectSeqRef.current) return setLoaded({ id: data.conversation.id, intentId: data.conversation.intent_id, contextRef: data.conversation.context_ref, title: data.conversation.title, messages: attachStagedOperations( normalizeStoredMessages(data.messages), data.staged_operations ?? [], ), }) setConversationId(data.conversation.id) } catch { if (seq === selectSeqRef.current) setLoadError('Kunde inte öppna konversationen.') } finally { if (seq === selectSeqRef.current) setLoadingConversation(false) } } return (
visible would otherwise replay the slide every time // the user re-expands the same session. A floating window has no edge // to arrive from, so it just fades in. entering && (floating ? 'animate-in fade-in-0' : 'animate-in slide-in-from-right-full fade-in-0'), collapsed && 'hidden', )} style={ floating && floatRect ? { left: floatRect.x, top: floatRect.y, width: floatRect.w, height: floatRect.h } : { // Width is the persisted dock preference; expanded (focus mode) // grows as far as the viewport allows while the page keeps a // readable column beside it. maxWidth: expanded ? expandedW : dockW, // iOS notch / Android cutout: the sheet top edge needs to clear // the status bar. Bottom is handled inside the form below. paddingTop: 'env(safe-area-inset-top, 0px)', } } > {/* Docked: left-edge width handle (desktop only; mobile is full-width). */} {!floating && (
)} {/* Undocked: edge + corner resize handles. Pointer-only affordances (aria-hidden): the keyboard path is dock -> arrow keys on the separator above. */} {floating && ( <>
onFloatResizeStart(e, { left: true })} className="absolute inset-y-0 left-0 z-10 w-2 cursor-ew-resize touch-none" aria-hidden="true" />
onFloatResizeStart(e, { right: true })} className="absolute inset-y-0 right-0 z-10 w-2 cursor-ew-resize touch-none" aria-hidden="true" />
onFloatResizeStart(e, { bottom: true })} className="absolute inset-x-0 bottom-0 z-10 h-2 cursor-ns-resize touch-none" aria-hidden="true" />
onFloatResizeStart(e, { left: true, bottom: true })} className="absolute bottom-0 left-0 z-10 h-4 w-4 cursor-nesw-resize touch-none" aria-hidden="true" />
onFloatResizeStart(e, { right: true, bottom: true })} className="absolute bottom-0 right-0 z-10 h-4 w-4 cursor-nwse-resize touch-none" aria-hidden="true" /> )} {view === 'list' ? (

Konversationer

) : (
{!isSandbox && ( )}

{displayTitle}

{/* What this conversation is anchored to. context_ref has been stored since the first intents shipped and read by nothing, so a thread resumed days later gave no clue which invoice it was about. Matters more now the panel sits BESIDE the page. */}
{/* Undock into a floating window the user can move and resize freely / dock it back to the right edge. Hidden on mobile where the sheet is always the full-screen surface. */} {!isSandbox && ( )} {/* Grow/shrink the panel in place: NEVER navigates away, so the user stays on the current page. Hidden on mobile where the sheet is already full-width (the toggle would be a no-op), and while floating (the window resizes by its edges instead). */} {!isSandbox && !floating && ( )} {/* Labeled (not icon-only) so it isn't mistaken for close/minimize, and gated on an existing conversation so there's nothing to mis-click on a fresh, empty chat. */} {activeConversationId && !isSandbox && ( )}
)} {isSandbox ? ( ) : view === 'list' ? ( ) : loadingConversation ? (
Öppnar konversation…
) : loadError ? (

{loadError}

) : loaded ? ( setConversationId(id)} onStatus={onStatus} /> ) : ( setConversationId(id)} onStatus={onStatus} /> )}
) }