The operator console is a React 19 SPA (src/client/) projecting over the REST v1 surface (one of the three transports). Routing is BrowserRouter with the serve.routes carve-out (see routing.md). All screens follow the bunfire invariants: <PageContainer> per page, Eden-derived types (Awaited<ReturnType<typeof api...>>, never hand-declared), t() for every user-facing string, theme via CSS vars, <Modal> render-always.
Dashboard (/, admin) · Conversations (/conversations) · Agents (/agents, admin) · Building blocks (/resources, admin) · Channels (/channels, admin) · Approvals (/approvals, admin) · Admin (/admin, admin) · Settings (/settings). filterNavItems hides admin items for AGENT.
- Dashboard (
pages/DashboardPage.tsx). Automation funnel KPIs (Involvement / Resolution / Automation — Resolution counts only conversations the AGENT closed viaresolve_conversation, read from the recordedConversation.resolvedByrather than inferred from status + assignee, with conversations resolved before the instance started recording reported separately under the funnel) with an absolute/% toggle, a funnel-bar card, a daily usage chart (calls/tokens toggle, range 7d/30d/90d/all), LLM cost summary + cost-by-model from Langfuse (/metrics/costs). Consumes/metrics,/metrics/kpis,/metrics/timeseries,/metrics/costs(+/agentsfor id→name). Charts are dependency-free (CSS bars), CSP-safe. Cost comes from Langfuse (which has an up-to-date price table); tokens/calls stay local inLlmUsagefor KPI calculations. - Conversations (
pages/ConversationsPage.tsx+ConversationDetailPage.tsx). List with status filter + live merge viauseTenantEvents(known row merges in place + re-sorts; unknown id refetches; refetch-on-mount is the disconnect safety net). Detail = message thread (incoming/outgoing/private-note bubbles) + reply composer (private-note toggle) + ops: handoff, return-to-AI, resolve, status select. All ops go through the Tier 3 REST and reconcile via the realtime channel. The thread is fetched live from Chatwoot (no message mirror), sogetConversationDetaildegrades gracefully: a slow/unreachable Chatwoot returns the metadata shell withmessagesUnavailable: true(a shorter interactivegetMessagestimeout, fail-fast) and the UI shows a retry instead of a hard error. Live agent status:onAgentActivity(same channel) drives a chat-style typing bubble in the detail (Pensando…/Transferindo para um humano…/Consultando a base de conhecimento…, mapped fromstage+toolvia a static-key switch so the i18n extractor sees the keys) and a per-row "working" dot in the list. Both clear on thefinishedphase, with a ~30s client-side TTL as the safety net for an event lost to a socket gap. - Agents (
pages/AgentsPage.tsx→pages/agents/AgentEditorPage.tsx). List → create (name only) → editor. The editor uses progressive disclosure: tabs General (name/prompt/enabled), Model (provider/model/credential/temperature/baseURL →modelConfig), Tools, Knowledge, Behavior (business-hours + follow-up selectors + transfer-with-summary), A/B. Clone + delete in the header.- Tool selection (
ToolGrantsEditor.tsx+KnowledgeGrantsEditor.tsx) edit ONE sharedgrantsworking set and PUT the whole set (replace-the-set).ToolGrantsEditorowns NATIVE/HTTP/MCP/INTEGRATION and preserves the RAG grant;KnowledgeGrantsEditorowns RAG and preserves the rest. NATIVE semantics: no NATIVE grant (or empty allowlist) = all native tools; the editor's "restrict" toggle sends an explicit subset. RAG: enabling sends the rag tool names + selected KB ids; empty = no RAG (fail-closed). MCP exposes a per-server discover→allowlist; INTEGRATION shows per-toolpack tool checkboxes.
- Tool selection (
- Building blocks (
pages/resources/ResourcesPage.tsx). Tabbed pools the editor draws from: Tools (HTTPToolDefinition), MCP servers (+ discover), Knowledge bases (+ ingest text, view chunks), Hours, Follow-ups, Integrations (catalog → instance, inbound route-token shown once), Vault (mask-after-save — values are write-only, list shows names + "set" only, plus a usage/references view). - Channels (
pages/ChannelsPage.tsx). Chatwoot instances (connect with admin token, provision the Agent Bot, delete) + the load-bearing inbox→agent binding (a select per inbox). Tokens are write-only; the DTO exposes only presence flags.
Added alongside the existing Button/Input/Card/Badge/Modal/Toast/Tooltip: Select, Textarea, Switch (role=switch button — wrap in a <div>, not a <label>, since a button isn't a labelable element), Tabs (in-page tablist with roving focus; routed sections still use <Outlet>), FormField (label + control + error/description), ConfirmDialog (payload-carrying confirm modal — one per page, render-always), DataBoundary (the loading/error/empty/content switch every list repeats), EmptyState, Skeleton (decorative animate-pulse placeholder; compose several to mirror a layout).
Loading states are skeletons, not spinners (see CLAUDE.md → UX). DataBoundary is the chokepoint: its loading branch renders a generic row-skeleton by default, or a caller-supplied skeleton={<…/>} for distinctive layouts (Dashboard, Conversations list, Channels, the Agent editor); it owns the role="status" + sr-only common.loading announcement, so a bespoke skeleton is purely visual. Pages outside DataBoundary (admin tables, Approvals, the conversation message thread) compose <Skeleton> inline with their own role="status" wrapper. Spinners survive only for <Button loading> and the boot/auth/invite-token splash. Skeleton bars are bg-bg-tertiary, so don't nest them inside a bg-bg-tertiary shell (zero contrast) — drop the shell's bg and let the bars read against the card.
The API answers a refusal as { error, field? } (src/api/lib/refusal.ts): the sentence, already localized for the request's Accept-Language, and — when the refusal is about one input — the server's own name for that input, identical in every language. field is a column (systemPrompt), a key of a patch (document), or a dotted path into a bag the server owns (guardrails.output.templateMessage); it is absent, never null, whenever the refusal is not about one input, which is most of them.
Two client pieces read it, and which one a call site wants depends on whether it is a form:
apiErrorMessage(e)(lib/apiError.ts) → the sentence, ornullfor a transport failure with no server behind it. For actions that are not a form (a delete, a retry, a connection test), where a toast is the answer either way.useFieldRefusal(fields)(hooks/useFieldRefusal.ts) for a form. The form declares the server names it can render (useFieldRefusal(COMPANY_FIELDS)), passeserror={refusal.at(name, draft[name])}to each<FormField>, and its submit handler doesconst toast = refusal.capture(error, fallback, sent, current); if (toast) showToast(toast, "error")—sentbeing what the request carried andcurrentwhat the inputs hold now.
The rule the mechanism holds is that the two channels are exclusive and exhaustive: a refusal about a declared input renders at that control and raises no toast; a refusal about anything else — another screen's field, no field at all, or no server at all — is a toast. Silence is the outcome that must never happen, and a message shown at the control and in a toast is the noise that teaches people to dismiss toasts unread. placeRefusal (lib/fieldRefusal.ts) is that decision as a pure function, with the table in tests/client/field-refusal.test.ts.
The decision is about whether the operator will actually read the message, not merely whether the field was declared, and three cases separate those. The form may have unmounted while its own save was out (a modal body does this), in which case the mark goes to state nobody renders. The operator may have corrected the value while the request was out, in which case marking the box blames a value the server never saw. And a refusal about a field the request never carried is about what is stored, so there is nothing to be stale against and the mark stands. All three are rows in the same table.
The mark then expires by value: at(field, value) shows the message only while the input still holds what was refused, so an edit takes it off with no onChange line to forget. clear() exists for the save that goes through, which the value key cannot recognise on its own.
Declared rather than discovered, because the submit handler needs the answer before the next render; matched exactly rather than by prefix, so a form rendering a parent path does not claim every leaf under it. The state is per form instance: a modal over the panel that opened it has its own, and both have a name.
Reference wiring: pages/resources/documents/CompanyProfileCard.tsx.
Where the console actually stands, because the two paragraphs above describe the destination and not today. Every error toast now shows the server's sentence when there is one — tests/client/error-toast-reason.test.ts holds that as a rule, and a handler that discards an error it has fails the suite. Rendering the refusal at the input it names is wired on that one card and nowhere else: AdvancedPanel, the vault picker, Channels and the admin surfaces all receive a field today (requireVaultRef names embedding.credentialRef, SettingsTextTooLongError names the settings path) and all of them still show it in a toast. Issue #320 tracks that, per screen, and it is deliberately separate: declaring the server's names for a form's inputs has to be read screen by screen, and doing it inside the sentence sweep would have meant editing the same handlers twice.
Refusals rendered into local error state rather than a toast are a third population, unswept and unfenced: issue #329.
t("key", "Default")everywhere;bun i18n:extractauto-adds keys toen.jsonANDpt-BR.json(copying the English default into pt-BR — new pt-BR keys must then be translated). Runbun check(which runs the extractor) after adding strings.- The extractor fails on conflicting defaults: the same key must use the identical default string at every call site (e.g. a Switch
aria-labeland its visible label sharing a key must match). Use distinct keys or align the defaults. - Decorative punctuation in JSX text must be in
biome.jsonc'snoJsxLiterals.allowedStrings, or computed into a variable ({pctLabel}); dynamict()needs a// t('key','Default')magic comment +biome-ignore, so prefer passing already-translated strings to helpers.
localStorageis not admissible for product data. History, "save", "remember", resume: all of it is designed with a backend (a Prisma table withtenant_id+ RLS, or an existing store like the LangGraph checkpointer). This is a real multi-user product, so persistence has to sync across browsers and devices, survive a cache clear, and be auditable and tenant-scoped. Pure UI preference (theme, sidebar) stays fine inlocalStorage; conversation or content data does not.- A day is defined by the viewer's browser timezone. The frontend sends
tz = Intl.DateTimeFormat().resolvedOptions().timeZoneas?tz=and the backend buckets in that zone, returningbucketas a LOCALYYYY-MM-DDviato_char. A fixedDASHBOARD_TIMEZONEenv and a per-tenant setting were both rejected. The non-obvious part is thatcreated_atistimestamp WITHOUT time zone(Prisma's default forDateTime), holding a naive UTC wall clock, socreated_at AT TIME ZONE $tzshifts the wrong way — it reads the value as already being in$tz. The correct form is the double shift,created_at AT TIME ZONE 'UTC' AT TIME ZONE $tz: first stamp it as UTC (a real instant), then render it as wall clock in the target zone.normalizeTimeZone(tz)validates the IANA name viaIntl.DateTimeFormatand falls back toUTC, so Postgres cannot blow up onAT TIME ZONE. Accepted caveat: the cost metric comes from the Langfuse Metrics API, which takes no timezone parameter, so cost keeps a UTC day boundary. The same bug class reappears in any report by date — always bucket in the operator's zone, and remember the doubleAT TIME ZONE.
ProtectedRoutewraps children in<Layout>. Page components must NOT wrap themselves in<Layout>, they render content only- Only
ProtectedRoute(insrc/client/components/ProtectedRoute.tsx) should render<Layout>: it is the single source of the app shell <Layout>composes<Header>(logo, mobile hamburger,<UserMenu>) +<Sidebar>(desktop aside or Radix Dialog drawer on mobile) +<main>. Navigation items are defined centrally insrc/client/lib/navigation.tsx<UserMenu>(src/client/components/UserMenu.tsx) is the single entry point for user-level actions: theme picker, language picker, Settings shortcut, logout. Do not add those controls to the header or sidebar directly<Sidebar>exposes a footer block (SidebarFooter) with external links (support / website / repo), configured viaSUPPORT_LINKandSECONDARY_LINKSinsrc/client/lib/navigation.tsx. The footer is pinned below the scrollable nav, collapses to icon-only with tooltips when the sidebar is collapsed, and does not render if both constants are empty/null. Icons come fromlucide-reactexcept GitHub, which uses the inlined Octocat insrc/client/components/icons/GithubIcon.tsx(lucide 1.8 does not export a GitHub glyph)<SidebarProvider>(src/client/contexts/SidebarContext.tsx) owns sidebar state:collapsedandwidthpersisted inlocalStorageunder@app:sidebar-*, plusmobileOpenfor the drawer. Keyboard shortcut Cmd/Ctrl+B toggles collapse (viauseSidebarShortcut)<TooltipProvider>wraps the app inApp.tsx; any<Tooltip>call (src/client/components/Tooltip.tsx) inherits a 200ms delay.<Modal>,<Tooltip>,<Toast>,<Sidebar>mobile drawer and<UserMenu>are all thin wrappers over Radix primitives (@radix-ui/react-{dialog,tooltip,toast,dropdown-menu}) to get focus trap, collision detection, and ARIA correctness for free<PageContainer>(src/client/components/PageContainer.tsx) is the single source of page-level max-width and horizontal centering. Every top-level page insrc/client/pages/*.tsxmust wrap its root JSX in<PageContainer>. Acceptssize="narrow" | "wide" | "full"(default"wide"):narrow=max-w-3xl(forms, settings, reading),wide=max-w-7xl(dashboards, tables),full= no max-width (ultrawide tables, canvas). This rule is enforced bybiome-plugins/require-page-container.grit, scoped tosrc/client/pages/*.tsx. Sub-pages under a layout route (e.g.src/client/pages/settings/*.tsx) are out of scope because their parent layout already wraps<Outlet />in<PageContainer>. Auth pages (Login, Signup) suppress the rule with// biome-ignore lint/plugin: <reason>because they render outside the main app shell