From b4b754900415a7de04bc7c599751f05ebb790760 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:13:02 +0200 Subject: [PATCH] feat(agent): resizable, undockable assistant panel (#1467) * feat(agent): resizable, undockable assistant panel User report: the assistant chat sheet sometimes covers the page content the user is asking about, with no way to resize or move it. - Docked mode is now drag-resizable from its left edge (380-800px, clamped so the page keeps a 480px readable column) and the page reflows beside it via the existing --agent-dock-w reservation. - Expanded (focus) mode reserves page margin like the compact dock instead of overlaying up to 1100px of the page. - New undock toggle turns the sheet into a floating window that can be dragged by its header and resized from edges/corners, clamped so the header always stays reachable. Desktop only; mobile keeps the full-screen sheet. - Geometry (mode, dock width, float rect) persists per user in user_preferences.ui_state.agent_panel, server-seeded to avoid a first-paint jump; the ui-state API schema gains a strict agent_panel key with nested merge. - Pure clamp/resize math lives in lib/agent-panel/geometry with unit tests; drag frames write styles imperatively and commit one preference update on release. Co-Authored-By: Claude Fable 5 * fix(agent): address review findings on panel drag, a11y, and persistence CodeRabbit round 1, all six findings fixed: - Bind drag listeners to window (plus lostpointercapture) so a failed pointer capture or mid-drag unmount can never leave the transition suppression and data-agent-resizing stuck for the session. - Keyboard resize now steps from the visible width (expandedW in focus mode) instead of jumping to the persisted dock width. - The width handle exposes window-splitter semantics: aria-valuenow, aria-valuemin, aria-valuemax. - --nav-w is read reactively via a MutationObserver on #dash-shell instead of computed-style reads in the render body and per drag frame. - The ui-state POST in updatePanelPrefs gets a 300ms trailing debounce (state stays immediate) so key auto-repeat cannot produce one read-merge-write per repeat; pending write flushes on unmount. - globals.css keeps one :root token block; the agent-resizing rule moved below it. Co-Authored-By: Claude Fable 5 * fix(agent): filter drag events by pointer id, clear fired debounce timer CodeRabbit round 2, both findings fixed: - Window-level drag listeners now ignore events from pointers other than the initiating one, so a second touch or pen cannot move the panel or end the first pointer's drag. - The persist debounce timer ref is nulled when the timer fires, so the unmount flush only writes genuinely pending values instead of replaying an already-persisted (possibly stale) geometry. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/layout.tsx | 3 + app/api/user/ui-state/__tests__/route.test.ts | 68 +++ app/api/user/ui-state/route.ts | 22 + app/globals.css | 8 + components/agent/AgentSheet.tsx | 425 ++++++++++++++++-- components/agent/AgentSheetProvider.tsx | 79 +++- lib/agent-panel/__tests__/geometry.test.ts | 195 ++++++++ lib/agent-panel/geometry.ts | 164 +++++++ types/index.ts | 21 + 10 files changed, 952 insertions(+), 34 deletions(-) create mode 100644 lib/agent-panel/__tests__/geometry.test.ts create mode 100644 lib/agent-panel/geometry.ts diff --git a/DECISIONS.md b/DECISIONS.md index 6b907c29..dc8758c2 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -833,6 +833,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows. [2026-07-29] Transaction-method backfill scope: classification and title-stripping are FEED-ROW concepts (import_source present, not manual/mcp), enforced identically at ingest and in the 20260808090100 backfill, plus an adjective guard so "Egen insättning"/"Eget uttag"/"Intern överföring" keep their full titles even on feed rows (the phrase IS the meaning after a possessive/scope adjective). Chosen over vocabulary tweaks because the failure mode for unknown bank phrasings must be "row unchanged", and user-authored titles must never be rewritten by a channel vocabulary. A read-only prod dry-run script exists for coverage measurement but prod reads were left to the founder (permission-gated). [2026-08-08] Compliance-bot finding on the transaction_method backfill (booked rows' titles rewritten without a rattelse trail) triaged as satisfied-by-design, not a blocker: BFL 5 kap 5 attaches to bokforingsposter, and the backfill touches no journal table; the verifikat description is snapshotted into journal_entries at commit and SIE #VER export reads journal_entries only (both verified in code, no report reads transactions.description lazily); the bank original is preserved byte-identical in original_description by the same UPDATE (enforced since 80ef1ee0, and prod has 0/25,566 feed rows lacking it). The stricter TRANSACTION_TITLE_LOCKED gate on booked rows blocks arbitrary user free-text renames, a different mutation class from a deterministic trailing-vocabulary strip that skips user-edited titles and keeps the original adjacent. Period-lock triggers sit on the journal tables and fiscal periods, not on transactions; the pg-upgrade CI run applied the backfill against seeded booked rows with all enforcement triggers active. +[2026-08-08] Assistant panel undock/resize hardcodes Swedish UI strings (title/aria on the new handle and dock/undock toggle) instead of next-intl: every existing string in components/agent (AgentSheet, AgentChat, session list) is hardcoded Swedish with zero next-intl usage, so two translated strings in an otherwise Swedish-only surface would be inconsistent both ways; migrating the whole agent surface to next-intl is its own change. Geometry persists in user_preferences.ui_state.agent_panel (design rule 9, never localStorage); expanded focus mode now reserves page margin like the compact dock instead of overlaying (the reported bug), clamped so the page keeps a 480px readable column; @use-gesture/react stayed unused in favor of a ~30-line pointer-capture helper (three call sites need only delta tracking). [2026-08-08] Receipts checklist step (PR #1461) done-signal = any invoice_inbox_items row for the company, not a document_attachments count: document_attachments also archives outbound invoice PDFs, ROT/RUT payout files and Bolagsverket submissions, so its count would mark the step done when the user merely sends their first invoice; invoice_inbox_items is written by exactly the three intake channels (email/WhatsApp/upload) and carries a company_id index. Same PR: vat_registered with moms_period=null renders an explicit "välj momsperiod"-link in the checklist instead of silence, because that state makes the deadline engine generate zero VAT deadlines without any error (all three moms configs require a concrete period). [2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly. [2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 0c4139e7..30c3b364 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -294,6 +294,9 @@ export default async function DashboardLayout({ avatarId: agentProfileIdentity?.avatar_id ?? null, isVerified: Boolean(agentProfileIdentity?.verified_at), }} + // Server-seeded panel geometry (docked width / floating rect / mode) + // so the assistant opens at the user's persisted size without a jump. + initialPanelPrefs={uiState.agent_panel} >
{ expect(res.status).toBe(400) }) + it('returns 400 on unknown agent_panel keys (strict schema)', async () => { + const res = await POST(request({ agent_panel: { mode: 'docked', evil: 1 } })) + expect(res.status).toBe(400) + }) + + it('returns 400 on an invalid agent_panel mode', async () => { + const res = await POST(request({ agent_panel: { mode: 'popup' } })) + expect(res.status).toBe(400) + }) + + it('returns 400 on fractional float pixels', async () => { + const res = await POST( + request({ agent_panel: { float: { x: 10.5, y: 0, w: 400, h: 500 } } }), + ) + expect(res.status).toBe(400) + }) + + it('returns 400 on an incomplete float rect', async () => { + const res = await POST(request({ agent_panel: { float: { x: 10, y: 0, w: 400 } } })) + expect(res.status).toBe(400) + }) + + it('merges agent_panel keys instead of replacing the object', async () => { + enqueue({ + data: { + ui_state: { + agent_panel: { mode: 'docked', dock_width: 620 }, + }, + }, + }) + enqueue({ data: null }) + + const { status, body } = await parseJsonResponse<{ + data: { ui_state: { agent_panel: Record } } + }>(await POST(request({ agent_panel: { mode: 'floating' } }))) + + expect(status).toBe(200) + // dock_width survives a mode-only patch: undocking must not forget the + // user's chosen docked width. + expect(body.data.ui_state.agent_panel).toEqual({ mode: 'floating', dock_width: 620 }) + }) + + it('accepts a full agent_panel geometry payload', async () => { + enqueue({ data: null }) + enqueue({ data: null }) + + const { status, body } = await parseJsonResponse<{ + data: { ui_state: { agent_panel: Record } } + }>( + await POST( + request({ + agent_panel: { + mode: 'floating', + dock_width: 480, + float: { x: 1200, y: 300, w: 420, h: 640 }, + }, + }), + ), + ) + + expect(status).toBe(200) + expect(body.data.ui_state.agent_panel).toEqual({ + mode: 'floating', + dock_width: 480, + float: { x: 1200, y: 300, w: 420, h: 640 }, + }) + }) + it('merges the patch into the existing ui_state', async () => { // select existing row enqueue({ diff --git a/app/api/user/ui-state/route.ts b/app/api/user/ui-state/route.ts index 87871af5..63663cec 100644 --- a/app/api/user/ui-state/route.ts +++ b/app/api/user/ui-state/route.ts @@ -16,6 +16,25 @@ const BodySchema = z .strict() .optional(), create_mode: z.record(z.string(), z.string().max(64)).optional(), + // Assistant panel geometry. Bounds are deliberately looser than the + // client's viewport clamps: a size saved on a large screen must round-trip + // even when later read on a small one (the client re-clamps on use). + agent_panel: z + .object({ + mode: z.enum(['docked', 'floating']).optional(), + dock_width: z.number().int().min(320).max(1600).optional(), + float: z + .object({ + x: z.number().int().min(-8000).max(16000), + y: z.number().int().min(-8000).max(16000), + w: z.number().int().min(280).max(4000), + h: z.number().int().min(280).max(4000), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict() @@ -57,6 +76,9 @@ export async function POST(request: Request) { ...(patch.create_mode ? { create_mode: { ...current.create_mode, ...patch.create_mode } } : {}), + ...(patch.agent_panel + ? { agent_panel: { ...current.agent_panel, ...patch.agent_panel } } + : {}), } const { error: upsertError } = await supabase diff --git a/app/globals.css b/app/globals.css index f37433b6..0bb1dde5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -103,6 +103,14 @@ --duration-slow: 500ms; } +/* While the assistant panel's width handle is being dragged, the page margin + must track the cursor 1:1: the page panel's 300ms margin transition would + trail the drag and leave a gap. AgentSheet sets the attribute for the + duration of the drag only. */ +:root[data-agent-resizing] #main-content { + transition: none !important; +} + .dark { color-scheme: dark; diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 1e5173b7..82237aa3 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -5,7 +5,9 @@ import { X, Expand, Shrink, + PanelRight, PanelRightClose, + PictureInPicture2, Eraser, History, ChevronLeft, @@ -16,7 +18,18 @@ import AgentChat, { normalizeStoredMessages, type ChatMessage, } from './AgentChat' -import type { StoredStagedOperation } from '@/types' +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' @@ -54,10 +67,127 @@ interface Props { onClose: () => void } -// The panel is max-w-[480px]; the page gives up that plus the frame's own -// 10px gutter, so the two panels float side by side on the frame with the -// same seam as everywhere else instead of butting their borders together. -const DOCKED_WIDTH = 490 +// 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 @@ -97,19 +227,39 @@ export default function AgentSheet({ 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. + // 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) - // Dock at the compact width only. Expanded is a deliberate focus mode: at - // 1100px there is no page left to read beside it, so it goes back to - // overlaying. Collapsed and mobile claim nothing (below md the panel is - // full-width and the frame layout ignores the variable anyway). - const dockWidth = collapsed || expanded ? null : DOCKED_WIDTH - useEffect(() => { - onDockWidthChange?.(dockWidth) - }, [dockWidth, onDockWidthChange]) - useEffect(() => () => onDockWidthChange?.(null), [onDockWidthChange]) - const { identity } = useAgentSheet() + 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 @@ -171,6 +321,123 @@ export default function AgentSheet({ 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). @@ -234,26 +501,92 @@ export default function AgentSheet({ // conversation state in AgentChat survives) while removing it from view // and layout entirely (no stray horizontal scroll from an off-screen box). className={cn( - 'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]', + 'fixed z-[60] flex flex-col bg-background', + floating + ? // Undocked window: free rect from inline styles; overlay chrome + // (rounded, hairline border, shadow) like every other overlay. + 'overflow-hidden rounded-lg border border-border shadow-lg' + : 'inset-y-0 right-0 w-full border-l border-border shadow-lg transition-[max-width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]', // Arrive along the same edge, on the same curve and duration, as the // page panel that animates its margin to make room (layout.tsx). Gated // on first mount only: the panel stays mounted while collapsed, and // display:none -> visible would otherwise replay the slide every time - // the user re-expands the same session. - entering && 'animate-in slide-in-from-right-full fade-in-0', + // 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', - // Expanded grows the panel leftward over the page (still non-modal: the - // page stays interactive); normal is the compact side sheet. - expanded ? 'max-w-[min(100vw,1100px)]' : 'max-w-[480px]', )} - style={{ - // 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)', - }} + 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' ? ( -
+
) : ( -
+
{!isSandbox && (
+ {/* 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). */} - {!isSandbox && ( + is already full-width (the toggle would be a no-op), and while + floating (the window resizes by its edges instead). */} + {!isSandbox && !floating && (