ui: confirming an alert dialog should close it #6

Open
jules wants to merge 7 commits from fix/alert-dialog-action-closes into main
2 changed files with 241 additions and 44 deletions
Showing only changes of commit a03fbd9e9b - Show all commits

View File

@@ -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
View 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.