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