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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
39f4ecdad4
commit
b4b7549004
@@ -833,6 +833,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<CompanyTabSync />
|
||||
<div
|
||||
|
||||
@@ -47,6 +47,74 @@ describe('POST /api/user/ui-state', () => {
|
||||
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<string, unknown> } }
|
||||
}>(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<string, unknown> } }
|
||||
}>(
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+394
-31
@@ -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<string | null>(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<number | null>(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<AgentPanelFloatRect | null>(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 && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Ändra panelens bredd"
|
||||
aria-valuenow={expanded ? expandedW : dockW}
|
||||
aria-valuemin={DOCK_WIDTH_MIN}
|
||||
aria-valuemax={clampDockWidth(DOCK_WIDTH_MAX, viewport.w, navW)}
|
||||
tabIndex={0}
|
||||
onPointerDown={onDockResizeStart}
|
||||
onKeyDown={onDockResizeKey}
|
||||
className="group absolute inset-y-0 left-0 z-10 hidden w-2 cursor-col-resize touch-none focus-visible:outline-none md:block"
|
||||
>
|
||||
<div className="mx-auto h-full w-px bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-border group-focus-visible:bg-ring" />
|
||||
</div>
|
||||
)}
|
||||
{/* Undocked: edge + corner resize handles. Pointer-only affordances
|
||||
(aria-hidden): the keyboard path is dock -> arrow keys on the
|
||||
separator above. */}
|
||||
{floating && (
|
||||
<>
|
||||
<div
|
||||
onPointerDown={(e) => onFloatResizeStart(e, { left: true })}
|
||||
className="absolute inset-y-0 left-0 z-10 w-2 cursor-ew-resize touch-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={(e) => onFloatResizeStart(e, { right: true })}
|
||||
className="absolute inset-y-0 right-0 z-10 w-2 cursor-ew-resize touch-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={(e) => onFloatResizeStart(e, { bottom: true })}
|
||||
className="absolute inset-x-0 bottom-0 z-10 h-2 cursor-ns-resize touch-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={(e) => 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"
|
||||
/>
|
||||
<div
|
||||
onPointerDown={(e) => 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' ? (
|
||||
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||
<header
|
||||
onPointerDown={floating ? onFloatMoveStart : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-3 border-b border-border px-5 py-4',
|
||||
floating && 'cursor-move touch-none select-none',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setView('chat')}
|
||||
className="h-9 w-9 -ml-1 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
@@ -273,7 +606,15 @@ export default function AgentSheet({
|
||||
</button>
|
||||
</header>
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-4">
|
||||
<header
|
||||
// In floating mode the header doubles as the window's drag surface
|
||||
// (buttons excluded inside onFloatMoveStart).
|
||||
onPointerDown={floating ? onFloatMoveStart : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 border-b border-border px-4 py-4',
|
||||
floating && 'cursor-move touch-none select-none',
|
||||
)}
|
||||
>
|
||||
{!isSandbox && (
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
@@ -296,10 +637,32 @@ export default function AgentSheet({
|
||||
<ContextChip contextRef={activeContextRef} className="mt-0.5" />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* 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 && (
|
||||
<button
|
||||
onClick={toggleFloating}
|
||||
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
aria-label={floating ? 'Docka mot högerkanten' : 'Frigör panelen'}
|
||||
title={
|
||||
floating
|
||||
? 'Docka mot högerkanten'
|
||||
: 'Frigör panelen: flytta och ändra storlek fritt'
|
||||
}
|
||||
>
|
||||
{floating ? (
|
||||
<PanelRight className="h-4 w-4" />
|
||||
) : (
|
||||
<PictureInPicture2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
|
||||
@@ -7,9 +7,17 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { AgentPanelState } from '@/types'
|
||||
import {
|
||||
resolveAgentPanelPrefs,
|
||||
serializeAgentPanelPrefs,
|
||||
type ResolvedAgentPanelPrefs,
|
||||
} from '@/lib/agent-panel/geometry'
|
||||
import { persistUiState } from '@/lib/ui-state/client'
|
||||
import {
|
||||
INITIAL_AGENT_STATUS,
|
||||
reduceAgentStatus,
|
||||
@@ -31,8 +39,13 @@ function AgentSheetSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
|
||||
style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}
|
||||
className="fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg"
|
||||
style={{
|
||||
// Tracks the user's persisted dock width (set by the provider below)
|
||||
// so the skeleton has the same geometry as the sheet it stands in for.
|
||||
maxWidth: 'var(--agent-panel-w, 480px)',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-4">
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
@@ -157,6 +170,12 @@ interface AgentSheetContextValue {
|
||||
// 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
|
||||
// Panel geometry preferences (docked width, floating rect, active mode).
|
||||
// Owned here rather than in the sheet so they survive sheet remounts
|
||||
// (intent changes bump the sheet's key) and persist across sessions via
|
||||
// user_preferences.ui_state.agent_panel.
|
||||
panelPrefs: ResolvedAgentPanelPrefs
|
||||
updatePanelPrefs: (patch: Partial<ResolvedAgentPanelPrefs>) => 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
|
||||
@@ -169,9 +188,16 @@ const AgentSheetContext = createContext<AgentSheetContextValue | null>(null)
|
||||
interface AgentSheetProviderProps {
|
||||
children: React.ReactNode
|
||||
identity?: AgentIdentity
|
||||
// Server-seeded ui_state.agent_panel, so the panel opens at the user's
|
||||
// persisted size/mode without a first-paint jump.
|
||||
initialPanelPrefs?: AgentPanelState
|
||||
}
|
||||
|
||||
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
|
||||
export function AgentSheetProvider({
|
||||
children,
|
||||
identity,
|
||||
initialPanelPrefs,
|
||||
}: AgentSheetProviderProps) {
|
||||
useSheetPrefetch()
|
||||
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
|
||||
// Collapsed = session alive but hidden. Kept separate from activeArgs so
|
||||
@@ -183,6 +209,49 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
|
||||
const [status, publishAgentStatus] = useReducer(reduceAgentStatus, INITIAL_AGENT_STATUS)
|
||||
const [dockWidth, setDockWidth] = useState<number | null>(null)
|
||||
|
||||
// Geometry preferences. The ref mirrors the state so updatePanelPrefs can
|
||||
// merge and persist from event handlers without a stale closure (drag
|
||||
// commits fire from listeners installed at drag start).
|
||||
const [panelPrefs, setPanelPrefs] = useState<ResolvedAgentPanelPrefs>(() =>
|
||||
resolveAgentPanelPrefs(initialPanelPrefs),
|
||||
)
|
||||
const panelPrefsRef = useRef(panelPrefs)
|
||||
// Trailing debounce on the POST only: local state stays immediate, but key
|
||||
// auto-repeat on the resize handle (one updatePanelPrefs per repeat) must
|
||||
// not become one read-merge-write against user_preferences per repeat.
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const updatePanelPrefs = useCallback((patch: Partial<ResolvedAgentPanelPrefs>) => {
|
||||
const next = { ...panelPrefsRef.current, ...patch }
|
||||
panelPrefsRef.current = next
|
||||
setPanelPrefs(next)
|
||||
// Fire-and-forget: cosmetic preference, a lost write self-corrects on the
|
||||
// next change.
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
// Null before writing: a fired timer is no longer pending, and the
|
||||
// unmount flush below must not replay this (possibly stale) value.
|
||||
persistTimerRef.current = null
|
||||
persistUiState({ agent_panel: serializeAgentPanelPrefs(panelPrefsRef.current) })
|
||||
}, 300)
|
||||
}, [])
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Flush on unmount so a pending debounced write is not lost.
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current)
|
||||
persistUiState({ agent_panel: serializeAgentPanelPrefs(panelPrefsRef.current) })
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// The lazy sheet's loading skeleton renders before the sheet can report any
|
||||
// geometry, so the persisted dock width is published as a CSS variable for
|
||||
// it (and only it) to read.
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--agent-panel-w', `${panelPrefs.dockWidth}px`)
|
||||
}, [panelPrefs.dockWidth])
|
||||
|
||||
// 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.
|
||||
@@ -244,6 +313,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
|
||||
status,
|
||||
publishAgentStatus,
|
||||
setDockWidth,
|
||||
panelPrefs,
|
||||
updatePanelPrefs,
|
||||
identity: resolvedIdentity,
|
||||
}),
|
||||
[
|
||||
@@ -255,6 +326,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
|
||||
activeArgs,
|
||||
collapsed,
|
||||
status,
|
||||
panelPrefs,
|
||||
updatePanelPrefs,
|
||||
resolvedIdentity,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DOCK_WIDTH_DEFAULT,
|
||||
DOCK_WIDTH_MIN,
|
||||
DOCK_WIDTH_MAX,
|
||||
DOCK_GUTTER,
|
||||
MIN_PAGE_WIDTH,
|
||||
EXPANDED_WIDTH_MAX,
|
||||
FLOAT_MIN_W,
|
||||
FLOAT_MIN_H,
|
||||
FLOAT_KEEP_ON_SCREEN,
|
||||
clampDockWidth,
|
||||
clampFloatRect,
|
||||
defaultFloatRect,
|
||||
expandedDockWidth,
|
||||
resizeFloatRect,
|
||||
resolveAgentPanelPrefs,
|
||||
serializeAgentPanelPrefs,
|
||||
} from '../geometry'
|
||||
|
||||
const WIDE = 1920
|
||||
const TALL = 1080
|
||||
const NAV = 248
|
||||
|
||||
describe('resolveAgentPanelPrefs', () => {
|
||||
it('defaults on absent input', () => {
|
||||
expect(resolveAgentPanelPrefs(undefined)).toEqual({
|
||||
mode: 'docked',
|
||||
dockWidth: DOCK_WIDTH_DEFAULT,
|
||||
float: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a valid persisted state', () => {
|
||||
const prefs = resolveAgentPanelPrefs({
|
||||
mode: 'floating',
|
||||
dock_width: 600,
|
||||
float: { x: 100, y: 50, w: 500, h: 700 },
|
||||
})
|
||||
expect(prefs).toEqual({
|
||||
mode: 'floating',
|
||||
dockWidth: 600,
|
||||
float: { x: 100, y: 50, w: 500, h: 700 },
|
||||
})
|
||||
})
|
||||
|
||||
it('discards garbage values instead of propagating them', () => {
|
||||
const prefs = resolveAgentPanelPrefs({
|
||||
mode: 'floating',
|
||||
dock_width: Number.NaN,
|
||||
// Partial/garbage rect: one bad member invalidates the whole rect.
|
||||
float: { x: 10, y: 10, w: Number.POSITIVE_INFINITY, h: 400 },
|
||||
})
|
||||
expect(prefs.dockWidth).toBe(DOCK_WIDTH_DEFAULT)
|
||||
expect(prefs.float).toBeNull()
|
||||
})
|
||||
|
||||
it('hard-clamps a persisted dock width to the fixed bounds', () => {
|
||||
expect(resolveAgentPanelPrefs({ dock_width: 40 }).dockWidth).toBe(DOCK_WIDTH_MIN)
|
||||
expect(resolveAgentPanelPrefs({ dock_width: 4000 }).dockWidth).toBe(DOCK_WIDTH_MAX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeAgentPanelPrefs', () => {
|
||||
it('rounds pixels to integers (the API schema rejects fractions)', () => {
|
||||
expect(
|
||||
serializeAgentPanelPrefs({
|
||||
mode: 'floating',
|
||||
dockWidth: 480.6,
|
||||
float: { x: 10.4, y: 20.5, w: 400.2, h: 500.9 },
|
||||
}),
|
||||
).toEqual({
|
||||
mode: 'floating',
|
||||
dock_width: 481,
|
||||
float: { x: 10, y: 21, w: 400, h: 501 },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits float when there is none', () => {
|
||||
expect(serializeAgentPanelPrefs({ mode: 'docked', dockWidth: 480, float: null })).toEqual({
|
||||
mode: 'docked',
|
||||
dock_width: 480,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampDockWidth', () => {
|
||||
it('passes through a width the viewport can afford', () => {
|
||||
expect(clampDockWidth(600, WIDE, NAV)).toBe(600)
|
||||
})
|
||||
|
||||
it('caps at the fixed maximum on huge viewports', () => {
|
||||
expect(clampDockWidth(5000, 3840, NAV)).toBe(DOCK_WIDTH_MAX)
|
||||
})
|
||||
|
||||
it('never lets the page drop below its minimum readable width', () => {
|
||||
const viewport = 1280
|
||||
const clamped = clampDockWidth(DOCK_WIDTH_MAX, viewport, NAV)
|
||||
expect(viewport - NAV - clamped - DOCK_GUTTER).toBeGreaterThanOrEqual(MIN_PAGE_WIDTH)
|
||||
})
|
||||
|
||||
it('floors at the panel minimum even when the viewport is too small for both', () => {
|
||||
expect(clampDockWidth(100, 900, NAV)).toBe(DOCK_WIDTH_MIN)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandedDockWidth', () => {
|
||||
it('caps at the fixed expanded maximum on huge viewports', () => {
|
||||
expect(expandedDockWidth(3840, NAV)).toBe(EXPANDED_WIDTH_MAX)
|
||||
})
|
||||
|
||||
it('reserves the minimum page width on mid-size viewports', () => {
|
||||
const viewport = 1440
|
||||
expect(expandedDockWidth(viewport, NAV)).toBe(viewport - NAV - MIN_PAGE_WIDTH - DOCK_GUTTER)
|
||||
})
|
||||
|
||||
it('floors at the default compact width', () => {
|
||||
expect(expandedDockWidth(1000, NAV)).toBe(DOCK_WIDTH_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampFloatRect', () => {
|
||||
it('keeps a legal rect unchanged', () => {
|
||||
const rect = { x: 200, y: 100, w: 420, h: 640 }
|
||||
expect(clampFloatRect(rect, WIDE, TALL)).toEqual(rect)
|
||||
})
|
||||
|
||||
it('enforces minimum size', () => {
|
||||
const r = clampFloatRect({ x: 0, y: 0, w: 10, h: 10 }, WIDE, TALL)
|
||||
expect(r.w).toBe(FLOAT_MIN_W)
|
||||
expect(r.h).toBe(FLOAT_MIN_H)
|
||||
})
|
||||
|
||||
it('keeps the panel reachable when dragged off the right edge', () => {
|
||||
const r = clampFloatRect({ x: 99999, y: 100, w: 420, h: 640 }, WIDE, TALL)
|
||||
expect(r.x).toBe(WIDE - FLOAT_KEEP_ON_SCREEN)
|
||||
})
|
||||
|
||||
it('never lets the header go above the top edge', () => {
|
||||
const r = clampFloatRect({ x: 100, y: -500, w: 420, h: 640 }, WIDE, TALL)
|
||||
expect(r.y).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the header reachable at the bottom', () => {
|
||||
const r = clampFloatRect({ x: 100, y: 99999, w: 420, h: 640 }, WIDE, TALL)
|
||||
expect(r.y).toBe(TALL - FLOAT_KEEP_ON_SCREEN)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultFloatRect', () => {
|
||||
it('spawns bottom-right and fully on screen', () => {
|
||||
const r = defaultFloatRect(WIDE, TALL)
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(WIDE)
|
||||
expect(r.y + r.h).toBeLessThanOrEqual(TALL)
|
||||
expect(r.x).toBeGreaterThan(WIDE / 2)
|
||||
})
|
||||
|
||||
it('shrinks to fit a small desktop viewport', () => {
|
||||
const r = defaultFloatRect(800, 500)
|
||||
expect(r.w).toBeLessThanOrEqual(800)
|
||||
expect(r.h).toBeLessThanOrEqual(500)
|
||||
expect(r.y).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizeFloatRect', () => {
|
||||
const base = { x: 400, y: 200, w: 500, h: 600 }
|
||||
|
||||
it('grows from the right edge without moving x', () => {
|
||||
const r = resizeFloatRect(base, 80, 0, { right: true }, WIDE, TALL)
|
||||
expect(r).toEqual({ ...base, w: 580 })
|
||||
})
|
||||
|
||||
it('grows from the bottom edge', () => {
|
||||
const r = resizeFloatRect(base, 0, 50, { bottom: true }, WIDE, TALL)
|
||||
expect(r).toEqual({ ...base, h: 650 })
|
||||
})
|
||||
|
||||
it('anchors the right edge on a left-edge drag', () => {
|
||||
const r = resizeFloatRect(base, -60, 0, { left: true }, WIDE, TALL)
|
||||
expect(r.x).toBe(base.x - 60)
|
||||
expect(r.x + r.w).toBe(base.x + base.w)
|
||||
})
|
||||
|
||||
it('keeps the right edge anchored when the minimum width clamp kicks in', () => {
|
||||
const r = resizeFloatRect(base, 400, 0, { left: true }, WIDE, TALL)
|
||||
expect(r.w).toBe(FLOAT_MIN_W)
|
||||
expect(r.x + r.w).toBe(base.x + base.w)
|
||||
})
|
||||
|
||||
it('resizes both axes from a corner', () => {
|
||||
const r = resizeFloatRect(base, 40, 30, { right: true, bottom: true }, WIDE, TALL)
|
||||
expect(r).toEqual({ ...base, w: 540, h: 630 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { AgentPanelFloatRect, AgentPanelState } from '@/types'
|
||||
|
||||
/**
|
||||
* Pure geometry for the assistant panel (components/agent/AgentSheet).
|
||||
*
|
||||
* The panel has two modes:
|
||||
* - docked: full-height sheet on the right edge; the page panel gives up
|
||||
* `width + DOCK_GUTTER` of right margin (via --agent-dock-w) so content
|
||||
* reflows beside the panel instead of being covered by it.
|
||||
* - floating: a free window the user drags and resizes anywhere; the page
|
||||
* keeps its full width and the user decides what the window may cover.
|
||||
*
|
||||
* All clamping lives here, testable and shared between the live drag path
|
||||
* (imperative style writes) and the persisted-preference path.
|
||||
*/
|
||||
|
||||
export const DOCK_WIDTH_DEFAULT = 480
|
||||
export const DOCK_WIDTH_MIN = 380
|
||||
export const DOCK_WIDTH_MAX = 800
|
||||
/** Frame gutter added on top of the panel width when reserving page margin,
|
||||
* so panel and page float side by side with the same 10px seam as the frame. */
|
||||
export const DOCK_GUTTER = 10
|
||||
/** The page keeps at least this much width before the docked panel stops growing. */
|
||||
export const MIN_PAGE_WIDTH = 480
|
||||
/** Ceiling for the expanded (focus) width; beyond this a chat column stops
|
||||
* gaining readability. */
|
||||
export const EXPANDED_WIDTH_MAX = 1100
|
||||
|
||||
export const FLOAT_MIN_W = 360
|
||||
export const FLOAT_MIN_H = 400
|
||||
export const FLOAT_DEFAULT_W = 420
|
||||
export const FLOAT_DEFAULT_H = 640
|
||||
/** Gap kept between a freshly spawned floating panel and the viewport edge. */
|
||||
export const FLOAT_SPAWN_MARGIN = 24
|
||||
/** At least this much of the panel stays inside the viewport, so the header
|
||||
* (the drag surface) is always reachable to drag it back. */
|
||||
export const FLOAT_KEEP_ON_SCREEN = 48
|
||||
|
||||
/** Resolved, always-valid panel preferences (persisted shape is all-optional). */
|
||||
export interface ResolvedAgentPanelPrefs {
|
||||
mode: 'docked' | 'floating'
|
||||
dockWidth: number
|
||||
float: AgentPanelFloatRect | null
|
||||
}
|
||||
|
||||
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
||||
|
||||
/**
|
||||
* Normalize a persisted (or absent, or garbage) agent_panel value into a
|
||||
* usable preference object. Viewport-dependent clamping happens at use time;
|
||||
* this only guards types and hard bounds.
|
||||
*/
|
||||
export function resolveAgentPanelPrefs(
|
||||
raw: AgentPanelState | null | undefined,
|
||||
): ResolvedAgentPanelPrefs {
|
||||
const mode = raw?.mode === 'floating' ? 'floating' : 'docked'
|
||||
const dockWidth = finite(raw?.dock_width)
|
||||
? Math.min(Math.max(raw.dock_width, DOCK_WIDTH_MIN), DOCK_WIDTH_MAX)
|
||||
: DOCK_WIDTH_DEFAULT
|
||||
const f = raw?.float
|
||||
const float =
|
||||
f && finite(f.x) && finite(f.y) && finite(f.w) && finite(f.h)
|
||||
? { x: f.x, y: f.y, w: f.w, h: f.h }
|
||||
: null
|
||||
return { mode, dockWidth, float }
|
||||
}
|
||||
|
||||
/** Persisted wire shape (integers: the API schema rejects fractional pixels). */
|
||||
export function serializeAgentPanelPrefs(prefs: ResolvedAgentPanelPrefs): AgentPanelState {
|
||||
return {
|
||||
mode: prefs.mode,
|
||||
dock_width: Math.round(prefs.dockWidth),
|
||||
...(prefs.float
|
||||
? {
|
||||
float: {
|
||||
x: Math.round(prefs.float.x),
|
||||
y: Math.round(prefs.float.y),
|
||||
w: Math.round(prefs.float.w),
|
||||
h: Math.round(prefs.float.h),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a docked width so the page keeps MIN_PAGE_WIDTH beside the panel.
|
||||
* `navWidth` is the sidebar column (--nav-w); on viewports too small for
|
||||
* both minimums the panel floor wins (the md: margin is inert there anyway).
|
||||
*/
|
||||
export function clampDockWidth(px: number, viewportW: number, navWidth: number): number {
|
||||
const available = Math.max(DOCK_WIDTH_MIN, viewportW - navWidth - MIN_PAGE_WIDTH - DOCK_GUTTER)
|
||||
const max = Math.min(DOCK_WIDTH_MAX, available)
|
||||
return Math.round(Math.min(Math.max(px, DOCK_WIDTH_MIN), max))
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of the expanded (focus) panel: as wide as the viewport allows while
|
||||
* the page keeps MIN_PAGE_WIDTH, capped at EXPANDED_WIDTH_MAX. Unlike the
|
||||
* old fixed 1100px overlay, this reserves page margin like the compact dock.
|
||||
*/
|
||||
export function expandedDockWidth(viewportW: number, navWidth: number): number {
|
||||
const available = viewportW - navWidth - MIN_PAGE_WIDTH - DOCK_GUTTER
|
||||
return Math.round(Math.min(EXPANDED_WIDTH_MAX, Math.max(DOCK_WIDTH_DEFAULT, available)))
|
||||
}
|
||||
|
||||
/** Keep a floating rect at legal size and reachably on screen. */
|
||||
export function clampFloatRect(
|
||||
rect: AgentPanelFloatRect,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): AgentPanelFloatRect {
|
||||
const w = Math.round(Math.min(Math.max(rect.w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, viewportW)))
|
||||
const h = Math.round(Math.min(Math.max(rect.h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, viewportH)))
|
||||
// Horizontally the panel may hang off either side as long as
|
||||
// FLOAT_KEEP_ON_SCREEN of it stays visible; vertically the header must
|
||||
// never go above the top edge (it is the only drag surface).
|
||||
const x = Math.round(
|
||||
Math.min(Math.max(rect.x, FLOAT_KEEP_ON_SCREEN - w), viewportW - FLOAT_KEEP_ON_SCREEN),
|
||||
)
|
||||
const y = Math.round(Math.min(Math.max(rect.y, 0), Math.max(0, viewportH - FLOAT_KEEP_ON_SCREEN)))
|
||||
return { x, y, w, h }
|
||||
}
|
||||
|
||||
/** First-undock placement: bottom-right, mirroring where the FAB lives. */
|
||||
export function defaultFloatRect(viewportW: number, viewportH: number): AgentPanelFloatRect {
|
||||
const w = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, viewportW - 2 * FLOAT_SPAWN_MARGIN))
|
||||
const h = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, viewportH - 2 * FLOAT_SPAWN_MARGIN))
|
||||
return clampFloatRect(
|
||||
{ x: viewportW - w - FLOAT_SPAWN_MARGIN, y: viewportH - h - FLOAT_SPAWN_MARGIN, w, h },
|
||||
viewportW,
|
||||
viewportH,
|
||||
)
|
||||
}
|
||||
|
||||
export interface ResizeEdges {
|
||||
left?: boolean
|
||||
right?: boolean
|
||||
bottom?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a pointer drag delta to a floating rect from the given edges.
|
||||
* A left-edge drag moves x with the width so the right edge stays anchored,
|
||||
* including when the width clamp kicks in.
|
||||
*/
|
||||
export function resizeFloatRect(
|
||||
base: AgentPanelFloatRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
edges: ResizeEdges,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): AgentPanelFloatRect {
|
||||
let w = base.w
|
||||
if (edges.right) w = base.w + dx
|
||||
if (edges.left) w = base.w - dx
|
||||
const h = edges.bottom ? base.h + dy : base.h
|
||||
|
||||
const clampedW = Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, viewportW))
|
||||
const x = edges.left ? base.x + (base.w - clampedW) : base.x
|
||||
|
||||
return clampFloatRect({ x, y: base.y, w: clampedW, h }, viewportW, viewportH)
|
||||
}
|
||||
@@ -77,6 +77,27 @@ export interface UserUiState {
|
||||
// Split-button last-used create modes, keyed per surface (plan PR 3/4),
|
||||
// e.g. create_mode.bookkeeping = 'mall'.
|
||||
create_mode?: Record<string, string>
|
||||
// Assistant panel geometry (components/agent/AgentSheet): docked width,
|
||||
// undocked floating rect, and which of the two modes is active. Client
|
||||
// re-clamps to the current viewport on read, so stale sizes from another
|
||||
// screen are safe.
|
||||
agent_panel?: AgentPanelState
|
||||
}
|
||||
|
||||
export type AgentPanelMode = 'docked' | 'floating'
|
||||
|
||||
// Viewport pixels of the undocked assistant window.
|
||||
export interface AgentPanelFloatRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface AgentPanelState {
|
||||
mode?: AgentPanelMode
|
||||
dock_width?: number
|
||||
float?: AgentPanelFloatRect
|
||||
}
|
||||
|
||||
// Transaction categories
|
||||
|
||||
Reference in New Issue
Block a user