diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index a461d731..448b1bb8 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -1,7 +1,16 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react' +import { + X, + Expand, + Shrink, + PanelRightClose, + Eraser, + History, + ChevronLeft, + Loader2, +} from 'lucide-react' import AgentChat, { attachStagedOperations, normalizeStoredMessages, @@ -9,6 +18,7 @@ import AgentChat, { } from './AgentChat' import type { StoredStagedOperation } from '@/types' import type { AgentStatusEvent } from './agent-status' +import ContextChip from './ContextChip' import AgentAvatar from './AgentAvatar' import AgentSessionList from './AgentSessionList' import SandboxAgentPreview from './SandboxAgentPreview' @@ -98,6 +108,10 @@ export default function AgentSheet({ const sheetTitle = intentToTitle(intentId, agentName) const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle const activeConversationId = loaded?.id ?? conversationId + // A resumed thread's stored ref wins: it says what THAT conversation was + // about, which is the whole reason to show this. Falls back to the ref the + // panel was opened with for a thread that has not been persisted yet. + const activeContextRef = loaded ? loaded.contextRef : (contextRef ?? null) const sheetRef = useRef(null) // Monotonic counter so a slow conversation fetch can't clobber a newer pick. const selectSeqRef = useRef(0) @@ -257,7 +271,16 @@ export default function AgentSheet({ )} -

{displayTitle}

+
+

+ {displayTitle} +

+ {/* What this conversation is anchored to. context_ref has been + stored since the first intents shipped and read by nothing, so a + thread resumed days later gave no clue which invoice it was + about. Matters more now the panel sits BESIDE the page. */} + +
{/* Grow/shrink the panel in place: NEVER navigates away, so the user stays on the current page. Hidden on mobile where the sheet diff --git a/components/agent/ChatConversationView.tsx b/components/agent/ChatConversationView.tsx index 3442c708..63f1cc0c 100644 --- a/components/agent/ChatConversationView.tsx +++ b/components/agent/ChatConversationView.tsx @@ -6,6 +6,7 @@ import { ArrowLeft } from 'lucide-react' import AgentChat, { attachStagedOperations, normalizeStoredMessages } from './AgentChat' import type { StoredStagedOperation } from '@/types' import AgentAvatar from './AgentAvatar' +import ContextChip from './ContextChip' import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' import { useCompanyOptional } from '@/contexts/CompanyContext' @@ -59,10 +60,11 @@ export default function ChatConversationView({ alt={identity.displayName ?? 'Assistent'} />
-

{title}

- {contextRef && ( -

{contextRef}

- )} +

{title}

+ {/* Was printing context_ref raw, so the subtitle read + "invoice:5f3a-9c21-...": a database identifier, shown to an + accountant, under the title of their own conversation. */} +
diff --git a/components/agent/ContextChip.tsx b/components/agent/ContextChip.tsx new file mode 100644 index 00000000..f97034b1 --- /dev/null +++ b/components/agent/ContextChip.tsx @@ -0,0 +1,59 @@ +'use client' + +import Link from 'next/link' +import { ArrowUpRight } from 'lucide-react' +import { contextRefToTarget } from '@/lib/agent/intents/route-mapping' +import { cn } from '@/lib/utils' + +/** + * What a conversation is anchored to, as a chip. + * + * `agent_conversations.context_ref` has been written since the first intents + * shipped. The panel ignored it entirely, and /chat printed it raw, so a + * resumed thread's subtitle 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. + * + * Kind-agnostic on purpose (plan seam 8.4): the mapping lives in + * route-mapping.ts, so teaching every surface about a new ref kind is one + * entry in that map and no component change. A kind the map does not know + * yet, a flow run for instance, renders nothing rather than a broken link. + */ +export default function ContextChip({ + contextRef, + className, +}: { + contextRef: string | null | undefined + className?: string +}) { + const target = contextRefToTarget(contextRef) + if (!target) return null + + const shape = + 'inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground' + + // Not every context has a page to go to: the document inbox is an extension + // route, and core cannot hardcode a path that exists only when the extension + // is enabled. Naming it without linking beats a link that 404s. + if (!target.href) { + return ( + + {target.label} + + ) + } + + return ( + + {target.label} + + + ) +} diff --git a/lib/agent/intents/__tests__/route-mapping.test.ts b/lib/agent/intents/__tests__/route-mapping.test.ts index 8a76d456..d2cdcaf8 100644 --- a/lib/agent/intents/__tests__/route-mapping.test.ts +++ b/lib/agent/intents/__tests__/route-mapping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { routeToIntent } from '../route-mapping' +import { routeToIntent, contextRefToTarget } from '../route-mapping' describe('routeToIntent', () => { it('falls back to general.help when pathname is null/undefined/empty', () => { @@ -110,3 +110,65 @@ describe('routeToIntent', () => { expect(out.intentId).toBe('general.help') }) }) + +describe('contextRefToTarget', () => { + /** + * Every shape the app actually writes. Collected by grepping the call sites + * rather than invented, so a new intent that writes a ref this cannot read + * shows up as a chip that never renders, not as a wrong link. + */ + it('resolves each ref the app writes today', () => { + expect(contextRefToTarget('invoice:abc-123')).toEqual({ + label: 'Faktura', + href: '/invoices/abc-123', + }) + expect(contextRefToTarget('supplier_invoice:abc-123')).toEqual({ + label: 'Leverantörsfaktura', + href: '/supplier-invoices/abc-123', + }) + expect(contextRefToTarget('transaction:abc-123')).toEqual({ + label: 'Transaktion', + href: '/transactions', + }) + expect(contextRefToTarget('verifikation:new')).toEqual({ + label: 'Verifikation', + href: '/bookkeeping', + }) + expect(contextRefToTarget('bokslut:overview')).toEqual({ + label: 'Bokslut', + href: '/bookkeeping/year-end', + }) + expect(contextRefToTarget('kpi:översikt')).toEqual({ label: 'Nyckeltal', href: '/kpi' }) + }) + + it('names the document inbox without linking it', () => { + // It is an extension route under /e/[sector]; core cannot hardcode a path + // that only exists when the extension is enabled. + expect(contextRefToTarget('inbox:bulk')).toEqual({ + label: 'Dokumentinkorgen', + href: null, + }) + }) + + it('returns nothing rather than a broken chip for refs it cannot read', () => { + expect(contextRefToTarget(null)).toBeNull() + expect(contextRefToTarget(undefined)).toBeNull() + expect(contextRefToTarget('')).toBeNull() + expect(contextRefToTarget('invoice')).toBeNull() + expect(contextRefToTarget('invoice:')).toBeNull() + expect(contextRefToTarget(':abc')).toBeNull() + expect(contextRefToTarget('flow_run:abc')).toBeNull() + }) + + it('keeps ids that need escaping out of the path', () => { + // Ids reach this from the database, not from the router. + expect(contextRefToTarget('invoice:a b/c')?.href).toBe('/invoices/a%20b%2Fc') + }) + + it('keeps the id intact when it contains a colon', () => { + // Split on the FIRST colon. Asserted on a kind that PUTS the id in the + // href: kpi discards its id, so the same assertion there would pass even + // if the parser dropped everything after the second colon. + expect(contextRefToTarget('invoice:abc:2026')?.href).toBe('/invoices/abc%3A2026') + }) +}) diff --git a/lib/agent/intents/route-mapping.ts b/lib/agent/intents/route-mapping.ts index e4e570f5..74972cff 100644 --- a/lib/agent/intents/route-mapping.ts +++ b/lib/agent/intents/route-mapping.ts @@ -113,3 +113,56 @@ export function routeToIntent(pathname: string | null | undefined): RouteIntent 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 + } +}