Files
accounted/components/agent/AgentSheetProvider.tsx
T
Jakob WennbergandClaude Opus 5 4de648fb5d fix(assistant): keep proposals, selections and picks intact across a resume (#1212)
* fix(assistant): keep proposals, selections and picks intact across a resume

PR3 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7):
resume fidelity. Four ways the chat lost state that the user had every reason to
think was still there.

Approval cards ride on streamed staged_operation events, which are never
persisted, so reopening a conversation rendered the tool trace and the answer
but silently dropped the card. The proposal then sat in Granskning for its full
30-day expiry with nothing in the thread pointing at it. run-turn already stamps
agent_metadata.conversation_id on every staged row, so both resume paths (the
sheet's history and the /chat page) now re-attach the still-pending ones to the
last assistant turn.

Regenerate abandoned whatever the discarded turn had staged: the card left the
screen, the operation stayed pending, and the regenerated turn usually staged a
second proposal for the same booking, leaving two live proposals for one action.
It now withdraws them through the same reject path the Avslå button uses, so the
audit trail records why they went away.

The sheet's remount key ignored intentArgs while some callers pass a CONSTANT
contextRef with varying args: bulk-book always uses 'inbox:bulk' and carries the
selected ids. Selecting A+B, collapsing, then selecting C+D reopened the A+B
conversation while the user believed C+D were being booked. The key now includes
a stable serialization of the args.

Picking conversation A (slow) then B (fast) let A's late response overwrite B,
leaving the user typing into a thread they did not choose. A sequence token now
means only the newest pick may write state.

Verified: 9540 unit tests pass (11 new), lint and tsc clean on every touched
file, guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: record the resume-fidelity decisions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): render hydrated proposals with the same preview as live ones

pending_operations.operation_type stores the bare action name
('categorize_transaction'), while the streamed card carries the MCP tool name
('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock dispatches on
that. Hydrated cards therefore fell through to the flat generic preview instead
of the journal-line one, so a resumed proposal looked materially worse than the
same proposal did live: the opposite of what this PR is for.

Found by checking the query against prod rather than trusting the mock, which is
also how the stored value space was confirmed: categorize_transaction,
create_voucher and approve_supplier_invoice are what exist in the wild, and the
four operation types that have a specialized renderer all stage unprefixed.

The test fixture now uses the real stored shape so the mapping is actually
covered rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): await proposal withdrawals, surface staged-query errors, share the type

Review follow-ups on the resume-fidelity batch. All four findings were valid.

The withdrawals were fire-and-forget and raced the replacement turn, so the new
turn could stage a second proposal before the old one was rejected: the exact
double-staging this change exists to prevent. They are now awaited, a 409 counts
as withdrawn (someone else resolved it, which is all we need), and if any
withdrawal genuinely fails the turn stays on screen with an error rather than
hiding a card whose operation is still pending.

Both staged-operation loaders ignored their error result, so a database or
policy failure rendered the conversation as successful with the proposals
silently missing: again the failure this query exists to prevent, reintroduced
through the error path. Both now propagate, matching the sibling message query.

StoredStagedOperation now lives in @/types: it is a persisted API contract that
crosses a server page and three components, not an AgentChat detail.

The unserializable-args fallback used a timestamp, which collides for two
objects created in the same millisecond and changes on every render tick for the
same object, remounting the sheet mid-session. A WeakMap gives each object one
stable id for its lifetime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:56:50 +02:00

233 lines
8.6 KiB
TypeScript

'use client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import dynamic from 'next/dynamic'
// 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 max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-4">
<div className="h-8 w-8 shrink-0 animate-pulse rounded-full bg-secondary" />
<div className="h-4 w-32 animate-pulse rounded bg-secondary" />
</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
// 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
}
export function AgentSheetProvider({ children, identity }: 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 openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
setCollapsed(false)
}, [])
const closeAgentSheet = useCallback(() => {
setActiveArgs(null)
setCollapsed(false)
}, [])
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,
identity: resolvedIdentity,
}),
[
openAgentSheet,
closeAgentSheet,
collapseAgentSheet,
expandAgentSheet,
restartAgentSheet,
activeArgs,
collapsed,
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}
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
}