refactor(agent): keep the assistant in the nav, FAB and underlag flow only (#1557)

The founder wants the scattered per-page assistant buttons gone: the
assistant is reachable from the nav and the floating tab everywhere, so
in-page duplicates were noise. Removed the AgentSparkleButton call sites
(year-end, verifikat detail, supplier invoice detail, invoice editor)
and the now-orphaned component, the soft hand-off link in the Ny
verifikat modal (plus its i18n keys), and the transaction-row overflow
item. Kept: nav entry, floating tab, the Dokumentinkorg flow, and the
sanctioned "Skapa med assistent" split-button mode on /bookkeeping
(design.md convention 14).

The command palette's hand-off entries hardcoded the agent name "Anna";
they now use the identity from AgentSheetProvider like every other
affordance, and hide until agent onboarding is done (same gate as the
FAB).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 11:19:45 +02:00
committed by GitHub
parent 5453f11330
commit 1e9f245f7c
10 changed files with 27 additions and 165 deletions
@@ -27,7 +27,6 @@ import StrikeLinesDialog from '@/components/bookkeeping/StrikeLinesDialog'
import CorrectMetadataDialog from '@/components/bookkeeping/CorrectMetadataDialog'
import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog'
import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import RetagLineDialog, { type RetagLine } from '@/components/dimensions/RetagLineDialog'
import { useCompanySettings } from '@/components/settings/useSettings'
@@ -452,14 +451,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{(entry.status === 'posted' || entry.status === 'draft') && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{entry.status === 'draft' && (
<AgentSparkleButton
intentId="verifikation.draft"
intentArgs={{ journal_entry_id: id }}
contextRef={`verifikation:${id}`}
className="w-full sm:w-auto"
/>
)}
{entry.status === 'draft' && (
<Button
variant="outline"
@@ -7,7 +7,6 @@ import { EmptyState } from '@/components/ui/empty-state'
import { ContextPicker } from '@/components/common/ContextPicker'
import { Skeleton } from '@/components/ui/skeleton'
import { CalendarPlus, Check, Lock } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -218,12 +217,6 @@ export default function YearEndPage() {
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="font-display text-2xl leading-8 tracking-tight">Årsbokslut</h1>
<div className="flex items-center gap-2">
<AgentSparkleButton
intentId="bokslut.step"
intentArgs={{ step_id: null }}
contextRef="bokslut:overview"
size="default"
/>
{showWizard && periods && periods.length > 0 && step !== 'result' && (
<ContextPicker
items={periods.map((p) => ({
@@ -14,7 +14,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate, cn } from '@/lib/utils'
@@ -526,12 +525,6 @@ export default function SupplierInvoiceDetailPage() {
{/* Actions */}
<div className="flex flex-wrap gap-2">
<AgentSparkleButton
intentId="supplier_invoice.review"
intentArgs={{ supplier_invoice_id: invoice.id }}
contextRef={`supplier_invoice:${invoice.id}`}
size="default"
/>
{/* Attest keys off approved_at, not the status: the overdue cron
flips unbooked invoices to 'overdue' just by aging, and gating on
'registered' alone left them with no way through attest (#1206). */}
-62
View File
@@ -1,62 +0,0 @@
'use client'
import { MessageCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useAgentSheet } from './AgentSheetProvider'
interface Props {
intentId: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intentArgs?: Record<string, any>
contextRef?: string
// Override the auto-derived "Fråga [namn]" label when the page wants
// something more contextual (e.g. "Förklara denna siffra"). Most surfaces
// should leave this unset.
label?: string
size?: 'sm' | 'default' | 'lg'
variant?: 'outline' | 'default' | 'ghost' | 'secondary'
className?: string
}
// Single source of truth for the in-page "Fråga [namn]" affordance. Every
// page-header button across the dashboard (invoice form, supplier invoice,
// bookkeeping, year-end, VAT report, KPI, …) routes through this component
// so they share the same icon size, label format, spacing, and resolved
// agent name. The transaction-row icon button stays separate: it's a
// different UX (icon-only, ghost) embedded inside a row's action group.
export default function AgentSparkleButton({
intentId,
intentArgs,
contextRef,
label,
size = 'sm',
variant = 'outline',
className,
}: Props) {
const { openAgentSheet, identity } = useAgentSheet()
// Same gate as AgentTrigger: hide all "Fråga …" affordances until the
// user has finished /onboarding/agent.
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
const resolvedLabel = label ?? `Fråga ${name}`
return (
<Button
type="button"
variant={variant}
size={size}
className={cn('shrink-0', className)}
onClick={() =>
openAgentSheet({
intentId,
intentArgs,
contextRef,
})
}
>
<MessageCircle className="mr-2 h-4 w-4" />
{resolvedLabel}
</Button>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Copy, Loader2, MessageCircle } from 'lucide-react'
import { Copy, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -9,7 +9,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
export interface CopyPrefill {
sourceId: string
@@ -43,7 +42,6 @@ export default function NewJournalEntryDialog({
isLoading,
}: Props) {
const t = useTranslations('bookkeeping')
const { openAgentSheet, identity } = useAgentSheet()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -63,24 +61,6 @@ export default function NewJournalEntryDialog({
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{identity.isVerified && !copyPrefill && (
// Hand off to the assistant: it reads the underlag (the figures the
// user often can't see), suggests accounts, and stages a balanced
// verifikat to approve: no copy-paste. Close the modal first so its
// focus trap doesn't fight the (non-modal) agent sheet.
<button
type="button"
onClick={() => {
onOpenChange(false)
openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' })
}}
className="inline-flex w-fit items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
{t('ask_assistant_handoff')}
</button>
)}
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
+25 -19
View File
@@ -26,6 +26,7 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
type Entry = {
@@ -93,6 +94,7 @@ export default function CommandPalette({ initialOpen = false }: { initialOpen?:
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { capabilities } = useCompany()
const { identity } = useAgentSheet()
// Drop entries that jump to a paywalled extension workspace the active
// company can't reach (e.g. the AI-only Dokumentinkorg). The page itself is
@@ -143,27 +145,31 @@ export default function CommandPalette({ initialOpen = false }: { initialOpen?:
return q ? visible.filter(e => matches(e, q)) : visible.slice(0, 6)
}, [q, allowedByCapability])
const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0
? {
id: 'anna-fallback',
label: `Fråga Anna: "${query.trim()}"`,
icon: Wand2,
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
}
: q
// The hand-off-to-assistant entries use the agent name the user chose in
// /onboarding/agent, and hide entirely until that onboarding is done: the
// same gate as the nav entry and the FAB.
const assistantName = identity.displayName?.trim() || 'assistenten'
const assistantFallback: Entry | null = !identity.isVerified || !q
? null
: filteredActions.length === 0 && filteredPages.length === 0
? {
id: 'anna-followup',
label: `Fråga Anna istället: "${query.trim()}"`,
id: 'assistant-fallback',
label: `Fråga ${assistantName}: "${query.trim()}"`,
icon: Wand2,
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
}
: {
id: 'assistant-followup',
label: `Fråga ${assistantName} istället: "${query.trim()}"`,
icon: Wand2,
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
}
: null
const flatEntries: Entry[] = [
...(annaFallback && filteredActions.length === 0 && filteredPages.length === 0 ? [annaFallback] : []),
...(assistantFallback && filteredActions.length === 0 && filteredPages.length === 0 ? [assistantFallback] : []),
...filteredActions,
...filteredPages,
...(annaFallback && (filteredActions.length > 0 || filteredPages.length > 0) ? [annaFallback] : []),
...(assistantFallback && (filteredActions.length > 0 || filteredPages.length > 0) ? [assistantFallback] : []),
]
function commit(entry: Entry) {
@@ -244,13 +250,13 @@ export default function CommandPalette({ initialOpen = false }: { initialOpen?:
})}
</Section>
)}
{annaFallback && (
<Section title="Anna">
{assistantFallback && (
<Section title={assistantName}>
<Row
entry={annaFallback}
active={flatEntries.indexOf(annaFallback) === activeIndex}
onSelect={() => commit(annaFallback)}
onHover={() => setActiveIndex(flatEntries.indexOf(annaFallback))}
entry={assistantFallback}
active={flatEntries.indexOf(assistantFallback) === activeIndex}
onSelect={() => commit(assistantFallback)}
onHover={() => setActiveIndex(flatEntries.indexOf(assistantFallback))}
/>
</Section>
)}
-6
View File
@@ -56,7 +56,6 @@ import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPr
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
@@ -1470,11 +1469,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</Heading>
{!bare && <p className="text-muted-foreground">{subtitleText}</p>}
</div>
<AgentSparkleButton
intentId="invoice.draft"
intentArgs={{ customer_id: watchCustomerId ?? null }}
contextRef={watchCustomerId ? `customer:${watchCustomerId}` : 'invoice:new'}
/>
</div>
{isCopyMode && copyInitial && (
@@ -17,7 +17,6 @@ import {
FileSearch,
Link2,
Loader2,
MessageCircle,
MoreHorizontal,
Paperclip,
Pencil,
@@ -39,7 +38,6 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
interface TransactionInboxCardProps {
@@ -107,11 +105,6 @@ export default function TransactionInboxCard({
// Attaching underlag is a write: hide the affordance from viewers so they
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
const { canWrite } = useCanWrite()
// The transaction-side entry point to the assistant ("Lena"). openAgentSheet
// hands this specific bank line to the transaction.categorization intent:
// the mirror of "Fråga assistenten" in Dokumentinkorgen, so the user can
// start a booking with the agent from the inbox they actually live in.
const { openAgentSheet, identity } = useAgentSheet()
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
@@ -150,12 +143,6 @@ export default function TransactionInboxCard({
// Unbooked rows are still actionable (match, split, edit, categorize): that
// includes imported bank rows, which are the whole point of the inbox.
const isUnbooked = !transaction.journal_entry_id
// "Fråga [namn]" hands the row to the assistant for categorization/booking.
// Only on unbooked rows (nothing to categorize once it's a verifikat) and
// only after the user has built their agent in /onboarding/agent
// (identity.isVerified): same gate as the FAB / AgentSparkleButton.
const assistantName = identity.displayName?.trim() || 'min assistent'
const showAskAssistant = isUnbooked && identity.isVerified
// ...but only rows the USER created in the app may be deleted. Imported rows
// (bank sync / CSV) are ignore-only: mirrors the server guard in
// DELETE /api/transactions/[id]. See lib/transactions/origin.ts.
@@ -211,14 +198,7 @@ export default function TransactionInboxCard({
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu =
showInvoiceMatchButton || showAskAssistant || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem
const askAssistant = () =>
openAgentSheet({
intentId: 'transaction.categorization',
intentArgs: { transaction_id: transaction.id },
contextRef: `transaction:${transaction.id}`,
})
showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem
// The foldout carries row detail only (actions live on the row: pill + ⋯).
// Rows with nothing to show don't expand at all; classified imported rows
@@ -360,17 +340,6 @@ export default function TransactionInboxCard({
{invoiceMatchLabel}
</DropdownMenuItem>
)}
{showAskAssistant && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
askAssistant()
}}
>
<MessageCircle className="h-4 w-4" />
{`Fråga ${assistantName}`}
</DropdownMenuItem>
)}
{showMatchVoucherItem && (
<DropdownMenuItem
onClick={(e) => {
-1
View File
@@ -5412,7 +5412,6 @@
"tab_accounts": "Chart of accounts",
"new_entry_dialog_title": "New journal entry",
"create_with_assistant": "Create with assistant",
"ask_assistant_handoff": "Let the assistant fill it in?",
"loading_source_voucher": "Loading source voucher...",
"copy_failed_title": "Could not copy journal entry",
"copy_source_missing": "Source voucher not found.",
-1
View File
@@ -5412,7 +5412,6 @@
"tab_accounts": "Kontoplan",
"new_entry_dialog_title": "Ny verifikation",
"create_with_assistant": "Skapa med assistent",
"ask_assistant_handoff": "Hellre låta assistenten fylla i?",
"loading_source_voucher": "Laddar källverifikat...",
"copy_failed_title": "Kunde inte kopiera verifikat",
"copy_source_missing": "Källverifikatet hittades inte.",