Phase 6: restructure Settings honestly

Settings was assistant config mislabeled, plus a dev-scaffold Account panel
("Wire ~/lib/identity.ts…") shown to users. Now:

- Assistant (was "LLM") + Personas (was "Agents", labelled "stored in this
  browser") — honest names for what these actually configure.
- Tenant (new, platform.tenants-gated) — reuses the Phase-4 Branding +
  Localization tab components scoped to the operator's active tenant. This is
  the component reuse the phase order was built for.
- Appearance — real inline pickers (theme/font/surface/background) instead of
  "use the icons in the appbar" text.
- Account — real read-only identity from the session + an Edit-profile link,
  replacing the dev scaffold.

Sections filter by capability. Typecheck 36→36, 24-route sweep clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-14 14:27:32 +10:00
parent af2c8d6663
commit a03fbd9e9b
2 changed files with 241 additions and 44 deletions

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router"
import {
Cpu,
Bot,
Palette,
User as UserIcon,
Info,
Users,
Building2,
Plus,
Trash2,
ExternalLink,
} from "lucide-react"
import { listModels } from "@crema/llm-ui"
import {
@@ -21,6 +24,16 @@ import { useArcadiaClient } from "@crema/arcadia-core-client"
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
import { AppShell } from "~/components/layout/app-shell"
import { DataState } from "~/components/data-state"
import { ThemeToggle } from "~/components/layout/theme-toggle"
import { BackgroundPicker } from "~/components/layout/background-picker"
import { FontSizePicker } from "~/components/layout/font-size-picker"
import { SurfacePicker } from "~/components/layout/surface-picker"
import { BrandingTab } from "~/components/tenant-detail/branding-tab"
import { LocalizationTab } from "~/components/tenant-detail/localization-tab"
import { getTenant, type Tenant } from "~/lib/arcadia/tenants"
import { useCapabilities } from "~/lib/capabilities"
import { useSession } from "~/lib/session"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
@@ -46,20 +59,42 @@ export const meta = () => pageTitle("Settings")
const SECTION_KEY = "crema.settings.section"
type SectionId = "llm" | "agents" | "appearance" | "account" | "about"
type SectionId =
| "assistant"
| "personas"
| "tenant"
| "appearance"
| "account"
| "about"
const sections: {
type SectionDef = {
id: SectionId
label: string
icon: React.ComponentType<{ className?: string }>
description: string
}[] = [
{ id: "llm", label: "LLM", icon: Cpu, description: "Model endpoint & budgets" },
/** When set, the section only shows if the session holds this capability. */
requiresCapability?: string
}
const sections: SectionDef[] = [
{
id: "agents",
label: "Agents",
id: "assistant",
label: "Assistant",
icon: Bot,
description: "Model, endpoint & budgets",
},
{
id: "personas",
label: "Personas",
icon: Users,
description: "Personas, roles, sub-prompts",
description: "Assistant personas (this browser)",
},
{
id: "tenant",
label: "Tenant",
icon: Building2,
description: "Branding & localization",
requiresCapability: "platform.tenants",
},
{
id: "appearance",
@@ -67,7 +102,7 @@ const sections: {
icon: Palette,
description: "Theme, font size, surface, background",
},
{ id: "account", label: "Account", icon: UserIcon, description: "Profile & preferences" },
{ id: "account", label: "Account", icon: UserIcon, description: "Your profile" },
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
]
@@ -159,13 +194,23 @@ export default function SettingsRoute() {
}
}
const caps = useCapabilities()
const visibleSections = sections.filter(
(s) => !s.requiresCapability || caps.has(s.requiresCapability as never),
)
const [section, setSection] = useState<SectionId>(() => {
if (typeof window === "undefined") return "llm"
if (typeof window === "undefined") return "assistant"
const stored = localStorage.getItem(SECTION_KEY)
return sections.some((s) => s.id === stored)
? (stored as SectionId)
: "llm"
: "assistant"
})
// If the stored section isn't available to this user (e.g. Tenant for a
// non-platform admin), fall back to Assistant.
useEffect(() => {
if (!visibleSections.some((s) => s.id === section)) setSection("assistant")
}, [visibleSections, section])
useEffect(() => {
if (typeof window !== "undefined")
localStorage.setItem(SECTION_KEY, section)
@@ -178,7 +223,7 @@ export default function SettingsRoute() {
aria-label="Settings sections"
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
>
{sections.map((s) => {
{visibleSections.map((s) => {
const Icon = s.icon
const active = section === s.id
return (
@@ -213,7 +258,7 @@ export default function SettingsRoute() {
</nav>
<div className="min-w-0">
{section === "llm" && (
{section === "assistant" && (
<div className="flex flex-col gap-4">
<LlmConfigurationsPanel />
@@ -245,39 +290,37 @@ export default function SettingsRoute() {
</div>
)}
{section === "agents" && <AgentsPanel />}
{section === "personas" && <AgentsPanel />}
{section === "tenant" && <TenantSettingsSection />}
{section === "appearance" && (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Theme, font size, surface tint, and background atmosphere are
in the appbar the toggles up top write to localStorage and
persist across sessions.
Theme, density, and background for this browser. These are
also in the top-right toolbar; they persist locally.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Use the icons in the appbar (top right) to change theme, font
size, surface tint, and background.
<CardContent className="flex flex-col divide-y">
<AppearanceRow label="Theme" hint="Light or dark.">
<ThemeToggle />
</AppearanceRow>
<AppearanceRow label="Font size" hint="Base text scale.">
<FontSizePicker />
</AppearanceRow>
<AppearanceRow label="Surface tint" hint="Panel background tint.">
<SurfacePicker />
</AppearanceRow>
<AppearanceRow label="Background" hint="Ambient page atmosphere.">
<BackgroundPicker />
</AppearanceRow>
</CardContent>
</Card>
)}
{section === "account" && (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Identity and profile preferences.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Wire <code className="font-mono">~/lib/identity.ts</code> to a
real session to populate this panel.
</CardContent>
</Card>
)}
{section === "account" && <AccountSection />}
{section === "about" && (
<Card>
@@ -286,11 +329,8 @@ export default function SettingsRoute() {
<CardDescription>App version and credits.</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>Arcadia Admin operator console for arcadia-core.</p>
<p>Built on the Crema design system.</p>
<p>
Hybrid traditional + AI-first scaffold with a virtual cursor
and command bus for assistant-driven UI control.
</p>
</CardContent>
</Card>
)}
@@ -318,6 +358,148 @@ function Field({
)
}
function AppearanceRow({
label,
hint,
children,
}: {
label: string
hint: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
{children}
</div>
)
}
function AccountSection() {
const session = useSession()
return (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your identity on this deployment.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<ReadOnlyFact label="Name" value={session?.name ?? "—"} />
<ReadOnlyFact label="Email" value={session?.email ?? "—"} />
<ReadOnlyFact label="Active tenant" value={session?.tenantSlug ?? "—"} mono />
<ReadOnlyFact
label="Roles"
value={session?.roles?.length ? session.roles.join(", ") : "—"}
/>
</div>
<div>
<Button asChild variant="outline" data-action="settings-account-profile">
<Link to="/profile">
Edit profile <ExternalLink className="size-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}
function ReadOnlyFact({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="rounded-lg border bg-card/40 px-3 py-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className={`mt-0.5 text-sm font-medium ${mono ? "font-mono" : ""}`}>{value}</div>
</div>
)
}
/**
* Tenant settings — reuses the Phase-4 tenant-detail tab components, scoped to
* the operator's active tenant. The branding/localization endpoints are
* platform-gated, so this section is only offered to platform admins (see the
* `requiresCapability` on the section); if the load 403s anyway, DataState
* surfaces it cleanly.
*/
function TenantSettingsSection() {
const arcadia = useArcadiaClient()
const session = useSession()
const tenantId = session?.tenantId
const [tenant, setTenant] = useState<Tenant | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const reload = useCallback(async () => {
if (!tenantId) {
setLoading(false)
return
}
setError(null)
setLoading(true)
try {
setTenant(await getTenant(arcadia, tenantId))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenantId])
useEffect(() => {
reload()
}, [reload])
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Branding and localization for your active tenant
{session?.tenantSlug ? (
<>
{" "}
(<code className="font-mono text-xs">{session.tenantSlug}</code>)
</>
) : null}
. To manage a different tenant, open it from{" "}
<Link to="/tenants" className="underline">
Tenants
</Link>
.
</p>
<DataState
loading={loading}
error={error}
isEmpty={!tenant}
onRetry={reload}
loadingLabel="Loading tenant…"
empty={
<div className="rounded-lg border py-8 text-center text-sm text-muted-foreground">
No active tenant on this session.
</div>
}
>
{tenant ? (
<div className="flex flex-col gap-6">
<BrandingTab tenant={tenant} reload={reload} />
<LocalizationTab tenant={tenant} reload={reload} />
</div>
) : null}
</DataState>
</div>
)
}
function AgentsPanel() {
const agents = useAgents()
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
@@ -354,12 +536,12 @@ function AgentsPanel() {
return (
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardTitle>Personas</CardTitle>
<CardDescription>
Personas with their own sub-system prompts. Switch the active one in
the chat status bar the assistant inherits its skills, tone, and
scope. Lets you keep contexts focused: a coder agent doesn't carry
writing-task context; a writer doesn't carry codebase context.
Assistant personas with their own sub-system prompts. Switch the
active one in the chat status bar the assistant inherits its skills,
tone, and scope. Stored in this browser only, not synced to your
account.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">