Wire AI assistant to arcadia: domain primer, tool calling, admin context
Make /ai and /assistant operate as the platform admin's assistant
against arcadia-app's API:
- Add `arcadia-knowledge.ts` — domain primer (multi-tenant Phoenix
backend, tenant lifecycle, platform_admins identity, etc.) baked into
every system prompt.
- Add `admin-tools.ts` — curated tool registry exposing `list_tenants`
and `get_tenant`, callable via OpenAI-native function calling. Tools
hit arcadia through `useArcadiaClient()` and inherit the operator's
JWT + tenant header. `runLLMToolCalls()` returns `tool` role messages
ready to push back into history.
- Add `admin-context.ts` — runtime registry pages publish to so the
assistant can answer factual questions about live UI state without
scraping the DOM. Tenants page registers its summary on mount.
- Replace generic Vibespace personas (Atlas/Forge/Inkwell/Pilot/Cursor)
with arcadia-flavoured ones: Operator, Auditor, Triage, Analyst,
UI Operator. Auto-migrate stored agents from the legacy set.
- /assistant: build admin preface (role + primer + persona + ctx) and
pass it as the `useChat` system at construction. Pass `tools` on every
`send()`. Auto-loop reads `toolCalls` off the streaming assistant
message and uses `continueChat()` to push tool results.
- /ai: same wiring (this is the canonical admin chat surface; the user
prefers its look).
- MessageBody renders tool-result cards (role: "tool") and a "Called X"
pill on assistant messages with toolCalls. Strips Qwen-style
`<tool_call>` XML from prose when the tags were converted to
structured calls.
- Extend ThreadMessage with the `tool` role + tool-call metadata so
conversations round-trip through localStorage.
- Tenants page: row actions get `data-action="tenant-<slug>-{suspend,
activate,deactivate}"` (via lib-table-ui's new dataAction prop);
registers tenant summary into admin-context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,6 @@ import {
|
||||
} from "~/components/ui/popover"
|
||||
import { useLLMSettings } from "~/lib/llm-settings"
|
||||
import {
|
||||
composeSystemPrompt,
|
||||
loadActiveAgentId,
|
||||
saveActiveAgentId,
|
||||
useAgents,
|
||||
@@ -62,6 +61,11 @@ import {
|
||||
import { addLibraryItem } from "~/lib/library"
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
import type { ToolCall } from "@crema/llm-ui"
|
||||
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
|
||||
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||
|
||||
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
||||
type StoredMessage = { role: "user" | "assistant"; content: string }
|
||||
@@ -221,12 +225,52 @@ function ChatSurface({
|
||||
isMock: boolean
|
||||
onRetryProbe: () => void
|
||||
}) {
|
||||
const baseSystem =
|
||||
"You are a helpful AI assistant. Be concise, warm, and direct."
|
||||
const systemPrompt = composeSystemPrompt(baseSystem, activeAgent)
|
||||
const { messages, setMessages, send, abort, isStreaming, reset } = useChat({
|
||||
const persona = activeAgent
|
||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||
: ""
|
||||
const systemPrompt = [
|
||||
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
||||
ARCADIA_KNOWLEDGE,
|
||||
persona,
|
||||
formatAdminContextForPrompt(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
const arcadia = useArcadiaClient()
|
||||
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
// Auto tool-loop using native function calls.
|
||||
const toolIterationsRef = useRef(0)
|
||||
const processedTurnRef = useRef(-1)
|
||||
const prevStreamingRef = useRef(isStreaming)
|
||||
const MAX_TOOL_ITERATIONS = 3
|
||||
useEffect(() => {
|
||||
const justFinished = prevStreamingRef.current && !isStreaming
|
||||
prevStreamingRef.current = isStreaming
|
||||
if (!justFinished) return
|
||||
const lastIdx = messages.length - 1
|
||||
if (lastIdx < 0) return
|
||||
const last = messages[lastIdx]
|
||||
if (last.role !== "assistant") return
|
||||
if (processedTurnRef.current === lastIdx) return
|
||||
processedTurnRef.current = lastIdx
|
||||
const calls = last.toolCalls ?? []
|
||||
if (calls.length === 0) {
|
||||
toolIterationsRef.current = 0
|
||||
return
|
||||
}
|
||||
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
|
||||
toolIterationsRef.current += 1
|
||||
void (async () => {
|
||||
const { toolMessages } = await runLLMToolCalls(calls, { arcadia })
|
||||
void continueChat(toolMessages, {
|
||||
system: systemPrompt,
|
||||
tools: getOpenAITools(),
|
||||
})
|
||||
})()
|
||||
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
|
||||
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
|
||||
const [input, setInput] = useState("")
|
||||
const [showPromptOpen, setShowPromptOpen] = useState(false)
|
||||
@@ -304,12 +348,12 @@ function ChatSurface({
|
||||
const text = messages[lastUserIdx].content
|
||||
setMessages(messages.slice(0, lastUserIdx))
|
||||
// Defer so the state flush completes before send() reads `messages`.
|
||||
setTimeout(() => void send(text), 0)
|
||||
setTimeout(() => void send(text, { tools: getOpenAITools() }), 0)
|
||||
}, [messages, setMessages, send, isStreaming])
|
||||
|
||||
const continueLast = useCallback(() => {
|
||||
if (isStreaming || messages.length === 0) return
|
||||
void send("Please continue your previous reply.")
|
||||
void send("Please continue your previous reply.", { tools: getOpenAITools() })
|
||||
}, [isStreaming, messages.length, send])
|
||||
|
||||
const compactConversation = useCallback(async () => {
|
||||
@@ -396,7 +440,7 @@ function ChatSurface({
|
||||
if (!text || isStreaming) return
|
||||
setInput("")
|
||||
stickRef.current = true
|
||||
void send(text)
|
||||
void send(text, { tools: getOpenAITools() })
|
||||
}, [input, isStreaming, send])
|
||||
|
||||
const isEmpty = messages.length === 0
|
||||
@@ -426,9 +470,16 @@ function ChatSurface({
|
||||
) : (
|
||||
<div className="flex-1 px-4 py-6 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
{messages.map((m, i) => (
|
||||
<MessageRow key={i} role={m.role} content={m.content} />
|
||||
))}
|
||||
{messages
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m, i) => (
|
||||
<MessageRow
|
||||
key={i}
|
||||
role={m.role as "user" | "assistant" | "tool"}
|
||||
content={m.content}
|
||||
toolCalls={m.toolCalls}
|
||||
/>
|
||||
))}
|
||||
{isStreaming && messages.at(-1)?.role !== "assistant" && (
|
||||
<div className="self-start">
|
||||
<TypingIndicator />
|
||||
@@ -499,10 +550,19 @@ function ChatSurface({
|
||||
function MessageRow({
|
||||
role,
|
||||
content,
|
||||
toolCalls,
|
||||
}: {
|
||||
role: "user" | "assistant"
|
||||
role: "user" | "assistant" | "tool"
|
||||
content: string
|
||||
toolCalls?: ToolCall[]
|
||||
}) {
|
||||
if (role === "tool") {
|
||||
return (
|
||||
<div className="self-start max-w-[80ch]">
|
||||
<MessageBody content={content} isToolResult />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="self-end">
|
||||
@@ -520,7 +580,7 @@ function MessageRow({
|
||||
}
|
||||
return (
|
||||
<div className="self-start max-w-[80ch]">
|
||||
<MessageBody content={content} />
|
||||
<MessageBody content={content} toolCalls={toolCalls} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ const PROBE_TIMEOUT_MS = 3000
|
||||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||||
// this catalog tells the model what exists elsewhere so it can plan
|
||||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||||
const UI_CONTROL_PREFACE = `You are the assistant inside this app and you can drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||||
|
||||
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||
|
||||
Rules:
|
||||
- Prefer \`click nav-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` so the user sees the cursor travel to the sidebar.
|
||||
@@ -42,7 +44,7 @@ Rules:
|
||||
|
||||
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
||||
|
||||
Sidebar / nav: nav-overview, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
||||
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
||||
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
||||
@@ -54,6 +56,7 @@ Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
||||
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
|
||||
Login page: login-email, login-password, login-submit
|
||||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||
@@ -77,6 +80,31 @@ wait_for assistant-ui-control
|
||||
\`\`\`"`
|
||||
|
||||
|
||||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
|
||||
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
/**
|
||||
* Always includes domain knowledge + tools + admin context + persona.
|
||||
* Adds the UI-control DSL/action catalog only when uiControl is on (those rules
|
||||
* are about driving the cursor, irrelevant when the assistant is in plain Q&A).
|
||||
*/
|
||||
function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): string {
|
||||
const persona = activeAgent
|
||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||
: ""
|
||||
const ctx = formatAdminContextForPrompt()
|
||||
const parts = [
|
||||
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
||||
ARCADIA_KNOWLEDGE,
|
||||
persona,
|
||||
ctx,
|
||||
]
|
||||
if (uiControl) parts.push(UI_CONTROL_PREFACE)
|
||||
return parts.filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
@@ -395,8 +423,17 @@ function AssistantSurface({
|
||||
return () => clearTimeout(t)
|
||||
}, [uiControl])
|
||||
|
||||
const { messages, send, abort, isStreaming, error, reset } = useChat({
|
||||
system: systemPrompt,
|
||||
// Always-on admin system prompt — domain primer + tools + persona, regardless
|
||||
// of UI Control. Per-call `send(text, {system})` overrides may not always be
|
||||
// honored across hook re-renders, so anchor it at construction too.
|
||||
const constructorSystemPrompt = buildSystemPrompt({
|
||||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
preface: buildAdminPreface(activeAgent, uiControl),
|
||||
includeActions: uiControl,
|
||||
})
|
||||
|
||||
const { messages, send, continueChat, abort, isStreaming, error, reset } = useChat({
|
||||
system: constructorSystemPrompt,
|
||||
initialMessages: thread.messages as StoredMessage[],
|
||||
})
|
||||
|
||||
@@ -408,8 +445,21 @@ function AssistantSurface({
|
||||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||
const prior = thread.messages[i]
|
||||
if (m.role === "user") return { role: "user", content: m.content }
|
||||
if (m.role === "tool") {
|
||||
return {
|
||||
role: "tool",
|
||||
content: m.content,
|
||||
toolCallId: m.toolCallId,
|
||||
name: m.name,
|
||||
}
|
||||
}
|
||||
const agentId = prior?.agentId ?? activeAgentId
|
||||
return { role: "assistant", content: m.content, agentId }
|
||||
return {
|
||||
role: "assistant",
|
||||
content: m.content,
|
||||
agentId,
|
||||
...(m.toolCalls ? { toolCalls: m.toolCalls } : {}),
|
||||
}
|
||||
})
|
||||
updateThread(thread.id, {
|
||||
messages: stamped,
|
||||
@@ -523,6 +573,50 @@ function AssistantSurface({
|
||||
}, [messages, onRequestRetry, thread.id, thread.pinned])
|
||||
|
||||
const handleSendRef = useRef<((t: string) => void) | null>(null)
|
||||
const arcadia = useArcadiaClient()
|
||||
const toolIterationsRef = useRef(0)
|
||||
const processedTurnRef = useRef(-1)
|
||||
const prevStreamingRef = useRef(isStreaming)
|
||||
const MAX_TOOL_ITERATIONS = 3
|
||||
|
||||
// Auto tool-loop using native function calls. When the streaming assistant
|
||||
// turn ends with toolCalls attached, run them, push tool result messages,
|
||||
// and continue the chat so the model produces its final answer.
|
||||
useEffect(() => {
|
||||
const justFinished = prevStreamingRef.current && !isStreaming
|
||||
prevStreamingRef.current = isStreaming
|
||||
if (!justFinished) return
|
||||
|
||||
const lastIdx = messages.length - 1
|
||||
if (lastIdx < 0) return
|
||||
const last = messages[lastIdx]
|
||||
if (last.role !== "assistant") return
|
||||
if (processedTurnRef.current === lastIdx) return
|
||||
processedTurnRef.current = lastIdx
|
||||
|
||||
const calls = last.toolCalls ?? []
|
||||
console.debug("[admin-tools] assistant turn finished, tool_calls:", calls)
|
||||
if (calls.length === 0) {
|
||||
toolIterationsRef.current = 0
|
||||
return
|
||||
}
|
||||
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) {
|
||||
console.warn("[admin-tools] tool-iteration cap reached, dropping calls", calls)
|
||||
return
|
||||
}
|
||||
|
||||
toolIterationsRef.current += 1
|
||||
void (async () => {
|
||||
console.debug("[admin-tools] running tool calls", calls)
|
||||
const { results, toolMessages } = await runLLMToolCalls(calls, { arcadia })
|
||||
console.debug("[admin-tools] tool results", results)
|
||||
void continueChat(toolMessages, {
|
||||
system: constructorSystemPrompt,
|
||||
tools: getOpenAITools(),
|
||||
maxTokens: responseBudget,
|
||||
})
|
||||
})()
|
||||
}, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget])
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [editingDraft, setEditingDraft] = useState("")
|
||||
const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
|
||||
@@ -597,18 +691,12 @@ function AssistantSurface({
|
||||
}, [thread.id])
|
||||
|
||||
const composedSystemPrompt = useMemo(() => {
|
||||
return uiControl
|
||||
? buildSystemPrompt({
|
||||
path:
|
||||
typeof window !== "undefined" ? window.location.pathname : "",
|
||||
preface: `${UI_CONTROL_PREFACE}\n\n${
|
||||
activeAgent
|
||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||
: ""
|
||||
}`,
|
||||
})
|
||||
: systemPrompt
|
||||
}, [uiControl, systemPrompt, activeAgent])
|
||||
return buildSystemPrompt({
|
||||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
preface: buildAdminPreface(activeAgent, uiControl),
|
||||
includeActions: uiControl,
|
||||
})
|
||||
}, [uiControl, activeAgent])
|
||||
|
||||
const buildTranscript = useCallback(() => {
|
||||
const lines: string[] = [
|
||||
@@ -768,22 +856,14 @@ function AssistantSurface({
|
||||
}, [isStreaming, messages, uiControl])
|
||||
|
||||
const handleSend: (text: string) => void = (text: string) => {
|
||||
const system = uiControl
|
||||
? buildSystemPrompt({
|
||||
path: window.location.pathname,
|
||||
preface: `${UI_CONTROL_PREFACE}\n\n${activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : ""}`,
|
||||
})
|
||||
: systemPrompt
|
||||
const sysTokens = estimateTokens(system)
|
||||
const historyBudget = Math.max(
|
||||
256,
|
||||
contextTokens - sysTokens - responseBudget,
|
||||
)
|
||||
const userMsg = { role: "user" as const, content: text }
|
||||
const trimmed = trimMessages([...messages, userMsg], historyBudget)
|
||||
const system = buildSystemPrompt({
|
||||
path: window.location.pathname,
|
||||
preface: buildAdminPreface(activeAgent, uiControl),
|
||||
includeActions: uiControl,
|
||||
})
|
||||
void send(text, {
|
||||
system,
|
||||
messages: trimmed,
|
||||
tools: getOpenAITools(),
|
||||
maxTokens: responseBudget,
|
||||
})
|
||||
}
|
||||
@@ -800,16 +880,15 @@ function AssistantSurface({
|
||||
}, [])
|
||||
|
||||
const usedTokens = useMemo(() => {
|
||||
const system = uiControl
|
||||
? buildSystemPrompt({
|
||||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
preface: UI_CONTROL_PREFACE,
|
||||
})
|
||||
: systemPrompt
|
||||
const system = buildSystemPrompt({
|
||||
path: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
preface: buildAdminPreface(activeAgent, uiControl),
|
||||
includeActions: uiControl,
|
||||
})
|
||||
const sysT = estimateTokens(system)
|
||||
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
|
||||
return sysT + histT
|
||||
}, [messages, uiControl, systemPrompt])
|
||||
}, [messages, uiControl, systemPrompt, activeAgent])
|
||||
|
||||
const suggestions = uiControl
|
||||
? [
|
||||
@@ -946,18 +1025,22 @@ function AssistantSurface({
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
|
||||
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
|
||||
m.role === "tool"
|
||||
? "text-sm leading-relaxed"
|
||||
: isUser
|
||||
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
|
||||
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
|
||||
}
|
||||
>
|
||||
{m.content ? (
|
||||
{m.role === "tool" ? (
|
||||
<MessageBody content={m.content} isToolResult />
|
||||
) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? (
|
||||
isUser ? (
|
||||
<span className="whitespace-pre-wrap">
|
||||
{m.content}
|
||||
</span>
|
||||
) : (
|
||||
<MessageBody content={m.content} />
|
||||
<MessageBody content={m.content} toolCalls={m.toolCalls} />
|
||||
)
|
||||
) : isStreaming && i === messages.length - 1 ? (
|
||||
"…"
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
|
||||
export const meta = () => pageTitle("Tenants")
|
||||
|
||||
@@ -131,13 +132,36 @@ export default function TenantsRoute() {
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<ActionsCell items={rowActions(t, arcadia, refresh, setPending, setError)} />
|
||||
<ActionsCell
|
||||
items={rowActions(t, arcadia, refresh, setPending, setError)}
|
||||
triggerDataAction={`tenant-${t.slug}-actions`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
)
|
||||
|
||||
const tenantSummary = useMemo(
|
||||
() => ({
|
||||
total: tenants.length,
|
||||
byStatus: tenants.reduce<Record<string, number>>((acc, t) => {
|
||||
acc[t.status] = (acc[t.status] ?? 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
tenants: tenants.map((t) => ({
|
||||
id: t.id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
status: t.status,
|
||||
plan: t.plan?.name ?? null,
|
||||
inserted_at: t.inserted_at,
|
||||
})),
|
||||
}),
|
||||
[tenants],
|
||||
)
|
||||
useRegisterAdminContext("tenants", tenantSummary)
|
||||
|
||||
const table = useTable<Tenant>({
|
||||
data: tenants,
|
||||
columns,
|
||||
@@ -304,6 +328,7 @@ function rowActions(
|
||||
id: "suspend",
|
||||
label: "Suspend",
|
||||
icon: <Pause className="size-4" />,
|
||||
dataAction: `tenant-${t.slug}-suspend`,
|
||||
onSelect: () => setPending({ kind: "suspend", tenant: t }),
|
||||
})
|
||||
} else {
|
||||
@@ -311,6 +336,7 @@ function rowActions(
|
||||
id: "activate",
|
||||
label: "Activate",
|
||||
icon: <Play className="size-4" />,
|
||||
dataAction: `tenant-${t.slug}-activate`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await activateTenant(arcadia, t.id)
|
||||
@@ -325,6 +351,7 @@ function rowActions(
|
||||
id: "deactivate",
|
||||
label: "Deactivate",
|
||||
destructive: true,
|
||||
dataAction: `tenant-${t.slug}-deactivate`,
|
||||
onSelect: () => setPending({ kind: "deactivate", tenant: t }),
|
||||
})
|
||||
return items
|
||||
|
||||
Reference in New Issue
Block a user