Files
accounted/lib/agent/intents/route-mapping.ts
T
Jakob Wennberg bcbe9b0903 feat(assistant): say what the conversation is anchored to (#1222)
* feat(assistant): say what the conversation is anchored to

agent_conversations.context_ref has been written since the first intents
shipped and read by nothing. The panel ignored it, so a thread resumed three
days later showed the messages with no indication of which invoice or which
bokslut it concerned, even though the row knew. /chat did worse: it printed the
ref raw, so the subtitle under someone's own conversation read
"invoice:5f3a-9c21-...", a database identifier shown to an accountant.

Both surfaces now render the same chip, which names the thing and links to it.
This matters more since the panel docks: sitting beside the page, "what is this
about" is a question the surface should answer rather than the user's memory.

The mapping is a data map in route-mapping.ts, not a switch in a component
(plan seam 8.5), so a flow run's ref renders in both surfaces with no change to
either. A ref it cannot read renders nothing rather than a broken chip.

Two refs deliberately have no link. There is no /transactions/[id] route, so a
transaction chip points at the list. The document inbox is an extension mounted
under /e/[sector], and core must not hardcode a path that exists only when the
extension is enabled, so that one is named without being linked.

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

* fix(assistant): review triage: make the colon test observable, drop an overclaim

The colon-splitting test asserted on a kpi ref, and kpi discards its id, so it
passed even with a parser that dropped everything after the second colon. Moved
to invoice:abc:2026, where the id reaches the href. Verified by switching
indexOf to lastIndexOf and confirming the test fails.

ContextChip's comment said a flow run's ref renders with no change to either
surface. It does not: an unknown kind maps to null and renders nothing until
the map gains an entry. The seam is that adding one is a single entry in one
file, which is what the comment now says.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:16 +02:00

169 lines
6.4 KiB
TypeScript

// Route → intent dispatch for the floating "Fråga [namn]" trigger.
//
// The page-specific buttons ("Granska med assistent" on a supplier invoice
// page, "Fråga om bokslutet" in the year-end wizard) already open the right
// intent because they know what they're attached to. The floating FAB
// previously always opened general.help with just the URL string, so clicking
// it on /invoices/abc-123 gave the agent zero context about that invoice.
//
// This module gives the FAB the same situational awareness: it inspects the
// pathname and picks the intent + intentArgs that the equivalent on-page
// button would have used.
//
// Pure function, no React deps: easy to test, easy to extend with new
// routes as more intents land.
export interface RouteIntent {
intentId: string
intentArgs: Record<string, unknown>
// Persisted on agent_conversations.context_ref so /chat can back-link.
contextRef?: string
// Short suffix appended to the FAB label ("Fråga [namn] om denna faktura").
// null → just "Fråga [namn]".
labelSuffix: string | null
}
const GENERAL_HELP = (route: string | null): RouteIntent => ({
intentId: 'general.help',
intentArgs: { route: route ?? undefined },
labelSuffix: null,
})
export function routeToIntent(pathname: string | null | undefined): RouteIntent {
if (!pathname) return GENERAL_HELP(null)
const segments = pathname.split('/').filter(Boolean)
const [first, second] = segments
// /invoices/new: drafting a brand-new invoice (no entity id yet).
if (first === 'invoices' && second === 'new') {
return {
intentId: 'invoice.draft',
intentArgs: {},
labelSuffix: 'om denna faktura',
}
}
// /invoices/[id] and /invoices/[id]/credit: entity in focus.
if (first === 'invoices' && second && second !== 'new') {
return {
intentId: 'invoice.draft',
intentArgs: { invoice_id: second },
contextRef: `invoice:${second}`,
labelSuffix: 'om denna faktura',
}
}
// /supplier-invoices/[id]: review/attest flow.
// /supplier-invoices/new has no entity to review yet: fall through to
// general.help so the agent doesn't load a heavy Opus intent on an empty
// capture.
if (first === 'supplier-invoices' && second && second !== 'new') {
return {
intentId: 'supplier_invoice.review',
intentArgs: { supplier_invoice_id: second },
contextRef: `supplier_invoice:${second}`,
labelSuffix: 'om denna leverantörsfaktura',
}
}
// /bookkeeping/year-end: the bokslut wizard. Match the page's "Fråga om
// bokslutet" button (bokslut.step) instead of general.help, so the FAB and the
// page button open the SAME assistant here rather than two different ones.
if (first === 'bookkeeping' && second === 'year-end') {
return {
intentId: 'bokslut.step',
intentArgs: { step_id: null },
contextRef: 'bokslut:overview',
labelSuffix: 'om bokslutet',
}
}
// /bookkeeping/[id] (single verifikation) is intentionally NOT mapped
// here: AgentTrigger suppresses the FAB on that route entirely. The
// verifikation editor is a dense regulatory surface and the floating
// pill earned its way off the page.
// /kpi: nyckeltal dashboard. Match the page's "Fråga om nyckeltalen" button
// (kpi.explain) so the FAB and the page button agree on this page.
if (first === 'kpi') {
return {
intentId: 'kpi.explain',
intentArgs: { kpi_key: 'översikt' },
contextRef: 'kpi:översikt',
labelSuffix: 'om nyckeltalen',
}
}
// Note: /transactions and /reports intentionally fall through to general.help.
// Their on-page triggers are entity/view-specific (a transaction row needs a
// transaction_id; the VAT report button needs the selected period/view): the
// FAB only knows the pathname, so page-level help is the honest default there.
// /settings/<panel>[/...]: settings.help captures which panel is active.
// Uses the second segment as panel slug so /settings/invoicing/templates
// still surfaces panel=invoicing.
if (first === 'settings' && second) {
return {
intentId: 'settings.help',
intentArgs: { panel: second },
labelSuffix: null,
}
}
return GENERAL_HELP(pathname)
}
/**
* The reverse direction: what a stored `context_ref` was about.
*
* `agent_conversations.context_ref` has been written since the first intents
* landed and has never been read by anything. Resuming a thread from three days
* ago therefore showed the messages with no indication of which invoice, which
* verifikat, which bokslut it concerned, even though the row knew. That matters
* more now the panel docks beside the page: "what is this conversation anchored
* to" is a question the surface should answer, not the user's memory.
*
* A data map rather than a switch in a component (plan seam 8.5), so flows can
* add their own ref kinds here and every surface picks them up at once.
*/
export interface ContextRefTarget {
/** Human noun for the thing, already in Swedish. */
label: string
/** Where to go to look at it, or null when there is no stable page. */
href: string | null
}
export function contextRefToTarget(ref: string | null | undefined): ContextRefTarget | null {
if (!ref) return null
const separator = ref.indexOf(':')
if (separator <= 0) return null
const kind = ref.slice(0, separator)
const id = ref.slice(separator + 1)
if (!id) return null
switch (kind) {
case 'invoice':
return { label: 'Faktura', href: `/invoices/${encodeURIComponent(id)}` }
case 'supplier_invoice':
return { label: 'Leverantörsfaktura', href: `/supplier-invoices/${encodeURIComponent(id)}` }
// No /transactions/[id] route exists: the list is the only page that can
// show it, so that is where the chip goes rather than a link that 404s.
case 'transaction':
return { label: 'Transaktion', href: '/transactions' }
case 'verifikation':
return { label: 'Verifikation', href: '/bookkeeping' }
case 'bokslut':
return { label: 'Bokslut', href: '/bookkeeping/year-end' }
case 'kpi':
return { label: 'Nyckeltal', href: '/kpi' }
// The document inbox is an extension, mounted under /e/[sector]. Core must
// not import from @/extensions or hardcode a route that only exists when
// the extension is enabled, so this names the context without linking it.
case 'inbox':
return { label: 'Dokumentinkorgen', href: null }
default:
return null
}
}