Files
accounted/components/agent/AgentTrigger.tsx
T
Jakob Wennberg 60cc51fe21 feat(assistant): dock the panel into the frame, give the trigger a status channel (#1220)
* feat(assistant): dock the panel into the frame and give the trigger a status channel

Two things the panel could not do.

It covered the page it was talking about. Opening it on /invoices laid a 480px
curtain over the invoice, so verifying an answer meant closing the thing that
gave it. The page panel now gives up that width plus the frame's own gutter, and
the two float side by side. Docking applies at the compact width only: expanded
is a deliberate focus mode, where there is no page left to read anyway, so it
goes back to overlaying. Driven by a --agent-dock-w custom property because the
frame layout is a server component; globals.css seeds the default so the first
paint is not a jump, and below md nothing changes.

And a minimized session was silent. The agent could be three tool calls into a
booking, or finished ten minutes ago, and the pill said "Fortsätt med Anna"
either way, so the only way to find out was to reopen it. There is now one
status channel: the trigger spins with the current step while work runs and
shows an unread dot when a turn landed behind a hidden panel.

The channel is a reducer in a React-free module rather than a pair of booleans,
because a durable background run has to publish to the same one later. Its
'detached' state (working somewhere the user cannot see) is built and rendered
now even though nothing dispatches it in v1, so adding runs is a publisher and
not a redesign. Turn boundaries derive from the streaming flag rather than being
published per call site: a turn can end by completing, erroring, aborting or
being stopped, and missing one would leave the trigger claiming the agent is
still working forever.

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

* docs: record the Sonnet 5 ceiling, dock and status-channel decisions

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:57:22 +02:00

142 lines
6.7 KiB
TypeScript

'use client'
import { useAgentSheet } from './AgentSheetProvider'
import { usePathname, useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import AgentAvatar from './AgentAvatar'
import { collapsedStatusLabel } from './agent-status'
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
// clicked. Hidden when the sheet is already open so the icon doesn't double up.
//
// Route-aware: routeToIntent(pathname) picks the right intent + intentArgs so
// clicking the FAB on /invoices/abc-123 opens invoice.draft with that invoice
// id (rather than the page-agnostic general.help with just the URL). The
// label suffix renders "Fråga Anna om denna faktura" so the user can tell at
// a glance that the agent is going to know which entity they're on.
//
// Reads the agent's display_name + avatar_id from the AgentSheet context so
// the button reads "Fråga Anna" (with Anna's face) rather than the generic
// "Fråga min assistent".
//
// Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice)
// still call useAgentSheet() directly from their own buttons because they
// know exactly which entity to pass. (Per-transaction help has its own
// row-level "Fråga [namn]" button in TransactionInboxCard, and the matching
// "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the
// pathname-only FAB can't know.)
export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, status, identity } = useAgentSheet()
const pathname = usePathname()
const router = useRouter()
const hasAi = useCapability(CAPABILITY.ai)
// User opt-out (Inställningar → Assistenten): the sidebar entry stays, the
// floating button goes. A collapsed session keeps its reopen handle even
// when hidden: it's the only way back to a minimized conversation, and its
// existence implies the user is actively using the assistant right now.
if (hidden && !collapsed) return null
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
// the session is merely collapsed we KEEP the FAB: it's the handle that
// brings the minimized conversation back.
if (isOpen && !collapsed) return null
// The page-suppression rules below apply only to a FRESH open. A collapsed
// session always gets its reopen handle, regardless of page: otherwise a
// conversation minimized on /chat or /bookkeeping/[id] could never be
// brought back.
if (!collapsed) {
// The /chat surface IS the chat: a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks): a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
const segs = pathname?.split('/').filter(Boolean) ?? []
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
return null
}
}
// Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead
// into a generic chat with no specialization. Better to hide it until
// the user has finished /onboarding/agent. (A collapsed session implies the
// agent is already in use, so this only gates fresh opens in practice.)
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
const dispatch = routeToIntent(pathname)
// AI assistant runs on a paid cloud service. Without the capability, opening
// the sheet would land the user in a chat whose send is dead. Keep the FAB
// visible (it's the conversion surface) but route it to billing instead.
// A minimized session used to be silent: the agent could be three tool calls
// into a booking, or finished ten minutes ago, and the pill said "Fortsätt
// med Anna" either way. While hidden, the status channel does the talking.
const statusText = collapsed ? collapsedStatusLabel(status, name) : null
const working = status.activity === 'working' || status.activity === 'detached'
const finished = collapsed && status.activity === 'done'
const labelText =
statusText ??
(collapsed
? `Fortsätt med ${name}`
: !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`)
const handleClick = () => {
// Collapsed → bring the existing session back, don't start a new one.
if (collapsed) {
expandAgentSheet()
return
}
if (!hasAi) {
router.push('/settings/billing')
return
}
openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
return (
<button
onClick={handleClick}
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
// no mobile nav to worry about.
className="fixed right-4 z-30 flex h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4"
aria-label={labelText}
// The label changes on its own while the panel is hidden, so it has to be
// announced rather than only redrawn. Polite: it never interrupts.
aria-live="polite"
>
<span className="relative shrink-0">
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20"
alt={name}
/>
{/* An answer waiting behind a hidden panel is the one thing worth a
dot: it is unread, not in progress. */}
{finished && (
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-foreground" />
)}
</span>
{working && collapsed && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
)}
<span className="text-sm font-medium truncate">{labelText}</span>
</button>
)
}