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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
22eaab82e5
commit
ce6efdb3dc
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user