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:
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Link } from "react-router"
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Bot,
|
||||||
Palette,
|
Palette,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Info,
|
Info,
|
||||||
Users,
|
Users,
|
||||||
|
Building2,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { listModels } from "@crema/llm-ui"
|
import { listModels } from "@crema/llm-ui"
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +24,16 @@ import { useArcadiaClient } from "@crema/arcadia-core-client"
|
|||||||
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
||||||
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
|
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
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 { Button } from "~/components/ui/button"
|
||||||
import { Input } from "~/components/ui/input"
|
import { Input } from "~/components/ui/input"
|
||||||
import { Textarea } from "~/components/ui/textarea"
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
@@ -46,20 +59,42 @@ export const meta = () => pageTitle("Settings")
|
|||||||
|
|
||||||
const SECTION_KEY = "crema.settings.section"
|
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
|
id: SectionId
|
||||||
label: string
|
label: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
description: string
|
description: string
|
||||||
}[] = [
|
/** When set, the section only shows if the session holds this capability. */
|
||||||
{ id: "llm", label: "LLM", icon: Cpu, description: "Model endpoint & budgets" },
|
requiresCapability?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: SectionDef[] = [
|
||||||
{
|
{
|
||||||
id: "agents",
|
id: "assistant",
|
||||||
label: "Agents",
|
label: "Assistant",
|
||||||
|
icon: Bot,
|
||||||
|
description: "Model, endpoint & budgets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "personas",
|
||||||
|
label: "Personas",
|
||||||
icon: Users,
|
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",
|
id: "appearance",
|
||||||
@@ -67,7 +102,7 @@ const sections: {
|
|||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Theme, font size, surface, background",
|
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" },
|
{ 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>(() => {
|
const [section, setSection] = useState<SectionId>(() => {
|
||||||
if (typeof window === "undefined") return "llm"
|
if (typeof window === "undefined") return "assistant"
|
||||||
const stored = localStorage.getItem(SECTION_KEY)
|
const stored = localStorage.getItem(SECTION_KEY)
|
||||||
return sections.some((s) => s.id === stored)
|
return sections.some((s) => s.id === stored)
|
||||||
? (stored as SectionId)
|
? (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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined")
|
if (typeof window !== "undefined")
|
||||||
localStorage.setItem(SECTION_KEY, section)
|
localStorage.setItem(SECTION_KEY, section)
|
||||||
@@ -178,7 +223,7 @@ export default function SettingsRoute() {
|
|||||||
aria-label="Settings sections"
|
aria-label="Settings sections"
|
||||||
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
|
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 Icon = s.icon
|
||||||
const active = section === s.id
|
const active = section === s.id
|
||||||
return (
|
return (
|
||||||
@@ -213,7 +258,7 @@ export default function SettingsRoute() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{section === "llm" && (
|
{section === "assistant" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<LlmConfigurationsPanel />
|
<LlmConfigurationsPanel />
|
||||||
|
|
||||||
@@ -245,39 +290,37 @@ export default function SettingsRoute() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "agents" && <AgentsPanel />}
|
{section === "personas" && <AgentsPanel />}
|
||||||
|
|
||||||
|
{section === "tenant" && <TenantSettingsSection />}
|
||||||
|
|
||||||
{section === "appearance" && (
|
{section === "appearance" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Theme, font size, surface tint, and background atmosphere are
|
Theme, density, and background for this browser. These are
|
||||||
in the appbar — the toggles up top write to localStorage and
|
also in the top-right toolbar; they persist locally.
|
||||||
persist across sessions.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="flex flex-col divide-y">
|
||||||
Use the icons in the appbar (top right) to change theme, font
|
<AppearanceRow label="Theme" hint="Light or dark.">
|
||||||
size, surface tint, and background.
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "account" && (
|
{section === "account" && <AccountSection />}
|
||||||
<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 === "about" && (
|
{section === "about" && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -286,11 +329,8 @@ export default function SettingsRoute() {
|
|||||||
<CardDescription>App version and credits.</CardDescription>
|
<CardDescription>App version and credits.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 text-sm text-muted-foreground">
|
<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>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>
|
</CardContent>
|
||||||
</Card>
|
</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() {
|
function AgentsPanel() {
|
||||||
const agents = useAgents()
|
const agents = useAgents()
|
||||||
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
|
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
|
||||||
@@ -354,12 +536,12 @@ function AgentsPanel() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Agents</CardTitle>
|
<CardTitle>Personas</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Personas with their own sub-system prompts. Switch the active one in
|
Assistant personas with their own sub-system prompts. Switch the
|
||||||
the chat status bar — the assistant inherits its skills, tone, and
|
active one in the chat status bar — the assistant inherits its skills,
|
||||||
scope. Lets you keep contexts focused: a coder agent doesn't carry
|
tone, and scope. Stored in this browser only, not synced to your
|
||||||
writing-task context; a writer doesn't carry codebase context.
|
account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
|||||||
15
arcadia-admin-ux-spec.md
Normal file
15
arcadia-admin-ux-spec.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
## Phase 6 — as built (2026-07-14) — Settings restructure
|
||||||
|
|
||||||
|
`arcadia-admin` (branch `feat/admin-ux-overhaul`). Settings was "assistant config mislabeled as Settings, plus a dev-scaffold Account panel." Restructured honestly:
|
||||||
|
|
||||||
|
- **Assistant** (was "LLM") — model/endpoint/budgets + advanced transport + reset. Honestly named.
|
||||||
|
- **Personas** (was "Agents") — the persona editor, now labelled "Stored in this browser only, not synced to your account" (they're localStorage).
|
||||||
|
- **Tenant** (new, `platform.tenants`-gated) — **reuses the Phase-4 BrandingTab + LocalizationTab** scoped to the operator's active tenant (loaded via getTenant(session.tenantId)). This is the component-reuse the phase order was designed for; verified end-to-end (branding save → "Branding updated"). Gated to platform admins because the underlying endpoints are platform-only (my Phase-1 change); a non-holder simply doesn't see the section.
|
||||||
|
- **Appearance** — replaced the "use the icons in the appbar" fluff with the real inline pickers (Theme, Font size, Surface tint, Background — the same components the appbar mounts).
|
||||||
|
- **Account** — replaced the dev scaffold ("Wire ~/lib/identity.ts…" shown verbatim to users) with real read-only identity (name/email/active tenant/roles from the session) + an "Edit profile" link to /profile.
|
||||||
|
- **About** — trimmed to honest one-liners.
|
||||||
|
|
||||||
|
Sections filter by capability; the persisted section falls back to Assistant if unavailable to the user.
|
||||||
|
|
||||||
|
Verified: honest section labels, no dev scaffold text, real Account identity, real Appearance controls, "this browser" persona label, and the Tenant section reusing Phase-4 tabs with a working save. Typecheck 36→36 (zero added), 24-route sweep clean.
|
||||||
Reference in New Issue
Block a user