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>
672 lines
22 KiB
TypeScript
672 lines
22 KiB
TypeScript
import { useCallback, useEffect, useState } from "react"
|
|
import { Link } from "react-router"
|
|
import {
|
|
Bot,
|
|
Palette,
|
|
User as UserIcon,
|
|
Info,
|
|
Users,
|
|
Building2,
|
|
Plus,
|
|
Trash2,
|
|
ExternalLink,
|
|
} from "lucide-react"
|
|
import { listModels } from "@crema/llm-ui"
|
|
import {
|
|
buildAdapter,
|
|
LLMProvidersSettingsCard,
|
|
resetSettings as resetProviderSettings,
|
|
useSettings as useProviderSettings,
|
|
type LLMProvidersSettings,
|
|
} from "@crema/llm-providers-ui"
|
|
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"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card"
|
|
import {
|
|
loadActiveAgentId,
|
|
newAgentId,
|
|
resetAgents,
|
|
saveActiveAgentId,
|
|
saveAgents,
|
|
useAgents,
|
|
type Agent,
|
|
} from "~/lib/agents"
|
|
import { pageTitle } from "~/lib/page-meta"
|
|
|
|
export const meta = () => pageTitle("Settings")
|
|
|
|
const SECTION_KEY = "crema.settings.section"
|
|
|
|
type SectionId =
|
|
| "assistant"
|
|
| "personas"
|
|
| "tenant"
|
|
| "appearance"
|
|
| "account"
|
|
| "about"
|
|
|
|
type SectionDef = {
|
|
id: SectionId
|
|
label: string
|
|
icon: React.ComponentType<{ className?: string }>
|
|
description: string
|
|
/** When set, the section only shows if the session holds this capability. */
|
|
requiresCapability?: string
|
|
}
|
|
|
|
const sections: SectionDef[] = [
|
|
{
|
|
id: "assistant",
|
|
label: "Assistant",
|
|
icon: Bot,
|
|
description: "Model, endpoint & budgets",
|
|
},
|
|
{
|
|
id: "personas",
|
|
label: "Personas",
|
|
icon: Users,
|
|
description: "Assistant personas (this browser)",
|
|
},
|
|
{
|
|
id: "tenant",
|
|
label: "Tenant",
|
|
icon: Building2,
|
|
description: "Branding & localization",
|
|
requiresCapability: "platform.tenants",
|
|
},
|
|
{
|
|
id: "appearance",
|
|
label: "Appearance",
|
|
icon: Palette,
|
|
description: "Theme, font size, surface, background",
|
|
},
|
|
{ id: "account", label: "Account", icon: UserIcon, description: "Your profile" },
|
|
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
|
|
]
|
|
|
|
export default function SettingsRoute() {
|
|
const arcadia = useArcadiaClient()
|
|
|
|
const testConnection = async (
|
|
s: LLMProvidersSettings,
|
|
): Promise<{ ok: boolean; message: string }> => {
|
|
try {
|
|
const arcadiaBaseURL =
|
|
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
|
|
const arcadiaTenantId =
|
|
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
|
|
const arcadiaAuthToken =
|
|
typeof window !== "undefined"
|
|
? sessionStorage.getItem("arcadia_access_token") ?? undefined
|
|
: undefined
|
|
|
|
const adapter = await buildAdapter({
|
|
settings: s,
|
|
// Direct-mode resolver — fetches the API key from the vault.
|
|
resolveSecret: async (name) => {
|
|
const res = await arcadia.GET<{ data: { value: string } }>(
|
|
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
|
)
|
|
return res.data.value
|
|
},
|
|
// Proxy-mode coordinates.
|
|
arcadiaBaseURL,
|
|
arcadiaAuthToken,
|
|
arcadiaTenantId,
|
|
})
|
|
|
|
// Proxy mode: round-trip a 1-token chat to verify auth → secret
|
|
// resolution → upstream dispatch end-to-end. Maps the contract's
|
|
// specific error codes to user-facing messages.
|
|
if (s.mode === "proxy") {
|
|
return probeProxy(arcadia, {
|
|
provider: s.providerId as LLMProxyProvider,
|
|
model: s.model || (s.providerId === "anthropic" ? "claude-opus-4-7" : "gpt-4o-mini"),
|
|
secretName: s.secretName || undefined,
|
|
})
|
|
}
|
|
|
|
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
|
|
if (s.providerId !== "anthropic") {
|
|
const baseURL =
|
|
s.baseURL ||
|
|
(s.providerId === "lmstudio"
|
|
? "http://localhost:1234/v1"
|
|
: s.providerId === "openai"
|
|
? "https://api.openai.com/v1"
|
|
: s.providerId === "deepseek"
|
|
? "https://api.deepseek.com"
|
|
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
|
// Resolve key for the probe (lmstudio doesn't need one).
|
|
let apiKey: string | undefined
|
|
if (s.providerId !== "lmstudio" && s.secretName) {
|
|
try {
|
|
const res = await arcadia.GET<{ data: { value: string } }>(
|
|
`/api/v1/secrets/${encodeURIComponent(s.secretName)}`,
|
|
)
|
|
apiKey = res.data.value
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
if (/404|not[_ ]found/i.test(msg)) {
|
|
return {
|
|
ok: false,
|
|
message: `No vault secret named "${s.secretName}". Create it under /secrets first (paste the API key as the Value), then enter the secret's name here.`,
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
const ac = new AbortController()
|
|
const t = setTimeout(() => ac.abort(), 5000)
|
|
try {
|
|
const rows = await listModels({ baseURL, apiKey, signal: ac.signal })
|
|
return { ok: true, message: `Connected. ${rows.length} model(s) reachable.` }
|
|
} finally {
|
|
clearTimeout(t)
|
|
}
|
|
}
|
|
// Anthropic doesn't expose a /models list; we just confirm adapter built.
|
|
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
}
|
|
}
|
|
|
|
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 "assistant"
|
|
const stored = localStorage.getItem(SECTION_KEY)
|
|
return sections.some((s) => s.id === stored)
|
|
? (stored as SectionId)
|
|
: "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)
|
|
}, [section])
|
|
|
|
return (
|
|
<AppShell>
|
|
<div className="grid gap-6 pt-10 md:grid-cols-[14rem_1fr] md:pt-0">
|
|
<nav
|
|
aria-label="Settings sections"
|
|
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
|
|
>
|
|
{visibleSections.map((s) => {
|
|
const Icon = s.icon
|
|
const active = section === s.id
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
data-action={`settings-section-${s.id}`}
|
|
onClick={() => setSection(s.id)}
|
|
aria-current={active ? "page" : undefined}
|
|
className={[
|
|
"group flex shrink-0 items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard",
|
|
active
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
].join(" ")}
|
|
>
|
|
<Icon className="size-4 shrink-0" />
|
|
<span className="flex flex-col">
|
|
<span className="font-medium leading-tight">{s.label}</span>
|
|
<span
|
|
className={[
|
|
"hidden text-xs leading-tight md:inline",
|
|
active ? "text-primary/80" : "text-muted-foreground/80",
|
|
].join(" ")}
|
|
>
|
|
{s.description}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<div className="min-w-0">
|
|
{section === "assistant" && (
|
|
<div className="flex flex-col gap-4">
|
|
<LlmConfigurationsPanel />
|
|
|
|
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
|
<summary className="cursor-pointer text-muted-foreground">
|
|
Advanced: tweak the active session settings (transport, system prompt,
|
|
context budget) directly
|
|
</summary>
|
|
<div className="pt-3">
|
|
<LLMProvidersSettingsCard
|
|
onTest={testConnection}
|
|
hideTransportToggle={false}
|
|
/>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => resetProviderSettings()}
|
|
data-action="settings-reset"
|
|
>
|
|
Reset to defaults
|
|
</Button>
|
|
<span className="text-xs text-muted-foreground">
|
|
Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{section === "personas" && <AgentsPanel />}
|
|
|
|
{section === "tenant" && <TenantSettingsSection />}
|
|
|
|
{section === "appearance" && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Appearance</CardTitle>
|
|
<CardDescription>
|
|
Theme, density, and background for this browser. These are
|
|
also in the top-right toolbar; they persist locally.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<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" && <AccountSection />}
|
|
|
|
{section === "about" && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>About</CardTitle>
|
|
<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>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string
|
|
hint?: string
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-sm font-medium">{label}</span>
|
|
{children}
|
|
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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())
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
saveActiveAgentId(activeId)
|
|
}, [activeId])
|
|
|
|
const editing = agents.find((a) => a.id === editingId) ?? null
|
|
|
|
const update = (next: Agent) => {
|
|
saveAgents(agents.map((a) => (a.id === next.id ? next : a)))
|
|
}
|
|
const remove = (id: string) => {
|
|
if (agents.length <= 1) return
|
|
const next = agents.filter((a) => a.id !== id)
|
|
saveAgents(next)
|
|
if (activeId === id) setActiveId(next[0].id)
|
|
if (editingId === id) setEditingId(null)
|
|
}
|
|
const create = () => {
|
|
const id = newAgentId()
|
|
const draft: Agent = {
|
|
id,
|
|
name: "New persona",
|
|
role: "Specialist",
|
|
prompt: "Describe what this persona is good at and how it should respond.",
|
|
}
|
|
saveAgents([...agents, draft])
|
|
setEditingId(id)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Personas</CardTitle>
|
|
<CardDescription>
|
|
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">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
data-action="settings-agent-new"
|
|
onClick={create}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
<Plus className="size-4" /> New persona
|
|
</Button>
|
|
<Button
|
|
data-action="settings-agent-reset"
|
|
onClick={() => {
|
|
resetAgents()
|
|
setEditingId(null)
|
|
}}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
Reset to defaults
|
|
</Button>
|
|
</div>
|
|
|
|
<ul className="flex flex-col gap-1.5">
|
|
{agents.map((a) => {
|
|
const isActive = activeId === a.id
|
|
const isEditing = editingId === a.id
|
|
return (
|
|
<li
|
|
key={a.id}
|
|
className={[
|
|
"rounded-lg border transition-colors",
|
|
isEditing
|
|
? "border-primary/40 bg-primary/5"
|
|
: "border-border bg-card/40",
|
|
].join(" ")}
|
|
>
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
data-action={`settings-agent-activate-${a.id}`}
|
|
onClick={() => setActiveId(a.id)}
|
|
className={[
|
|
"size-2.5 shrink-0 rounded-full ring-2 transition-colors",
|
|
isActive
|
|
? "bg-primary ring-primary/30"
|
|
: "bg-muted ring-transparent hover:ring-foreground/20",
|
|
].join(" ")}
|
|
aria-label={
|
|
isActive ? `${a.name} (active)` : `Activate ${a.name}`
|
|
}
|
|
title={isActive ? "Active" : "Set active"}
|
|
/>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-sm font-medium">{a.name}</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{a.role}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
data-action={`settings-agent-edit-${a.id}`}
|
|
onClick={() => setEditingId(isEditing ? null : a.id)}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
{isEditing ? "Done" : "Edit"}
|
|
</Button>
|
|
<Button
|
|
data-action={`settings-agent-delete-${a.id}`}
|
|
onClick={() => remove(a.id)}
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
disabled={agents.length <= 1}
|
|
aria-label="Delete persona"
|
|
title="Delete persona"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
{isEditing && editing && (
|
|
<div className="flex flex-col gap-3 border-t bg-background/60 px-3 py-3">
|
|
<Field label="Name">
|
|
<Input
|
|
data-action={`settings-agent-name-${a.id}`}
|
|
value={editing.name}
|
|
onChange={(e) =>
|
|
update({ ...editing, name: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Role">
|
|
<Input
|
|
data-action={`settings-agent-role-${a.id}`}
|
|
value={editing.role}
|
|
onChange={(e) =>
|
|
update({ ...editing, role: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Sub-system prompt"
|
|
hint="Stacked on top of the main system prompt only when this persona is active."
|
|
>
|
|
<Textarea
|
|
data-action={`settings-agent-prompt-${a.id}`}
|
|
value={editing.prompt}
|
|
onChange={(e) =>
|
|
update({ ...editing, prompt: e.target.value })
|
|
}
|
|
rows={6}
|
|
className="min-h-32 font-mono text-xs"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|