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
@@ -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' }]