fix(transactions): keep the assistant usable through the whole booking flow (#1975)

* fix(transactions): keep the assistant usable through the whole booking flow

Three gaps around the agent sheet during booking on Transaktioner:

- The AgentTrigger bubble lived inside #dash-shell, so the inert that
  non-modal dialogs set on the shell made the assistant impossible to
  OPEN once a booking dialog was up. Moved outside the shell (it is
  position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so
  it stays clickable, below dialog content (z-50). Under true modal
  dialogs Radix's body pointer-events lock keeps it dead as before.
- Wide dialogs centered on the full viewport while the docked sheet
  (z-60) covered their right edge, hiding e.g. the Granska button.
  DialogContent now centers in the space left of --agent-dock-w, and
  the wide booking/review variants cap their width against it.
- Step 1 of the flow (template picker, QuickReviewDialog) was still
  fully modal, so the assistant was dead there. Both are now non-modal
  with DialogVeil + a shared ref-counted useDashShellInert hook (also
  replacing the duplicated inert effects in TransactionBookingDialog
  and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they
  hold no half-filled form. Clicks in the agent sheet or its trigger
  never dismiss any dialog: data-agent-ui counts as inside, same
  mechanism as data-dialog-companion.

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

* fix(ui): dialog centering reads a docked-only sheet width variable

Skeptic review refuted the first cut: globals.css seeds --agent-dock-w
at the 10px frame gutter on :root by design, so the 0px fallback in the
new dialog centering never applied and every dialog sat 5px left of
center (full-bleed dialogs clipped 5px off-screen).

Introduce --agent-sheet-w, set inline by AgentSheetProvider only while
the sheet is docked and removed otherwise, so the 0px fallback is real:
sheet closed or floating renders byte-identical to the old left-[50%]
and old max widths. Also cap the template picker and QuickReview's
narrow variant, which could clip off-screen left on narrow desktops
with the sheet docked.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-27 14:22:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 99c94d467a
commit c981384a0e
9 changed files with 107 additions and 44 deletions
+15 -3
View File
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -203,6 +203,10 @@ export default function QuickReviewDialog({
}
}, [open, transaction, t])
// Non-modal dialog (see the Dialog below): hand-restore page modality so
// the agent sheet stays live. See useDashShellInert in ui/dialog.tsx.
useDashShellInert(open)
if (!transaction || !category) return null
const tx = enrichedTx ?? transaction
@@ -432,7 +436,11 @@ export default function QuickReviewDialog({
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
// Non-modal so the agent sheet and its trigger stay usable during review
// (useDashShellInert above hand-restores page modality). Existing
// dismissal semantics kept: Esc/veil-click close unless processing, and
// assistant clicks never dismiss (data-agent-ui counts as inside).
<Dialog modal={false} open={open} onOpenChange={isProcessing ? undefined : (o) => {
if (!o) {
setUploadedFiles([])
setPickedInboxDocs([])
@@ -440,7 +448,11 @@ export default function QuickReviewDialog({
}
onOpenChange(o)
}}>
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
<DialogVeil />
{/* Both variants cap at the space left of a docked agent sheet so the
right edge never lands unreachable under it (sheet is z-60).
--agent-sheet-w is docked-only: sheet closed = the old widths. */}
<DialogContent className={preAttachedDocumentId ? 'max-w-[min(72rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[90vh] overflow-y-auto' : 'max-w-[min(28rem,calc(100vw-var(--agent-sheet-w,0px)))] sm:max-w-[min(32rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[85vh] overflow-y-auto'}>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
@@ -1,8 +1,8 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -158,20 +158,9 @@ export default function TransactionBookingDialog({
return { bankAccount: account, bankAccountName: matched?.name ?? null }
}, [transaction, cashAccounts, cashAccountsLoading])
// The dialog is non-modal (see the Dialog below), so Radix does not trap
// focus or inert the page. Restore page modality by hand: `inert` on the
// dash shell blocks pointer, keyboard, and AT access to the page behind
// (including the mobile bottom nav, which sits above the veil), while the
// agent sheet (outside the shell) stays live. Same as NewInvoiceDialog.
useEffect(() => {
if (!open) return
const shell = document.getElementById('dash-shell')
if (!shell) return
shell.inert = true
return () => {
shell.inert = false
}
}, [open])
// Non-modal dialog (see below): page modality is restored by hand so the
// agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx.
useDashShellInert(open)
if (!transaction) return null
@@ -271,7 +260,11 @@ export default function TransactionBookingDialog({
}} modal={false}>
<DialogVeil />
<DialogContent
className="max-w-6xl max-h-[90vh] overflow-y-auto"
// Width caps at the space left of a docked agent sheet so the form's
// right edge, and the Granska button, never end up unreachable under
// the sheet (z-60 over z-50). --agent-sheet-w is docked-only, so with
// the sheet closed this is exactly the old max-w-6xl.
className="max-w-[min(72rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[90vh] overflow-y-auto"
// Non-modal so the agent sheet (fixed z-[60], portaled outside this
// dialog) stays interactive beside a booking in progress; a click in
// its text field must not count as outside-dismissal. A half-booked