Skip to content

Dashboard

The dashboard is a Next.js UI that talks to the runtime web API. The short architecture summary identifies it as the web UI for chat, agents, and settings over HTTP and SSE endpoints exposed by the runtime web-chat backend.

The dashboard visual system is the Claude Design “Steelmoth App.html” handoff: porcelain/paper light surfaces, carbon/graphite dark surfaces, hairline rules, and champagne metal accents. globals.css maps those design values into the existing HSL triplet token contract so legacy hsl(var(--...)) consumers keep working without per-component rewrites.

The unauthenticated login surface lives in LoginScreen.tsx. On mobile the form pane carries its own top-right theme toggle because the desktop brand panel is hidden below md; the large display headline is the transparent moth mark, not text. The SSO actions are a centered, full-width Steelmoth primary button followed by a split Google/GitHub row. The lower form row exposes only Request invite and Forgot password. Prontera currently has no unauthenticated Authentik recovery flow: default recovery slugs return 404 and default-password-change is access-denied without a pending user, so the dashboard links forgotten-password users to the support reset mailbox until a recovery-flow slice is configured.

The dashboard shell brand mark is BrandMark.tsx, which renders the transparent moth PNGs from the design handoff: moth-dark-t.png on light surfaces and moth-light-t.png on dark surfaces. Sidebar and mobile drawer wrappers preserve the wide moth silhouette instead of squeezing it into the old square logo slot.

The visible navigation is owned by dashboard/components/layout/sidebar-nav.ts. The COLLAPSED sidebar (64px rail) renders EVERY resolved item as a direct icon link under small section-icon headers — full parity with the expanded sidebar for operator, clients and owlet (operator-directed 2026-07-14; RailItemLink in Sidebar.tsx). This replaced the section-icon + CSS-hover-flyout rail, which was unreachable on touch and hid badges; badges now render on the rail icons, the active ITEM carries the stripe, and sidebar-icons.test.ts pins every current section label to a real, distinct icon (Governance/Advanced/Help had all been the Bookmark fallback). The ⌘K command palette (components/command-palette/CommandPalette.tsx) gained a “Documents” group (2026-07-14): useForgeDocuments("active") fetched only while the palette is open AND the caller’s forge flag is on, matched by title via cmdk’s value filter, opened via deskStore.openDocument + router.push("/forge"); KIND_ORDER now leads with documents + chats (the operator’s priority directive) and drives both the group order and the chip row. Feature-flagged sections are hidden until /v1/me reports the relevant slice flag. Runtime-side flag parsing lives in dashboard_flags.py and route wiring lives in the FastAPI app under web_chat/app.py. (For an OBSERVER the flags come from the operator brain’s GET /v1/system/dashboard-feature-flags instead — PR-3 S3.2 — because /v1/me stays on their own gateway, whose flag set differs from the operator’s.)

Roles and the observer read-only view (ADR 0225, fix stack 2026-07-24/25)

Section titled “Roles and the observer read-only view (ADR 0225, fix stack 2026-07-24/25)”

Three roles: admin (Mark), user (a client), and observer (a teammate with a READ-ONLY view of the operator sidebar). The observer is a client in their own world (own gateway, own chat/workspace via the TesterShell) plus a read-only audit view of the operator surface. The render model is a third view-mode (ViewMode = "admin"|"user"|"observer"): AdminOnly gates render for admin OR observer, isAdmin stays false (every write affordance gated on it stays hidden), and the observer keeps their own-world userOnly rows while operator rows with colliding labels disambiguate (observerLabel) or collapse same-href twins (observerHidden). API classification is per endpoint, not per prefix (OPERATOR_READ_API_PATHS — see route-access.ts); the 4 formerly secrets-classified pages open with their secret fields redacted at the runtime (redact-not-hide). A persistent “Viewing operator data — read only” banner marks every operator-surface page (S4.2), and write controls on those panels are visibly disabled via useReadOnly() (S4.1) — the server refusal (require_admin / the canonical pin) is the real lock. Forge (Forge PR-3) adds one operator-only Workspace row — labelled Desk since the Desk revamp (/forge, adminOnly + featureFlag:"forge", so it is hidden from clients and until the forge slice flag is flipped) — plus a document canvas that opens beside the chat from the live “Open in Desk” chip. The row is purely additive (HR7-bis) and, being adminOnly, needs no CLIENT_ALLOWED_PREFIXES change (the route-access.ts drift-guard stays green untouched). ChatPane returns the pre-Forge tree byte-identically while the canvas is closed. The full Forge dashboard surface (page, canvas, editor, serializer, downloads, and the forge_document SSE wire fix) is documented on forge.md.

The Desk revamp renamed this surface to “Desk” wherever the user sees it (sidebar-nav.ts label + LampDesk icon, the page eyebrow/title/metadata, “New Desk item”, the ForgeDocumentSurface badge fallback, the “Open in Desk” chip, and the #desk composer tag paired with the runtime #forge alias). The route path (/forge), the component/file names, the forge feature flag and the forge REST/tool internals are ALL unchanged — labels only (HR16). Three behaviours ride the same page, all gated on BOTH focusActive AND usePathname() being /forge, so no other page is ever affected and leaving the page restores everything:

  • Help button removed site-wide (2026-07-14). The floating “?” HelpButton FAB was deleted on every page (operator-directed; the second HR7-bis-confirmed removal after the 2026-07-13 Desk-only one — the operator was shown the rule conflict and explicitly chose the site-wide removal). components/help/HelpButton.tsx + its per-route content map (lib/help-content.ts) are gone; the /help wiki PAGES (app/help/page.tsx + lib/help/help-docs.tsx + the separate components/help/help-content.tsx) are untouched. The bottom-right corner now belongs to the Desk Focus button on /forge and is empty elsewhere; the chat-bubble FAB keeps its one-slot-above position on every page for consistency.
  • Focus mode (stores/deskStore.ts, zustand + localStorage desk-focus). A bottom-right circular Focus button on the Desk page toggles focusActive; while active, Topbar renders null (top bar + breadcrumb hidden), Sidebar drops to its 64px rail, and the page header is hidden so the documents list gains vertical space (the ForgeWorkbench height math is made focus-aware). The rail is the focus DEFAULT, not a lock (operator-directed 2026-07-13): the expand/collapse buttons and ⌘B stay usable and flip the EPHEMERAL deskStore.focusSidebarExpanded flag — never the stored dashboard.sidebar.collapsed preference — so leaving focus (or the page) restores the user’s saved sidebar shape and each focus activation starts back at the rail (navCollapsed = deskFocus ? !focusSidebarExpanded : collapsed). The interception lives in ONE seam — useSidebarState.toggleCollapsed itself — so the header buttons and ⌘B can never drift. The exit affordance is ALWAYS visible: an “Exit focus” pill, the Escape key (guarded — not while a dialog is open), or Ctrl/Cmd+Shift+F; leaving /forge also restores chrome. DeskWorkspace.tsx (a client component) owns the page frame, the Focus/Exit affordances and the keyboard handlers, so app/forge/page.tsx stays a thin server component that still exports metadata.
  • Chat bubble (components/desk/DeskChatBubble.tsx, mounted SITE-WIDE by components/desk/GlobalChatBubble.tsx in the root layout — operator-directed 2026-07-13; before that it was Desk-focus-only). Renders on every authenticated page EXCEPT /, after a mounted gate (its state seeds from localStorage, so SSR must not render it). A bottom-right panel mounting <ChatPane variant="bubble" /> — the SAME one canonical conversation as the homepage ChatPane (same chatStore + useConversations() seed + useHistory cache), so a message typed in the bubble also appears in the homepage chat. ChatPane gained an optional variant?: "full" | "bubble" prop that DEFAULTS to "full" and renders byte-identically to the pre-variant tree when absent (every bubble-only change is isBubble-gated); "bubble" drops the ForgeCanvas aside, the mobile inset-0 takeover and the floating mobile ModelPicker, and puts the composer in-flow. Single-lease rule: the bubble renders on every route EXCEPT /, and the homepage ChatPane only ON / — mutually exclusive, so exactly one live ChatPane ever holds the runtime’s single SSE stream lease. Because the mount lives in the persistent root layout, an open panel / streaming reply / unread badge survive client-side navigation. Collapsed, it is a FAB: on DESKTOP one slot ABOVE the bottom-right corner (right-4 md:bottom-[4.5rem]), on MOBILE at the bottom-LEFT with uniform 1rem insets (bottom-4 left-4, operator 2026-07-25 — bottom-left is free on every page, the Focus button owns bottom-right), and the OPEN panel also bottoms out at md:bottom-[4.5rem] — so neither state can ever cover the Desk Focus button that owns that corner on /forge (the #1312 overlap lesson; review-confirmed for the panel too; the “?” HelpButton that used to share the corner was removed site-wide 2026-07-14). It ALWAYS starts collapsed for a fresh session (seeding it open from the persisted Desk-focus flag would pop the panel over whatever unrelated page the session lands on); Desk focus activation auto-opens it (setFocusActive(true) sets bubbleCollapsed:false). The ChatPane inside is LAZY-LATCHED: it mounts on the FIRST expand (a full ChatPane costs 2 chat GETs + a persistent SSE stream — a hidden always-mount would pay that on every page load site-wide) and stays mounted from then on; collapsing only toggles CSS visibility (hidden), so the lease + streaming state survive; the FAB shows an unread badge (counted from first use onward). A bubble send carries page_context {kind:"forge_document", document_id} ONLY while the pathname is /forge (a doc staged in the store but no longer on screen is never reported), so “make the intro shorter” edits the open document; on message_complete the bubble — ONLY while on /forge, the same gate as the send side (review-confirmed: a “this document” toast on /settings would be nonsense) — invalidates ["forge"] to refresh the workbench, with a draft-clobber guard (an unsaved draft ⇒ refresh only versions + list + toast, never the open detail). A live open_forge_document hint auto-opens the doc in place on /forge; from a bubble on ANY other page it rides the same chat→Desk handoff the homepage chat uses (stage + focusActive + requestDeskNavigation()DeskOpenRedirect). Because SendRequest is extra="forbid", the RUNTIME must be deployed BEFORE the dashboard or every bubble send is a 422 (see forge.md). The current admin/operator sidebar sections (2026-06-23 restructure), in order, are Workspace, Memory, Governance, Morpheus, Agent Authority, Operations, Settings, and Advanced. Memory is a top-level category. Since the 2026-07-04 client hub merge, a client sees Manage (/memory/hub — a chip-switched Search / Browse / Review page that reuses the three admin panels unchanged), Knowledge Graph, and Agent Profile, all memory-flag-gated. The operator additionally sees the now-adminOnly Retrieval, Manage, and Review rows (the three tools the client hub merges) plus the other operator rows (What Luna Learned, Living Documents, Luna). CLIENT_ALLOWED_PREFIXES in route-access.ts gains /memory/hub and drops the three old prefixes in lockstep. Settings groups Integrations and the Tokens tree.

The Memory · Manage surface (MemoryManageSurface) renders, per record row, a read-only confidence-state pill (Memory Control Panel Phase 1): grounded → “Confirmed”, provisional → “Tentative”, contradicted → “Conflicted”, superseded → “Outdated”, with a neutral “Ungraded” fallback when the grade is absent (the row is never dropped — HR7-bis). A StateSeg segmented control (a role=tablist clone of the existing Current/Archived StatusSeg) adds a state filter that defaults to “All” (no predicate, every grade shown) and narrows to one grade server-side so paging stays truthful. The badge labels are deliberately distinct from the Current/Archived lifecycle words even though the underlying confidence_state and status enums share the superseded/contradicted tokens. This is additive: the kind ledger + counts, search, paging, Archive, and Restore are unchanged, and no edit/promote/downgrade control is added (later phases). It is backed by one nullable confidence_state field threaded through the existing GET /v1/memory/objects contract chain (no migration — the column exists on every memory family since the initial schema) plus one optional, validated confidence_state query param applied as a parameterized per-family predicate alongside the existing status predicate. Morpheus (its own category) and Agent Authority (its own category, led since 2026-07-25 by Overview — an at-a-glance all-time stats landing page backed by GET /v1/operator-authority/stats; Active Sessions stays directly below it) were lifted out of Settings and Governance respectively. Governance groups the governance activity/review/options pages, plus the admin-only Browser Logs page (/governance/browser-logs, dashboard/components/admin/governance/BrowserLogsPanel.tsx — task run outcomes from GET /v1/browser-tasks?include_finished=true and the browser slice of the decision log via the additive capability query param on GET /v1/governance/decisions; PRD 0050 / RFC 0108 / ADR 0194). Advanced groups Mail Broker, env overrides, a Memory maintenance sub-group (Object Inspector / Consolidation / Admin Repairs / Backfill Status), the runtime Tools/Browser entries, and the Wiki. Operations groups the agents/access-requests/provisioning/pending-approvals pages.

In user/Owlet mode, the Memory section no longer exposes a separate Living Documents item. The living-documents route is surfaced under the Memory category as Memory > Agent Profile (a userOnly shortcut relocated out of Settings, 2026-06-30). The same Memory category also shows the client Memory > Agent Lessons row — the per-audience userLabel for the operator’s What Luna Learned item; the operator still sees What Luna Learned. Both client rows are flag-free so they render together for a client even with no memory flag. The desktop Owlet sidebar keeps Workspace expanded regardless of persisted section state, renders Workspace, Memory, Settings, and Governance rows as children of their section labels, and uses user-mode icon overrides for Chat, Todos, Reminders, Settings, and client-facing Memory. Operator/admin mode keeps the existing section expansion behavior, icon set, and Memory > Living Documents placement. The vertical-line rail under Workspace / Memory / Governance / Settings (the section-level verticalRule flag) now renders in BOTH operator and client views; Browser tasks is operator-only (adminOnly, removed from the client route allowlist in lockstep). The Advanced section is hidden in user mode, so Docs, Operations, Endpoints, Health API, Chat API, and Settings API are not client sidebar entries. The client route allowlist mirrors that visible surface: clients keep access to their own chat, todos, reminders, living documents, settings, integrations, governance activity/review/options, team overview, and inbox routes, while Advanced routes such as /docs/wiki and /endpoints redirect for clients. Within Living Documents, clients may open the index, the five persona detail routes, and /memory/living-documents/learning-profile. Learning Profile is the client-facing alias for the owner-scoped user_model_summary document. The client editor calls the dashboard API with the same learning-profile alias; the runtime maps that alias to user_model_summary server-side and rejects raw user_model_summary for non-admin callers. Operator-only detail routes such as /memory/living-documents/agent_system_prompt, tool_playbook, operational_notes, and user_model_summary redirect for clients but remain reachable for admins. The detail page validates route kinds server-side, then a client audience gate waits for /v1/me before choosing the admin editor or the client editor so operator-only admin routes do not cold-load as false 404s while current-user state is still pending. For the agent_system_prompt document specifically, the redesigned in-place sheet (LivingDocsSheet) and the admin detail panel both render a shared read-only EffectiveSystemPromptDefaultBlock — the runtime-assembled “Current effective default” the assistant runs on when no operator override is set (GET /v1/system/effective-system-prompt-default). This was restored to the redesigned sheet, which had shipped without it, so the operator can always see the live system prompt even when the override document is empty (INV-002 / HR7-bis). It is read-only and never writes the override.

The /docs/wiki dashboard route is a read-only user-facing wiki browser over docs/system-wiki, backed by /v1/wiki/*. It is intentionally separate from the engineering standards editor; the wiki API is admin-only and read-only.

The Mail Broker dashboard surfaces are behind the mail_broker feature flag. The Settings sidebar section exposes the admin /workspace/mail-broker page and /integrations for brokered Gmail connection state. Mail Broker health/control/connect/audit destinations are no longer separate sidebar sub-anchors; they remain reachable from the Mail Broker page itself. The Google connect form on /integrations posts through /api/integrations/google/connect, which currently self-links the canonical Gmail account through the runtime service-account endpoint and maps missing tenant credentials to the service_account_unavailable page banner. The OAuth callback route remains wired for a future handoff path. /integrations now renders three cards over one shared Google grant: Gmail (read-only), Google Calendar (read-only calendar layering), and Google Drive (read-only search/read).

/integrations also carries three UX controls. A client-side search box (IntegrationsScreen.integrationMatchesSearch) filters the card list only — never the WebsiteCredential/Passkey panels above or the header status pills, which always count the full set (HR7-bis). An operator-directed BLOCKED_INTEGRATIONS set (in integration-seed.ts) renders named cards greyed-but-visible: they resolve to the disabled state with a short reason and show their real connect affordance made inert inside a disabled fieldset, rather than being removed. And each connected, capability-managed card shows a gear beside its status pill that opens IntegrationCapabilityDialog; the dialog reads the runtime capability catalog through the /api/integrations/capabilities proxy (operator → runtime via capability-prefs.ts; enrolled client → own gateway via client-capability-prefs.ts, INV-007) and PUTs the full unchecked action set. The dashboard only records the preference — runtime enforcement ships separately; the catalog fetch is fail-closed to an empty set (no gear, page intact).

The Anytype card is in the knowledge category. Its connect form accepts the user’s API key, a non-secret API origin, and an API route layout. The operator form defaults to the NAS Tailscale listener http://100.112.139.47:31012 with cli_v1; Steelmoth appends /v1 to that origin. desktop_v1 remains selectable for legacy desktop API rows, and stored cli_root values normalize to cli_v1. Operator and enrolled-client flows both use the generic token step-up connector; the client form writes the same client-anytype-token alias inside that client’s own vault. Authentik returns through /login, where the shared callback table maps ay. to the operator callback and cay. to the client callback before the broker write completes. After the write, the dashboard marks the owner connection row active with the validated API origin and layout. Disconnect revokes that row without returning or deleting the broker value.

The same form exposes a default-off inbound-chat option. Enabling it requires the exact Anytype space ID, the dedicated “Ask Steelmoth” chat ID, and a comma-separated exact creator-ID allowlist. These values are non-secret connection metadata; the runtime flag must also be enabled before a listener starts. The user creates and invites the dedicated Anytype CLI identity, generates its API key, and selects these IDs. The dashboard and runtime do not perform account creation, key generation, space joining, or chat discovery.

The Telegram card on /integrations posts a bot token to /api/integrations/telegram/connect. The route validates the token shape before touching the broker, creates a client-mark-telegram-bot-token write challenge, stores the plaintext and PKCE verifier only in a five-minute server-memory pending map, and redirects through Authentik with prompt=login, max_age=0, the broker nonce, an S256 code_challenge, and the provider-advertised acr_values when present. The flow uses https://app.steelmoth.ai/login as the OAuth redirect_uri; the /login route relays Authentik code/state or error/state query params to /api/integrations/telegram/callback, where the callback exchanges the Authentik code with the stored code_verifier, sends the returned ID token as X-Steelmoth-Step-Up-Token, and writes the secret through the prontera broker. The broker challenge authorization resolves the Authentik OAuth access_token from a short-lived server-side cache keyed by an Auth.js access_token_ref; sessions without a valid cached reference are rejected with telegram_session_refresh_required before any prontera write-challenge call. The connect/callback routes build their redirects from the forwarded public host/proto, not the container-internal origin, so the operator’s browser stays on https://app.steelmoth.ai through the step-up flow. The token is not sent to chat, the runtime API, memlink, or client-side storage.

An enrolled client (owlet) now sees the same /integrations surface as the operator — the Website Credentials panel plus the Gmail/Calendar/Drive cards and the Telegram card — instead of the earlier stripped view (additive; the client surface only grows, HR7-bis / INV-002). IntegrationsScreen takes an optional typed readiness map (websiteCredential / google / telegram) that defaults to all-true; the operator call site omits it, so the operator render is byte-identical. The client branch of page.tsx derives readiness from three default-off, fail-closed flags (STEELMOTH_DASHBOARD_CLIENT_WEBSITE_CREDENTIALS_ENABLED, STEELMOTH_DASHBOARD_CLIENT_GOOGLE_ENABLED, STEELMOTH_DASHBOARD_CLIENT_TELEGRAM_ENABLED, read via clientIntegrationReadiness in dashboard/lib/integration-principal.ts). With every flag off — the default after merge — each surface renders present but non-interactive (the card’s “Available in your workspace” affordance and a non-interactive Website Credentials placeholder that keeps its heading/copy; WebsiteCredentialPanel gains an optional viewOnly), so the slice merges inert with no broker change. The client branch never calls the operator status fetchers, hard-nulls googleEmail (no operator address leaks), and restricts its banner/success-modal derivation to the website_credential_* status family. Turning a flag on would let the client cards POST to the operator-scoped /api/integrations/* routes, which is a later client-scoped backend/broker phase — hence the flags stay default-off for now. The website-credential connect + resume API routes additionally enforce the same clientIntegrationReadiness().websiteCredential flag server-side (defense-in-depth): a client principal whose flag is off fails closed with a website_credential_disabled status, so the readiness gate cannot be bypassed by POSTing past the UI. Operator principals are never gated (they do not carry the client kind).

The scheduling reminders page (/scheduling/reminders) now includes a CalendarSourcesCard in the right rail beside the weather and reminder form cards. It reads /v1/scheduling/calendar-sources, toggles one source through POST /v1/scheduling/calendar-sources/{calendar_id}, and invalidates both calendar-source and calendar-event query keys so the month grid re-fans out after each toggle.

The Automations page (/scheduling/browser-tasks) carries a labelled Triggers section (TriggersSection.tsx, background work lane / ADR 0205) below the automations list and the unsubscribe follow-ups — purely additive (HR7-bis), dual-mounted in BOTH the <AdminOnly> and fallback positions like its siblings, and owner-scoped the same way (/v1/triggers is require_authed and server-clamps to web_chat_key(user.email)). It lists standing trigger rules (“when an email from X arrives, do Y” / “N minutes before a matching calendar event, do Z”) with a status pill per rule (Draft / Armed / Paused / Cancelled). A rule drafted from chat waits here for the operator’s Save & switch on button — the ratification gate that POSTs /v1/triggers/{id}/arm (the ONLY draft→armed path; the store’s arm_trigger initializes the no-backlog checkpoint server-side). Pause/Resume are direct reversible mutations with no confirm dialog (the UnsubscribeFollowups “Mark done” idiom); Cancel is terminal and sits behind a confirm dialog (the BrowserTasksPanel idiom). Each expanded rule offers a lazy “Recent fires” disclosure (GET /v1/triggers/{id}/fires fetched only when opened) whose rows link the run_id each fire started. A chat chip’s “Review & Save” deep link lands as ?draft=<trigger_id>, which highlights and auto-expands that draft row so Save is one click away. Data layer: dashboard/lib/triggers-queries.ts (browser-tasks-queries conventions — inline keys, 30 s staleTime, retry: false mutations invalidating the bare ["triggers"] prefix).

UserSettingsPanel.tsx renders the user-settings tab. The #user-notifications fieldset now contains a Channels sub-section listing the seven canonical notification channels (Inbox, Telegram, Email, Slack, WhatsApp, Instagram, Webhook) in server-canonical order returned by /v1/settings.user.channels (PRD 0029 / RFC 0064 / ADR 0084). Configured channels render as toggleable checkboxes; unconfigured rows render greyed with an aria-disabled checkbox and a muted (Not configured) suffix. Save now PATCHes the typed channels: [{name, enabled}] shape; the legacy notifications shape is still accepted by the runtime for back-compat but is no longer in the outbound body. The fieldset id #user-notifications is preserved (HR7-bis / INV-002 — visible surface does not shrink); the three pre-existing toggles (Desktop, Email, Chat) are reincarnated as the canonical Inbox / Email / Telegram channel rows.

UserSettingsPanel.tsx also exposes a Strict email allow list checkbox directly below the Self-learning toggle in #user-profile (PRD 0049 / RFC 0107 / ADR 0193, 2026-07-03). Default unchecked: email sends skip the recipient allow-list scan and are gated by the approval chip alone; checked restores the fail-closed allow-list block. It reads/writes user_settings_profile.strict_email_allowlist_enabled (migration 0126) through the same GET/PATCH settings contract (toFormState maps a missing field to false so the panel stays safe against an older runtime).

UserSettingsPanel.tsx also exposes a Self-learning Ability toggle (relabeled from “Morpheus self-evolution”, plain-English copy, 2026-06-30) under the #user-profile section (PRD 0033 Phase 2.5a, default-on). It reads/writes user_settings_profile.morpheus_auto_enabled through the PATCH route; the deploy-wide MOTH_HYBRID_MORPHEUS_AUTO_ENABLED env var remains the global kill switch, mentioned on the operator copy as a generic “deploy-wide kill switch” caveat (the env-var name is no longer shown in the UI). The runtime begins honoring this toggle once Phase 2.5c activates the LLM consolidation adapter — Phase 2.5a only plumbs the read/write end-to-end. The same control is also surfaced to clients on the Agent Profile page (/memory/living-documents, user view) via the exported SelfLearningAbilityCard, which reuses the same morpheus_auto_enabled PATCH contract but saves immediately on toggle and omits the kill-switch caveat.

The Settings User rail appends #user-delete-account after Profile and Notifications. DeleteAccountSection.tsx renders the destructive client account deletion UI: an empty type-your-email confirmation input, a disabled destructive button until the typed value matches the signed-in email case-insensitively, a direct browser POST to /api/v1/clients/me/delete-account, inline error handling, and persistent scheduled/already-scheduled result blocks. Admin/operator sessions keep the section visible but render only an informational note; the operator view does not render the form or issue the deletion request.

Reminder delivery (out-of-process daemon at steelmoth-runtime/scripts/reminder-daemon.py, core loop in steelmoth_runtime.reminder_daemon.run_cycle) now enqueues per-channel delivery intents into steelmoth.notification_outbox instead of calling providers directly. Each enqueue writes a synthetic steelmoth.sidecar_runs row for provenance; one-shot reminders are marked sent when at least one enabled channel enqueue succeeds. steelmoth-notifier performs delivery from the outbox; explicit reminder channels (email, inbox_only, dashboard, webhook) are preserved, while preference auto-routing is only used for legacy default rows. The dashboard reminders page is not affected — the change is in the daemon/outbox delivery path. The loop now also emits reminder_daemon.stale_cycle_alert (default 120s threshold) and reminder_daemon.backlog_alert events when due reminders persist across multiple cycles.

The Tokens dashboard section is admin-only and gated by the tokens feature flag. sidebar-nav.ts exposes /tokens, /tokens/by-model, /tokens/by-lane, and /tokens/events. The overview page shows 24h, 7d, and 30d totals, top models, top lanes, and recent events; the by-model and by-lane pages show sortable 30-day breakdown tables; the event log supports 24h/7d/30d range presets, model/lane/provider filters, cursor pagination, and a Tool column backed by the source_tool field for external proxy callers. The dashboard query layer mirrors the runtime /v1/tokens/summary, /v1/tokens/events, and /v1/tokens/series contracts, while the current pages consume summary and event feeds. The current lane/provider dropdown options are defined in LlmEventFilterBar.tsx; external-tool rows are visible through the Tool column even though the lane selector currently does not enumerate every backend lane.

Dashboard session identity separates operator identity from route-session authentication. dashboardEmailFromClaims() remains fail-closed to the canonical Authentik email for operator-only paths, while hasDashboardSessionIdentity() accepts any non-empty Auth.js email so enrolled client sessions can reach client-allowed routes such as /scheduling/todos; the /api/v1/* proxy still enforces enrollment and gateway routing for non-operator users. The helper no longer synthesizes emails from username, subject, or name claims. Frontend admin checks now trust the server role only: dashboard/lib/auth.ts:isAdmin() returns true only for user.role === "admin". The docs-host edge gate checks the Auth.js session’s dashboard-admins group claim because middleware cannot round-trip to /v1/me; private loopback bypass defaults to the same dashboard-admins group but only carries username/groups when the configured private email is canonical. The /api/v1/* proxy returns 403 for non-canonical private identities before forwarding trusted runtime headers. The proxy strips inbound x-steelmoth-internal-* headers and re-injects the internal runtime hop credential from a server-only secret file before calling runtime upstreams; runtime-like containers receive only the SHA-256 digest used to verify that header.

dashboard/auth.ts stores only a short-lived access_token_ref in the Auth.js JWT/session for broker-boundary step-up challenge calls; the real OAuth access_token stays in server memory and is rehydrated per request. It also exposes endSessionEndpoint() derived from AUTHENTIK_ISSUER. The Auth.js JWT no longer stores raw OIDC tokens, which keeps protected-route response headers smaller on callback/sign-in completion. The sidebar account footer uses the current user’s display name, then username, then email fallback. The account dropdown shows the signed-in identity, links My Settings to https://app.steelmoth.ai/settings, and exposes Sign out. The separate footer settings cog is not rendered. The Sign out control navigates to /api/dashboard/logout, a plain route that expires Auth.js dashboard cookies, including chunked session-token cookies and configured cross-subdomain variants, before redirecting to Authentik’s end-session endpoint. Middleware treats the logout route as public so it can clear already-stale sessions. Logging out directly at auth.steelmoth.ai only clears the IdP cookie; dashboard logout must also clear the app.steelmoth.ai Auth.js session cookie. Public mode defaults to the Steelmoth Authentik issuer https://auth.steelmoth.ai/application/o/steelmoth-dashboard/, while the cross-subdomain redirect allowlist accepts HTTPS Steelmoth hosts under steelmoth.ai. The dashboard entrypoint can resolve AUTH_SECRET and AUTHENTIK_CLIENT_SECRET from prontera broker aliases into temporary files. The public deploy script feeds the entrypoint a one-shot token through /run/steelmoth-dashboard-token, and the entrypoint deletes that token file immediately after reading it. Since Agent Vault 0.22 requires member-level authority to mint temporary sessions and the runtime identity is intentionally proxy-only, the entrypoint can use that proxy token directly, in memory only, for the read-only broker secret fetch before unsetting it. A Node preload reads the dashboard secret files after process start so Auth.js can use normal runtime env without raw secret values appearing in Docker inspect or the process startup environment.

The Admin Assignments settings section is now a read-only viewer for the configured Authentik admin group and the current actor’s membership status. It does not PATCH admin emails or group membership; the admin_emails client write mapping is removed, the server rejects admin-email env PATCHes, and actual membership changes happen in Authentik.

The chat surface creates an empty pending assistant bubble immediately after send, swaps it to the acknowledged server id when /v1/chat/send responds, and buffers incoming SSE token_delta text separately from the assistant text currently visible in the transcript. useTypewriterStream advances the visible prefix with requestAnimationFrame, renders the first streamed character immediately, clamps long hidden-tab elapsed intervals, and accelerates the tail after completion without dumping the whole buffered reply in one frame. message_complete marks the bubble completed, keeps the accumulated token text as the typewriter target, warns if server final_text diverges, and only allows the persisted history twin to replace the streaming row after typewriterDone is true. Completed message ids are remembered for the current conversation so post-completion SSE reconnects cannot replay a fresh transient streaming bubble over the persisted reply. Stop snaps the visible prefix to the buffered text, marks the turn stopped, and ignores later events for that message id.

Each chat message carries a Reroll + Reply-to action row (chat-reroll-reply plan). Hover a bubble (desktop) or long-press (mobile) to reveal, in the shared message-actions row: Reply (message-quote-reply, both user and assistant bubbles) quotes the message into the composer as a chip and rides the next send as reply_to {message_id, author}; Regenerate (message-reroll, assistant bubbles only, finished + non-errored) fires useRerollMessage()POST /v1/chat/reroll and the fresh answer streams in as a NEW tail bubble via the existing channel-turn path (append-only — the old reply stays). The quote lives in chatStore.quotedReply (a distinct namespace from the ADR 0187 2FA replyTarget; the quote chip renders in the composer’s NORMAL branch so it is naturally suppressed while the 2FA ReplyComposer is mounted). A quoting user bubble renders a compact quoted block (message-quoted) above its body that jumps to the quoted message on click (the ⌘K anchor path); it survives reload via the history endpoint join. On mobile the long-press opens the 2FA reply when a code-ask is present (exact prior behavior) and quote-reply otherwise. Nothing was removed — this is additive to the 2FA reply affordance (HR7-bis).

The chat shows a progressive staged status line instead of a tool/duration timeline. On send, ChatPane sets a stable liveTurnKey (the clientMessageId, held for the whole turn so it survives the streaming.messageId mutations) and the useStagedStatus hook shows an instant stage-1 line, advances one generic status line every ~1.5 s (capped at five in-progress stages), and settles on a terminal stage-6 “Done”-pool line when the turn completes (liveTimelineStatecomplete). message_start also sets liveTurnKey as a fallback so channel-originated turns get a status line too. The status lines live in chat-status-stages.ts (six pools of ~200 generic, task-agnostic phrases) and are picked deterministically by a seeded hash, so re-renders never reshuffle them and no tool name or duration is ever shown.

ChatPane still builds liveToolSteps from the SSE planner/tool events, but only to size the scroll reserve — the steps are no longer rendered. ToolUseTimeline renders the staged status (a staged prop): plain text the same size as the reply but faded, a very slow soft left→right shimmer while running, and a trailing > that expands the line — as a left-bordered, indented block — to reveal the status phrases it moved through.

MessageList renders the streaming bubble from the typewriter-visible renderedText, keeps the spiral streaming loader visible until the typewriter is done, and keeps a dynamic bottom reserve while the sent-message anchor or active stream needs space. At end-of-turn it holds the last reserve until the persisted assistant twin lands, the conversation changes, or the user explicitly scrolls to the bottom. This keeps short completed replies from collapsing the transcript while history refetches catch up. If a late stream update arrives without an active anchor, MessageList keeps using the held reserve instead of replacing it with a zero active reserve. After history has loaded for a non-empty conversation, MessageList performs an automatic scroll to the latest message the first time the current conversation key is rendered after mount or key change; it skips that restore while scrollAnchorId, streaming, or streamingActive is present so active turns keep the send-anchor scroll behavior. ChatPane hides any persisted assistant twin while the typewriter is still draining. If message_complete has fired, the typewriter is done, and no persisted assistant twin appears in history, ChatPane keeps the streaming bubble for the first 2.5 seconds and then releases the bubble, live timeline, and scroll anchor after 3 seconds as a safety net. useHistory disables reconnect-triggered history refetches so a network reconnect does not manually refresh the transcript during a live turn. MessageBubble no longer renders a separate empty-text thinking placeholder; the live staged status line is the progress cue while streaming. The composer returns focus to the textarea immediately after Send.

For persisted history rows, MessageList renders finalized message.reasoning_blocks as collapsible ThinkingSection blocks above the reply body when the runtime supplied readable reasoning summaries/content. If no reasoning block exists, persisted rows render no extra thinking or settled-status block above the assistant reply (the canned staged-status fallback was dropped in #512). The real message.tool_timeline is no longer shown (no tool names, no durations). While a turn is still streaming, live reasoning_delta events feed the same ThinkingSection affordance and the live timer-driven staged status remains the fallback when no readable reasoning stream exists.

MessageBubble chrome is intentionally minimal: it renders no avatar and no visible sender name (role reads from row alignment plus the user bubble tint, which uses the --rule token; a screen-reader-only label preserves per-message attribution). The timestamp and copy-to-clipboard action share a single hover-revealed bottom row (sm+ only), so neither is visible until the message row is hovered. The bottom row also renders for attachment-only messages so their timestamp is not lost. The spiral streaming loader plus stopped/error chips still render in a compact status row above the body when present.

Phase 10 attachments render in both modes: ChatPane parses the additive attachments array on tool_call_end (intermediate, e.g. screenshot tool output mid-turn) and message_complete (terminal, captured on the final bubble) SSE events and threads them into MessageList; MessageBubble renders image attachments as inline thumbnails via GET /v1/chat/attachments/{attachment_id}. Memlink history round-trips attachments through the metadata_json.attachments back-channel, so a reload after a long-running turn preserves the rendered image instead of degrading to text-only.

User-uploaded attachments use the same render surface. InputBar keeps the full Attachment metadata returned by POST /v1/chat/attachments, passes it to ChatPane for the optimistic user row, and ChatPane still sends only attachment_ids to /v1/chat/send. If a matching persisted user history row arrives without Message.attachments during a backend/memlink catch-up window, ChatPane overlays the optimistic attachments onto that row so the chip does not disappear mid-turn. MessageBubble renders non-image user attachments as download chips through GET /v1/chat/attachments/{attachment_id} and omits the empty user text bubble for attachment-only sends.

The composer (InputBar) is a rounded card whose + attach menu, textarea, and send/stop button sit on one vertically-centered row: a 42px circular + (surface fill + hairline border) whose Photos item opens an images-only picker (png/jpg/gif/webp) and Documents item opens a pdf/txt/md picker — two scoped <input> accept lists over the one upload route (POST /v1/chat/attachments), which still enforces the real server allowlist (so executables / office / svg are 415’d; svg + office are follow-ups). Below them are visual-scaffold-only Research and Apps → Drive items (Apps reveals a right-hand flyout reusing the google-drive ProviderLogo mark; neither is wired to behavior yet), a single-line auto-grow textarea (24px line that grows to a max then scrolls internally), and a 42px circular send button (it morphs to a Stop square while streaming). The earlier composer chips (Attach / Deep think / Use memory), the composer token counter, and the “Moth can make mistakes” disclaimer footer were all removed. The topbar no longer shows the runtime · ok · p50 status pill or the Codex-limit “Resets in…” timer.

The composer also carries a “#” app-tag autocomplete: when the caret sits at the end of a # + letters token that starts a word, a small cmdk picker pops up above the composer card (chrome matches the attach menu; row anatomy matches the command palette rows) listing the canonical app tags — Gmail, Calendar, Drive, Tasks, Todos, Web, Browser, PDF, Reminders, Weather, Contacts, Code, Devices. Detection, filtering, dismissed-token memory, and insertion are pure helpers in dashboard/components/chat/app-tag-autocomplete.ts (the vocabulary mirrors the runtime’s steelmoth_runtime/tool_router/app_tags.py, canonical tags only); the popup itself is dashboard/components/chat/AppTagAutocomplete.tsx. Row-reference shapes like #2/#g2 never open it; nothing is ever auto-inserted — only an explicit Enter/Tab/click splices #tag (always with a trailing space, since two tags without a space between them parse as zero tags server-side), and the caret is restored in an effect after the state commit. While the popup is open, a guard-first branch at the top of the composer’s keydown handler owns ArrowUp/ArrowDown/Enter/Tab/Escape and early-returns, so an Enter that accepts a suggestion can never fall through to send; Escape dismisses the current token until it changes.

Mobile chat PWA chrome (2026-06): on mobile (<md) the chat shell goes chrome-light. The burger becomes a floating, transparent, drop-shadowed Steelmoth logo top-left (Sidebar) that opens the same nav drawer; and the bottom tab bar (MobileBottomBar) is removed — its destinations stay reachable from the drawer. The drawer gains a search row (opens the ⌘K command palette) plus the theme toggle, above the nav. Since 2026-07-25 (operator) the top bar also renders on mobile — no longer hidden md:grid — as a shorter transparent strip whose only job is anchoring the collapsible icon tray (TopbarIconTray: reload / pinned / ghost / theme as a vertical stack of round filled chips, the menu/close toggle chipped too) at the top-RIGHT corner (Topbar.tsx; the right cluster is pinned to grid column 3 because the hidden col-1 search wrapper would otherwise shift grid auto-placement and centre the tray). The standalone mobile ghost-chat FAB that floated at top-[5.5rem] was folded into that tray the same day (operator-directed; ghost stays reachable on / one tap away). The chat stream fills to the top edge and scrolls up behind the floating logo/tray with a top fade scrim — ChatPane goes absolute inset-0 on mobile to cover main’s top padding, MessageList pads its first message down (max-md:pt-[88px], matching the bumped send-anchor offset), and a from-background → transparent gradient keeps messages legible under the chrome. The composer floats over the stream (translucent + backdrop-blur) rather than sitting in flow, and the composer textarea is exactly one line while empty (it only grows once the user types — an empty field’s scrollHeight reflects the placeholder, which wraps on mobile). The installed PWA is locked to portrait: the dashboard manifest sets orientation: "portrait" (the declarative lock Android obeys), and PortraitLock adds screen.orientation.lock('portrait') + a guarded force-portrait CSS rotate fallback for iOS / browser tabs. All of this is mobile-only (md:/max-md:); the desktop sidebar/topbar shell is unchanged.

The memory living-document detail panel treats owner id as display-only. It derives the visible owner from the selected client or runtime fallback, omits owner_id from revision write payloads, and relies on the runtime memory API to inject the canonical owner.

The dashboard shell is host-aware. layout.tsx renders a minimal no-sidebar view when the request host is docs.steelmoth.com, while the normal authenticated dashboard host keeps the full sidebar/topbar shell on desktop (mobile uses the chrome-light chat variant described above). The auth middleware also applies host-aware policy: docs-host requests require a dashboard-admins session for non-public routes. In docs-host mode, layout also registers /sw.js and the metadata manifest is host-aware so the install profile is branded as Steelmoth Wiki. The authenticated dashboard manifest is branded simply as Steelmoth and its app/touch icon family is generated from the transparent moth mark on the carbon app background, replacing the old pre-rebrand icon tile. The authenticated dashboard host now registers the same service worker, mounts the offline banner, and renders the install prompt. The install prompt follows the app design’s graphite/champagne sheet treatment: a dim scrim, 52px app-icon tile, folio steps, and full-width primary action for iOS add-to-home-screen guidance. On non-iOS browsers it prefers the native beforeinstallprompt event, but falls back to generic browser-menu install instructions when the event is suppressed or unavailable. Only explicit Not now / Got it actions persist the 14-day suppression; closing the sheet or tapping the scrim hides only the current prompt, and the old pre-redesign suppression key is cleared on load. The service worker precaches only the public offline shell and icon/logo assets, uses network-first navigation, and bypasses API, login, and OAuth paths so protected dashboard/wiki responses are not persisted in browser caches.

Sign-out flow: the avatar dropdown invokes the signOutFromDashboard server action defined in dashboard/app/login/actions.ts. The action clears the Auth.js session cookie (via signOut({ redirect: false })) and then redirects the browser to Authentik’s end_session_endpoint with post_logout_redirect_uri (no id_token_hint). This still clears the local session and routes through Authentik logout; the post-logout browser now lands at /, where the root-route auth gate renders the no-shell login view for unauthenticated visitors. Private-loopback mode short-circuits to / because the env is the identity and there is no Auth.js session to destroy. The Authentik application steelmoth-dashboard on Prontera must allowlist https://app.steelmoth.ai/api/auth/callback/authentik and https://app.steelmoth.ai/ under its redirect and post-logout URI settings; the same list governs authorization callbacks and post-logout redirects. The dashboard CSP form-action allows https://auth.steelmoth.ai as the Authentik form target. The CSP also sets img-src 'self' data: (RFC 0095 / ADR 0148 Phase 6c): the browser refuses external image hosts, so a model-supplied markdown image cannot auto-exfiltrate on render. Every legitimate image is same-origin — the /public logos and chat attachments served via the same-origin /api/v1/chat/attachments/... proxy (NEXT_PUBLIC_API_BASE=/api) — so nothing visible breaks; MarkdownContent renders a model image as a click-through link as the in-renderer companion. steelmoth.app is a redirect alias only and must not proxy the dashboard directly.

The Automations page (/scheduling/browser-tasks, dashboard/app/scheduling/browser-tasks/page.tsx) composes three owner-scoped sections, each dual-mounted in BOTH the <AdminOnly> children and its fallback positions so the admin-only-must-gate lint finds an enclosing gate while client sessions still render their own owner-clamped view: BrowserTasksPanel (scheduled browser automations), UnsubscribeFollowups, and — background work lane, PRD 0058 / ADR 0203 — BackgroundRunsSection (dashboard/components/admin/scheduling/BackgroundRunsSection.tsx + dashboard/lib/background-runs-queries.ts). The Background runs section lists this owner’s off-chat runs from GET /v1/background-runs in status buckets (Running / Awaiting approval / Paused / Queued plus Completed / Failed / Cancelled behind a “Show finished” toggle), expands a row to lazily fetch the run’s recorded step ledger from GET /v1/background-runs/{run_id} (re-polled every 30 s only while the run is live; a terminal run’s detail never polls), and offers Pause/Resume as direct no-dialog text buttons (reversible soft signals) with Cancel behind the section’s confirm dialog. Affordances gate on explicit statuses and the pause_requested_at/cancel_requested_at soft-signal timestamps, never on display labels. Purely additive (HR7-bis) — no sidebar or route-access change; the page was already client-visible.

Inside BrowserTasksPanel’s create/edit form (2026-07-25): the mode radio reads Repeat (was Daily) and the time field Time — display-only label renames; the wire recurrence stays daily@HH:MM everywhere. Repeat mode also offers an optional from/to date pair mapping to the nullable starts_on/ends_on DATE columns on world.browser_tasks (migration 0151): inclusive calendar days in the task’s timezone, each independently optional, NULL unbounded so existing rows are unchanged. The route clamps the first firing to starts_on, the finish path stamps finished_at once the next computed firing falls past ends_on, and a malformed date, ends_on < starts_on, or a zero-firing window is a 400 INVALID_ARGUMENT; the same window threads the chat tool’s BrowserTaskCreateArgs (INV-028 parity), and the dashboard omits the keys from the payload when blank. The form’s timezone default also can no longer capture UTC before the saved zone resolves: runtime.get_web_user_profile is now really implemented (it reads steelmoth.user_settings_profile.timezone through the shared attached repository, fail-open to None so /v1/me stays 200-best-effort) and the panel’s loading gate waits on /v1/me before rendering.

Dashboard work has repo-specific hard rules. CLAUDE.md requires canonical dashboard deploy paths, public/private fingerprint parity, visible browser verification for UI behavior, and no unapproved build or container recreation. It also forbids shrinking the visible dashboard surface unless the operator explicitly confirms that scope.

  • Update this page when dashboard routes, sidebar surface, API relationship, or deploy verification rules change.
  • Pair dashboard page changes with the corresponding web_chat/routes_*.py source when the page depends on runtime data.
  • Do not add dashboard system-wiki editing without design artifacts, auth classification, tests, and page contracts.
  • This page does not inventory every dashboard route or component; use the sidebar source and app directory for exact UI coverage.