6d9846b1e7
* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar Founder-approved concept (2026-07-25) applied to the whole settings surface, modal and full-page variants alike: - New primitives in components/settings/SettingsRows.tsx: section header (serif title + one-line intro), eyebrow groups, hairline label/control rows, flat inputs/selects/textareas, segmented control, animated reveal for gated settings, danger zone. - Every static explanation paragraph moved behind a "?" popover (HelpPopover) at row or group level; dynamic status stays visible. - Modal chrome: company kicker over serif title, fixed 920x680 window. - SettingsFormWrapper: save is a sticky bar that appears only when the form is dirty; collapses to zero height when clean. - All 11 sections converted (Konto, Abonnemang, Företag, Bokföring, Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel, Assistenten, API) with handlers, validation, role/entitlement/sandbox gates and i18n keys preserved; checkboxes became switches, cards dissolved into groups. - Fix: Escape with an open help popover closed the whole settings modal; it now closes the popover first. - New i18n keys: settings_intro.*, group labels, wrapper_unsaved (sv+en). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): founder feedback round 1 on the Fönster redesign - Abonnemang paying state: status and manage split into two rows so the row no longer wraps awkwardly; the included-features list now shows for paying companies too. - Logos where the counterpart has one: BankID mark on the security row and on the Koppla BankID button, Skatteverket mark on the connection rows. - Buttons are unmistakably buttons: 27 text-labeled row actions went from ghost to outline pills; icon-only actions stay quiet. - The agent-knowledge view (Regler & profil: Dina regler, Momsprofil, Konventioner) converted to the flat row language; it was the last old-style surface inside settings. Descriptions moved behind "?", rules render as hairline rows, the per-row "Regel" chip demoted to muted text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): address review-bot findings on the Fönster redesign - SettingsFormWrapper marks the form dirty on switch clicks too: Radix Switch is a button and fires no input event, so switch-only changes (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar. - i18n: the migrated hardcoded strings got keys in both locales (fiscal-period start date/range/months, security set-password trio); dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass the active locale to formatDateLong. - A11y: member remove/revoke buttons and the invite role select got correct accessible names; BankNameCombobox accepts aria-label wired from its row; the pinned-fact icon exposes role img. - BankIdSettings: explicit Avbryt under the QR block so a cancelled BankID flow cannot strand isLinking. - VoucherSeriesManager: clear the skeleton when no company is resolved. Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings 200 for text and switch saves, persistence across hard reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import {
|
|
SettingsGroup,
|
|
SettingsReveal,
|
|
SettingsRow,
|
|
SettingsRowEnd,
|
|
SettingsRowNote,
|
|
SettingsSeg,
|
|
SettingsSelect,
|
|
SettingsTextarea,
|
|
} from '@/components/settings/SettingsRows'
|
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
|
import { cn, formatDateLong } from '@/lib/utils'
|
|
|
|
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
|
|
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
|
|
|
interface AgentMemoryRow {
|
|
id: string
|
|
kind: Kind
|
|
content: string
|
|
source: Source
|
|
source_ref: string | null
|
|
relevance_score: number
|
|
is_pinned: boolean
|
|
is_active: boolean
|
|
last_accessed_at: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
const KIND_LABEL: Record<Kind, string> = {
|
|
fact: 'Fakta',
|
|
preference: 'Preferens',
|
|
pattern: 'Mönster',
|
|
correction: 'Korrigering',
|
|
}
|
|
|
|
const SOURCE_LABEL: Record<Source, string> = {
|
|
composer: 'Inläst vid uppstart',
|
|
user_taught: 'Du lärde mig',
|
|
agent_learned: 'Jag noterade',
|
|
derived: 'Härlett',
|
|
}
|
|
|
|
const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
|
|
{ value: 'all', label: 'Alla' },
|
|
{ value: 'fact', label: 'Fakta' },
|
|
{ value: 'preference', label: 'Preferenser' },
|
|
{ value: 'pattern', label: 'Mönster' },
|
|
{ value: 'correction', label: 'Korrigeringar' },
|
|
]
|
|
|
|
// The API returns errors either as a plain string (legacy/validation) or as
|
|
// the canonical { code, message } envelope; extract something renderable.
|
|
function apiErrorText(error: unknown): string | undefined {
|
|
if (typeof error === 'string') return error
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
const m = (error as { message?: unknown }).message
|
|
return typeof m === 'string' ? m : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function AgentMemoryPanel() {
|
|
const { toast } = useToast()
|
|
const { canWrite } = useCanWrite()
|
|
|
|
const [rows, setRows] = useState<AgentMemoryRow[] | null>(null)
|
|
const [includeDismissed, setIncludeDismissed] = useState(false)
|
|
const [kindFilter, setKindFilter] = useState<'all' | Kind>('all')
|
|
const [busyId, setBusyId] = useState<string | null>(null)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [editDraft, setEditDraft] = useState('')
|
|
const [showAdd, setShowAdd] = useState(false)
|
|
const [newContent, setNewContent] = useState('')
|
|
const [newKind, setNewKind] = useState<Kind>('fact')
|
|
const [adding, setAdding] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
const params = new URLSearchParams()
|
|
if (includeDismissed) params.set('include_dismissed', 'true')
|
|
if (kindFilter !== 'all') params.set('kind', kindFilter)
|
|
const res = await fetch(`/api/agent/memory?${params.toString()}`)
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte hämta minne', description: apiErrorText(json.error), variant: 'destructive' })
|
|
setRows([])
|
|
return
|
|
}
|
|
setRows(json.data as AgentMemoryRow[])
|
|
}, [includeDismissed, kindFilter, toast])
|
|
|
|
useEffect(() => { void load() }, [load])
|
|
|
|
const counts = useMemo(() => {
|
|
const active = rows?.filter((r) => r.is_active).length ?? 0
|
|
const pinned = rows?.filter((r) => r.is_active && r.is_pinned).length ?? 0
|
|
const dismissed = rows?.filter((r) => !r.is_active).length ?? 0
|
|
return { active, pinned, dismissed }
|
|
}, [rows])
|
|
|
|
async function patch(id: string, body: Partial<Pick<AgentMemoryRow, 'content' | 'is_pinned' | 'is_active'>>) {
|
|
setBusyId(id)
|
|
try {
|
|
const res = await fetch(`/api/agent/memory/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte uppdatera', description: apiErrorText(json.error), variant: 'destructive' })
|
|
return
|
|
}
|
|
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
async function addMemory() {
|
|
if (newContent.trim().length < 2) return
|
|
setAdding(true)
|
|
try {
|
|
const res = await fetch('/api/agent/memory', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: newContent.trim(), kind: newKind }),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte spara minne', description: apiErrorText(json.error), variant: 'destructive' })
|
|
return
|
|
}
|
|
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
|
|
setNewContent('')
|
|
setNewKind('fact')
|
|
setShowAdd(false)
|
|
toast({ title: 'Minne sparat' })
|
|
} finally {
|
|
setAdding(false)
|
|
}
|
|
}
|
|
|
|
function startEdit(row: AgentMemoryRow) {
|
|
setEditingId(row.id)
|
|
setEditDraft(row.content)
|
|
}
|
|
|
|
async function saveEdit(row: AgentMemoryRow) {
|
|
const next = editDraft.trim()
|
|
if (next.length < 2 || next === row.content) {
|
|
setEditingId(null)
|
|
return
|
|
}
|
|
await patch(row.id, { content: next })
|
|
setEditingId(null)
|
|
}
|
|
|
|
// The view wrapper in AssistantSettingsContent already provides the gap
|
|
// under the segmented control, so the group starts flush (pt-0).
|
|
return (
|
|
<SettingsGroup
|
|
label="Vad min assistent kommer ihåg"
|
|
help={
|
|
<>
|
|
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
|
|
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30
|
|
minnen ingår i samtal per tur.
|
|
</>
|
|
}
|
|
className="pt-0 first:pt-0"
|
|
>
|
|
{/* Toolbar row: kind filter + the add-memory entry point. */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-1 py-3">
|
|
<SettingsSeg
|
|
value={kindFilter}
|
|
onChange={setKindFilter}
|
|
options={KIND_FILTER}
|
|
aria-label="Filtrera minnen"
|
|
/>
|
|
{canWrite && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowAdd((v) => !v)}
|
|
disabled={adding}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Lägg till minne
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{canWrite && (
|
|
<SettingsReveal open={showAdd}>
|
|
<div className="space-y-3 py-3">
|
|
<SettingsTextarea
|
|
value={newContent}
|
|
onChange={(e) => setNewContent(e.target.value)}
|
|
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
|
|
rows={3}
|
|
maxLength={2000}
|
|
aria-label="Nytt minne"
|
|
className="w-full border-border"
|
|
/>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">Typ</span>
|
|
<SettingsSelect
|
|
value={newKind}
|
|
onChange={(e) => setNewKind(e.target.value as Kind)}
|
|
aria-label="Typ"
|
|
>
|
|
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
|
|
<option key={k} value={k}>{KIND_LABEL[k]}</option>
|
|
))}
|
|
</SettingsSelect>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
|
|
Avbryt
|
|
</Button>
|
|
<Button size="sm" onClick={addMemory} disabled={adding || newContent.trim().length < 2}>
|
|
{adding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
Spara
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsReveal>
|
|
)}
|
|
|
|
<SettingsRow label="Visa dolda">
|
|
<SettingsRowEnd>
|
|
<Switch
|
|
checked={includeDismissed}
|
|
onCheckedChange={setIncludeDismissed}
|
|
aria-label="Visa dolda"
|
|
/>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
|
|
{/* Dynamic status stays visible; the static "how it's used" copy lives
|
|
in the group help above. */}
|
|
{rows && (
|
|
<p className="px-1 pt-3">
|
|
<SettingsRowNote className="tabular-nums">
|
|
{counts.active} aktiva · {counts.pinned} fästa
|
|
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
|
|
</SettingsRowNote>
|
|
</p>
|
|
)}
|
|
|
|
{rows === null && (
|
|
<div className="space-y-3 pt-3">
|
|
{[0, 1, 2].map((i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{rows && rows.length === 0 && (
|
|
<EmptyState
|
|
icon={Brain}
|
|
title="Inga minnen ännu"
|
|
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
|
|
/>
|
|
)}
|
|
|
|
{rows && rows.length > 0 && (
|
|
<ul>
|
|
{rows.map((row) => {
|
|
const isEditing = editingId === row.id
|
|
const isBusy = busyId === row.id
|
|
const dimmed = !row.is_active
|
|
return (
|
|
<li
|
|
key={row.id}
|
|
className={cn(
|
|
'border-b border-border px-1 py-3 transition-colors',
|
|
dimmed && 'opacity-70',
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{canWrite && row.is_active ? (
|
|
<button
|
|
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
|
|
disabled={isBusy}
|
|
className={cn(
|
|
'mt-0.5 shrink-0 rounded-md p-1.5 transition-colors duration-150',
|
|
row.is_pinned
|
|
? 'bg-secondary text-foreground'
|
|
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
|
)}
|
|
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
|
|
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
|
|
>
|
|
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
|
|
</button>
|
|
) : (
|
|
<div className="mt-0.5 shrink-0 p-1.5">
|
|
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
|
|
</span>
|
|
{dimmed && <Badge variant="secondary">Dold</Badge>}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<SettingsTextarea
|
|
value={editDraft}
|
|
onChange={(e) => setEditDraft(e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
autoFocus
|
|
aria-label="Redigera minne"
|
|
className="w-full border-border"
|
|
/>
|
|
) : (
|
|
<p className="whitespace-pre-wrap break-words text-sm text-foreground">{row.content}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
|
<p className="text-[11px] text-muted-foreground tabular-nums">
|
|
Skapad {formatDateLong(row.created_at)}
|
|
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
|
|
</p>
|
|
|
|
{canWrite && (
|
|
<div className="flex items-center gap-1">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditingId(null)}
|
|
disabled={isBusy}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Avbryt</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => saveEdit(row)}
|
|
disabled={isBusy || editDraft.trim().length < 2}
|
|
>
|
|
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
|
|
</Button>
|
|
</>
|
|
) : row.is_active ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => startEdit(row)}
|
|
disabled={isBusy}
|
|
>
|
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
|
Redigera
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => patch(row.id, { is_active: false })}
|
|
disabled={isBusy}
|
|
>
|
|
{isBusy ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
Dölj
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => patch(row.id, { is_active: true })}
|
|
disabled={isBusy}
|
|
>
|
|
{isBusy ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
Återställ
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</SettingsGroup>
|
|
)
|
|
}
|