Files
arcadia-admin/app/routes/search.tsx
jules 7415b40240 Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases:

P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never
imported). Added imports + a shared route-level error boundary
(components/route-error.tsx) re-exported from all shell routes, so one
crashing panel degrades to an explained card with the nav intact instead of
replacing the whole app with a stack trace. Corrected the stale CLAUDE.md
claim that `npm run typecheck` crashes — it works, and would have caught the
missing import.

P2 — Error/empty/feedback foundations. Fixed useSession identity churn that
fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially
stable snapshot). New lib/errors.ts (describeError → plain-language + the fix)
and components/data-state.tsx (DataState renders exactly one of
error/loading/empty/content, so a failed load never shows as "empty";
DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15
list routes; every mutation now toasts. Surfaced+fixed two silent-failure
bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules
on save).

P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent
sections; collapsed the Apps/Plan/Entitlements stub triplication into one
honest /billing page; deleted fabricated seeded notifications and the dead
Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't
exist); removed orphan /assistant + /library routes; gated dev-seed login
hints behind DEV; renamed /activity → /audit-log with a redirect.

P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*),
closing the provision→configure gap. 8 tabs (Overview, Plan & quotas,
Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound
webhooks), each verified saving to the real backend. Row name links to detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:43:57 +10:00

920 lines
27 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import {
CheckCircle2,
Database,
FileText,
Plus,
Power,
RefreshCw,
Trash2,
} from "lucide-react"
import {
ActionsCell,
DataTable,
Pagination,
useTable,
type ActionItem,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { useToast } from "@crema/notification-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError, ErrorState } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardHeader,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Textarea } from "~/components/ui/textarea"
import {
searchAdmin,
type CorpusSummary,
type TenantSummary,
} from "~/lib/search-admin"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterContext } from "@crema/aifirst-ui/context"
export const meta = () => pageTitle("Search")
type Row = CorpusSummary & { rowId: string }
type EditorState =
| { kind: "new-tenant" }
| { kind: "new-corpus"; tenant: string }
| { kind: "edit-corpus"; tenant: string; corpus: string }
| null
export default function SearchRoute() {
const session = useSession()
const toast = useToast()
const [tenants, setTenants] = useState<TenantSummary[]>([])
const [corpora, setCorpora] = useState<Row[]>([])
const [loading, setLoading] = useState(false)
// arcadia-search is a separate sidecar, so this is often a plain Error /
// TypeError (connection refused) rather than an ArcadiaError. Pass it through
// raw — `describeError` already turns that into "Can't reach arcadia".
const [error, setError] = useState<unknown>(null)
const [corporaError, setCorporaError] = useState<unknown>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
null,
)
const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{
tenant: string
corpus: string
} | null>(null)
const [restartConfirm, setRestartConfirm] = useState(false)
const [rebuilding, setRebuilding] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
setCorporaError(null)
try {
const tRes = await searchAdmin.listTenants()
setTenants(tRes.tenants)
// Fan out per-tenant corpus lookups in parallel. A tenant whose lookup
// fails no longer disappears silently: if every lookup failed we surface
// the failure instead of rendering "No corpora yet."
const settled = await Promise.allSettled(
tRes.tenants.map((t) => searchAdmin.listCorpora(t.id)),
)
const ok = settled.filter(
(r): r is PromiseFulfilledResult<{ corpora: CorpusSummary[] }> =>
r.status === "fulfilled",
)
const firstFailure = settled.find((r) => r.status === "rejected")
setCorpora(
ok
.flatMap((r) => r.value.corpora)
.map((c) => ({ ...c, rowId: `${c.tenant}/${c.corpus}` })),
)
if (settled.length > 0 && ok.length === 0 && firstFailure) {
setCorporaError((firstFailure as PromiseRejectedResult).reason)
}
} catch (err) {
setError(err)
setTenants([])
setCorpora([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!session) return
refresh()
}, [session, refresh])
const totals = useMemo(() => {
const indexed = corpora.filter((c) => c.indexed).length
const docs = corpora.reduce((a, c) => a + (c.num_docs ?? 0), 0)
return { indexed, docs }
}, [corpora])
// Publish a snapshot to the assistant's admin context so the agent
// can answer "what corpora exist?" / "is the docs corpus indexed?"
// without having to call list_search_corpora.
const adminSurface = useMemo(
() => ({
endpoint: searchAdmin.baseUrl,
tenants: tenants.map((t) => ({ id: t.id, corpus_count: t.corpus_count })),
corpora: corpora.map((c) => ({
tenant: c.tenant,
corpus: c.corpus,
indexed: c.indexed,
num_docs: c.num_docs,
})),
}),
[tenants, corpora],
)
useRegisterContext("search", adminSurface)
const rebuild = useCallback(
async (tenant: string, corpus: string) => {
const id = `${tenant}/${corpus}`
setRebuilding(id)
try {
const out = await searchAdmin.rebuild(tenant, corpus)
await refresh()
toast.success(`Rebuilt ${id}`, {
description: `${out.chunk_count} chunks indexed.`,
})
} catch (err) {
toast.error(errorMessage(err, `rebuild ${id}`))
} finally {
setRebuilding(null)
}
},
[refresh, toast],
)
return (
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
<p className="text-sm text-muted-foreground">
Manage arcadia-search tenants and corpora. Trigger rebuilds and
restart the service after env changes.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="search-refresh"
>
<RefreshCw
className={`size-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRestartConfirm(true)}
data-action="search-restart"
>
<Power className="size-4" />
Restart service
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setEditor({ kind: "new-tenant" })}
data-action="search-new-tenant"
>
<Plus className="size-4" />
New tenant
</Button>
<Button
size="sm"
disabled={tenants.length === 0}
onClick={() =>
setEditor({
kind: "new-corpus",
tenant: tenants[0]?.id ?? "",
})
}
data-action="search-new-corpus"
>
<Plus className="size-4" />
New corpus
</Button>
</div>
</header>
{/* A standing configuration fact, not a load failure — so it isn't an
error state and it isn't dismissible. It stays until it's fixed. */}
{!searchAdmin.hasToken ? (
<div
role="note"
className="rounded-md border bg-muted/40 px-3 py-2.5 text-sm"
data-action="search-token-missing"
>
<p className="font-medium">Admin token not configured</p>
<p className="text-muted-foreground">
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset, so every call below will
come back 401. Endpoint:{" "}
<code className="font-mono">{searchAdmin.baseUrl}</code>
</p>
</div>
) : null}
{/* The whole screen hangs off one call to the sidecar. If that call
failed, nothing here loaded — say so once, rather than rendering
three cheerful empty states over a dead connection. */}
{error ? (
<Card>
<CardContent className="p-0">
<ErrorState error={error} onRetry={refresh} />
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
<div className="grid grid-cols-3 gap-3 min-w-0">
<KpiTile
label="Tenants"
value={formatCompact(tenants.length)}
/>
<KpiTile
label="Corpora indexed"
value={`${totals.indexed} / ${corpora.length}`}
/>
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
</div>
</CardHeader>
</Card>
<TenantsCard
tenants={tenants}
loading={loading}
onRetry={refresh}
onDelete={(id) => setPendingDeleteTenant(id)}
/>
<CorporaCard
corpora={corpora}
loading={loading}
error={corporaError}
onRetry={refresh}
rebuildingId={rebuilding}
onRebuild={rebuild}
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
/>
</>
)}
</div>
{/* New tenant */}
<NewTenantDialog
open={editor?.kind === "new-tenant"}
onClose={() => setEditor(null)}
onCreated={async (msg) => {
setEditor(null)
await refresh()
toast.success(msg)
}}
/>
{/* New / edit corpus */}
<CorpusEditor
editor={
editor?.kind === "new-corpus" || editor?.kind === "edit-corpus"
? editor
: null
}
tenants={tenants}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
await refresh()
toast.success(msg)
}}
/>
{/* Delete tenant */}
<ConfirmDialog
open={pendingDeleteTenant !== null}
onOpenChange={(o) => !o && setPendingDeleteTenant(null)}
title={`Delete tenant ${pendingDeleteTenant ?? ""}?`}
description="Removes the tenant's config directory AND its entire index directory. This cannot be undone."
confirmLabel="Delete tenant"
variant="danger"
onConfirm={async () => {
if (!pendingDeleteTenant) return
const id = pendingDeleteTenant
try {
await searchAdmin.deleteTenant(id)
setPendingDeleteTenant(null)
await refresh()
toast.success(`Deleted tenant ${id}`)
} catch (err) {
setPendingDeleteTenant(null)
toast.error(errorMessage(err, `delete tenant ${id}`))
}
}}
/>
{/* Delete corpus */}
<ConfirmDialog
open={pendingDeleteCorpus !== null}
onOpenChange={(o) => !o && setPendingDeleteCorpus(null)}
title={
pendingDeleteCorpus
? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?`
: ""
}
description="Removes the corpus config and its index directory. The tenant is preserved."
confirmLabel="Delete corpus"
variant="danger"
onConfirm={async () => {
if (!pendingDeleteCorpus) return
const { tenant, corpus } = pendingDeleteCorpus
try {
await searchAdmin.deleteCorpus(tenant, corpus)
setPendingDeleteCorpus(null)
await refresh()
toast.success(`Deleted ${tenant}/${corpus}`)
} catch (err) {
setPendingDeleteCorpus(null)
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
}
}}
/>
{/* Restart confirm */}
<ConfirmDialog
open={restartConfirm}
onOpenChange={(o) => !o && setRestartConfirm(false)}
title="Restart arcadia-search admin?"
description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted."
confirmLabel="Restart"
variant="danger"
onConfirm={async () => {
setRestartConfirm(false)
try {
await searchAdmin.restart()
toast.success("Requested a restart of arcadia-search")
} catch (err) {
toast.error(errorMessage(err, "restart arcadia-search"))
}
}}
/>
</AppShell>
)
}
// --- Tenants card --------------------------------------------------------
function TenantsCard({
tenants,
loading,
onRetry,
onDelete,
}: {
tenants: TenantSummary[]
loading: boolean
onRetry: () => void
onDelete: (id: string) => void
}) {
return (
<Card>
<CardHeader>
<h2 className="text-base font-semibold">Tenants</h2>
</CardHeader>
<CardContent className="relative p-4">
{/* The sidecar's own failure is rendered once at page level, so by the
time we get here the load succeeded — this empty state is true. */}
<DataState
loading={loading}
error={null}
isEmpty={tenants.length === 0}
onRetry={onRetry}
loadingLabel="Loading tenants…"
empty={
<EmptyState
title="No tenants yet."
description="Create one to start adding corpora."
className="py-8"
/>
}
>
<ul className="flex flex-wrap gap-2">
{tenants.map((t) => (
<li
key={t.id}
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5"
>
<code className="font-mono text-xs">{t.id}</code>
<Badge variant="secondary" className="text-xs">
{t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
</Badge>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onDelete(t.id)}
aria-label={`Delete tenant ${t.id}`}
data-action={`tenant-${t.id}-delete`}
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
)
}
// --- Corpora table -------------------------------------------------------
function CorporaCard({
corpora,
loading,
error,
onRetry,
rebuildingId,
onRebuild,
onEdit,
onDelete,
}: {
corpora: Row[]
loading: boolean
error: unknown
onRetry: () => void
rebuildingId: string | null
onRebuild: (tenant: string, corpus: string) => void
onEdit: (tenant: string, corpus: string) => void
onDelete: (tenant: string, corpus: string) => void
}) {
const [search, setSearch] = useState("")
const columns = useMemo<Column<Row>[]>(
() => [
{
id: "tenant",
header: "Tenant",
accessor: "tenant",
sortable: true,
cell: (r) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{r.tenant}
</code>
),
},
{
id: "corpus",
header: "Corpus",
accessor: "corpus",
sortable: true,
cell: (r) => (
<span className="flex items-center gap-2 font-medium">
<Database className="size-4 text-muted-foreground" />
{r.corpus}
</span>
),
},
{
id: "indexed",
header: "Status",
sortable: true,
accessor: (r) => (r.indexed ? 1 : 0),
cell: (r) =>
r.indexed ? (
<Badge variant="default" className="text-xs">
Indexed
</Badge>
) : (
<Badge variant="secondary" className="text-xs">
Not built
</Badge>
),
},
{
id: "docs",
header: "Docs",
sortable: true,
accessor: (r) => r.num_docs ?? -1,
cell: (r) =>
r.num_docs != null ? (
<span className="font-mono text-xs">
{r.num_docs.toLocaleString()}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "actions",
header: "",
align: "right",
cell: (r) => {
const id = `${r.tenant}/${r.corpus}`
const isRebuilding = rebuildingId === id
const items: ActionItem[] = [
{
id: "rebuild",
label: isRebuilding ? "Rebuilding…" : "Rebuild",
icon: (
<RefreshCw
className={`size-4 ${isRebuilding ? "animate-spin" : ""}`}
/>
),
dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`,
onSelect: () =>
isRebuilding ? undefined : onRebuild(r.tenant, r.corpus),
},
{
id: "edit",
label: "Edit config",
icon: <FileText className="size-4" />,
dataAction: `corpus-${r.tenant}-${r.corpus}-edit`,
onSelect: () => onEdit(r.tenant, r.corpus),
},
{
id: "delete",
label: "Delete",
icon: <Trash2 className="size-4" />,
destructive: true,
dataAction: `corpus-${r.tenant}-${r.corpus}-delete`,
onSelect: () => onDelete(r.tenant, r.corpus),
},
]
return (
<ActionsCell
items={items}
triggerDataAction={`corpus-${r.tenant}-${r.corpus}-actions`}
/>
)
},
},
],
[rebuildingId, onRebuild, onEdit, onDelete],
)
const table = useTable<Row>({
data: corpora,
columns,
getRowId: (r) => r.rowId,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by tenant or corpus"
data-action="corpora-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto text-xs text-muted-foreground">
{table.total} of {corpora.length}
</div>
</CardHeader>
<CardContent className="relative p-0">
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRetry}
loadingLabel="Loading corpora…"
empty={
<EmptyState
icon={<Database className="size-6" />}
title={search ? "No matches." : "No corpora yet."}
description={
search ? "Try a different search." : "Create one above."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(r) => r.rowId}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && corpora.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
</Card>
)
}
// --- Dialogs -------------------------------------------------------------
function NewTenantDialog({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: (msg: string) => Promise<void>
}) {
const [id, setId] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
setId("")
setError(null)
}
}, [open])
const submit = async () => {
setError(null)
setSaving(true)
try {
await searchAdmin.createTenant(id)
await onCreated(`Created tenant ${id}`)
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New tenant</DialogTitle>
<DialogDescription>
Creates an empty config dir at{" "}
<code className="font-mono text-xs">
$INDEX_CONFIG_DIR/&lt;id&gt;/
</code>
. Add corpora separately. Names are alphanumeric, dash, or
underscore.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-tenant-id">Tenant id</Label>
<Input
id="new-tenant-id"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="acme"
data-action="tenant-form-id"
/>
</div>
{error ? <DialogError error={error} context="create the tenant" /> : null}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="tenant-form-cancel"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !id}
data-action="tenant-form-save"
>
{saving ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const CORPUS_CONFIG_TEMPLATE = `{
"corpus": "docs",
"sources": [
{
"type": "arcadia",
"list_url": "/api/v1/files?tenant_id={tenant}",
"item_url": "/api/v1/files/{id}/content",
"title_field": "name",
"id_field": "id",
"mtime_field": "updated_at",
"tags": ["uploaded"]
}
]
}`
function CorpusEditor({
editor,
tenants,
onClose,
onSaved,
}: {
editor:
| { kind: "new-corpus"; tenant: string }
| { kind: "edit-corpus"; tenant: string; corpus: string }
| null
tenants: TenantSummary[]
onClose: () => void
onSaved: (msg: string) => Promise<void>
}) {
const [tenant, setTenant] = useState("")
const [text, setText] = useState("")
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
// Covers both the hydrate-on-open failure and the save failure. Either way
// the operator is looking at this dialog, so this is where it has to speak.
const [error, setError] = useState<unknown>(null)
const isEdit = editor?.kind === "edit-corpus"
const headerCorpus = isEdit ? editor.corpus : ""
// Hydrate on open: load existing config for edit, template for new.
useEffect(() => {
if (!editor) return
setTenant(editor.tenant)
setError(null)
if (editor.kind === "edit-corpus") {
setLoading(true)
searchAdmin
.getCorpus(editor.tenant, editor.corpus)
.then((res) => {
setText(JSON.stringify(res.config, null, 2))
})
.catch((err) => setError(err))
.finally(() => setLoading(false))
} else {
setText(CORPUS_CONFIG_TEMPLATE)
}
}, [editor])
if (!editor) return null
const submit = async () => {
setError(null)
setSaving(true)
try {
const parsed = JSON.parse(text)
if (typeof parsed !== "object" || parsed === null) {
throw new Error("config must be a JSON object")
}
if (editor.kind === "new-corpus") {
const corpus = parsed.corpus
if (typeof corpus !== "string" || !corpus) {
throw new Error('config must have a string "corpus" field')
}
await searchAdmin.createCorpus(tenant, parsed)
await onSaved(`Created ${tenant}/${corpus}`)
} else {
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
await onSaved(`Updated ${editor.tenant}/${editor.corpus}`)
}
} catch (err) {
// The JSON the operator just wrote stays in the textarea.
setError(err)
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"}
</DialogTitle>
<DialogDescription>
JSON config matching arcadia-search's IndexerConfig schema. The{" "}
<code className="font-mono text-xs">tenant</code> field is set
from the URL your value is overwritten.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{!isEdit ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor="corpus-tenant">Tenant</Label>
<Select value={tenant} onValueChange={setTenant}>
<SelectTrigger
id="corpus-tenant"
data-action="corpus-form-tenant"
>
<SelectValue placeholder="Pick a tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="flex flex-col gap-1.5">
<Label htmlFor="corpus-config">Config JSON</Label>
<Textarea
id="corpus-config"
value={text}
onChange={(e) => setText(e.target.value)}
rows={20}
className="font-mono text-xs"
spellCheck={false}
disabled={loading}
data-action="corpus-form-config"
/>
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the corpus" : "create the corpus"}
/>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="corpus-form-cancel"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || loading || !tenant}
data-action="corpus-form-save"
>
{saving ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
{isEdit ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"