Skip to content

feat: adopt operator-console redesign + modular adapter system - #128

Merged
AbirAbbas merged 79 commits into
mainfrom
feat/ui-redesign
Jun 29, 2026
Merged

AbirAbbas merged 79 commits into
mainfrom
feat/ui-redesign

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

What this is

Adopts feat/ui-redesign onto main: the operator-console redesign and the
modular adapter / plugin-based connection system. This one branch contains the
full chain supportdesk-first-dx (#118) → feat/modular-adapter-system → the
UI redesign, authored by @santoshkumarradha. It merges conflict-free (current
main is fully contained as an ancestor).

Scope (788 files, +68k / −20k)

  • Operator console redesign — new, leaner IA: Inbox · Cost · Health ·
    Activity/Runs · People/Tenants. Replaces the old Build/Infra/Operate nav.
  • Modular adapter system across 8 slots + adapter registry contract — the
    user-authorable, plugin-based connection system (swap providers behind an
    interface).
  • Observability adapters (prometheus, glitchtip, remote shims) + runtime
    telemetry write hooks and history endpoints.
  • Customer-app SupportDesk-first flow.

Validation

CI is currently workflow_dispatch-only, so this was validated locally:

  • go build ./... ✅ · go vet ./... ✅
  • go test ./... ✅ except two pre-existing, environment-dependent tests in
    services/runtime/internal/server (TestRunAgentFieldAFUnreachable,
    TestHandleRunsSubscribeAFUnreachable). They fail identically on main in
    a sandboxed network (dialing 127.0.0.1:1 returns 504 instead of 502 because
    the port doesn't RST instantly) — not introduced by this branch; they pass
    under normal CI networking.
  • pnpm --filter dashboard build ✅ — every redesign route compiles.

Consolidation

Planned follow-ups after merge (separate PRs): CLI-first binary fix, CLI init
scaffold + anonymous opt-out telemetry, flip CI to on: [push, pull_request],
positioning/docs truth pass, and regenerate the dependabot set against the new
main.

🤖 Generated with Claude Code

santoshkumarradha and others added 30 commits June 8, 2026 00:37
End-to-end demo of the Shipwright pattern (customer pastes a GitHub
issue → agent reads repo, plans, edits, tests, opens PR). Built on
standard shadcn components with a stub agent so iteration on the UX
is fast; swap in the real SWE-AF library by replacing one file.

## Backend (examples/02-shipwright/)

- `migrations/00001_shipwright.sql` — `shipwright_tasks` +
  `shipwright_steps` with RLS policies; auto-touch trigger on
  `updated_at`.
- `agents/shipwright/` — AgentField agent.
  - `Agent(node_id="shipwright-v2")` + `@app.reasoner execute_task`
  - Stub simulates 7 steps × asyncio.sleep totaling ~10s with
    deterministic output (status / summary / diff_preview / steps).
  - `__capabilities__` reasoner with harness + MCP runner probe.
  - Dockerfile mirrors the sample-agent pattern (NODE_ID + PORT +
    AGENT_CALLBACK_URL env).
- `handlers/` — Python FastAPI workload-module sidecar.
  - Routes: POST /tasks, GET /tasks, GET /tasks/{id},
    POST /tasks/{id}/cancel, GET /stats.
  - Tenant-bound DB connection via `SET LOCAL app.tenant_id`.
  - `_drive_task` runs in `asyncio.create_task` so POST returns
    immediately; persists task row + 7 step rows on completion.
- `docker-compose.yml` — compose overlay with shipwright-migrate,
  shipwright-agent, shipwright-api services.

## Frontend (apps/customer-app/)

- `(app)/shipwright/page.tsx` — list page with queue table +
  new-task form. shadcn Card / Table / Badge / Field / Input /
  Textarea / Button. Polls /tasks every 1.5s; status badges
  color-coded (queued / running / completed / failed / cancelled);
  durations + relative timestamps render live.
- `(app)/shipwright/[id]/page.tsx` — detail with step timeline,
  summary card (duration / steps / status), monospace diff preview.
  Live-polls every 1s until terminal state.
- `api/customer/shipwright/[...path]/route.ts` — proxy to the
  workload-module sidecar; reads session via `auth.api.getSession`,
  resolves `lookupCustomerContext(email)` → forwards
  `x-af-stack-tenant-id` + `x-af-stack-user-id` headers.
- `components/layout/customer-sidebar.tsx` — replaced "Code Helper"
  nav entry with "Shipwright" (Hammer icon).

## Verified E2E

Captured screenshots in `dashboard-screenshots/`:
- shipwright-with-sidebar.png — sidebar nav, Shipwright active
- shipwright-form-filled.png — new-task form filled
- shipwright-queue-mixed.png — list showing Running + Completed
- shipwright-detail-completed.png — full task detail with steps,
  summary, diff preview
- shipwright-agent-logs.txt — agent log lines from the 3 runs

## Gotchas baked into the README + code comments

1. Don't name a reasoner `run` — collides with `Agent.run()` (the
   decorator overrides the method; agent exits immediately).
2. AgentField caches reasoner metadata keyed on node_id. After
   renaming a reasoner, bump the node_id to force fresh registration.
3. Reasoner payload shape: `{"input": {"payload": {...}}}` for
   signatures like `async def f(payload: dict)`.
4. Result shape: runtime returns `{"result": {...}}`, not `output`.

## Swapping in real SWE-AF

Replace `agents/shipwright/main.py` with the real SWE-AF agent.
Keep the node_id + reasoner name + RunResult schema. Everything
else (UI, workload module, DB, polling) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shipwright workload module now routes to the actual SWE-AF library
at /Users/santoshkumarradha/Documents/agentfield/code/examples/af-swe by
default. The iteration stub remains available behind an env flag.

## Compose

- swe-af-agent service added — builds from the real af-swe Dockerfile
  (gh CLI + OpenCode + Claude Agent SDK + git). Registers as node_id
  `swe-planner` with 34 reasoners (build, plan, execute, resolve,
  resume_build, plus 29 specialist reasoners: run_coder, run_qa,
  run_code_reviewer, run_github_pr, run_ci_fixer, run_replanner, etc.).
- shipwright-agent (the stub) stays running for fast UI iteration.
- swe-af-workspaces named volume persists build artifacts.

## handler.py — sync + async modes

- `SHIPWRIGHT_AGENT` env var selects the agent (default
  `swe-planner.build`; `shipwright-v2.execute_task` for the stub).
- `SHIPWRIGHT_MODE=async` posts to `/api/v1/agents/async/<name>` and
  polls `/api/v1/runs/{run_id}/agentfield` every 5s for up to 1 hour.
  This is the only path that works for real SWE-AF builds (which take
  minutes-to-hours).
- `SHIPWRIGHT_MODE=sync` keeps the original inline pattern for the
  stub.
- `_build_payload()` translates the customer-app form into either
  shape: the stub's `{payload: {...}}` or SWE-AF's `{goal, repo_url,
  artifacts_dir, ...}`.
- `_persist_result()` handles both response shapes — coerces SWE-AF's
  BuildResult schema (pr_url, repos, plan, diff) to the canonical
  {status, summary, diff_preview, steps} the UI expects.

## .env additions

- SHIPWRIGHT_AGENT=swe-planner.build (default — the real SWE-AF)
- SHIPWRIGHT_MODE=async

## Verified E2E

Submitted "[real SWE-AF] hello docs" via the customer-app UI. The
workload module posted /api/v1/agents/async/swe-planner.build with
goal + repo_url. The runtime routed to the SWE-AF agent. The build
reasoner started, attempted git clone of the target repo, and failed
with `could not read Username for 'https://github.com'` — expected
because GH_TOKEN isn't set on this dev box.

Screenshots captured:
- operator-home.png — operator dashboard after first signup
- operator-agents-with-sweaf.png — Build > Agents tab
- agentfield-with-swe-planner.png — AgentField shows 4 build runs
  (real SWE-AF) + execute_task runs (stub) side by side
- agentfield-swe-af-run-detail.png — full run detail in AgentField's
  UI showing the actual git clone error from the SWE-AF pipeline

## Operator credentials

- Customer: demo@shipwright.test / DemoPassword123!
- Operator: admin@af-stack.test / AdminPassword123!

## To actually complete a SWE-AF build

Set these in .env then `docker compose up -d swe-af-agent`:
- ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN) — for Claude Code
- GH_TOKEN — for git clone + PR creation
- OPENROUTER_API_KEY — for the harness model fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove obsolete planning notes under development/discussions
- Archive prior discussion drafts under development/_archive
- Add UX journey notes under development/ux
- Stage codex run screenshots under .codex-screenshots
Strip the existing admin shells and prepare for a backend-driven UI
rebuild. Preserve API clients, auth, runtime proxy, and middleware.

Dashboard:
- Remove (admin)/, old/, components/new-admin/, components/layout/,
  guided-tour, dashboard-plugins-list, lib/new-admin/, lib/nav.ts,
  and the cost-explorer example plugin
- Add shadcn sidebar-08 block (app-sidebar + nav-* + ui primitives)
- Promote the generated dashboard page to src/app/page.tsx so the
  existing middleware auth gate covers it

Customer-app:
- Remove (app)/, components/layout/, guided-tour
- Add shadcn sidebar-08 block at src/app/dashboard/page.tsx
  (middleware already redirects "/" -> "/dashboard")

Both apps:
- Drop unused driver.js dependency and CSS import
- Update shadcn primitives that the block ships fresh versions of

Pages that will rebuild on top of lib/api.ts are not in this commit.
Frontend (apps/dashboard):
- Theme module: globals.css extended with semantic CSS vars
  (spacing-page-x/y/section/stack/inline/tile, radius-tile/pill,
  text-eyebrow/meta/body/kpi-value, success/warning) wired through
  @theme inline so JSX consumes Tailwind utilities only. lib/theme.ts
  mirrors the non-className values (motion timings, chart sizing,
  polling cadence, layout constants).
- Home shell built from standard shadcn primitives only (Card, Badge,
  Tooltip, Skeleton, Separator, ScrollArea, Alert, Chart). Monochrome
  zinc; status colour only on KPI dots, severity chips, service pills.
- Sections: KPI strip (8 tiles with sparklines), unified Activity feed,
  4 Quick actions, Backing services strip with admin_url deep links.
- Welcome block stubbed (deferred per home.md §20).
- Server-component data loader does Promise.allSettled over 6
  endpoints; partial failure renders degraded sections rather than
  crashing the page.

Backend (services/runtime):
- home/overview extended with live_runs, failed_runs_last_24h,
  budgets_aggregate (tenants_at_risk, avg_consumed_pct, tenant_count).
- queue_depth field is now populated (was declared but unused).
- recent_webhook_deliveries populated from suite_webhook_deliveries
  with direction/status enum mapping.
- New GET /api/v1/admin/events endpoint: server-side merge of runs,
  webhook deliveries, system alerts, activity log into a typed union
  sorted DESC by occurred_at, with limit + kind filter.

Schemas (zod): HomeOverviewSchema extended, BudgetsAggregateSchema +
AdminEventSchema + AdminEventListSchema added, api.admin.events.list
method added.

Tests: TestHomeOverviewEmptyDeps extended with new field checks;
TestAdminEventsEmptyDeps / RespectsLimitParam / KindFilterEmpty added.
All 265 server tests green; pnpm tsc --noEmit clean; Next.js production
build succeeds.

Docs: development/ux/implementation-status.md created (running ledger).
required-backend-gaps.md updated with closed status per gap.

Closes gaps 1, 2, 3, 4, 5, 7 from required-backend-gaps.md. Gaps 6 and
8-11 belong to layout / Inbox briefs and stay deferred.
A bash CLI that signs in once, caches the session cookie + an LLM API
key, and exposes subcommands that fire one real request each at the
runtime so Home tiles tick in response. Designed to be reusable across
every page brief we build.

Subcommands:
  status, events, services — read-only diagnostics
  setup, login, reset      — auth lifecycle
  llm, fail, job, webhook,
  activity, budget         — drive one tile per call
  all, loop                — combined / continuous traffic

Implementation notes:
- Uses /usr/bin/curl explicitly to bypass the RTK shell wrapper.
- Talks to the runtime directly for read-only calls (no auth needed for
  most admin/* reads); routes write calls through the dashboard's
  same-origin /api/v1/[...path] proxy so the better-auth session cookie
  flows to the runtime as the operator credential.
- LLM calls use a Bearer api key issued through /api/v1/admin/keys and
  cached at /tmp/backai-demo-apikey.txt.
Addresses the hard critique end to end: the page no longer reads like a
starter template. Single-row dense KPI strip with deltas + status-aware
sparklines, persistent top bar with live anchors, hairline-divided
activity feed, flat quick-action list, monochrome service pills, and a
welcome block surfacing the dev tenant key + try-it snippet.

Backend (close gaps 6 + new demo seeder):
- New GET /api/v1/admin/anchors — unified Inbox / Cost / Health values
  the top bar polls on every page (closes Gap 6).
- New POST /api/v1/admin/demo/seed — one-shot seeder that inserts ~200
  gateway requests, ~20 cost events, ~6 webhook deliveries, ~30 activity
  rows spread across the last 24h. Idempotent (reset=true wipes prior
  demo-flagged rows). No mocking — real rows in real tables, marked so
  follow-up runs stay clean.
- home/overview gains request_delta_pct, error_delta_pct, cost_delta_pct
  (server-side, avg-of-last-4 vs avg-of-prior-4 buckets) so KPI tiles
  render real trend arrows instead of decorative sparklines.

Frontend — app shell:
- (dashboard) route group with its own layout — SidebarProvider, real
  sidebar nav grouped by IA pillars (Anchor / Activity / People / Build
  / Platform), and a persistent TopBar.
- TopBar: SidebarTrigger · wordmark · tenant switcher · anchors
  (Inbox / Cost / Health pills with live dots) · ⌘K trigger · theme
  toggle · notifications bell · profile menu. Anchors poll every 5s.

Frontend — Home rebuild:
- KPI strip: 8 dense tiles in one row, hairline-divided, no card-per-tile.
  Each tile shows [dot] LABEL · VALUE · delta-with-arrow · sparkline on
  a single line. Status colours strictly: zero is idle (not green-go),
  thresholds match page-design-framework §8. Per-tile good-direction
  drives delta arrow tone (RPM up = success, errors up = destructive).
- Sparklines: status-coloured stroke, no decorative gradient. Empty
  series renders a dashed baseline so layout never shifts between tiles.
- Activity feed: severity-bubbled list (critical → warning → info, then
  newest first), one row per event with dot · mono timestamp · source
  icon · kind · message · drill arrow on hover. No subhead.
- Quick actions: flat dense link list with shortcut hints. No big
  illustrated icons; no marketing copy.
- Backing services strip: compact pills with trimmed versions ("v16",
  not "v16.14 (Debian 16.14-1.pgdg12+1)"). Status dots distinguish
  healthy (ok) from configured-but-not-probed (idle). Arrow only when
  admin_url is set.
- Welcome block: dev tenant key reveal + curl snippet, dismissible.
- Live ticks: 5s polling of home/overview + admin/events; the layout
  polls anchors. KPI value transitions use motion.tick.

Theme expansion:
- Denser default spacing across the board (row-y, row-x, strip, page-x/y).
- Added size-icon-inline and size-icon-dot tokens so JSX never hardcodes
  px values for icons.
- text-kpi-value sized down to 18px so eight tiles fit one row at xl.

Sidebar:
- Replaced sidebar-08 placeholder nav (Playground/Models/etc.) with
  real IA derived from journeys-v1: Home/Inbox/Cost/Health/Playground
  at top, then Activity/People/Build/Platform groups.

Scripts:
- ./scripts/demo-traffic.sh gains `seed` and `anchors` subcommands.
  `setup → seed → status` is the canonical first-run path.

Verification:
- pnpm exec tsc --noEmit clean.
- Next.js production build clean.
- 5 Home + AdminEvents server tests still passing.
- Live verified: 11% error rate, $0.07 cost today, 16 failed-24h, 20-row
  activity feed, healthy anchors after `./demo-traffic.sh seed`.

Deferred (still): Cmd+K palette content, notifications panel content,
profile menu beyond sign-out, WebSocket upgrade, activity row drill
drawers (per-page briefs to come).
Three concrete fixes against the visual review:

1. KPI tiles overflow into neighbour cells
   Root cause: prior tile was a horizontal flex with the value-text
   competing with a fixed 80px sparkline. On narrow widths Recharts'
   intrinsic width pushed the sparkline past the cell boundary; combined
   with `divide-x` it visually leaked across the divider.
   Fix: vertical-stack tile (dot/label, value, delta, sparkline,
   sublabel). Sparkline now fills the tile's full width via Recharts
   ResponsiveContainer + `overflow:hidden` on the row. Strip also wraps
   the whole ul in `overflow-hidden` and replaces `divide-x/y` with
   explicit per-cell borders so wrap behaviour is unambiguous across
   breakpoints (2/4/8 cols).

2. Activity feed needs fixed height + internal scroll
   Was: section grew with content, pushing the right column down.
   Fix: section is `height: 480px` with the row list wrapped in the
   shadcn ScrollArea primitive. Header stays pinned; only the list
   scrolls.

3. Top bar wasn't sticky and items felt scattered
   Fix: `sticky top-0 z-30 bg-background/95 backdrop-blur`. Left/right
   are now explicit grouped flex containers with internal `gap-inline`,
   joined by visible vertical `Separator`s, so the wordmark, anchors,
   and chrome icons sit in distinct visual lanes. Empty tenant list now
   renders a quiet "default tenant" badge instead of a missing slot.

Also bumped the home shell to refresh the backing services strip on
the same 5s polling cadence as overview + events.

Verification: pnpm exec tsc --noEmit clean, next build clean,
demo seed re-fires correctly (17 failed runs / $0.066 cost / 1.9%
budget after re-seed).
Three targeted fixes from the third visual review:

1. Top bar
   - Switch Sidebar variant from "inset" to "sidebar" so the inset is
     flush with the viewport edges (no 8px top margin from
     md:peer-data-[variant=inset]:m-2).
   - Drop both vertical Separators. The left/right groups speak for
     themselves through spacing; the dividers were creating visual
     noise rather than structure.
   - Remove the profile menu entirely per request (no "BA" avatar). Sign
     out moves to /api/auth/sign-out and will be reachable from the
     settings page when that brief ships.
   - Tighten the bar from h-14 to h-12 — it was a hair too tall for the
     dense console aesthetic.

2. Right-column height
   - Activity feed exposes ACTIVITY_FEED_HEIGHT_PX as a named constant.
   - Home shell sets the right-column container to that exact height.
   - Quick actions stays `shrink-0` (natural height — 4 rows + header);
     backing services becomes `flex-1 min-h-0` and wraps its pills
     inside a shadcn ScrollArea so it fills the remaining space without
     overflow.
   - Net effect: left and right columns end on the same baseline; no
     ragged bottom edge.

Verification: tsc clean, next build clean, dashboard rebuilt.
rotate-key button

The previous reveal swallowed errors in a bare try/catch so any 401 /
network blip looked like "click does nothing". Rewritten with explicit
state:

- Reveal click issues a fresh key via /api/v1/admin/keys with
  credentials: "include" so the better-auth session cookie always flows.
- Any failure (HTTP non-2xx, missing `value` field, clipboard blocked)
  is surfaced as an inline destructive banner with a dismiss control,
  not silently dropped.
- Loader2 spinner while the issue request is in flight.
- Separate copied-state booleans for the key copy button and the
  snippet copy button — both work independently with their own check
  feedback.
- New rotate button (RefreshCw) appears once a key is revealed; clicking
  it issues a new key, swapping the displayed one. The old key stays
  valid on the runtime until manually revoked.
- Snippet now embeds the actual revealed key (not the $BACKAI_API_KEY
  placeholder) once Reveal has run.
Root cause of the OPERATOR_AUTH_REQUIRED 401 on Reveal: the dashboard
middleware (middleware.ts) only checks that the better-auth session
cookie EXISTS, not that it's valid against the DB. After a dashboard
restart or a session-table wipe the browser still holds the cookie and
gets past middleware, but the runtime's resolveOperatorPrincipal can't
match it to a session row and rejects.

Fix:
- Recognise 401 specifically and raise a SESSION_STALE: prefixed error
  rather than dumping the raw HTTP body.
- Error banner detects the SESSION_STALE prefix and renders a "Sign out
  & back in" link instead of the dismiss button. The link calls
  /api/auth/sign-out with redirect_to=/login?next=/ so the operator
  lands back on Home with a fresh session.

No backend change — the runtime's behaviour is correct; the dashboard
just needed to handle the case gracefully.
Root cause of the recurring OPERATOR_AUTH_REQUIRED: the middleware only
checks the better-auth session COOKIE exists, never validates the token
against the session table. After any dashboard restart (or session
table wipe) the browser still holds the old cookie and gets past
middleware, but every runtime call returns 401.

The right fix lives in the layout, not the middleware (which runs on
Edge and can't cheaply touch the DB):

  app/(dashboard)/layout.tsx now calls requireOperator() which:
    - reads the session via auth.api.getSession({ headers })
    - confirms the user exists in suite_operators
    - redirects to /login when either check fails

Both checks happen once per page navigation on the server, which is
where DB calls belong. The welcome block's SESSION_STALE handling
remains as a belt-and-suspenders fallback for client-side calls that
race with a server-side expiry.

Net effect: stale cookies trigger a clean redirect to /login without
any manual sign-out. Operator lands back on Home the moment they
re-authenticate.
Decision queue per development/ux/pages/inbox.md. v1 scope: HITL
approvals + AgentField/DB unhealthy probes, client-side merged. Gaps
8/9/10/11 deferred per brief's partial-scope plan; mitigations recorded
in required-backend-gaps.md.

Decisions locked:
- 30s polling, no WebSocket
- Filter chips on severity + kind, URL-persistent
- Badge = total pending count; red when any item is critical
- Approval detail in right-side shadcn Sheet drawer (URL state ?item=)
- Severity-tiered ordering (critical → warning → info), newest first
- Affirmative "All clear" empty state

Backend (admin/anchors):
- Added inbox_has_critical for badge colour
- inbox_pending now counts system alerts too
- Pending-approvals query uses app.bypass_rls=on; the prior store call
  was tenant-scoped and silently returned 0 (badge never moved)
The active filter chip rendered white-on-white in dark mode. Root cause:
I composed shadcn's Button(variant=default) with a nested
Badge(variant=secondary) to fake a chip. shadcn variants are tuned
per-component, not per-composition — an inverted-surface Button with a
nested Badge is not a tested pattern, and class-merge order left the
label colour unreliable across themes.

Fix:
- New primitive at components/ui/filter-chip.tsx owns BOTH halves of
  the active state (chip surface + count pill) in one place. All colour
  comes from semantic tokens — no raw hex, no variant-default magic,
  no cross-component inheritance.
- Active state is an intentional, auditable inversion: bg-foreground +
  text-background. The count pill uses bg-background/20 + text-background
  so it reads against the inverted chip background by construction.
- Inactive state is border + bg-card + muted text, with the label
  brightening on hover.
- inbox/filter-chips.tsx becomes a thin domain wrapper that maps
  inbox-specific options onto the primitive without re-styling.

Rule going forward: any selectable / inverted-surface UI gets its own
primitive. Don't reuse a Button variant when the semantics differ —
make the new primitive and own the contrast explicitly.
Previous chip used h-7 + text-meta font-medium + h-4 count pill, which
read as a button-sized control. Filter chips are navigation, not the
primary affordance — they should be visually subordinate to the list
rows below.

- Chip height: h-7 -> h-6 (28px -> 24px); add leading-none so the
  meta-size font doesn't introduce extra vertical air.
- Chip weight: drop font-medium so the label has the same weight as
  body text (the active inversion already provides emphasis).
- Count pill: h-4 -> h-3.5, text-meta -> text-eyebrow, px-1.5 -> px-1.
  The count is metadata; it shouldn't compete with the label.
- Active count halo: bg-background/20 -> bg-background/25 for a touch
  more contrast on the inverted surface.
4-zone page per development/ux/pages/cost.md, sized to v1 scope. Gaps
12-19 deferred per brief §24; each v1 mitigation recorded in the gaps
ledger.

Zones shipped:
- Zone 1: Period total + forecast tiles with sparkline/forecast bar;
  client-side anomaly heuristic (top-share spike + forecast overrun)
- Zone 2: Tenant → Model hierarchy (2 levels); agent + reasoner depth
  surfaces a v0.2 note inline so the partial scope is intentional
- Zone 3: Stacked area + top-N tenant list; per-tenant fan-out (top 5
  /cost?tenant=X calls, shared with Zone 2 to avoid duplicate work)
- Zone 4: Cache donut + cost-by-model share bars + budgets table with
  edit Dialog (PUT /admin/budgets) + Sonner toast

Decisions locked:
- 10s polling (theme.polling.services) — cost moves slower than KPIs
- Range chips Today/7d/30d/90d, URL-persistent (?range=)
- All chips use the FilterChip primitive established for Inbox — same
  contrast + density
- Adapter footer surfaces the LiteLLM pill + dropdown (Open admin,
  Change adapter) per framework Part 9

New tokenised primitives in components/ui/:
- Sparkline, DeltaIndicator, ForecastBar, GaugeBar
All use semantic CSS variables — no raw hex, no per-component overrides.

Backend touched:
- api.cost(params) extended with optional tenant filter (was already
  backend-supported; client wrapper just didn't expose it)
- top-bar Cost AnchorPill now links to /cost (Inbox already linked)

No new runtime endpoints required. 266 server tests still green.
Page-design-framework principle #2 — "should feel dense even at zero
data" — drove every change here. The page now reads as a real platform
that's waiting for traffic, not a half-built skeleton.

Top bar:
- Cost anchor renders a DeltaIndicator (today vs same window yesterday).
  Required a new backend scalar — cost_yesterday_same_window_usd added
  to /api/v1/admin/anchors so the delta lands without a second round
  trip. Demo seed widened from 24h to 48h so the anchor delta has data
  to compute.
- Tenant switcher truncates auto-generated slugs to a friendly prefix.
  Full slug + display name still appear in the dropdown row.

Sparkline primitive:
- Auto-switches to a BarChart fallback when data has fewer than 4
  points; the dashed-underline "empty" frame became a real chart frame
  with a baseline + dots so it reads as "shape coming" instead of
  "nothing here".

Zone 1 (At a glance):
- AllClear card replaces the "Nothing unusual." footnote: positive
  card with check icon + spend-shape sparkline.
- TodayTile now uses the new Sparkline so a 1-2 point series renders
  as visible bars.

Zone 2 (Explain spend):
- Neutral muted-foreground/30 share bar. Green was reserved for status
  semantics; a hierarchy proportion isn't a success signal.
- Bar hidden entirely when N=1 (a full bar at 100% read as alarm).
- Single-tenant row auto-expands so the model breakdown is the actual
  content; first-row auto-expand at N>1.
- Model names formatted (last segment of the slash path, friendly
  casing per provider).

Zone 3 (Explore):
- Group-by chips visible with Tenant active + Agent/Model/Day disabled
  (v0.2, gated on Gap 16). Operators see the surface, not blank space.
- "Other" stack suppressed when no remainder or when N=1 to avoid the
  monochrome muddy-blend.
- Per-series fill opacity ramps so stacked tenants stay distinct in
  monochrome dark mode.

Zone 4 (Inference economics):
- New BreakEvenPlaceholder card with a stylised diagonal so the
  Cost÷MRR frame reads as a chart, not a void. Copy directs operators
  to the billing adapter (Gap 17).
- Cost-by-model bars thickened to h-3, with friendly model name +
  provider sub-label.
- Cache donut renders even with zero traffic — placeholder slice + "—"
  numbers so the frame doesn't collapse.

Zone wrapping:
- New ZoneCard / ZoneCardHeader primitive. Every zone is now a clearly
  bordered card with a heading row, so section hierarchy reads at a
  glance instead of relying on uppercase eyebrow text on flat black.

AdapterFooter:
- Promoted from text-meta footer line to a bordered strip with a
  dot+border LiteLLM pill. Modularity signal can no longer be missed.

Sidebar:
- Playground removed from pinned anchors (per-agent surface; lives in
  Build).
- Activity gains Cache; Webhooks renamed to Webhook flow.
- People gains Sessions / Budgets / Activity log / OAuth connections.
- Build gains Reasoners / Tools / Harnesses / Crons / Playground /
  Feature flags / API explorer / Shipwright.
- Platform gains Containers; API explorer demoted.
- New v0.2 stub mechanism: items without a groomed page render dimmed
  with a v0.2 chip badge. Sidebar communicates the platform's surface
  area instead of hiding it.
…me at /health

5-zone page per development/ux/pages/health.md, sized to v1 scope. Gaps
20-24 deferred per brief, each v1 mitigation recorded in the gaps
ledger.

Zones shipped:
- Zone 0: Incident banner with affirmative All-clear / degraded
  variants — same height across both so the page doesn't jump on
  transitions
- Zone A: LLM providers grid (3/2/1 responsive cols) with status dot +
  uptime + p95 + sparkline + Switch-fallback link when degraded;
  EmptyProvidersCard when none configured
- Zone B: Connected services grouped by kind (Runtime / Data /
  Intelligence / Storage / Queue / Delivery / Observability), each
  group its own sub-card; rows are click-out to OSS admin UI when
  admin_url is present (Block 0 decision #4 — Health is THE hub)
- Zone C: Database — 5 sub-cards (Connections gauge / Cache donut /
  Slow queries table / Largest tables bars / Vacuum). Every sub-card
  renders structure at zero data — no blank rectangles
- Zone D: Runtime self — Version / Uptime / Memory / Goroutines tiles
  + HTTP summary + Top routes table

Decisions locked:
- 10s polling (theme.polling.services) — DB queries are expensive
- Provider window chips 24h / 7d / 30d, URL-persistent (?window=)
- Manual Refresh button in the header, toasts on success
- Overall summary computed client-side; drives the Incident banner.
  v0.2 swaps for backend-emitted incident events (Gap 20)

Reused primitives:
- ZoneCard / ZoneCardHeader promoted from components/cost/ to
  components/ui/ (Cost re-exports the shim for the old import path)
- FilterChip / FilterChipGroup for window selection
- Sparkline + GaugeBar from prior pages
- Sonner toast on refresh

Backend touched:
- Top-bar Health AnchorPill points at /health
- Sidebar Health removed from the v0.2 coming-soon set
- No new endpoints — /admin/services, /admin/llm/provider-health,
  /admin/db/health, and /metrics/summary were all in place

266 server tests still green.
Addresses critical (A1–A5), layout (B1–B3), component (C1–C6), and
sidebar (D1) items from the brief's "v0.1 → v1 Corrections" appendix.

A1 — Zone A now shows upstream providers, not the adapter
- health_poller now parses LiteLLM's /health response and emits one
  row per upstream provider per poll cycle (openrouter / anthropic /
  openai / google / deepseek / qwen / ...) instead of a single
  "litellm" aggregate. Falls back to the aggregate row only when the
  response can't be parsed.

A2 — Status threshold logic (semantic correctness)
- deriveProviderStatus() applies the brief's threshold table
  client-side: healthy = uptime ≥ 99% AND p95 ≤ 1s; degraded if either
  signal slips; down if both fail. ProviderHealthCard now uses the
  derived status so a green pill never sits next to red latency.

A3 — AgentField belongs in INTELLIGENCE
- mapKindToGroup() collapses llm-gateway, llm, reasoning, agent-runtime
  into the INTELLIGENCE bucket. Billing + Notifications now route into
  DELIVERY (per brief taxonomy).

A4 — Recovery actions on provider cards
- Provider card always renders "Open LiteLLM ↗"; "Switch fallback"
  shows when status ≠ healthy. Operator never sees a degraded
  provider without inline recovery affordance.

A5 — [Open ↗] on every service row with admin_url
- ServiceHealthRow renders an outline Button "Open ↗" right-aligned
  for any service that ships an admin URL. Per Block 0 decision #4
  (Health is the central OSS-link hub).

B1 — Provider card layout at N=1
- ProviderHealthCard gained a `layout="wide"` mode used by the
  ZoneAProviders grid when providers.length === 1. Three metrics
  (Uptime / p95 / Median) row + taller sparkline + footer actions.

B2 — Three-tier section header weight
- ZoneCardHeader bumped to text-lg font-semibold so zone titles
  dominate; sub-card titles remain text-body font-medium; tertiary
  labels stay text-eyebrow uppercase. Cost + Health both benefit.

B3 — DB sub-sections in their own sub-cards
- SubCard primitive already in place; padding tightened so the gap
  between Connections / Cache / Slow queries / etc. is visible.

C1 — Connections stacked segment bar
- ConnectionsCard now renders a single h-3 bar with three segments
  (active / idle / free) plus 4 stats above (Active / Idle / Free /
  Max). Operator sees pool saturation + warm/cold ratio at a glance.

C2 — Cache donut sample threshold
- CacheCard treats hit_ratio = 0 or = 1 as "small sample" and renders
  "—" with a caveat instead of a perfect-looking donut on a cold
  cache.

C3 — Slow query truncate + DDL filter + HoverCard
- 60-char truncate with full-query title tooltip. DDL statements
  (CREATE / ALTER / DROP / TRUNCATE / GRANT / REINDEX / VACUUM / ...)
  are filtered by default with an "Include DDL (N)" toggle.

C4 — Consistent status indicators
- Provider + service rows now follow the same pattern: dot on the
  left, status text on the right with semantic colour
  (muted-foreground when healthy, warning/destructive when not).

C5 — Single timestamp convention
- formatRelative() reserves "now" for genuinely sub-second age;
  everything else uses "Ns / Nm / Nh ago".

C6 — Health anchor quiet when healthy
- Top-bar Health pill hides the word "healthy" when status === healthy;
  text only appears when something needs attention.

D1 — Sidebar v0.2 badge cleanup
- The dim + tooltip + route-to-home behaviour stays so operators see
  the platform's surface area, but the visible "v0.2" chip is dropped.
  Operators no longer read the badge as "feature missing".
List + drawer composition per development/ux/pages/runs.md, sized to
v1 scope. Gaps 27-31 deferred per brief, each mitigation recorded in
the gaps ledger.

Shipped:
- Sticky filter bar: status + time chips (FilterChip primitive),
  debounced-style search input. Status counts on each chip.
- Table: sticky header always rendered (even at zero data), skeleton
  loader rows, empty + degraded copy. Failed/running rows pick up a
  4-px left border accent (destructive/warning).
- Row: status dot + time + agent + error/id + tenant prefix +
  duration + cost + status text. Chevron on hover.
- Drawer: shadcn Sheet with status dot in title, meta tiles
  (status/duration/cost/tenant/started/id), error block, Input +
  Output JSON, Cancel/Pause/Resume actions, "Open in AgentField"
  link.
- URL state: ?status=, ?time=, ?search=, ?drawer=<runId>.
- Polling: 5s tick (theme.polling.home) — closest the dashboard gets
  to a live feed without WS.

Client-side fallbacks for unshipped backend filters:
- Time range (Gap 27) — applyClientFilters() in lib/runs/derive.ts
- Search (Gap 28) — substring match across id/agent/tenant/error
- Trigger column intentionally absent until Gap 31 lands

Deferred to v0.2 (per brief):
- Group-by tenant/agent/status/hour
- Bulk cancel / saved views / compare runs
- WebSocket subscription
- Trigger pill (depends on Gap 31)

Sidebar: Runs item removed from the v0.2 dim set so it routes to the
real page.
Addresses MUST FIX items from the v0.1 review of /activity/runs.

Backend:
- runs handler joins suite_tenants so each row carries tenant_name in
  addition to tenant_id. Default tenant now renders as "Default".
- run_agentfield response gains ui_url alongside the existing
  details_url. The previous DetailsURL pointed at the JSON API
  endpoint, so "Open in AgentField" opened raw JSON in a browser
  instead of the UI page. Operators land on /executions/<id> now.

Frontend (Runs row):
- Tenant column shows tenant_name as primary text with the truncated
  id as muted mono below. Full id visible on hover via the row title
  tooltip.

Frontend (RunDrawer rewrite):
- shadcn Tabs (Input / Output / Errors / Audit). Errors tab only
  renders when the run has an error. Steps + Tools tabs deferred —
  AgentField timeline data isn't surfaced in v1.
- Quick facts grid now uses formatRunAge for STARTED (with absolute
  ISO in the title tooltip). ID + Tenant fields ship with the new
  CopyButton primitive.
- Related links section above the actions footer — Tenant detail +
  Agent detail. Parent-run link deferred until backend exposes the
  relation.
- Keyboard nav: ← / → move between rows in the current filtered
  view; Esc closes (Sheet default). Hint bar at the bottom shows the
  available shortcuts plus Pause / Cancel keys when applicable.
- Open in AgentField uses ui_url when the backend returns one; falls
  back to NEXT_PUBLIC_AGENTFIELD_URL + execution id.
- Empty payload now shows "No input/output payload recorded for this
  run" inside a dashed-border block instead of a bare em-dash.

New primitive:
- components/ui/copy-button.tsx — single-click clipboard control with
  the standard "Copy" / "Copied" affordance. Reusable across future
  drawers.
Composition page per development/ux/pages/tenant-detail.md, sized to
v1 scope. Gaps 33-35 deferred per brief, each mitigation recorded in
the gaps ledger.

Shipped:
- Sticky header: identity + at-risk callouts + 5 KPI tiles (Cost 30d /
  Requests 30d / Members / Keys / Storage) + Suspend / Reactivate
  actions. KPI tiles reuse the Sparkline primitive.
- 4 tabs: Overview / Members / Keys / Settings. URL-persistent
  via ?tab=. Tab triggers carry live count badges where useful.
- Overview tab: client-side merged activity feed (recent_runs +
  recent_webhooks chronological — Gap 35 mitigation) + recent-runs
  list with status accents, both linking to /activity/runs filtered
  to the tenant.
- Members tab: list with role badge (owner / admin / member tones)
  + email + last-active. Mutations (Invite / Remove) deferred.
- Keys tab: card per key with status dot + masked prefix +
  CopyButton + rate limits + GaugeBar against budget cap + Revoke
  action.
- Settings tab: Identity form (Save name) + Danger zone
  (type-to-confirm Delete tenant). Suspend / Reactivate live in the
  header so the operator can act without first navigating in.
- At-risk computation client-side (Gap 33 mitigation): recent-run
  failure rate + per-key budget overrun + trial expiry. Each signal
  becomes an AtRiskAlert card; the page status flips to warning /
  destructive when warranted.
- Minimal /people/tenants list hub linking every tenant to its
  detail page.

New primitive:
- AtRiskAlert (slim Alert with warning/critical accent — reusable on
  future User detail / Agent detail surfaces).

Backend: no new endpoints — api.tenantDrilldown(id) hydrates header +
Overview + Members + Keys in a single fetch. Tenant + key mutations
reuse existing PATCH/DELETE endpoints.

Sidebar: Tenants lifted from the v0.2 dim set.
@AbirAbbas
AbirAbbas merged commit 5e53756 into main Jun 29, 2026
3 of 9 checks passed
@AbirAbbas
AbirAbbas deleted the feat/ui-redesign branch June 29, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants