Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)
* fix(sie-import): accept tab as field separator (Bollbok exports) The SIE 4 spec allows either space or tab between fields, but splitSIELine() only treated space (0x20) as a separator. Bollbok exports tab-separated lines for every record except #RAR, which silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS records — imports appeared empty even though the file was well-formed. Also adds a parser-side diagnostic that emits a warning when raw #IB or #VER lines are present in the input but parsing produced none. The previous silent failure is how this bug stayed hidden; the warning gives the import preview something visible to surface next time. Verified against two real reproducer files (Sean / Erik Hellqvist): erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS. erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers. Both now parse with zero warnings/errors. Tests: + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants). + 4 silent-failure diagnostic-warning tests. All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning Two non-blocking P2 findings from Greptile review on PR #513: 1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'. Latent defect — accountType is unused downstream today, but my tab- separator fix made the quoted-value path reachable. Now routes through parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T") land as 'T'. 2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning fired alongside per-record 'error'-severity issues for malformed #IB / #VER records, producing a misleading hint when the parser had already pinpointed the structural problem. Now suppressed when an error-severity issue with the same tag already exists. Test coverage: + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes. + VER aggregate-warning test now uses #VER lines without { } blocks (silent loss, no per-record error) — the canonical case the diagnostic is designed for. + New suppression test: bare #VER produces per-record errors AND the aggregate warning is absent. 75/75 sie-parser tests pass; 156/156 in lib/import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: agent chat + composer + memory + document extraction In-progress work on this branch beyond the SIE-import fixes: - Specialized accountant agent (composer + intents + chat loop) - Persistent agent_conversations/messages, agent_profiles, agent_memory - /chat surface + /onboarding/agent + /settings/agent-memory - document-extraction extension with status hooks - MCP server staging refactor + new skills (atoms, bank reconciliation, customer onboarding, kreditfaktura) - pending_operations rejection feedback (category + reason) + realtime - TIC company profile cached snapshot on companies - 17 migrations (all additive — see prior conversation analysis) Parked while branch waits for review/merge. Migrations are already applied to prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(tic): migrate company-data client from api-core v1 to Lens v2 Swaps the seven TIC company-data endpoints we call from the api-core paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`). Hard cutover; proxy pattern preserved. Schema shifts handled inside the extension so consumers (TicWorkspace, Step2CompanyDetails) don't need changes: - `/companies/{id}/bank-accounts` now returns Bankgirot only — map to the existing `{ type, accountNumber, bic }` shape, drop terminated. - `/companies/{id}/industries` returns a discriminated array — filter to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior. - `/companies/{id}/phone-numbers` renamed the field to `phoneNumberFormatted` (fall back to `e164PhoneNumber`). - `/companies/{id}/documents` replaces `/financial-report-summaries`; filter `type === 'annualReport'` and read nested `financialReportMetadata` to rebuild the legacy summary shape. - `isCeased` is now a top-level boolean; `activityStatus` is an enum. Translate enum -> 'ceased' for the workspace's existing check. BankID identity flow (id.tic.io) is untouched — separate TIC product. Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io with an `x-api-key` Lens key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tic): expose v2 onboarding & workspace data Adds six new Lens (v2) fetchers on top of the migration that already landed in this branch, surfacing the data through /lookup and /profile. New fetchers in lib/tic-client.ts: - getFiscalYears /companies/{id}/fiscal-years - getAccountingPeriods /companies/{id}/accounting-periods - getPayrolls /companies/{id}/payrolls - getSignatory /companies/{id}/signatory - getRepresentatives /companies/{id}/representatives - getCompanyStatus /companies/{id}/status /lookup gains a fiscalYear field (current fiscal-year configuration) so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult extended with optional fiscalYear; consumers without it keep working. /profile gains five new sections on TICCompanyProfile: - fiscalYear + fiscalYearHistory current + deduped period list - signatory firmateckning descriptions - board + representatives board-composition summary + active officers (positionEnd in future) - payrolls payroll2 array newest-first, with deviation vs annual-report - statuses current+historical status entries with red/yellow/green/neutral color TicWorkspace renders the new data as four cards (Status, Fiscal year + Signatory, Board + Representatives, Payroll history) plus a Badge mapping for the traffic-light status color. Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2 paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus Three small wins that unlock more of the v2 cutover. No new endpoints — the data was already in the snapshot, just not flowing where it should. Step 1 (entity_type) — deep-link path only: - /lookup now returns `legalEntityType` and `registrationDate` (added to CompanyLookupResult). - /onboarding/page.tsx does a server-side /lookup prefetch when ?org_number= is present (BankID picker path), maps "AB"/"EF" to the EntityType enum, and seeds Step 1's radio. Falls through silently for unsupported codes (HB, KB, …) and on TIC errors. - WelcomeOnboarding hydrates ticLookup state from the server prefetch so Step 2's debounced client fetch and Step 3's first-year inference both have data on first render — no flash. Step 3 (is_first_fiscal_year) — every path: - deriveFirstYearDefaults() parses ticLookup.registrationDate and returns { isFirstFiscalYear, firstYearStart } when registered <12 months ago. Step 3's initialData picks it up; the user only confirms the end date. - Settings value wins when present so existing users with a saved choice don't get overridden. Composer prompt: - redactTic allowlist was the bottleneck — it stripped beneficialOwners, signatory, board, representatives, payrolls, statuses, fiscalYear before Opus ever saw the JSON. Existing filterRedundantQuestions ownership logic was effectively dead because the data path was severed. Expanded allowlist to include those v2 sections; kept bankAccounts/ email/phone/fiscalYearHistory/financialReports out (token cost > signal). - SYSTEM_PROMPT now documents each v2 section and the rules Opus should apply: payroll signal switches from "registration.payroll" to "actual payrolls[] filings" (kills the false-positive swedish-payroll selection for newly registered employers); beneficialOwners[] becomes the authoritative ownership source (single owner → FMB modifier; multiple → multi-owner); statuses[] isCeased/red triggers an uncertainty_note. Tests: 4112 unchanged. Build: green. No schema or migration changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): onboarding polish + composer signal fixes from first-run feedback UX: - AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval" escape hatch. The fallback path runs automatically on timeout; the manual skip just teased users into a degraded build. - ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna" (imperative form matches the rest of the steps). - Drop em-dashes from user-visible Swedish strings in AgentOnboarding + ReviewCard (fallback labels, subtitles, placeholder, error message, final CTA). Em-dashes survive in code comments only. - "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced: AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard fallback comment, general.help intent buttonLabel + prompt text. - AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink / TransactionInboxCard ask-button all gated on identity.isVerified. Pre-onboarding users no longer see the floating FAB or per-page Sparkle buttons. AgentSheetProvider.identity gained an isVerified field; (dashboard)/layout.tsx selects agent_profiles.verified_at and passes it through. TIC verksamhetsbeskrivning: - tic/index.ts /profile: /companies/{id}/purposes returns every historical verksamhetsföremål filing. Picking [0] was returning the oldest "äga och förvalta" holding-company boilerplate for companies whose later filings narrowed the purpose ("tillhandahålla företagskrediter och finansiella teknologilösningar"). Sort the array by lastUpdatedAtUtc desc and take the most recent non-empty purpose. Composer banking signal: - loadBankingSummary now reads journal_entry_id alongside description/amount/date and returns per-counterparty `direction` ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked). Aggregate `unbooked_count` accompanies the rollup. - buildUserPrompt emits each counterparty as `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost on sight and tell which counterparties are still open questions. - SYSTEM_PROMPT now explicitly forbids verification questions about counterparties whose direction is unambiguous AND status is 'bokförd'. Should kill the regressions from the first agent build: * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is clearly negative. * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is already categorized. Tests: 4112 unchanged. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon Representation booking: - transaction-categorization prompt now requires the agent to capture participants (name + company) AND purpose before staging a representation categorization. SKV's representationsregler + ML 8 kap require the verifikation to document who attended and what the meeting was about; without that the avdrag is denied and the post should be booked as non-deductible / personalkostnad. - The agent confirms back in plain text (audit trail in the chat), writes the deltagare + syfte to gnubok_remember_fact (long-term), THEN stages. Saknas deltagare/syfte: explicitly tell the user the avdrag won't go through and offer the non-deductible alternative. - Known gap (followup, not this commit): the staged op's journal entry description doesn't yet carry the deltagare text. Until we add a `notes` field to gnubok_categorize_transaction, the audit trail lives in chat + agent_memory only. TransactionInboxCard duplicate attachment indicator: - Drop the FileCheck2 "open document" button from the trailing slot. TransactionAttachmentIndicator (Paperclip) next to the description already opens the underlag on click. Two icons doing the same thing was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment, handleOpenAttachment) and dropped now-unused imports (FileCheck2, useToast). Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent,nav): notes on verifikation + redesigned sidebar Audit-trail notes for representation: - gnubok_categorize_transaction gains an optional `notes` string. Threaded through stagePendingOperation → commitCategorizeTransaction → createTransactionJournalEntry, which now appends notes to the entry's description (capped at 500 chars). The verifikation an external auditor reads now carries deltagare + syfte directly — not just chat history / agent_memory. - transaction-categorization prompt updated: representation flow now REQUIRES the agent to pass deltagare+syfte via the notes parameter. Without it the booking is non-deductible / personalkostnad per SKV. DashboardNav redesign: - Top section: flat, no header — Hem (/chat), Underlag (was Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline badge on /pending shows the count when there are pending ops. - Mid section: four collapsible dropdowns (Försäljning, Inköp, Redovisning, Personal). Each auto-expands when the active route lives inside it. KPI moved from main to Redovisning. Extension nav items (TIC workspace, etc.) fold into Redovisning. - Bottom-left: new account popover (DropdownMenu, opens upward) holding CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces the old top company-switcher card + the bottom Support/Logout block. - Mobile drawer mirrors the new structure: top items as flat list, same four dropdown groups, separate "Tillägg" section when extensions exist, "Mitt konto" section at the bottom. - i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag" ("Documents" in en). New keys: mitt_konto, group_extensions. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): unhide Leverantörer under Inköp The /suppliers entry existed in navItems but was marked hidden — leftover from when the supplier list lived elsewhere in the IA. Removing the hidden flag puts Leverantörer in the Inköp dropdown alongside Leverantörsfakturor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): CompanySwitcher back to top-left, user account moves bottom-left The previous pass collapsed both concepts into the bottom popover. They mean different things: the company is the org context everything below operates against (top-of-sidebar, scannable); the user is the account-holder (bottom-of-sidebar, where settings/logout live). - (dashboard)/layout.tsx: fetch profiles.full_name alongside the existing identity queries; pass userName + userEmail into DashboardNav. - DashboardNav: restore CompanySwitcher at the top of the sidebar (pre-redesign placement). Bottom-left popover trigger now shows the signed-in user's name + single-letter initial (accountInitial helper falls back to email's first char, then "?"). Popover header carries full name + email; items unchanged (Inställningar, Hjälp, Support, Logga ut). CompanySwitcher removed from inside the popover — nested dropdowns were awkward and the top placement is where it belongs. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pending): trim the agent context strip The row-level AgentContextStrip on /pending was rendering the model name (eu.anthropic.claude-sonnet-4-6) and the full atoms array (horizontal/swedish-vat, vertical/konsult-it, …) inline, which made each row 60–80 chars of mostly-the-same metadata. Reviewers never scan that text; they scan amounts and decide approve/reject. Now the strip shows only the conversation deep-link (Konversation #<short id>) — the one piece that's actually useful for diving into context. Model + atoms remain available in agent_metadata for debugging surfaces; they're just not in the list view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): shared ground rules + paragraph breaks after tool calls Two regressions surfaced in real usage. Both are systemic. Shared agent ground rules: - /chat surface (general.help) was happily inventing four-digit BAS account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående moms…") and proposing booking decisions on invoices it had never seen, with no follow-up questions about currency/scope/etc. - transaction-categorization had those rules baked into its prompt; general-help / bokslut-step / invoice-draft / supplier-invoice-review / verifikation-draft / vat-review never inherited them. - Extracted lib/agent/intents/shared-rules.ts with five cross-cutting rules: underlag first (check inbox + ask user to upload to Dokumentinkorgen when missing), ask follow-ups when ambiguous, never write four-digit BAS account numbers in chat (category names only), cite atoms / load skills (don't guess), check counterparty history before proposing. - Injected renderAgentGroundRules() into all six intents above. transaction-categorization left alone — it has more detailed inline rules tied to its specific underlag-flow. Paragraph break after tool calls: - text_delta from the model often resumes after a tool call without a leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget historik hittades…" appended directly). Markdown rendered the concatenation as one paragraph. - AgentChat text_delta handler now inserts \n\n when (a) the buffer ends with text content, (b) the incoming delta starts with text content, (c) at least one tool call has run, and (d) the buffer doesn't already end with a blank line. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nav): default-open dropdown groups; closing is per-user Dropdowns started collapsed which meant first-time users had to open each group to discover what's inside. Inverted the state: default open, user can collapse, active route still forces a group open. - manualExpanded → manualCollapsed (semantics flip) - toggleGroup unchanged externally; flips the bit - isGroupExpanded returns !manualCollapsed[g] || hasActiveChild Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings Three pre-ship quality wins. Rate-limit-safe TIC v2 upgrade: - The /profile endpoint fans out to ~13 Lens calls; the account has a ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across the customer base would blow the budget. - ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still inside the 7-day window is re-fetched only when (a) the caller passes upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only `statuses` key). Gated to the two agent-onboarding call sites — a deliberate, once-per-company action and the only consumer of the v2 sections. Workspace + signup keep the natural 7-day staleness, so the v1→v2 migration is lazy and bounded to companies actually building an agent. Known-counterparty defaults (shared-rules): - Agent now proposes a sensible default for well-known counterparties instead of asking the same question monthly: Almi → lån, Tillväxtverket/ Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring, Bolagsverket → avgift, Försäkringskassan → ersättning, EF private withdrawal → eget uttag. Stated as an assumption the user can correct, not a hard rule — underlag/history still wins. Företagsprofil settings page: - New /settings/agent-profile (Företagsprofil / "Company profile"): view + edit the agent's company profile after onboarding — assistant name + avatar, the profile summary the agent reasons from, and a read-only chip view of loaded specialities (atoms). Backed by the existing GET/PATCH /api/agent/profile. - New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles for the chips (registry is globally-readable reference data). - Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil" / en "Company profile"). Note: /chat already redirects unverified users to / (chat layout guard), and / renders WelcomeGate → /onboarding/agent. No redirect work needed. AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it). Tests: 4112. Build: green. Both new routes compile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup Nav restructure: - "Hem" now points to / (Översikt dashboard) again, not /chat. The agent chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat. Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner). - / restored to render DashboardContent (the Översikt) for built-agent users instead of redirecting to /chat. Users who haven't built their assistant yet still get WelcomeGate (the build-agent checklist); once verified, / shows the dashboard. Chat is reachable anytime via its nav entry. Restored main's dashboard data-fetch; added an agent_profiles verified_at probe to drive the WelcomeGate branch. - i18n: nav.assistant ("Assistent" / "Assistant"). agent_memory dedup (gnubok_remember_fact): - The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd skattskyldighet" on every Vercel categorization), which would bloat agent_memory with paraphrases over months. - Before insert, compare the incoming fact against the 300 most-recent active memories by word-set Jaccard similarity (lowercased, punctuation- stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as already-known: bump its relevance toward the new score + refresh updated_at instead of writing a new row. Embedding-free, zero added latency beyond one bounded SELECT. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting Företagsprofil settings page (the right content this time): - Replaced the agent atoms/summary panel with CompanyProfileView — a read-only "Bolagsuppgifter" view of the cached TIC company snapshot (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank, verksamhet, employees, latest financials, status traffic-lights, fiscal year, firmateckning, företrädare). Server component reads the companies.tic_snapshot column directly — no extension import, stays inside the core-build boundary. - Route renamed /settings/agent-profile → /settings/company-profile. Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles endpoint. "Assistent" nav icon = the agent's chosen avatar: - DashboardNav reads agent identity from AgentSheetProvider and renders the onboarding-chosen avatar for the /chat ("Assistent") entry across desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to the Sparkles glyph pre-onboarding (no avatar yet). Nav cleanup: - Dropped the beta badge from Underlag. - Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of the nav — the same Bolagsuppgifter now lives under Inställningar → Företagsprofil, so it shouldn't appear in two places. Doubled intake greeting fix: - /chat/intake fires an invoke with no conversation_id, then swaps the URL to /chat/[id] the instant the `conversation` event lands — which can beat the greeting being persisted. /chat/[id] then hydrated with 0 messages and, because the auto-fire guard keyed on (id && messages>0), fired a SECOND invoke on the same conversation → two greetings. Guard now keys on conversation-id presence alone: a set id means resume, never bootstrap. Closes the race. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): paragraph-break-after-tool split words mid-stream The earlier "insert \n\n when text resumes after a tool call" heuristic re-evaluated on EVERY text_delta (any delta not starting/ending with whitespace, once a tool had run). Streaming deltas arrive in sub-word chunks, so it injected breaks between fragments of the same word: "minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation". Replace the per-delta heuristic with a consume-once ref: - tool_use sets breakBeforeNextTextRef = true - the next text_delta consumes it: prepends \n\n exactly once (only when the buffer has content, doesn't already end in whitespace, and the delta doesn't start with whitespace), then clears the flag So the break fires once per tool→text resume, never mid-word. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): much shorter replies, representation headcount + VAT cap, dot separator Brevity (system-prompt Svarsformat — affects every reply): - Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with the answer/action, no warm-up ("Här är vad som gäller…"), don't derive VAT in prose, don't restate what the approval card shows, one question at a time. The agent was writing textbook-length essays. Representation rule now in shared-rules (so verifikation-draft, vat-review, etc. all get it — previously only transaction-categorization had it, which is why the verifikation flow guessed 25% VAT and skipped the cap): - Require ANTAL deltagare (headcount), not just one name — the moms deduction is per person (underlag cap 300 kr/person ex moms). - Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume 25%. - Meal representation isn't income-tax deductible (post-2017); whole cost booked as non-deductible representation. Verifikation description separator: - createTransactionJournalEntry appended notes with an em-dash ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a middle dot " · ". journal_entries has no separate notes column — the description IS the BFL verifikationstext / audit field, so deltagare + syfte correctly live there. Tests: 4112. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning From first-look feedback on the Företagsprofil page: - Status: dropped the coloured traffic-light badges (red/yellow/green). Per the design system semantic colour is data-only, never chrome, so status now renders as plain label + date. Also filtered to dated entries only — Bolagsverket emits flags like "Har aldrig varit verksam" with no date that read as noise next to the real status. Ceased status gets muted destructive text (the one chrome colour the system keeps). - Firmateckning: the source text carries ">" list markers and crams several rules onto one line, and repeats "Firman tecknas av styrelsen" across rows. cleanSignatory() strips the markers, normalises whitespace, splits run-on "Firman tecknas …" clauses onto separate lines, and the render dedupes — so each rule reads as its own sentence. Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): inbox items expose all terminal links + processed flag The Eatnam receipt was booked against its bank transaction (so the inbox row had matched_transaction_id + created_journal_entry_id set), yet the agent reported it as loose/unmatched and a duplicate risk. Root cause: gnubok_list_inbox_items only selected and returned matched_supplier_id + created_supplier_invoice_id — the supplier-invoice path. The transaction-match and direct-journal-entry paths were invisible, so any receipt cleared via /transactions looked unprocessed. - list_inbox_items now selects + returns matched_transaction_id and created_journal_entry_id alongside the supplier fields, plus a derived `processed` boolean (true when ANY of the three terminal links is set). - New unprocessed_only=true input filters to items with no terminal link — the "what still needs handling" view that prevents the agent from flagging already-booked docs as duplicates. (Fetches a wider window then filters client-side so limit applies post-filter.) - Description updated to document the processed semantics, within the 280-char tool-description budget. The DB linkage itself already worked: /transactions attach-document sets matched_transaction_id, and commitCategorizeTransaction stamps created_journal_entry_id. This was purely a read/surface gap. Tests: 4112 (+ MCP description guard). Build: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): repair stage-but-never-commit tools + consolidate tool surface - post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member. - Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration. - import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count. - batch-match-invoices passed user.id where companyId was expected (silently matched zero). - VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): load skill atom bodies from the DB so they survive the build Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead: - Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard. - Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms). - The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors - Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate. - FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page). - Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users. - Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status. - /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(pending): declutter the review queue rows + header Fold the conversation deep-link onto the actor label (drop the separate "Konversation #xxxx" strip and its icon), hide the quick-pick when there's only one operation type (it duplicated "Markera alla"), and drop the "(0)" from the disabled bulk-approve button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(vat): enhance VAT handling by integrating document validation and improving error messaging * feat(settings): add assistant knowledge surface + consolidate settings tabs Expose the agent's skill atoms (agent_atom_registry) in a read-only surface beside the existing memory view, and tighten the settings tab bar from 14 to 10 tabs. - New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags which are active for the company from agent_profiles, and lazy-loads each SKILL.md body on expand. - New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills); /settings/agent-memory and /settings/agent-skills redirect into it. - Merge Företagsprofil (TIC snapshot) into the Företag tab via CompanyProfileSection; /settings/company-profile redirects. - Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the callback toast now target /settings/tax; /settings/skatteverket redirects. - Drop the Säkerhetsbackup tab (already under Importera/Exportera). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(inbox): keep booked underlag out of the unmatched queue + widen match window - categorize: after booking an inbox underlag onto a verifikat, backfill the inbox row's matched_transaction_id + created_journal_entry_id so it stops showing as unmatched (mirrors the /attach-document paperclip path). - TransactionMatchPicker: bias the candidate window forward (60d before → 180d after the invoice date) so late payments aren't dropped before scoring, and widen the ranking date tolerance to 120d so the true match floats to the top instead of collapsing to "Svag match". Fix "okatigoriserade" typo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: bundle in-progress branch work + agent onboarding chat optimizations Captures the uncommitted work-in-progress on this branch so it lives on the remote. Heterogeneous changeset — bundled as one commit since the work was already entangled across files. Headline change in this commit (from this session): - Remove the double interview in agent onboarding. Phase B's verification- question form stepper is gone — the Phase C chat (onboarding.intake) now owns the entire interview and reads the composer's verification_questions server-side as its question bank. - ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with value-first ordering: profile + "vad jag kan hjälpa dig med" + facts + optional seed note. CTA reads "Möt {namn}" to signal the chat follows. - ChatIntakeStarter handoff subcopy updated to match reality (assistant greets first; user can leave anytime). - Stamp agent_profiles.intake_completed_at server-side in app/api/agent/invoke/route.ts on the first user-typed reply in any onboarding.intake conversation (idempotent IS NULL guard, best-effort). Closes the previously dead-write column and unlocks the opportunistic- follow-up hook the migration anticipated. Plus in-progress branch work being carried forward (not introduced here): agent runtime + intent prompts, composer + atom-discovery scripts, MCP server skills surface, onboarding flow components, dashboard/inbox tweaks, two new agent_atom_registry migrations, additional agent-chat tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware and picks the right intent per page, so duplicating it as inline page- header buttons and empty-state links is noise. Removed: - EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång") + the AgentHelpLink component + agent_default_name/agent_ask_link i18n keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions. - AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi (kpi.explain) page headers. The FAB stays — when verified, it appears on those routes and routes to the right intent automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): gate the last two ungated "Fråga assistenten" affordances Both surfaces previously called useAgentSheet directly without checking identity.isVerified, so they appeared pre-onboarding (everywhere else the FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at). - Settings page header: remove the "Fråga {namn}" pill entirely. The FAB covers /settings routes route-aware (settings.help) — no need for a duplicate inline trigger. - Invoice inbox transaction picker: hide the "Fråga assistenten" button when the agent isn't built. Done at the parent (InvoiceInboxWorkspace) by passing onAskAssistant only when identity.isVerified is true; the child renders the button only when the callback is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice - TIC: collapse the company lookup from 6 endpoint calls to 1 (search-public already exposes sniCodes, bank accounts, emails, phones, and registration flags). Derive fiscal-year MM-DD from mostRecentFinancialSummary; newly-registered companies fall through to the client's first-year defaults. - Onboarding: BankID picker no longer auto-provisions companies. Every pick routes through the wizard with orgnr (and entity_type via the CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding reuses CompanyLookupResult and adds a defensive top-level catch so server-action errors surface to the UI instead of being redacted. - Agent composer: loadUserDirectorship() checks BankID CompanyRoles for a director-like position (ceo/boardMember/chairman/externalSignatory, active) before the narrative uses second-person ownership voice ("Du driver…"); unknown users get neutral third-person voice so we never put ownership words in the user's mouth. Tests cover loadUserDirectorship, narrative voice, tic-fetch path, onboarding page, and updated TIC client + lookup/profile suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers The 5s TIC fetch timeout aborted client-side before the upstream Lens fan-out (~13 calls) could complete, but the in-flight upstream calls still counted against quota — actions.ts already documents ~530 wasted calls from this in May. Same bug still applied to the agent-onboarding stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so deliberate wait-screen callers (agent onboarding stream) can run with 10s while background/dev callers stay on the conservative 5s default. Page-level server fetch (page.tsx) intentionally stays at 5s to avoid blocking TTFB without a visible progress affordance. Backfill migration mirrors `company_settings.org_number` to `companies.org_number` for the 105 cases where it's safe (after dedup + conflict filtering). 56 of those are on active companies — unblocks duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC API calls — pure data move. Idempotent. Also sweeps a pre-existing SSRF guard on the stream route's origin derivation that was sitting unstaged in the working tree — it lives in the same diff hunks as the TIC budget change and couldn't be split cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip: bundle in-progress branch work Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully backed up to origin. Not reviewed in detail — committed as-is to preserve working state alongside the TIC fixes in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta Adds a Beta badge next to the assistant-setup heading on the dashboard banner, dashboard inline card, and onboarding checklist row. Also drops the stale "Gratis i 30 dagar" subline from the dashboard card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions PR #584 went red on three things: 1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on 'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing both literals. Add them to the union. 2. Supabase preview: migration version 20260526120000 collided with main's newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql. Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of 20260526120100_restvardeavskrivning so ordering is preserved. 3. 20260527170000 was used twice on this branch (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second to 20260527170100 so the pair stays orderable and Supabase doesn't choke on the duplicate schema_migrations PK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): reword comment so core-only guard stops flagging it The "Check no core imports from extensions" step greps for the literal \`from '@/extensions/\` across lib/, app/api/, components/. A comment in lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to explain *why* the file does a self-fetch instead of importing the TIC extension directly — which the grep matched even though no actual import exists. Rewrite the line to keep the same meaning without the literal pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Emil
parent
a9b43ebeb7
commit
f53725b20a
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAvatarUrl } from './avatars'
|
||||
|
||||
interface Props {
|
||||
avatarId: string | null | undefined
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
// Renders the agent's avatar — either the chosen dicebear SVG from the
|
||||
// AVATAR_OPTIONS registry, or a fallback MessageCircle glyph on a dark circle
|
||||
// when no avatar is set yet (free tier / older profiles).
|
||||
//
|
||||
// `next/image` is intentionally NOT used: avatars are tiny remote SVGs from
|
||||
// the dicebear CDN, and adding the domain to next.config just to render a
|
||||
// 28px image is overkill. Browser caches the SVG forever via the seed-keyed
|
||||
// URL.
|
||||
export default function AgentAvatar({ avatarId, size = 'sm', className, alt }: Props) {
|
||||
const url = getAvatarUrl(avatarId)
|
||||
const dim = SIZES[size]
|
||||
const altText = alt ?? 'Avatar'
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full bg-foreground text-background shrink-0',
|
||||
dim.box,
|
||||
className,
|
||||
)}
|
||||
aria-label={altText}
|
||||
>
|
||||
<MessageCircle className={dim.icon} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={altText}
|
||||
className={cn(
|
||||
'rounded-full shrink-0 bg-secondary object-cover',
|
||||
dim.box,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
xs: { box: 'h-5 w-5', icon: 'h-2.5 w-2.5' },
|
||||
sm: { box: 'h-8 w-8', icon: 'h-3.5 w-3.5' },
|
||||
md: { box: 'h-10 w-10', icon: 'h-4 w-4' },
|
||||
lg: { box: 'h-14 w-14', icon: 'h-5 w-5' },
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Send,
|
||||
Square,
|
||||
RotateCw,
|
||||
BookmarkCheck,
|
||||
BookmarkX,
|
||||
Check,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ApprovalCard from './ApprovalCard'
|
||||
|
||||
// Reusable chat surface — used both inside the right-hand AgentSheet and on
|
||||
// the full-page /chat route. Owns:
|
||||
// * Message state (rendered list)
|
||||
// * NDJSON stream consumer for /api/agent/invoke
|
||||
// * Markdown rendering + tool-call badges + approval cards
|
||||
// * Input form
|
||||
//
|
||||
// What it does NOT own:
|
||||
// * Sheet chrome (title bar, close button) — wrapper's job
|
||||
// * Page layout / sidebar — wrapper's job
|
||||
//
|
||||
// Two modes:
|
||||
// * Fresh start (initialMessages empty, initialConversationId null):
|
||||
// mount fires the first POST /api/agent/invoke with intent_args, which
|
||||
// creates a new conversation_id and streams the intent's templated first
|
||||
// turn back.
|
||||
// * Resume (initialMessages + initialConversationId supplied): hydrate from
|
||||
// DB rows, skip the first-turn template, just await user input.
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
// Extended-thinking reasoning, streamed token-by-token via reasoning_delta.
|
||||
// Shown in a collapsible "Tänkte…" block. Stream-time only — not hydrated.
|
||||
reasoning?: string
|
||||
// Tool-use chips. `completed` flips true when the matching `tool_result`
|
||||
// event arrives so the UI can swap the pulsing dot for a static check
|
||||
// instead of yanking the chip out from under the user. Hydrated messages
|
||||
// are always completed (they would not have been persisted otherwise).
|
||||
toolCalls?: { tool_use_id: string; name: string; completed?: boolean }[]
|
||||
staged?: StagedOperation[]
|
||||
memoryEvents?: MemoryEvent[]
|
||||
}
|
||||
|
||||
// Emitted by run-turn.ts after a successful remember_fact / forget_fact call
|
||||
// so the chat surface can render a quiet "Sparat som minne: …" chip below the
|
||||
// assistant message. Stream-time only — not hydrated on /chat resume.
|
||||
interface MemoryEvent {
|
||||
tool_use_id: string
|
||||
action: 'remembered' | 'forgotten'
|
||||
memory_id: string
|
||||
memory_kind?: 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface StagedOperation {
|
||||
tool_use_id: string
|
||||
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.
|
||||
tool_name?: string
|
||||
// The structured operation preview from the staged envelope. Shape varies
|
||||
// by tool; ApprovalCard's renderers do the type-narrowing.
|
||||
preview?: unknown
|
||||
// Period state at the operation's effective date. Surfaced as a small
|
||||
// badge — open|locked|closed.
|
||||
period_status?: {
|
||||
period_id?: string | null
|
||||
status: 'open' | 'locked' | 'closed'
|
||||
lock_date?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgentChatProps {
|
||||
intentId: string
|
||||
intentArgs?: Record<string, unknown>
|
||||
contextRef?: string
|
||||
initialMessages?: ChatMessage[]
|
||||
initialConversationId?: string | null
|
||||
onConversationIdChange?: (id: string) => void
|
||||
// Fires after the first turn_complete in a fresh-start session — used by
|
||||
// bootstrap starters (ChatNewStarter, ChatIntakeStarter) to defer the URL
|
||||
// swap until streaming is done. Swapping on the early `conversation`
|
||||
// event unmounts the component mid-stream and the assistant reply is
|
||||
// never persisted before /chat/[id] hydrates.
|
||||
onFirstTurnComplete?: (id: string) => void
|
||||
// Optional vertical padding override — defaults to py-6 inside the
|
||||
// scroller. The full-page chat uses py-8 for breathing room.
|
||||
scrollerClassName?: string
|
||||
// Pre-baked first user message. When set, the mount effect fires the first
|
||||
// turn with this verbatim (skipping the intent's promptTemplate path) AND
|
||||
// renders it as a user-side message in the timeline. Used by /chat empty
|
||||
// state suggestion chips.
|
||||
seedUserMessage?: string
|
||||
}
|
||||
|
||||
export default function AgentChat({
|
||||
intentId,
|
||||
intentArgs,
|
||||
contextRef,
|
||||
initialMessages,
|
||||
initialConversationId,
|
||||
onConversationIdChange,
|
||||
onFirstTurnComplete,
|
||||
scrollerClassName,
|
||||
seedUserMessage,
|
||||
}: AgentChatProps) {
|
||||
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
|
||||
// Track whether the first-turn callback has fired so the bootstrap
|
||||
// starters get exactly one notification even if a turn fires before
|
||||
// the conversation_id event (defensive — order shouldn't matter).
|
||||
const firstTurnFiredRef = useRef(false)
|
||||
const conversationIdRef = useRef<string | null>(initialConversationId ?? null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? [])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Active turn's controller, kept in a ref (not state) so the stop button
|
||||
// can read it without re-renders churning the AbortController identity.
|
||||
const activeControllerRef = useRef<AbortController | null>(null)
|
||||
// Set when a tool call runs; consumed by the NEXT text_delta to insert a
|
||||
// single paragraph break so post-tool narration starts on its own line.
|
||||
// A ref (not state) because it must be read/cleared synchronously inside
|
||||
// the streaming loop without triggering re-renders — and because the
|
||||
// break must fire exactly once per resume, not on every delta.
|
||||
const breakBeforeNextTextRef = useRef(false)
|
||||
// Fresh-start vs. resume — only kick off the first turn when we have neither
|
||||
// a hydrated conversation nor pre-existing messages. React 19 Strict Mode
|
||||
// runs effects twice in dev; the first call's cleanup aborts its fetch, the
|
||||
// second completes. The invoke endpoint is idempotent on first-turn when
|
||||
// no conversation_id is supplied (it creates a fresh row each time, so a
|
||||
// transient duplicate just orphans the first conversation — harmless).
|
||||
useEffect(() => {
|
||||
// Only bootstrap a first turn on a genuine fresh start — i.e. NO
|
||||
// conversation id. A present id means the conversation already exists
|
||||
// (or is mid-creation elsewhere), so we must not fire an invoke.
|
||||
//
|
||||
// Why id-alone, not id+messages: the intake flow fires an invoke with
|
||||
// no conversation_id, then swaps the URL to /chat/[id] the moment the
|
||||
// `conversation` event lands — which can beat the greeting being
|
||||
// persisted. /chat/[id] then hydrates with 0 messages. If we keyed the
|
||||
// guard on messages.length we'd auto-fire a SECOND invoke against the
|
||||
// same conversation and render two greetings. Keying on id presence
|
||||
// alone closes that race.
|
||||
const hasResumeState = !!initialConversationId
|
||||
if (hasResumeState) return
|
||||
|
||||
// Seed-message path: render the user's pre-baked starter in the timeline
|
||||
// and send it as the first turn's user_message (skips intent.capture +
|
||||
// promptTemplate). Empty seed runs the normal capture-driven flow.
|
||||
if (seedUserMessage && seedUserMessage.trim().length > 0) {
|
||||
setMessages([{ role: 'user', text: seedUserMessage.trim() }])
|
||||
void startTurn({
|
||||
conversationId: initialConversationId ?? null,
|
||||
userMessage: seedUserMessage.trim(),
|
||||
})
|
||||
} else {
|
||||
void startTurn({
|
||||
conversationId: initialConversationId ?? null,
|
||||
userMessage: '',
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
activeControllerRef.current?.abort()
|
||||
activeControllerRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Autoscroll on new content — but only if the user was already pinned to the
|
||||
// bottom. Scrolling up to re-read a long answer should NOT yank the user
|
||||
// back on every streaming token. Threshold accounts for sub-pixel rounding.
|
||||
const wasAtBottomRef = useRef(true)
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current
|
||||
if (!el) return
|
||||
const onScroll = () => {
|
||||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
wasAtBottomRef.current = distance < 64
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => el.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current
|
||||
if (!el) return
|
||||
if (wasAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
async function startTurn(body: {
|
||||
conversationId: string | null
|
||||
userMessage: string
|
||||
// When true, the user_message is persisted for agent context but flagged
|
||||
// hidden so it never renders as a user bubble (e.g. a rejection correction
|
||||
// fed back into the chat). The caller also skips adding a visible bubble.
|
||||
hidden?: boolean
|
||||
}): Promise<void> {
|
||||
// Abort any in-flight turn before starting a new one — guards against
|
||||
// racing two turns when handleSend is triggered twice fast.
|
||||
activeControllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
activeControllerRef.current = controller
|
||||
const signal = controller.signal
|
||||
|
||||
// Reset the post-tool paragraph-break ref at the start of every turn so a
|
||||
// prior turn that ended on tool_use can't leak a leading "\n\n" into the
|
||||
// next turn's first text delta.
|
||||
breakBeforeNextTextRef.current = false
|
||||
|
||||
setStreaming(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/agent/invoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
intent_id: intentId,
|
||||
intent_args: intentArgs,
|
||||
context_ref: contextRef,
|
||||
conversation_id: body.conversationId,
|
||||
user_message: body.userMessage,
|
||||
user_message_hidden: body.hidden ?? false,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte nå assistenten.')
|
||||
setStreaming(false)
|
||||
activeControllerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
// Surface the server's friendly Swedish message (rate-limit sentence,
|
||||
// "ingen aktiv firma", etc.) rather than a raw "HTTP 429".
|
||||
let msg = 'Kunde inte nå assistenten. Försök igen om en stund.'
|
||||
try {
|
||||
const errBody = await response.json()
|
||||
if (errBody && typeof errBody.error === 'string' && errBody.error.trim()) {
|
||||
msg = errBody.error
|
||||
}
|
||||
} catch {
|
||||
// non-JSON / empty body — keep the generic message
|
||||
}
|
||||
setErrorMessage(msg)
|
||||
setStreaming(false)
|
||||
activeControllerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Assistant bubble is appended LAZILY — only when the first event that
|
||||
// produces user-visible content arrives. Eagerly appending here would
|
||||
// leave an empty bubble dangling if the stream errors or yields zero
|
||||
// events (e.g. proxy hiccup) before any content.
|
||||
let assistantBubbleAppended = false
|
||||
const ensureAssistantBubble = () => {
|
||||
if (assistantBubbleAppended) return
|
||||
assistantBubbleAppended = true
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: '' }])
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let nl: number
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (!line) continue
|
||||
// Guard JSON.parse per line — a malformed line (proxy split,
|
||||
// partial buffer flush) must NOT abort the entire stream. Skip and
|
||||
// continue; the next well-formed line will be handled normally.
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
// First user-visible event lazily mounts the bubble. `conversation`
|
||||
// is a metadata event with no visible payload so it does not.
|
||||
const ev = parsed as { kind?: string } | null
|
||||
if (
|
||||
ev &&
|
||||
typeof ev.kind === 'string' &&
|
||||
ev.kind !== 'conversation' &&
|
||||
ev.kind !== 'turn_complete'
|
||||
) {
|
||||
ensureAssistantBubble()
|
||||
}
|
||||
handleEvent(parsed)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Streamen avbröts.')
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
// Guard against an aborted prior turn clobbering the new turn's
|
||||
// streaming flag — only the active controller may reset the state.
|
||||
if (activeControllerRef.current === controller) {
|
||||
setStreaming(false)
|
||||
activeControllerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
activeControllerRef.current?.abort()
|
||||
activeControllerRef.current = null
|
||||
setStreaming(false)
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
// Re-run the last user message and let the agent produce a fresh
|
||||
// response. UI truncates back to the last user message; DB rows are
|
||||
// append-only, so the previous assistant turn stays in agent_messages
|
||||
// (audit trail intact). The new turn is appended on top.
|
||||
let lastUserIdx = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastUserIdx === -1) return
|
||||
const userMsg = messages[lastUserIdx]
|
||||
setMessages(messages.slice(0, lastUserIdx + 1))
|
||||
void startTurn({ conversationId, userMessage: userMsg.text })
|
||||
}
|
||||
|
||||
// Fired after the user rejects a proposal with a reason. The rejection is
|
||||
// already recorded server-side; here we feed the correction back as a HIDDEN
|
||||
// user turn so the agent re-proposes inline — no synthetic user bubble (we
|
||||
// don't add a user row, and the turn is persisted hidden).
|
||||
function handleCorrection(correctionMessage: string) {
|
||||
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
|
||||
}
|
||||
|
||||
function handleEvent(event: unknown) {
|
||||
if (typeof event !== 'object' || event === null) return
|
||||
const ev = event as { kind: string } & Record<string, unknown>
|
||||
|
||||
switch (ev.kind) {
|
||||
case 'conversation': {
|
||||
const id = ev.conversation_id as string
|
||||
setConversationId(id)
|
||||
conversationIdRef.current = id
|
||||
onConversationIdChange?.(id)
|
||||
break
|
||||
}
|
||||
case 'reasoning_delta':
|
||||
// Extended-thinking tokens. Accumulate onto the active assistant
|
||||
// message; the ReasoningBlock renders them live, then collapses.
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
reasoning: (m.reasoning ?? '') + (ev.delta as string),
|
||||
})),
|
||||
)
|
||||
break
|
||||
case 'text_delta':
|
||||
// Insert a paragraph break ONCE when text resumes after a tool
|
||||
// call, so post-tool narration starts on its own line instead of
|
||||
// gluing onto the previous sentence ("kategoriseras.Inget historik").
|
||||
// breakBeforeNextTextRef is set by tool_use/tool_result and consumed
|
||||
// here on the first delta. Critically, the break is applied to the
|
||||
// delta exactly once — NOT re-evaluated per delta, which previously
|
||||
// split mid-word ("minnes\n\nno\n\nterna") because streaming deltas
|
||||
// arrive in sub-word chunks.
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => {
|
||||
let delta = ev.delta as string
|
||||
if (breakBeforeNextTextRef.current) {
|
||||
breakBeforeNextTextRef.current = false
|
||||
// Only add the break if the buffer has content and doesn't
|
||||
// already end with whitespace, and the delta isn't itself
|
||||
// starting with a newline.
|
||||
if (m.text.length > 0 && !/\s$/.test(m.text) && !/^\s/.test(delta)) {
|
||||
delta = '\n\n' + delta
|
||||
}
|
||||
}
|
||||
return { ...m, text: m.text + delta }
|
||||
}),
|
||||
)
|
||||
break
|
||||
case 'tool_use':
|
||||
// Next text_delta should open a fresh paragraph.
|
||||
breakBeforeNextTextRef.current = true
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
toolCalls: [
|
||||
...(m.toolCalls ?? []),
|
||||
{ tool_use_id: ev.tool_use_id as string, name: ev.name as string },
|
||||
],
|
||||
})),
|
||||
)
|
||||
break
|
||||
case 'tool_result':
|
||||
// Mark the matching chip as completed instead of removing it. Tools
|
||||
// run in 100–500 ms so yanking the chip the moment it finishes makes
|
||||
// the indicator feel like a flicker rather than a record of what
|
||||
// happened. Leaving the chip in place (with a static check dot,
|
||||
// no pulse) gives the user a stable trace of which calls ran.
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
toolCalls: m.toolCalls?.map((tc) =>
|
||||
tc.tool_use_id === (ev.tool_use_id as string) ? { ...tc, completed: true } : tc,
|
||||
),
|
||||
})),
|
||||
)
|
||||
break
|
||||
case 'memory_captured': {
|
||||
const evt: MemoryEvent = {
|
||||
tool_use_id: ev.tool_use_id as string,
|
||||
action: (ev.action as 'remembered' | 'forgotten') ?? 'remembered',
|
||||
memory_id: ev.memory_id as string,
|
||||
memory_kind: ev.memory_kind as MemoryEvent['memory_kind'],
|
||||
content: ev.content as string | undefined,
|
||||
}
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
memoryEvents: [...(m.memoryEvents ?? []), evt],
|
||||
// Drop the matching tool_use chip — the richer memory chip
|
||||
// replaces it and they convey the same event.
|
||||
toolCalls: m.toolCalls?.filter((tc) => tc.tool_use_id !== evt.tool_use_id),
|
||||
})),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'staged_operation': {
|
||||
const stagedRaw = ev.staged as {
|
||||
operation_id?: string
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
message: string
|
||||
preview?: unknown
|
||||
period_status?: {
|
||||
period_id?: string | null
|
||||
status: 'open' | 'locked' | 'closed'
|
||||
lock_date?: string | null
|
||||
}
|
||||
}
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
staged: [
|
||||
...(m.staged ?? []),
|
||||
{
|
||||
tool_use_id: ev.tool_use_id as string,
|
||||
tool_name: (ev.tool_name as string | undefined) ?? undefined,
|
||||
operation_id: stagedRaw.operation_id,
|
||||
risk_level: stagedRaw.risk_level,
|
||||
message: stagedRaw.message,
|
||||
preview: stagedRaw.preview,
|
||||
period_status: stagedRaw.period_status,
|
||||
},
|
||||
],
|
||||
})),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
setErrorMessage(ev.message as string)
|
||||
break
|
||||
case 'turn_complete': {
|
||||
if (!firstTurnFiredRef.current && conversationIdRef.current) {
|
||||
firstTurnFiredRef.current = true
|
||||
onFirstTurnComplete?.(conversationIdRef.current)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
setInput('')
|
||||
setMessages((prev) => [...prev, { role: 'user', text }])
|
||||
await startTurn({ conversationId, userMessage: text })
|
||||
}
|
||||
|
||||
// Auto-resize the textarea as the user types. Capped at 8rem (~128px) so
|
||||
// the input bar never devours the message list. Shrinks back when the
|
||||
// user clears or backspaces.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
const max = 128
|
||||
el.style.height = `${Math.min(el.scrollHeight, max)}px`
|
||||
}, [input])
|
||||
|
||||
// Index of the last assistant bubble — used to gate the Regenerate
|
||||
// affordance so it only appears on the latest response.
|
||||
let lastAssistantIdx = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') {
|
||||
lastAssistantIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full min-h-0">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto px-5 py-6 space-y-6',
|
||||
scrollerClassName,
|
||||
)}
|
||||
>
|
||||
{messages.length === 0 && streaming && <SkeletonBubble />}
|
||||
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className="animate-slide-up">
|
||||
<MessageBubble
|
||||
message={m}
|
||||
streamingTail={streaming && i === messages.length - 1}
|
||||
showRegenerate={
|
||||
!streaming &&
|
||||
i === lastAssistantIdx &&
|
||||
m.role === 'assistant' &&
|
||||
m.text.length > 0
|
||||
}
|
||||
onRegenerate={handleRegenerate}
|
||||
onCorrection={handleCorrection}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
// padding-bottom = base 1rem + safe-area-inset-bottom on phones so
|
||||
// the iOS home indicator / Android gesture bar doesn't overlap the
|
||||
// input.
|
||||
className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Skriv din fråga…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring max-h-32 overflow-y-auto"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{streaming ? (
|
||||
// Stop button while the agent is producing tokens — biggest
|
||||
// pain killer. Aborts the in-flight fetch + reader.
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
aria-label="Avbryt"
|
||||
title="Avbryt strömning"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={input.trim().length === 0}
|
||||
aria-label="Skicka"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Enter att skicka · Shift+Enter för ny rad
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
streamingTail,
|
||||
showRegenerate,
|
||||
onRegenerate,
|
||||
onCorrection,
|
||||
}: {
|
||||
message: ChatMessage
|
||||
streamingTail: boolean
|
||||
showRegenerate?: boolean
|
||||
onRegenerate?: () => void
|
||||
onCorrection?: (message: string) => void
|
||||
}) {
|
||||
const isUser = message.role === 'user'
|
||||
// An assistant turn that contains only tool calls (no text, no streaming
|
||||
// tail) is the LLM's "I want to call tool X" handshake. Rendering the
|
||||
// empty border-card around nothing looks like a broken bubble; show the
|
||||
// chips standalone in that case.
|
||||
// While the model is still in its extended-thinking phase (reasoning streamed
|
||||
// but no answer text yet), the ReasoningBlock is the activity indicator, so
|
||||
// suppress the empty cursor bubble underneath it.
|
||||
const isThinking = !isUser && streamingTail && !message.text && !!message.reasoning
|
||||
const hideEmptyBubble = (!isUser && !message.text && !streamingTail) || isThinking
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}>
|
||||
{!isUser && message.reasoning && (
|
||||
<ReasoningBlock reasoning={message.reasoning} active={isThinking} />
|
||||
)}
|
||||
{!hideEmptyBubble && (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-lg px-4 py-3 text-sm leading-6',
|
||||
isUser
|
||||
? 'bg-secondary text-foreground whitespace-pre-wrap'
|
||||
: 'border border-border bg-card',
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
message.text || (streamingTail ? <Cursor /> : '')
|
||||
) : message.text ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
|
||||
</div>
|
||||
) : streamingTail ? (
|
||||
<Cursor />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{message.toolCalls.map((tc) => (
|
||||
<span
|
||||
key={tc.tool_use_id}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-[11px] rounded-full border border-border px-2 py-0.5',
|
||||
tc.completed
|
||||
? 'text-muted-foreground/70 bg-card'
|
||||
: 'text-muted-foreground bg-secondary/40',
|
||||
)}
|
||||
>
|
||||
{tc.completed ? (
|
||||
<Check className="h-2.5 w-2.5 text-muted-foreground/60" strokeWidth={3} />
|
||||
) : (
|
||||
<span className="relative inline-flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-foreground/60" />
|
||||
</span>
|
||||
)}
|
||||
{prettyToolName(tc.name)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.memoryEvents && message.memoryEvents.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
||||
{message.memoryEvents.map((m) => (
|
||||
<MemoryChip key={m.tool_use_id} event={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.staged && message.staged.length > 0 && (
|
||||
<div className="w-full max-w-[85%] space-y-2">
|
||||
{message.staged.map((s) =>
|
||||
s.operation_id ? (
|
||||
<ApprovalCard
|
||||
key={s.tool_use_id}
|
||||
operationId={s.operation_id}
|
||||
riskLevel={s.risk_level}
|
||||
message={s.message}
|
||||
toolName={s.tool_name}
|
||||
preview={s.preview}
|
||||
periodStatus={s.period_status}
|
||||
onRequestCorrection={onCorrection}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={s.tool_use_id}
|
||||
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
Förslag stageat men ingen operation-id mottagen. Granska i gnubok under <em>Förslag</em>.
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRegenerate && onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Generera om svaret"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
Generera om
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pre-token "typing" indicator. Three staggered pulsing dots — reads as
|
||||
// "Anna is typing" much faster than the single blinking caret it replaced.
|
||||
// Stays only until the first text_delta lands, then the message body takes
|
||||
// over.
|
||||
function Cursor() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 align-middle" aria-label="Skriver" role="status">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '0ms' }} />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '150ms' }} />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible extended-thinking trace. While the model is still reasoning
|
||||
// (active), it auto-expands and streams — doubling as the "working" indicator
|
||||
// in place of the typing cursor. Once the answer starts it collapses to a
|
||||
// quiet toggle so the reply stays the focus and the surface stays calm.
|
||||
function ReasoningBlock({ reasoning, active }: { reasoning: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const show = open || active
|
||||
return (
|
||||
<div className="w-full max-w-[85%]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-expanded={show}
|
||||
>
|
||||
{active ? (
|
||||
<span className="relative inline-flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-foreground/60" />
|
||||
</span>
|
||||
) : (
|
||||
<Brain className="h-3 w-3" />
|
||||
)}
|
||||
{active ? 'Tänker…' : show ? 'Dölj resonemang' : 'Visa resonemang'}
|
||||
</button>
|
||||
{show && (
|
||||
<div className="mt-1.5 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MEMORY_KIND_LABEL: Record<'fact' | 'preference' | 'pattern' | 'correction', string> = {
|
||||
fact: 'Fakta',
|
||||
preference: 'Preferens',
|
||||
pattern: 'Mönster',
|
||||
correction: 'Korrigering',
|
||||
}
|
||||
|
||||
function MemoryChip({ event }: { event: MemoryEvent }) {
|
||||
const Icon = event.action === 'remembered' ? BookmarkCheck : BookmarkX
|
||||
const verb = event.action === 'remembered' ? 'Sparat som minne' : 'Glömt minne'
|
||||
const kindLabel = event.memory_kind ? MEMORY_KIND_LABEL[event.memory_kind] : null
|
||||
const snippet = event.content
|
||||
? event.content.length > 140
|
||||
? `${event.content.slice(0, 140).trim()}…`
|
||||
: event.content
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
href="/settings/assistant"
|
||||
className="group inline-flex items-start gap-2 rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
|
||||
title="Visa i Assistentens minne"
|
||||
>
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-foreground/70" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="font-medium text-foreground">{verb}</span>
|
||||
{kindLabel && <span className="ml-1 text-muted-foreground">· {kindLabel}</span>}
|
||||
{snippet && (
|
||||
<span className="block text-muted-foreground mt-0.5 leading-snug break-words">
|
||||
{snippet}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Rendered for the brief moment between sending the first request and the
|
||||
// first text_delta. Three pulsing lines that fade out as soon as a real
|
||||
// bubble takes their place.
|
||||
function SkeletonBubble() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start animate-fade-in">
|
||||
<div className="max-w-[85%] rounded-lg border border-border bg-card px-4 py-3 space-y-2 w-72">
|
||||
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-full" />
|
||||
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-[85%]" />
|
||||
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-[60%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function updateLastAssistant(
|
||||
prev: ChatMessage[],
|
||||
update: (m: ChatMessage) => ChatMessage,
|
||||
): ChatMessage[] {
|
||||
if (prev.length === 0) return prev
|
||||
const last = prev[prev.length - 1]
|
||||
if (last.role !== 'assistant') return prev
|
||||
return [...prev.slice(0, -1), update(last)]
|
||||
}
|
||||
|
||||
// Swedish present-progressive labels for the most common MCP tools, so the
|
||||
// inline badge reads as "what the agent is doing right now" rather than
|
||||
// dumping the raw tool slug. Anything not in the map falls back to a
|
||||
// humanized stem ("gnubok_foo_bar" → "kör foo bar…").
|
||||
const TOOL_BADGE_LABELS: Record<string, string> = {
|
||||
// Discovery
|
||||
gnubok_search_tools: 'Letar efter verktyg…',
|
||||
gnubok_list_skills: 'Letar bland kunskap…',
|
||||
gnubok_load_skill: 'Slår upp regelverk…',
|
||||
// Reading / context
|
||||
gnubok_get_document_content: 'Läser underlaget…',
|
||||
gnubok_get_counterparty_templates: 'Letar i mottagarmallar…',
|
||||
gnubok_get_supplier_ledger: 'Hämtar leverantörshistorik…',
|
||||
gnubok_get_ar_ledger: 'Hämtar kundreskontra…',
|
||||
gnubok_get_trial_balance: 'Hämtar saldobalans…',
|
||||
gnubok_get_balance_sheet: 'Hämtar balansräkning…',
|
||||
gnubok_get_income_statement: 'Hämtar resultatrapport…',
|
||||
gnubok_get_general_ledger: 'Slår i huvudboken…',
|
||||
gnubok_get_kpi_report: 'Beräknar nyckeltal…',
|
||||
gnubok_get_vat_report: 'Hämtar momsrapport…',
|
||||
gnubok_vat_close_check: 'Kontrollerar momsperiod…',
|
||||
gnubok_query_journal: 'Söker i bokföringen…',
|
||||
gnubok_year_end_readiness: 'Kontrollerar bokslutsläge…',
|
||||
gnubok_list_customers: 'Söker bland kunder…',
|
||||
gnubok_list_invoices: 'Listar fakturor…',
|
||||
// Writes (staged)
|
||||
gnubok_categorize_transaction: 'Förbereder bokning…',
|
||||
gnubok_match_transaction_to_invoice: 'Matchar mot faktura…',
|
||||
gnubok_create_customer: 'Skapar kund…',
|
||||
gnubok_create_invoice: 'Förbereder faktura…',
|
||||
gnubok_create_voucher: 'Förbereder verifikation…',
|
||||
gnubok_create_transactions: 'Förbereder transaktioner…',
|
||||
gnubok_approve_supplier_invoice: 'Stagear attestering…',
|
||||
gnubok_credit_supplier_invoice: 'Förbereder kreditfaktura…',
|
||||
gnubok_propose_accruals: 'Räknar fram periodiseringar…',
|
||||
gnubok_propose_annual_depreciation: 'Beräknar avskrivningar…',
|
||||
gnubok_propose_dispositioner: 'Förbereder dispositioner…',
|
||||
gnubok_preview_arsredovisning: 'Förhandsgranskar årsredovisning…',
|
||||
gnubok_preview_ef_declaration: 'Förbereder NE-bilaga…',
|
||||
gnubok_post_annual_depreciation: 'Bokar avskrivningar…',
|
||||
// Memory
|
||||
gnubok_remember_fact: 'Sparar i minnet…',
|
||||
gnubok_forget_fact: 'Tar bort från minnet…',
|
||||
}
|
||||
|
||||
function prettyToolName(name: string): string {
|
||||
if (TOOL_BADGE_LABELS[name]) return TOOL_BADGE_LABELS[name]
|
||||
return `kör ${name.replace(/^gnubok_/, '').replace(/_/g, ' ')}…`
|
||||
}
|
||||
|
||||
// Helper used by /chat/[id] server component to normalize agent_messages
|
||||
// rows into the ChatMessage shape this component expects. Exported here so
|
||||
// both the sheet (for future "resume" support) and the page can use it.
|
||||
export function normalizeStoredMessages(
|
||||
rows: { role: string; content: unknown; hidden?: boolean | null }[],
|
||||
): ChatMessage[] {
|
||||
const out: ChatMessage[] = []
|
||||
for (const r of rows) {
|
||||
if (r.role === 'tool') continue // tool_result blocks aren't shown in the timeline
|
||||
if (r.hidden === true) continue // synthetic first-turn templates + hidden correction turns
|
||||
const content = r.content
|
||||
if (typeof content === 'string') {
|
||||
out.push({ role: r.role === 'assistant' ? 'assistant' : 'user', text: content })
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(content)) continue
|
||||
let text = ''
|
||||
const toolCalls: { tool_use_id: string; name: string; completed?: boolean }[] = []
|
||||
for (const block of content as { type: string; text?: string; id?: string; name?: string }[]) {
|
||||
if (block.type === 'text' && block.text) text += block.text
|
||||
else if (block.type === 'tool_use' && block.id && block.name) {
|
||||
// Hydrated rows are historical — the tool already finished by
|
||||
// definition (otherwise the assistant content wouldn't have been
|
||||
// persisted). Mark every chip as completed so the rendered state
|
||||
// matches the live tool_result-handled state.
|
||||
toolCalls.push({ tool_use_id: block.id, name: block.name, completed: true })
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
role: r.role === 'assistant' ? 'assistant' : 'user',
|
||||
text,
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X, Expand } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
|
||||
// Undimmed non-modal side sheet — sits above the page on a hairline border +
|
||||
// shadow, but the page underneath stays fully interactive. Plan §3b.
|
||||
//
|
||||
// The sheet is a thin wrapper around AgentChat: it owns the title bar, close
|
||||
// button, and "expand to /chat/[id]" affordance. All message rendering and
|
||||
// streaming live in AgentChat so the full-page chat view can reuse them.
|
||||
|
||||
interface Props {
|
||||
intentId: string
|
||||
intentArgs?: Record<string, unknown>
|
||||
contextRef?: string
|
||||
seedUserMessage?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function AgentSheet({
|
||||
intentId,
|
||||
intentArgs,
|
||||
contextRef,
|
||||
seedUserMessage,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const { identity } = useAgentSheet()
|
||||
const agentName = identity.displayName?.trim() || null
|
||||
const sheetTitle = intentToTitle(intentId, agentName)
|
||||
|
||||
// Esc closes the sheet.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={sheetTitle}
|
||||
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
|
||||
// covers the full screen including where the nav would otherwise show.
|
||||
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
|
||||
style={{
|
||||
// iOS notch / Android cutout — the sheet top edge needs to clear the
|
||||
// status bar. Bottom is handled inside the form below.
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
|
||||
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{conversationId && (
|
||||
<Link
|
||||
href={`/chat/${conversationId}`}
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
aria-label="Öppna i fullskärm"
|
||||
title="Öppna i fullskärm"
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
intentArgs={intentArgs}
|
||||
contextRef={contextRef}
|
||||
seedUserMessage={seedUserMessage}
|
||||
onConversationIdChange={(id) => setConversationId(id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function intentToTitle(intentId: string, agentName: string | null): string {
|
||||
switch (intentId) {
|
||||
case 'general.help':
|
||||
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
|
||||
case 'transaction.categorization':
|
||||
return 'Hjälp med transaktion'
|
||||
case 'invoice.draft':
|
||||
return 'Hjälp med faktura'
|
||||
case 'supplier_invoice.review':
|
||||
return 'Granska leverantörsfaktura'
|
||||
default:
|
||||
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import AgentSheet from './AgentSheet'
|
||||
|
||||
export interface AgentIdentity {
|
||||
displayName: string | null
|
||||
avatarId: string | null
|
||||
// True only after the user has completed Phase B verification in
|
||||
// /onboarding/agent. Consumers (AgentTrigger, page-level Sparkle
|
||||
// buttons) should hide themselves when this is false so the FAB
|
||||
// doesn't pop up before the agent build flow has run.
|
||||
isVerified: boolean
|
||||
}
|
||||
|
||||
// Provider exposes a single imperative function: openAgentSheet({...}). Any
|
||||
// client component (top-nav button, transaction row "Fråga om" button, etc.)
|
||||
// calls it to bring the sheet up with a specific intent + capture args.
|
||||
//
|
||||
// The sheet itself manages its own message list, streaming state, and
|
||||
// dismissal. The provider just owns "what is open" and re-opens or replaces
|
||||
// the panel when called again.
|
||||
|
||||
export interface OpenAgentSheetArgs {
|
||||
intentId: string
|
||||
// Intent-specific args passed to the server's intent.capture() — e.g.
|
||||
// { transaction_id: '...' } for transaction.categorization.
|
||||
intentArgs?: Record<string, unknown>
|
||||
// Optional ref persisted on agent_conversations.context_ref so the UI can
|
||||
// surface a back-pointer ("om transaktion 12 mar / 1 240 kr") later.
|
||||
contextRef?: string
|
||||
// Pre-populated first user message. When set, the chat skips the intent's
|
||||
// promptTemplate and sends this verbatim instead. Used by /chat empty-state
|
||||
// suggestion chips to give the user a one-click starting prompt.
|
||||
seedUserMessage?: string
|
||||
}
|
||||
|
||||
interface AgentSheetContextValue {
|
||||
openAgentSheet: (args: OpenAgentSheetArgs) => void
|
||||
closeAgentSheet: () => void
|
||||
isOpen: boolean
|
||||
// Agent name + avatar — set once from the server-loaded agent_profile
|
||||
// and exposed through context so the trigger / chat headers can render
|
||||
// them without their own fetches. Null when the user hasn't verified a
|
||||
// profile yet (free tier or pre-onboarding).
|
||||
identity: AgentIdentity
|
||||
}
|
||||
|
||||
const AgentSheetContext = createContext<AgentSheetContextValue | null>(null)
|
||||
|
||||
interface AgentSheetProviderProps {
|
||||
children: React.ReactNode
|
||||
identity?: AgentIdentity
|
||||
}
|
||||
|
||||
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
|
||||
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
|
||||
|
||||
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
|
||||
setActiveArgs(args)
|
||||
}, [])
|
||||
|
||||
const closeAgentSheet = useCallback(() => {
|
||||
setActiveArgs(null)
|
||||
}, [])
|
||||
|
||||
const resolvedIdentity: AgentIdentity =
|
||||
identity ?? { displayName: null, avatarId: null, isVerified: false }
|
||||
|
||||
const value = useMemo<AgentSheetContextValue>(
|
||||
() => ({
|
||||
openAgentSheet,
|
||||
closeAgentSheet,
|
||||
isOpen: activeArgs !== null,
|
||||
identity: resolvedIdentity,
|
||||
}),
|
||||
[openAgentSheet, closeAgentSheet, activeArgs, resolvedIdentity],
|
||||
)
|
||||
|
||||
return (
|
||||
<AgentSheetContext.Provider value={value}>
|
||||
{children}
|
||||
{activeArgs && (
|
||||
<AgentSheet
|
||||
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}`}
|
||||
intentId={activeArgs.intentId}
|
||||
intentArgs={activeArgs.intentArgs}
|
||||
contextRef={activeArgs.contextRef}
|
||||
seedUserMessage={activeArgs.seedUserMessage}
|
||||
onClose={closeAgentSheet}
|
||||
/>
|
||||
)}
|
||||
</AgentSheetContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAgentSheet(): AgentSheetContextValue {
|
||||
const ctx = useContext(AgentSheetContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAgentSheet must be used inside <AgentSheetProvider>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
|
||||
interface Props {
|
||||
intentId: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
intentArgs?: Record<string, any>
|
||||
contextRef?: string
|
||||
// Override the auto-derived "Fråga [namn]" label when the page wants
|
||||
// something more contextual (e.g. "Förklara denna siffra"). Most surfaces
|
||||
// should leave this unset.
|
||||
label?: string
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
variant?: 'outline' | 'default' | 'ghost' | 'secondary'
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Single source of truth for the in-page "Fråga [namn]" affordance. Every
|
||||
// page-header button across the dashboard (invoice form, supplier invoice,
|
||||
// bookkeeping, year-end, VAT report, KPI, …) routes through this component
|
||||
// so they share the same icon size, label format, spacing, and resolved
|
||||
// agent name. The transaction-row icon button stays separate — it's a
|
||||
// different UX (icon-only, ghost) embedded inside a row's action group.
|
||||
export default function AgentSparkleButton({
|
||||
intentId,
|
||||
intentArgs,
|
||||
contextRef,
|
||||
label,
|
||||
size = 'sm',
|
||||
variant = 'outline',
|
||||
className,
|
||||
}: Props) {
|
||||
const { openAgentSheet, identity } = useAgentSheet()
|
||||
// Same gate as AgentTrigger — hide all "Fråga …" affordances until the
|
||||
// user has finished /onboarding/agent.
|
||||
if (!identity.isVerified) return null
|
||||
const name = identity.displayName?.trim() || 'min assistent'
|
||||
const resolvedLabel = label ?? `Fråga ${name}`
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={() =>
|
||||
openAgentSheet({
|
||||
intentId,
|
||||
intentArgs,
|
||||
contextRef,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
{resolvedLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
|
||||
|
||||
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
|
||||
// clicked. Hidden when the sheet is already open so the icon doesn't double up.
|
||||
//
|
||||
// Route-aware: routeToIntent(pathname) picks the right intent + intentArgs so
|
||||
// clicking the FAB on /invoices/abc-123 opens invoice.draft with that invoice
|
||||
// id (rather than the page-agnostic general.help with just the URL). The
|
||||
// label suffix renders "Fråga Anna om denna faktura" so the user can tell at
|
||||
// a glance that the agent is going to know which entity they're on.
|
||||
//
|
||||
// Reads the agent's display_name + avatar_id from the AgentSheet context so
|
||||
// the button reads "Fråga Anna" (with Anna's face) rather than the generic
|
||||
// "Fråga min assistent".
|
||||
//
|
||||
// Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice)
|
||||
// still call useAgentSheet() directly from their own buttons because they
|
||||
// know exactly which entity to pass. (Per-transaction help is reached from
|
||||
// Dokumentinkorgen, not a transactions-page row button.)
|
||||
export default function AgentTrigger() {
|
||||
const { openAgentSheet, isOpen, identity } = useAgentSheet()
|
||||
const pathname = usePathname()
|
||||
|
||||
if (isOpen) return null
|
||||
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
|
||||
// is redundant and overlaps the input. Suppress while the user is here.
|
||||
if (pathname?.startsWith('/chat')) return null
|
||||
// The verifikation editor is a dense regulatory surface (debits/credits,
|
||||
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
|
||||
// pill on top of it adds noise without earning its place. Suppress on
|
||||
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
|
||||
// and /bookkeeping/year-end still get the FAB.
|
||||
{
|
||||
const segs = pathname?.split('/').filter(Boolean) ?? []
|
||||
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead
|
||||
// into a generic chat with no specialization. Better to hide it until
|
||||
// the user has finished /onboarding/agent.
|
||||
if (!identity.isVerified) return null
|
||||
|
||||
const name = identity.displayName?.trim() || 'min assistent'
|
||||
const dispatch = routeToIntent(pathname)
|
||||
const labelText = dispatch.labelSuffix
|
||||
? `Fråga ${name} ${dispatch.labelSuffix}`
|
||||
: `Fråga ${name}`
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
openAgentSheet({
|
||||
intentId: dispatch.intentId,
|
||||
intentArgs: dispatch.intentArgs,
|
||||
contextRef: dispatch.contextRef,
|
||||
})
|
||||
}
|
||||
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
|
||||
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
|
||||
// no mobile nav to worry about.
|
||||
className="fixed right-4 z-30 flex h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4"
|
||||
aria-label={labelText}
|
||||
>
|
||||
<AgentAvatar
|
||||
avatarId={identity.avatarId}
|
||||
size="sm"
|
||||
className="ring-2 ring-background/20 shrink-0"
|
||||
alt={name}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{labelText}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Check, X, Loader2, AlertTriangle, Lock, ShieldCheck, ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { PendingOperationRejectionCategory } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
// Inline approval card for an agent-staged pending_operation.
|
||||
//
|
||||
// Risk tiers (plan §9, §12):
|
||||
// low — single-click "Godkänn". Trust UI for auto-approve lives
|
||||
// post-V0 (data model supports it via agent_profiles.trust_per_tool).
|
||||
// medium — single-click "Godkänn".
|
||||
// high — requires the user to type "godkänn" verbatim. Never auto-
|
||||
// approvable, by design (legal compliance).
|
||||
//
|
||||
// Reject is always one-click.
|
||||
//
|
||||
// The card posts to the existing /api/pending-operations/<id>/{commit,reject}
|
||||
// endpoints — same surface the gnubok "Förslag" page uses, so there is
|
||||
// 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.
|
||||
|
||||
interface PeriodStatus {
|
||||
period_id?: string | null
|
||||
status: 'open' | 'locked' | 'closed'
|
||||
lock_date?: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
operationId: string
|
||||
riskLevel: 'low' | 'medium' | 'high'
|
||||
message: string
|
||||
toolName?: string
|
||||
preview?: unknown
|
||||
periodStatus?: PeriodStatus
|
||||
// Fired after a reject that carries a reason — the chat feeds this synthetic
|
||||
// correction back as a hidden user turn so the agent re-proposes inline.
|
||||
onRequestCorrection?: (correctionMessage: string) => void
|
||||
}
|
||||
|
||||
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
|
||||
// surface as links are declared.
|
||||
interface CommitResultData {
|
||||
journal_entry_id?: string | null
|
||||
invoice_id?: string | null
|
||||
customer_id?: string | null
|
||||
supplier_invoice_id?: string | null
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
operationId,
|
||||
riskLevel,
|
||||
message,
|
||||
toolName,
|
||||
preview,
|
||||
periodStatus,
|
||||
onRequestCorrection,
|
||||
}: Props) {
|
||||
const [state, setState] = useState<State>('pending')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
// Reject-with-reason form (mirrors the granskning dialog). Clicking "Avslå"
|
||||
// opens it; both fields are optional. When a reason is given, the rejection
|
||||
// is fed back so the agent re-proposes.
|
||||
const [showRejectForm, setShowRejectForm] = useState(false)
|
||||
const [rejectCategory, setRejectCategory] = useState<PendingOperationRejectionCategory | ''>('')
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
// Surfaced in the "Godkänt" success state so the user can jump directly
|
||||
// to the newly-created artifact (verifikation / faktura / kund) instead
|
||||
// of hunting through /bookkeeping.
|
||||
const [commitResult, setCommitResult] = useState<CommitResultData | null>(null)
|
||||
// Set when commit fails because the booking posts to BAS accounts not yet
|
||||
// active in the chart. Drives the inline "activate and approve" affordance
|
||||
// (the op stays pending server-side, so retrying after activation works).
|
||||
const [accountsToActivate, setAccountsToActivate] = useState<string[] | null>(null)
|
||||
|
||||
const requiresTextConfirm = riskLevel === 'high'
|
||||
const canCommit =
|
||||
!requiresTextConfirm || confirmText.trim().toLowerCase() === 'godkänn'
|
||||
|
||||
async function handleCommit() {
|
||||
setState('committing')
|
||||
setErrorMessage(null)
|
||||
setAccountsToActivate(null)
|
||||
try {
|
||||
const res = await fetch(`/api/pending-operations/${operationId}/commit`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
data?: CommitResultData
|
||||
error?: string | { code?: string; message?: string; account_numbers?: string[] }
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Recoverable: the booking posts to BAS accounts not active in the
|
||||
// chart. Offer to activate them and retry — the op stays pending.
|
||||
const structured = typeof body.error === 'object' && body.error !== null ? body.error : null
|
||||
if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && structured.account_numbers?.length) {
|
||||
setAccountsToActivate(structured.account_numbers)
|
||||
setState('pending')
|
||||
return
|
||||
}
|
||||
throw new Error(errorText(body.error) || `HTTP ${res.status}`)
|
||||
}
|
||||
// Best-effort deep-link to the created artifact in the success state.
|
||||
if (body?.data) setCommitResult(body.data)
|
||||
setState('committed')
|
||||
} catch (err) {
|
||||
setState('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte godkänna.')
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the missing BAS accounts (one POST) then retry the commit. The
|
||||
// pending_operation was left 'pending' server-side precisely so this retry
|
||||
// commits the same booking without re-staging it.
|
||||
async function handleActivateAndCommit() {
|
||||
if (!accountsToActivate || accountsToActivate.length === 0) return
|
||||
setState('committing')
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_numbers: accountsToActivate }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error || 'Kunde inte aktivera kontona.')
|
||||
}
|
||||
setAccountsToActivate(null)
|
||||
await handleCommit()
|
||||
} catch (err) {
|
||||
setState('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte aktivera kontona.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setState('rejecting')
|
||||
setErrorMessage(null)
|
||||
const categoryLabel = rejectCategory ? REJECTION_CATEGORY_LABELS[rejectCategory] : null
|
||||
const reason = rejectReason.trim()
|
||||
// Both fields optional — a bare "Avvisa" still rejects (parity with the
|
||||
// granskning dialog and older bodyless clients).
|
||||
const body =
|
||||
rejectCategory || reason
|
||||
? {
|
||||
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
|
||||
...(reason ? { rejection_reason: reason } : {}),
|
||||
}
|
||||
: undefined
|
||||
try {
|
||||
const res = await fetch(`/api/pending-operations/${operationId}/reject`, {
|
||||
method: 'POST',
|
||||
...(body
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
setShowRejectForm(false)
|
||||
setState('rejected')
|
||||
// Feed the correction back so the agent re-proposes — only when the user
|
||||
// actually said what was wrong. A bare reject just stops here.
|
||||
const parts = [categoryLabel, reason].filter(Boolean) as string[]
|
||||
if (parts.length > 0) {
|
||||
onRequestCorrection?.(
|
||||
`Jag avvisade förslaget. Det som var fel: ${parts.join(' — ')}. Föreslå en korrigerad bokning.`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
setState('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte avslå.')
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'committed') {
|
||||
// Build a deep-link to the newly-created artifact when the commit
|
||||
// response told us what it was. Falls back to nothing if no relevant
|
||||
// id was returned (e.g. period close / unlock / mark-as-sent).
|
||||
let deepLink: { href: string; label: string } | null = null
|
||||
if (commitResult?.journal_entry_id) {
|
||||
deepLink = {
|
||||
href: `/bookkeeping/${commitResult.journal_entry_id}`,
|
||||
label: 'Öppna verifikation',
|
||||
}
|
||||
} else if (commitResult?.invoice_id) {
|
||||
deepLink = {
|
||||
href: `/invoices/${commitResult.invoice_id}`,
|
||||
label: 'Öppna faktura',
|
||||
}
|
||||
} else if (commitResult?.supplier_invoice_id) {
|
||||
deepLink = {
|
||||
href: `/supplier-invoices/${commitResult.supplier_invoice_id}`,
|
||||
label: 'Öppna leverantörsfaktura',
|
||||
}
|
||||
} else if (commitResult?.customer_id) {
|
||||
deepLink = {
|
||||
href: `/customers/${commitResult.customer_id}`,
|
||||
label: 'Öppna kund',
|
||||
}
|
||||
}
|
||||
// The server's `message` field (e.g. "Operation staged for review …
|
||||
// Open the gnubok web app to approve or reject it.") was written for
|
||||
// MCP clients without an inline approval surface. Inside the in-app
|
||||
// chat it's redundant noise — the agent already narrated the why
|
||||
// above the card. We keep it accessible via aria-description for
|
||||
// screen readers but don't render it.
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-success/40 bg-success/10 px-4 py-3 text-sm"
|
||||
aria-description={message}
|
||||
>
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<Check className="h-4 w-4" /> Godkänt
|
||||
</p>
|
||||
{deepLink && (
|
||||
<Link
|
||||
href={deepLink.href}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{deepLink.label}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'rejected') {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
aria-description={message}
|
||||
>
|
||||
<p className="flex items-center gap-2">
|
||||
<X className="h-4 w-4" /> Avslaget
|
||||
{rejectCategory && (
|
||||
<span className="text-xs text-muted-foreground/80">· {REJECTION_CATEGORY_LABELS[rejectCategory]}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isBusy = state === 'committing' || state === 'rejecting'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Subtle accent border-top tells the eye what to do BEFORE reading
|
||||
// the risk label. high = destructive red, medium = warning yellow,
|
||||
// low = neutral foreground. animate-scale-in gives the card a soft
|
||||
// entrance when it first lands inline in the conversation.
|
||||
'rounded-lg border bg-card px-4 py-3 space-y-3 border-t-2 animate-scale-in',
|
||||
riskLevel === 'high'
|
||||
? 'border-destructive/50 border-t-destructive'
|
||||
: riskLevel === 'medium'
|
||||
? 'border-border border-t-warning'
|
||||
: 'border-border border-t-foreground/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Förslag · risk {translateRisk(riskLevel)}
|
||||
</p>
|
||||
{periodStatus && <PeriodBadge status={periodStatus} />}
|
||||
</div>
|
||||
|
||||
<PreviewBlock toolName={toolName} preview={preview} />
|
||||
|
||||
{requiresTextConfirm && (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Hög risk — skriv <strong className="font-semibold">godkänn</strong> för att bekräfta.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
disabled={isBusy}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoComplete="off"
|
||||
aria-label="Bekräfta med ordet godkänn"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="text-xs text-destructive">{errorMessage}</p>}
|
||||
|
||||
{showRejectForm ? (
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/30 px-3 py-2">
|
||||
<p className="text-xs font-medium">Vad är fel?</p>
|
||||
<Select
|
||||
value={rejectCategory}
|
||||
onValueChange={(v) => setRejectCategory(v as PendingOperationRejectionCategory)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs" aria-label="Anledning">
|
||||
<SelectValue placeholder="Anledning (valfritt)" />
|
||||
</SelectTrigger>
|
||||
{/* The agent sheet panel is z-[60]; SelectContent defaults to z-50
|
||||
and portals to <body>, so without this it opens BEHIND the
|
||||
sheet. z-[70] sits above the sheet, below toasts (z-[100]). */}
|
||||
<SelectContent className="z-[70]">
|
||||
{(Object.keys(REJECTION_CATEGORY_LABELS) as PendingOperationRejectionCategory[]).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{REJECTION_CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="T.ex. ska vara IT-tjänster, inte telefoni…"
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
disabled={isBusy}
|
||||
className="text-xs"
|
||||
aria-label="Notering"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Med en anledning eller notering föreslår assistenten en korrigerad bokning direkt.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
{state === 'rejecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Avvisa'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRejectForm(false)}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : accountsToActivate ? (
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/30 px-3 py-2">
|
||||
<p className="text-xs leading-5">
|
||||
Bokningen använder konton som inte är aktiva i din kontoplan:{' '}
|
||||
<strong className="tabular-nums">{accountsToActivate.join(', ')}</strong>. Aktivera dem för att godkänna bokningen.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleActivateAndCommit}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
{state === 'committing' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Aktivera och godkänn'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAccountsToActivate(null)}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCommit}
|
||||
disabled={isBusy || !canCommit}
|
||||
className="flex-1"
|
||||
>
|
||||
{state === 'committing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Godkänn'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
Avslå
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
// Server emits { account_number, debit_amount, credit_amount, description }
|
||||
// per VAT line (extensions/general/mcp-server/server.ts:390-395). One side
|
||||
// is non-zero, the other 0 — render the active side with D/K prefix.
|
||||
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>
|
||||
{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 errorText(error: string | { message?: string } | undefined): string | null {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && typeof error.message === 'string') return error.message
|
||||
return null
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-success">
|
||||
<ShieldCheck className="h-3 w-3" /> Period öppen
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status.status === 'locked') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-warning">
|
||||
<Lock className="h-3 w-3" /> Period låst
|
||||
{status.lock_date ? <span className="tabular-nums">· {status.lock_date}</span> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-destructive">
|
||||
<Lock className="h-3 w-3" /> Period stängd
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function translateRisk(risk: 'low' | 'medium' | 'high'): string {
|
||||
if (risk === 'low') return 'låg'
|
||||
if (risk === 'medium') return 'medel'
|
||||
return 'hög'
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import AgentChat, { normalizeStoredMessages } from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
|
||||
interface Props {
|
||||
conversationId: string
|
||||
intentId: string
|
||||
contextRef: string | null
|
||||
title: string
|
||||
rawMessages: { role: string; content: unknown; hidden?: boolean }[]
|
||||
}
|
||||
|
||||
// Full-page conversation view. Wraps AgentChat with a header that shows the
|
||||
// title (or intent label). AgentChat handles the streaming + input + render.
|
||||
export default function ChatConversationView({
|
||||
conversationId,
|
||||
intentId,
|
||||
contextRef,
|
||||
title,
|
||||
rawMessages,
|
||||
}: Props) {
|
||||
const initialMessages = useMemo(() => normalizeStoredMessages(rawMessages), [rawMessages])
|
||||
const { identity } = useAgentSheet()
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
|
||||
{/* Mobile-only back-to-list arrow. On desktop the sidebar is always
|
||||
visible so a back button would be redundant. */}
|
||||
<Link
|
||||
href="/chat"
|
||||
className="md:hidden inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors -ml-1"
|
||||
aria-label="Tillbaka till konversationer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<AgentAvatar
|
||||
avatarId={identity.avatarId}
|
||||
size="sm"
|
||||
alt={identity.displayName ?? 'Assistent'}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{title}</h1>
|
||||
{contextRef && (
|
||||
<p className="text-xs text-muted-foreground truncate">{contextRef}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
contextRef={contextRef ?? undefined}
|
||||
initialConversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
|
||||
// Tiny client component for /chat empty state. Reads the agent identity from
|
||||
// the provider so it can show the user's chosen avatar + name above the
|
||||
// "starta en konversation" CTA.
|
||||
//
|
||||
// The three suggestion chips below the headline give users a one-click way
|
||||
// in. They navigate to /chat/new?intent=…&prompt=… which mounts AgentChat
|
||||
// inline and swaps to /chat/[id] once the conversation is created — so the
|
||||
// flow stays full-screen instead of opening a slide-in sheet.
|
||||
const SUGGESTIONS: { label: string; prompt: string }[] = [
|
||||
{
|
||||
label: 'Vad är min största utgiftspost den här månaden?',
|
||||
prompt: 'Vad är min största utgiftspost den här månaden? Visa de fem största kategorierna.',
|
||||
},
|
||||
{
|
||||
label: 'Hur ser min momsrapport ut för senaste perioden?',
|
||||
prompt: 'Hur ser min momsrapport ut för den senaste perioden? Vad blir moms att betala eller få tillbaka, och ser något ovanligt ut?',
|
||||
},
|
||||
{
|
||||
label: 'När är min nästa skatte- eller momsdeadline?',
|
||||
prompt: 'När är min nästa skatte- eller momsdeadline, och vad behöver jag göra inför den?',
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChatEmptyState() {
|
||||
const { identity } = useAgentSheet()
|
||||
const name = identity.displayName?.trim() || 'din assistent'
|
||||
|
||||
// Hidden on mobile — the sidebar IS the page when no conversation is open.
|
||||
// On desktop, fills the right pane with a centered prompt.
|
||||
return (
|
||||
<div className="hidden md:flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="lg" alt={name} className="mb-5" />
|
||||
<h1 className="font-display text-2xl tracking-tight mb-2">Fråga {name}</h1>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
Välj en konversation till vänster, eller starta en ny om något har dykt upp.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full max-w-md mb-6">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<Link
|
||||
key={s.label}
|
||||
href={`/chat/new?intent=general.help&prompt=${encodeURIComponent(s.prompt)}`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-left text-sm transition-colors hover:border-foreground/30 hover:bg-secondary/30"
|
||||
>
|
||||
<span className="flex-1 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{s.label}
|
||||
</span>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/chat/new?intent=general.help">Eller skriv din egen fråga</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
|
||||
// Phase C entry surface. Lands here from ReviewCard's "kör" after Phase B
|
||||
// verify succeeds. Renders AgentChat in fresh-start mode — no
|
||||
// initialConversationId, no initialMessages — so the auto-fire effect
|
||||
// kicks the first invoke. As soon as /api/agent/invoke emits the
|
||||
// conversation event, the URL swaps to /chat/[id] so reload / share /
|
||||
// browser-back all work like any other conversation.
|
||||
//
|
||||
// Plan refs: §7 Phase C.
|
||||
export default function ChatIntakeStarter() {
|
||||
const router = useRouter()
|
||||
const { identity } = useAgentSheet()
|
||||
const agentName = identity.displayName?.trim() || 'Din assistent'
|
||||
// Lock the swap to the first id we see — defensive guard against the
|
||||
// AgentChat callback firing twice during React 19 Strict Mode reruns.
|
||||
const [swapped, setSwapped] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName} är redo</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId="onboarding.intake"
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the greeting to finish streaming AND persist before
|
||||
// swapping the URL. Swapping on the early `conversation` event
|
||||
// unmounts AgentChat mid-stream, so the greeting is never saved
|
||||
// and /chat/[id] hydrates empty — the bug where the chat lands
|
||||
// blank and only shows the intro on a later visit.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
|
||||
// Inline starter used by suggestion chips and ⌘K. Mirrors ChatIntakeStarter
|
||||
// but accepts any intent + seed so we don't fork the intake-specific
|
||||
// onboarding path. When AgentChat emits the new conversation_id, the URL
|
||||
// is swapped to /chat/[id] so reload / share / browser-back all work.
|
||||
export default function ChatNewStarter({
|
||||
intentId,
|
||||
seedUserMessage,
|
||||
}: {
|
||||
intentId: string
|
||||
seedUserMessage?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { identity } = useAgentSheet()
|
||||
const agentName = identity.displayName?.trim() || 'Din assistent'
|
||||
const [swapped, setSwapped] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">Ny konversation</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
seedUserMessage={seedUserMessage}
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the first turn to finish before swapping the URL —
|
||||
// otherwise the unmount aborts the in-flight stream and
|
||||
// /chat/[id] hydrates with only the user message.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Pin, PinOff, Archive, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
|
||||
interface ConversationRow {
|
||||
id: string
|
||||
intent_id: string
|
||||
context_ref: string | null
|
||||
title: string | null
|
||||
pinned: boolean
|
||||
archived: boolean
|
||||
last_message_at: string | null
|
||||
last_message_preview: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialConversations: ConversationRow[]
|
||||
}
|
||||
|
||||
// Time buckets for date grouping. Computed once per render against now().
|
||||
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
|
||||
// Mail and iMessage.
|
||||
type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
|
||||
|
||||
const BUCKET_LABELS: Record<DateBucket, string> = {
|
||||
pinned: 'Fästade',
|
||||
today: 'Idag',
|
||||
yesterday: 'Igår',
|
||||
thisWeek: 'Denna vecka',
|
||||
older: 'Äldre',
|
||||
}
|
||||
|
||||
function bucketFor(c: ConversationRow): DateBucket {
|
||||
if (c.pinned) return 'pinned'
|
||||
const when = c.last_message_at ?? c.created_at
|
||||
if (!when) return 'older'
|
||||
const t = new Date(when)
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
|
||||
if (t >= todayStart) return 'today'
|
||||
if (t >= yesterdayStart) return 'yesterday'
|
||||
if (t >= weekStart) return 'thisWeek'
|
||||
return 'older'
|
||||
}
|
||||
|
||||
// Compact relative-time label shown to the right of each row. Locale-tuned
|
||||
// to feel native in Swedish without going full date-fns.
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const t = new Date(iso).getTime()
|
||||
const now = Date.now()
|
||||
const diffMin = Math.round((now - t) / 60000)
|
||||
if (diffMin < 1) return 'nu'
|
||||
if (diffMin < 60) return `${diffMin} min`
|
||||
const diffHr = Math.round(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr} h`
|
||||
const diffDay = Math.round(diffHr / 24)
|
||||
if (diffDay < 7) return `${diffDay} d`
|
||||
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export default function ChatSidebar({ initialConversations }: Props) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { openAgentSheet, identity } = useAgentSheet()
|
||||
const agentName = identity.displayName?.trim() || null
|
||||
const [conversations, setConversations] = useState<ConversationRow[]>(initialConversations)
|
||||
const [query, setQuery] = useState('')
|
||||
const [, startTransition] = useTransition()
|
||||
// Collapsed by default; persisted across reloads so power users keep
|
||||
// their preference. Hidden behind a thin rail when collapsed so the
|
||||
// conversation pane runs nearly edge-to-edge.
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('gnubok:chat-sidebar-collapsed')
|
||||
if (stored === 'false') setCollapsed(false)
|
||||
}, [])
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(c => {
|
||||
const next = !c
|
||||
try { localStorage.setItem('gnubok:chat-sidebar-collapsed', next ? 'true' : 'false') } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeId = pathname?.startsWith('/chat/') ? pathname.split('/')[2] : null
|
||||
const isConversationOpen = !!activeId
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return conversations
|
||||
return conversations.filter((c) => {
|
||||
return (
|
||||
(c.title ?? '').toLowerCase().includes(q) ||
|
||||
(c.last_message_preview ?? '').toLowerCase().includes(q) ||
|
||||
(c.context_ref ?? '').toLowerCase().includes(q) ||
|
||||
c.intent_id.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [conversations, query])
|
||||
|
||||
// Group filtered into ordered buckets, preserving the sort order already
|
||||
// applied server-side (pinned first, then last_message_at desc).
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<DateBucket, ConversationRow[]> = {
|
||||
pinned: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: [],
|
||||
}
|
||||
for (const c of filtered) buckets[bucketFor(c)].push(c)
|
||||
const order: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
|
||||
return order
|
||||
.map((b) => ({ bucket: b, rows: buckets[b] }))
|
||||
.filter((g) => g.rows.length > 0)
|
||||
}, [filtered])
|
||||
|
||||
async function togglePin(id: string, current: boolean) {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, pinned: !current } : c)),
|
||||
)
|
||||
await fetch(`/api/agent/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pinned: !current }),
|
||||
})
|
||||
}
|
||||
|
||||
async function archive(id: string) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id))
|
||||
await fetch(`/api/agent/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ archived: true }),
|
||||
})
|
||||
if (activeId === id) startTransition(() => router.push('/chat'))
|
||||
}
|
||||
|
||||
// Collapsed rail (desktop only). Mobile keeps the existing behavior where
|
||||
// the sidebar IS the page when no conversation is open, so the rail is
|
||||
// hidden below md. On desktop the rail keeps a thin column with toggle
|
||||
// + new-chat buttons so the conversation pane runs near-edge-to-edge.
|
||||
const railAside = collapsed ? (
|
||||
<aside
|
||||
className="hidden md:flex md:w-12 flex-col items-center border-r border-border bg-card/40 shrink-0 py-3 gap-2"
|
||||
aria-label="Konversationer (hopfälld)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label="Visa konversationer"
|
||||
title="Visa konversationer"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openAgentSheet({ intentId: 'general.help' })}
|
||||
aria-label="Ny konversation"
|
||||
title="Ny konversation"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors text-lg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</aside>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{railAside}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-col border-r border-border bg-card/40 shrink-0',
|
||||
// Mobile: sidebar IS the page when no conversation; hidden otherwise.
|
||||
isConversationOpen ? 'hidden' : 'flex w-full',
|
||||
// Desktop: hidden if collapsed (rail takes its place); else 320px.
|
||||
collapsed ? 'md:hidden' : 'md:flex md:w-80',
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border px-5 py-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-display text-base tracking-tight truncate">
|
||||
{agentName ?? 'Din assistent'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Konversationer</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
aria-label="Dölj konversationer"
|
||||
title="Dölj konversationer"
|
||||
className="hidden md:inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAgentSheet({ intentId: 'general.help' })}
|
||||
className="text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+ Ny
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Sök…"
|
||||
className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label="Rensa sökning"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
{conversations.length === 0
|
||||
? 'Inga konversationer ännu. Klicka på + Ny för att börja.'
|
||||
: 'Inga träffar.'}
|
||||
</div>
|
||||
) : (
|
||||
grouped.map(({ bucket, rows }) => (
|
||||
<section key={bucket} className="py-2">
|
||||
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{BUCKET_LABELS[bucket]}
|
||||
</p>
|
||||
<ul>
|
||||
{rows.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
href={`/chat/${c.id}`}
|
||||
className={cn(
|
||||
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
|
||||
activeId === c.id
|
||||
? 'bg-secondary/50 border-foreground'
|
||||
: 'border-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium truncate flex-1 min-w-0">
|
||||
{c.title ?? intentLabel(c.intent_id)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
|
||||
{relativeTime(c.last_message_at ?? c.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
||||
{c.last_message_preview ?? intentLabel(c.intent_id)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Always-visible action icons. Touch-friendly, no
|
||||
hover-only invisibility on mobile. */}
|
||||
<div className="flex flex-col gap-1 shrink-0 -mr-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void togglePin(c.id, c.pinned)
|
||||
}}
|
||||
title={c.pinned ? 'Avfäst' : 'Fäst'}
|
||||
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
|
||||
c.pinned
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
|
||||
)}
|
||||
>
|
||||
{c.pinned ? (
|
||||
<Pin className="h-3 w-3" fill="currentColor" />
|
||||
) : (
|
||||
<PinOff className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void archive(c.id)
|
||||
}}
|
||||
title="Arkivera"
|
||||
aria-label="Arkivera konversation"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function intentLabel(intentId: string): string {
|
||||
switch (intentId) {
|
||||
case 'general.help':
|
||||
return 'Fråga din assistent'
|
||||
case 'transaction.categorization':
|
||||
return 'Hjälp med transaktion'
|
||||
case 'invoice.draft':
|
||||
return 'Hjälp med faktura'
|
||||
case 'supplier_invoice.review':
|
||||
return 'Granska leverantörsfaktura'
|
||||
case 'vat.review':
|
||||
return 'Granska momsdeklaration'
|
||||
case 'bokslut.step':
|
||||
return 'Hjälp med bokslut'
|
||||
case 'verifikation.draft':
|
||||
return 'Hjälp med verifikation'
|
||||
case 'kpi.explain':
|
||||
return 'Förklara nyckeltal'
|
||||
default:
|
||||
return intentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Avatar registry for the specialized accountant agent.
|
||||
//
|
||||
// We use the dicebear "notionists" style — clean line illustrations that
|
||||
// match the editorial monochrome brand without the cartoony feel of most
|
||||
// avatar libraries. 8 hand-picked seeds give distinct faces without being
|
||||
// overwhelming. The user picks one during Phase B review; the choice is
|
||||
// persisted as agent_profiles.avatar_id.
|
||||
//
|
||||
// URLs are served by dicebear's free CDN. They're public SVGs derived from
|
||||
// the seed only — no user data leaves gnubok. If we ever need fully offline
|
||||
// generation, swap to @dicebear/core npm package and render server-side.
|
||||
|
||||
export interface AvatarOption {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// Build URL from seed. Public dicebear CDN. ?radius=50 rounds the bounding
|
||||
// box; ?backgroundColor=transparent keeps the editorial paper-white feel.
|
||||
function dicebearNotionists(seed: string): string {
|
||||
return `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(seed)}&radius=50&backgroundColor=f5f3ed`
|
||||
}
|
||||
|
||||
// Eight neutral seeds — names chosen to produce visibly different faces.
|
||||
// Labels are just for the picker tooltip; the user names the agent
|
||||
// themselves in the adjacent text field.
|
||||
export const AVATAR_OPTIONS: readonly AvatarOption[] = [
|
||||
{ id: 'notionists-1', label: 'Linn', url: dicebearNotionists('linn-revisor-1') },
|
||||
{ id: 'notionists-2', label: 'Erik', url: dicebearNotionists('erik-revisor-2') },
|
||||
{ id: 'notionists-3', label: 'Maja', url: dicebearNotionists('maja-revisor-3') },
|
||||
{ id: 'notionists-4', label: 'Anders', url: dicebearNotionists('anders-revisor-4') },
|
||||
{ id: 'notionists-5', label: 'Karin', url: dicebearNotionists('karin-revisor-5') },
|
||||
{ id: 'notionists-6', label: 'Johan', url: dicebearNotionists('johan-revisor-6') },
|
||||
{ id: 'notionists-7', label: 'Eva', url: dicebearNotionists('eva-revisor-7') },
|
||||
{ id: 'notionists-8', label: 'Per', url: dicebearNotionists('per-revisor-8') },
|
||||
]
|
||||
|
||||
export function getAvatarUrl(avatarId: string | null | undefined): string | null {
|
||||
if (!avatarId) return null
|
||||
return AVATAR_OPTIONS.find((a) => a.id === avatarId)?.url ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user