Files
accounted/components/settings/sections/AssistantSettingsContent.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

227 lines
8.6 KiB
TypeScript

'use client'
import { useEffect, useId, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { Switch } from '@/components/ui/switch'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsSectionHeader,
SettingsSeg,
} from '@/components/settings/SettingsRows'
// "Assistenten": the ledger profile the agent reads before booking (Kunskap =
// "Vad din agent vet", opens on the konteringskarta and is the default view),
// what the assistant remembers about this company (Minne, editable), and the
// domain knowledge it ships with (Kompetens, read-only). The segmented control
// keeps all three one click away instead of stacked.
type View = 'knowledge' | 'memory' | 'skills'
const VIEW_ROUTE: Record<View, string> = {
knowledge: '/settings/assistant',
memory: '/settings/assistant?view=memory',
skills: '/settings/assistant?view=skills',
}
const VIEW_OPTIONS: Array<{ value: View; label: string }> = [
{ value: 'knowledge', label: 'Kunskap' },
{ value: 'memory', label: 'Minne' },
{ value: 'skills', label: 'Kompetens' },
]
export function AssistantSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams()
const router = useRouter()
const raw = searchParams.get('view')
const view: View = raw === 'skills' ? 'skills' : raw === 'memory' ? 'memory' : 'knowledge'
function setView(next: View) {
// 'knowledge' is the default: keep its URL clean (no query string).
router.replace(VIEW_ROUTE[next] ?? VIEW_ROUTE.knowledge, { scroll: false })
}
return (
<div>
<SettingsSectionHeader title={tNav('assistant')} intro={tIntro('assistant')} />
<div className="mt-6">
<SettingsSeg value={view} onChange={setView} options={VIEW_OPTIONS} aria-label="Välj vy" />
</div>
{/* Only the active view mounts, so each panel's data is fetched lazily
the first time its view is opened (same behavior as when Radix Tabs
unmounted the inactive panels). */}
<div className="mt-6">
{view === 'knowledge' && <AgentKnowledgePanel />}
{view === 'memory' && <AgentMemoryPanel />}
{view === 'skills' && <AgentSkillsPanel />}
</div>
<FabVisibilityRow />
</div>
)
}
// Per-user toggle for the floating assistant button bottom-right. The value
// lives on user_preferences (server-rendered into the dashboard layout), so
// a successful save triggers router.refresh() to make the button react
// immediately instead of on next navigation.
function FabVisibilityRow() {
const t = useTranslations('settings_assistant')
const errorLocale = useLocale() as ErrorLocale
const { toast } = useToast()
const router = useRouter()
// null = the value is not known: still loading, or the read failed (loadError).
const [hideFab, setHideFab] = useState<boolean | null>(null)
// detail === null: transient, so the line carries a retry. A detail sentence
// means the user has to act (an expired session) and a retry cannot help.
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [saving, setSaving] = useState(false)
const errorId = useId()
// A settings row that cannot read its own value must not render one.
// Defaulting to false on a failed GET drew a loaded-looking, interactive
// switch in the "assistant button visible" position whatever the stored
// preference was, and here that claim is contradicted on screen: the button
// is server-rendered from the same column in the dashboard layout, so an
// empty corner sat next to a switch insisting it was on.
//
// So the unknown value stays unknown: the switch keeps its disabled state and
// one line says the value could not be read. 401/403 is the only case where
// the reason changes what the user must do, so that message (from the same
// status map the save path uses) replaces the retry instead of sitting next
// to it; everything else is transient and the retry is the whole answer.
useEffect(() => {
let cancelled = false
async function load() {
setLoadError(null)
try {
const res = await fetch('/api/user/preferences')
if (!res.ok) {
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
if (cancelled) return
const sessionGone = res.status === 401 || res.status === 403
setHideFab(null)
setLoadError({
detail: sessionGone
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
: null,
})
return
}
// A 200 whose body will not parse is a failed read too: let it land in
// the catch below rather than become a fabricated `false`.
const body = await res.json()
if (!cancelled) setHideFab(Boolean(body?.data?.hide_assistant_fab))
} catch {
if (!cancelled) {
setHideFab(null)
setLoadError({ detail: null })
}
}
}
void load()
return () => {
cancelled = true
}
}, [reloadKey, errorLocale])
// Rolling the switch back is necessary but not sufficient: a switch that
// flips and flips again reads as a broken control, not as a refused save, so
// the user retries the same click instead of signing in again (401 on an
// expired session is by far the likeliest refusal here, and the status map
// says exactly that). One toast per failed click, never two: TOAST_LIMIT is
// 1 (components/ui/use-toast.tsx) and a second would evict the first.
//
// Only the fetch sits inside the try. A router.refresh() that threw must not
// roll the switch back and claim the save failed, because by then the write
// has landed.
async function handleToggle(showFab: boolean) {
const nextHide = !showFab
const previous = hideFab
setHideFab(nextHide)
setSaving(true)
try {
const res = await fetch('/api/user/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hide_assistant_fab: nextHide }),
})
if (!res.ok) {
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
setHideFab(previous)
toast({
title: t('fab_save_failed'),
description: getErrorMessage(body, {
statusCode: res.status,
locale: errorLocale,
}),
variant: 'destructive',
})
return
}
} catch (err) {
setHideFab(previous)
toast({
title: t('fab_save_failed'),
description: getErrorMessage(err, { locale: errorLocale }),
variant: 'destructive',
})
return
} finally {
setSaving(false)
}
router.refresh()
}
return (
<SettingsGroup>
<SettingsRow label={t('fab_title')} help={t('fab_description')}>
{/* Live region always mounted so the failure is announced when it
appears, not merely inserted. */}
<div id={errorId} role="status" aria-live="polite" className="min-w-0">
{loadError && (
<AttnLine
action={
loadError.detail
? undefined
: { label: t('fab_load_retry'), onClick: () => setReloadKey((k) => k + 1) }
}
>
{loadError.detail
? `${t('fab_load_failed')} ${loadError.detail}`
: t('fab_load_failed')}
</AttnLine>
)}
</div>
<SettingsRowEnd>
<Switch
checked={hideFab === null ? true : !hideFab}
onCheckedChange={handleToggle}
disabled={hideFab === null || saving}
aria-label={t('fab_title')}
aria-describedby={loadError ? errorId : undefined}
/>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
)
}