bcbe9b0903
* 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>
169 lines
6.4 KiB
TypeScript
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
|
|
}
|
|
}
|