refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)

* refactor(pending): one pending-op-owned preview for chat, /pending and flow views

A staged pending_operation was rendered three separate ways: the /pending
page's OperationPreview switch (8 specialized renderers keyed on
operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers
keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack
that mapped stored operation_types onto 'gnubok_'-prefixed tool names on
hydration. This is the weakest seam ahead of flow-run views (plan seam
8.3): every new operation type had to be taught to render in two places
and silently degraded in the third.

Now there is one owner:

- components/pending-operations/OperationPreview.tsx: the /pending
  renderers moved verbatim, dispatched on operation_type, consumed by
  /pending, ApprovalCard and future flow-run views.
- components/pending-operations/vocabulary.ts: operation labels,
  single-action warnings and the one canonical rejection-category list
  (ApprovalCard's copy was byte-identical and is deleted).
- lib/pending-operations/tool-name.ts: the single translation point
  between bare operation_types and 'gnubok_' tool names, with tests.

toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only
recognized 4 tool names, so a hydrated card for any other operation type
(attach_document_to_transaction, match_transaction_invoice, ...) silently
fell back to a raw generic preview. Hydration now passes the stored
operation_type straight through attachStagedOperations to the card, and
live streamed cards derive it from the event's tool name, so every
operation type keeps its specialized preview on resume.

Per-surface chrome (list row on /pending vs inline chat card) is
deliberately kept: only the preview + vocabulary were the duplicated seam.

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

* chore: drop a stray hunt_title copy rename that rode along

'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was
uncommitted working-tree state from another session, swept into the
extraction commit by git add breadth. It is a product-naming call with
no en.json counterpart and does not belong in this refactor; preserved
in this branch's first commit if it turns out to be wanted.

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

* fix(pending): carry params to chat previews; guard preview amounts

CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders
its DocumentViewButton from params.document_id, which neither chat path
carried: the staged_operation stream event now includes the tool-use
input (the same values the staging tool stored as
pending_operations.params) and hydration selects the params column, so
an attach-document card in chat shows its evidence button live and on
resume. (2) InvoicePreview and CreateTransactionPreview cast amounts
straight into formatCurrency; a payload without one rendered 'NaN kr'.
They now share the same show-the-gap guard the legacy summary already
had.

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

---------

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 16:17:15 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 22eaab82e5
commit ce6efdb3dc
12 changed files with 794 additions and 929 deletions
+18 -15
View File
@@ -145,9 +145,16 @@ interface StagedOperation {
operation_id?: string
risk_level: 'low' | 'medium' | 'high'
message: string
// The originating tool name (e.g. 'gnubok_categorize_transaction'). Lets
// ApprovalCard pick the right structured-preview renderer.
// The originating tool name (e.g. 'gnubok_categorize_transaction'), as
// carried by live staged_operation stream events.
tool_name?: string
// The stored pending_operations.operation_type (e.g.
// 'categorize_transaction'), set on hydrated cards. ApprovalCard prefers
// this for preview dispatch and derives it from tool_name otherwise.
operation_type?: string
// pending_operations.params (the staging tool's input): some previews
// read it (attach_document's DocumentViewButton needs params.document_id).
params?: Record<string, unknown>
// The structured operation preview from the staged envelope. Shape varies
// by tool; ApprovalCard's renderers do the type-narrowing.
preview?: unknown
@@ -686,6 +693,7 @@ export default function AgentChat({
{
tool_use_id: ev.tool_use_id as string,
tool_name: (ev.tool_name as string | undefined) ?? undefined,
params: (ev.params as Record<string, unknown> | undefined) ?? undefined,
operation_id: stagedRaw.operation_id,
risk_level: stagedRaw.risk_level,
message: stagedRaw.message,
@@ -999,6 +1007,8 @@ function MessageBubble({
riskLevel={s.risk_level}
message={s.message}
toolName={s.tool_name}
operationType={s.operation_type}
params={s.params}
preview={s.preview}
periodStatus={s.period_status}
onRequestCorrection={onCorrection}
@@ -1288,18 +1298,6 @@ function prettyToolName(name: string): string {
* on the last assistant message so they read as that turn's proposal, which is
* where they were when the turn streamed.
*/
/**
* `pending_operations.operation_type` stores the bare action name
* ('categorize_transaction'), while the live streamed card carries the MCP tool
* name ('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock
* dispatches on that. Without this, every hydrated card 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.
*/
export function toolNameFor(operationType: string): string {
return operationType.startsWith('gnubok_') ? operationType : `gnubok_${operationType}`
}
export function attachStagedOperations(
messages: ChatMessage[],
staged: StoredStagedOperation[],
@@ -1314,7 +1312,12 @@ export function attachStagedOperations(
risk_level:
op.risk_level === 'high' || op.risk_level === 'medium' ? op.risk_level : 'low',
message: op.title ?? 'Förslag väntar på granskning.',
tool_name: toolNameFor(op.operation_type),
// The stored bare operation_type drives the preview dispatch directly.
// Its predecessor mapped it onto an MCP tool name here ('gnubok_' +
// type) for ApprovalCard's old 4-case tool-name switch, so every other
// hydrated type silently lost its specialized preview.
operation_type: op.operation_type,
params: (op.params ?? undefined) as Record<string, unknown> | undefined,
preview: op.preview_data,
}))
+36 -331
View File
@@ -10,8 +10,10 @@ import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { PendingOperationRejectionCategory } from '@/types'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { OperationPreview } from '@/components/pending-operations/OperationPreview'
import { REJECTION_CATEGORY_LABELS } from '@/components/pending-operations/vocabulary'
import { operationTypeFromToolName } from '@/lib/pending-operations/tool-name'
// Inline approval card for an agent-staged pending_operation.
//
@@ -29,9 +31,11 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
// exactly one approval source of record.
//
// Structured preview: when the staged envelope carries a preview object, we
// render a scannable summary block under the prose. Each common tool has its
// own renderer; unknown tools fall through to a flat key/value list so a new
// tool can ship without an ApprovalCard change.
// render the shared OperationPreview (components/pending-operations), the
// same renderers /pending uses, dispatched on operation_type. Live streamed
// cards carry the MCP tool name; hydrated cards carry the stored
// operation_type directly. Unknown types fall through to the generic
// key/value renderer so a new tool can ship without an ApprovalCard change.
interface PeriodStatus {
period_id?: string | null
@@ -44,6 +48,13 @@ interface Props {
riskLevel: 'low' | 'medium' | 'high'
message: string
toolName?: string
// The stored pending_operations.operation_type. Preferred over deriving it
// from toolName: hydrated cards pass it straight from the DB row, so every
// operation type keeps its specialized preview on resume.
operationType?: string
// pending_operations.params (the staging tool's input). Some previews read
// it: attach_document's DocumentViewButton needs params.document_id.
params?: Record<string, unknown>
preview?: unknown
periodStatus?: PeriodStatus
// Fired after a reject that carries a reason: the chat feeds this synthetic
@@ -53,17 +64,6 @@ interface Props {
type State = 'pending' | 'committing' | 'committed' | 'rejecting' | 'rejected' | 'error'
// Mirrors the granskning (/pending) reject dialog so chat rejections capture
// the same structured feedback. Stored on the op + surfaced to the agent via
// gnubok_get_recent_rejections.
const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, string> = {
wrong_category: 'Fel kategori / konto',
wrong_amount: 'Fel belopp',
duplicate: 'Dubblett',
wrong_period: 'Fel period',
other: 'Annat',
}
// Subset of fields the commit response may return that the success state
// uses to deep-link to the freshly-created artifact. Different
// operation_types return different shapes: only the ones we actually
@@ -86,6 +86,8 @@ export default function ApprovalCard({
riskLevel,
message,
toolName,
operationType,
params,
preview,
periodStatus,
onRequestCorrection,
@@ -117,6 +119,14 @@ export default function ApprovalCard({
const canCommit =
!requiresTextConfirm || confirmText.trim().toLowerCase() === 'godkänn'
// Render key for the shared preview. Hydrated cards pass operation_type
// straight from the pending_operations row; live streamed cards carry the
// MCP tool name, which maps to the bare operation_type by stripping the
// wire prefix. The old dispatch keyed on 4 hardcoded tool names, so every
// other hydrated type silently lost its specialized preview.
const previewOperationType =
operationType ?? (toolName ? operationTypeFromToolName(toolName) : '')
async function handleCommit() {
setState('committing')
setErrorMessage(null)
@@ -341,7 +351,17 @@ export default function ApprovalCard({
{periodStatus && <PeriodBadge status={periodStatus} />}
</div>
<PreviewBlock toolName={toolName} preview={preview} />
{preview != null && typeof preview === 'object' && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
<OperationPreview
op={{
operation_type: previewOperationType,
preview_data: preview as Record<string, unknown>,
params,
}}
/>
</div>
)}
{requiresTextConfirm && (
<div className="space-y-1">
@@ -489,321 +509,6 @@ export default function ApprovalCard({
)
}
// ─── Structured preview block ──────────────────────────────────────────────
//
// Dispatches on tool_name. Adding a new tool: write a specialized renderer
// here. Falling back to the generic flat list is fine for low-volume tools.
interface PreviewBlockProps {
toolName?: string
preview?: unknown
}
function PreviewBlock({ toolName, preview }: PreviewBlockProps) {
if (!preview || typeof preview !== 'object') return null
const p = preview as Record<string, unknown>
if (toolName === 'gnubok_categorize_transaction') {
return <CategorizeTransactionPreview preview={p} />
}
if (toolName === 'gnubok_create_invoice') {
return <CreateInvoicePreview preview={p} />
}
if (toolName === 'gnubok_create_voucher' || toolName === 'gnubok_correct_entry') {
return <VoucherPreview preview={p} />
}
return <GenericPreview preview={p} />
}
// 20 categories from types/index.ts TransactionCategory. Kept inline so the
// component has no cross-module enum import; sync if the type changes.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: 'income_services', label: 'Intäkt: tjänster' },
{ value: 'income_products', label: 'Intäkt: produkter' },
{ value: 'income_other', label: 'Intäkt: övrigt' },
{ value: 'expense_software', label: 'Kostnad: mjukvara' },
{ value: 'expense_equipment', label: 'Kostnad: utrustning' },
{ value: 'expense_office', label: 'Kostnad: kontor' },
{ value: 'expense_travel', label: 'Kostnad: resor' },
{ value: 'expense_marketing', label: 'Kostnad: marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad: konsult/tjänster' },
{ value: 'expense_education', label: 'Kostnad: utbildning' },
{ value: 'expense_representation', label: 'Kostnad: representation' },
{ value: 'expense_consumables', label: 'Kostnad: förbrukning' },
{ value: 'expense_vehicle', label: 'Kostnad: fordon' },
{ value: 'expense_telecom', label: 'Kostnad: telefon/internet' },
{ value: 'expense_bank_fees', label: 'Kostnad: bankavgifter' },
{ value: 'expense_card_fees', label: 'Kostnad: kortavgifter' },
{ value: 'expense_currency_exchange', label: 'Kostnad: valutaväxling' },
{ value: 'expense_other', label: 'Kostnad: övrigt' },
{ value: 'private', label: 'Privat uttag' },
]
function CategorizeTransactionPreview({
preview,
}: {
preview: Record<string, unknown>
}) {
const debit = preview.debit_account as string | undefined
const credit = preview.credit_account as string | undefined
const amount = preview.amount as number | undefined
const currency = (preview.currency as string | undefined) ?? 'SEK'
const category = preview.category as string | undefined
// The exact journal lines the approval will post (net cost line, VAT line,
// gross bank line, SEK), staged by the server since the preview-lines fix.
const lines = (preview.lines as
| {
account_number?: string
debit_amount?: number
credit_amount?: number
description?: string
}[]
| undefined) ?? []
// Legacy summary fields, rendered only for operations staged before the
// preview carried full lines. Pairing the gross amount with the cost
// account reads as an unbalanced entry: never show it when lines exist.
const vatLines = (preview.vat_lines as
| {
account_number?: string
debit_amount?: number
credit_amount?: number
description?: string
}[]
| undefined) ?? []
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
<div className="flex items-baseline gap-3">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
Kategori
</span>
<span className="flex-1 min-w-0 leading-5 text-foreground">
{prettyCategory(category)}
</span>
</div>
{lines.length > 0 ? (
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{lines.map((l, i) => {
const debitAmt = typeof l.debit_amount === 'number' ? l.debit_amount : 0
const creditAmt = typeof l.credit_amount === 'number' ? l.credit_amount : 0
const side: 'D' | 'K' = debitAmt > 0 ? 'D' : 'K'
return (
<Row
key={i}
label={i === 0 ? 'Verifikat' : ''}
value={
<span className="tabular-nums">
<span className="text-muted-foreground">{side} </span>
<strong className="font-medium">{l.account_number ?? '?'}</strong>
<span className="ml-2">
{formatCurrency(side === 'D' ? debitAmt : creditAmt)}
</span>
</span>
}
/>
)
})}
</div>
) : (
<>
{debit && credit && amount != null && (
<Row
label="Bokning"
value={
<span className="tabular-nums">
<span className="text-muted-foreground">D </span>
<strong className="font-medium">{debit}</strong>
<span className="text-muted-foreground"> / K </span>
<strong className="font-medium">{credit}</strong>
<span className="ml-2">{formatCurrency(amount, currency)}</span>
</span>
}
/>
)}
{vatLines.length > 0 && (
<div className="pt-1 mt-1 border-t border-border">
{vatLines.map((v, i) => {
const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0
const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0
const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null
const amount = side === 'D' ? debit : side === 'K' ? credit : 0
return (
<Row
key={i}
label={i === 0 ? 'Moms' : ''}
value={
<span className="tabular-nums">
{side && <span className="text-muted-foreground">{side} </span>}
<span className="text-muted-foreground">{v.account_number ?? ''} </span>
{formatCurrency(amount, currency)}
</span>
}
/>
)
})}
</div>
)}
</>
)}
</div>
)
}
// Pull a human message out of an API error body that may be either a bare
// string ({ error: "…" }) or the structured envelope ({ error: { message } }).
function prettyCategory(value: string | undefined): string {
if (!value) return '(saknas)'
return CATEGORY_OPTIONS.find((o) => o.value === value)?.label ?? value
}
function CreateInvoicePreview({ preview }: { preview: Record<string, unknown> }) {
const customer = preview.customer_name as string | undefined
const subtotal = preview.subtotal as number | undefined
const vatAmount = preview.vat_amount as number | undefined
const total = preview.total as number | undefined
const currency = (preview.currency as string | undefined) ?? 'SEK'
const items =
(preview.items as { description?: string; line_total?: number }[] | undefined) ?? []
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{customer && (
<Row label="Kund" value={<span className="text-foreground">{customer}</span>} />
)}
{items.length > 0 && (
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{items.slice(0, 5).map((it, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums truncate">
<span className="text-muted-foreground">
{it.description ?? '(rad)'}
</span>
{it.line_total != null && (
<span className="ml-2">{formatCurrency(it.line_total, currency)}</span>
)}
</span>
}
/>
))}
{items.length > 5 && (
<p className="pl-[88px] text-muted-foreground/70">
+ {items.length - 5} ytterligare rader
</p>
)}
</div>
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{subtotal != null && (
<Row
label="Netto"
value={
<span className="tabular-nums">{formatCurrency(subtotal, currency)}</span>
}
/>
)}
{vatAmount != null && (
<Row
label="Moms"
value={
<span className="tabular-nums">{formatCurrency(vatAmount, currency)}</span>
}
/>
)}
{total != null && (
<Row
label="Totalt"
value={
<span className="tabular-nums font-medium text-foreground">
{formatCurrency(total, currency)}
</span>
}
/>
)}
</div>
</div>
)
}
function VoucherPreview({ preview }: { preview: Record<string, unknown> }) {
const lines = (preview.lines as { account?: string; debit?: number; credit?: number; description?: string }[] | undefined) ?? []
const date = preview.date as string | undefined
const description = preview.description as string | undefined
if (lines.length === 0) return <GenericPreview preview={preview} />
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{date && <Row label="Datum" value={<span className="tabular-nums">{date}</span>} />}
{description && (
<Row label="Notering" value={<span className="text-foreground">{description}</span>} />
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{lines.map((l, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums">
<strong className="font-medium">{l.account ?? '?'}</strong>
<span className="text-muted-foreground"> · </span>
{l.debit != null && l.debit !== 0 && <span>D {formatCurrency(l.debit)}</span>}
{l.credit != null && l.credit !== 0 && <span>K {formatCurrency(l.credit)}</span>}
{l.description && (
<span className="text-muted-foreground/70 ml-2 truncate">{l.description}</span>
)}
</span>
}
/>
))}
</div>
</div>
)
}
// Fallback: render the top-level key/value pairs from any preview object.
// Strips internal-looking keys, formats numbers tabular, truncates long
// strings. Caps at 8 rows to keep the card compact.
function GenericPreview({ preview }: { preview: Record<string, unknown> }) {
const rows: { key: string; value: string }[] = []
for (const [k, v] of Object.entries(preview)) {
if (rows.length >= 8) break
if (k.startsWith('_') || k === 'period_status') continue
if (v == null) continue
if (typeof v === 'object') continue
rows.push({ key: prettyKey(k), value: String(v) })
}
if (rows.length === 0) return null
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1">
{rows.map((r) => (
<Row key={r.key} label={r.key} value={<span className="tabular-nums">{r.value}</span>} />
))}
</div>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex gap-3 items-baseline">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
{label}
</span>
<span className="flex-1 min-w-0 leading-5">{value}</span>
</div>
)
}
function prettyKey(k: string): string {
// 'customer_name' → 'Customer name' → keep Swedish-leaning by capitalising
// first letter only; lots of preview keys are already short.
const spaced = k.replace(/_/g, ' ')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
function PeriodBadge({ status }: { status: PeriodStatus }) {
if (status.status === 'open') {
return (
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { attachStagedOperations, toolNameFor } from '../AgentChat'
import { attachStagedOperations } from '../AgentChat'
import type { StoredStagedOperation } from '@/types'
/**
@@ -43,31 +43,35 @@ describe('attachStagedOperations', () => {
expect(assistant!.staged![0]).toMatchObject({
operation_id: 'op-1',
risk_level: 'low',
tool_name: 'gnubok_categorize_transaction',
operation_type: 'categorize_transaction',
message: 'Kontering: Circle K, 689 kr',
})
})
it('maps the stored operation_type onto the tool name the preview dispatches on', () => {
// pending_operations stores the bare action name; the live card carries the
// MCP tool name, and ApprovalCard's PreviewBlock keys off that. Getting this
// wrong is invisible in a mock but drops every hydrated card to the flat
// generic preview instead of the journal-line one. These four are the
// operation types that have a specialized renderer.
it('passes the stored operation_type through untranslated, for EVERY type', () => {
// The predecessor mapped operation_type onto an MCP tool name here and
// ApprovalCard's old preview dispatch only recognized 4 of them, so a
// hydrated attach_document_to_transaction card (among others) silently
// fell back to the raw generic preview. The stored bare name now drives
// the dispatch directly; nothing may rewrite it on the way.
for (const t of [
'categorize_transaction',
'create_invoice',
'create_voucher',
'correct_entry',
'attach_document_to_transaction',
'match_transaction_invoice',
'create_customer',
'create_transaction',
]) {
expect(toolNameFor(t)).toBe(`gnubok_${t}`)
const out = attachStagedOperations(
[{ role: 'assistant', text: 'svar' }],
[op({ operation_type: t })],
)
expect(out[0]!.staged![0]!.operation_type).toBe(t)
}
})
it('leaves an already-prefixed operation type alone', () => {
expect(toolNameFor('gnubok_create_voucher')).toBe('gnubok_create_voucher')
})
it('keeps risk level for medium and high, and floors anything unknown to low', () => {
const messages = [{ role: 'assistant' as const, text: 'svar' }]