Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-FE-TENANT-PICKER-COCKPIT-CHROME-AND-WIZARD-RESUME — multi-tenant cockpit chrome + wizard resume#207

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-TENANT-PICKER-COCKPIT-CHROME-AND-WIZARD-RESUME-MEGABUCKET
May 24, 2026
Merged

feat(kora): KR-FE-TENANT-PICKER-COCKPIT-CHROME-AND-WIZARD-RESUME — multi-tenant cockpit chrome + wizard resume#207
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-TENANT-PICKER-COCKPIT-CHROME-AND-WIZARD-RESUME-MEGABUCKET

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

CC#2 megabucket — two deliverables on the multi-tenant cockpit-chrome arc that CC#1 + CC#3 are also pushing on.

Deliverable A — Tenant-picker cockpit chrome (primary). Sidebar-header dropdown that re-scopes cost / audit / promotion telemetry to the operator-selected tenant. New BE endpoint /api/tenants/list enumerates registered cost-holder tenants; useActiveTenant() hook resolves URL ?tenant=localStorage → "default"; FE forwards ?tenant_id= to cost + audit + promotion endpoints. Single-tenant deployments (Joshua today) see no picker chrome — it appears automatically when a second tenant emerges.

Deliverable B — Wizard sessionStorage resume. Wizard state (minus credentials) persists on every step transition; on mount past step 0, an inline Resume? prompt offers to restore. Credentials (Anthropic key / IsoKron service-role key / Slack bot token) are never stored — operator re-enters + re-validates on resume.

What changed

Backend (kora_cli/web_server.py)

Frontend

  • web/src/hooks/useActiveTenant.ts (new) — URL → localStorage → default resolution; cross-tab + in-tab event-driven updates; fetches /api/tenants/list on mount + tab focus; exposes isAllTenants + isMultiTenant.
  • web/src/components/TenantPicker.tsx (new) — sidebar-header dropdown; renders nothing when availableTenants.length < 2 (A.6 degradation); aggregate "All tenants" pseudo-option.
  • web/src/App.tsx — placed <TenantPicker /> directly below the "Hermes Agent" sidebar branding.
  • web/src/lib/api.tsgetCostState({tenantId}), listTenants(), plus ?tenant_id= forwarding through every audit + promotion getter via shared buildAuditQueryString() helper.
  • DashboardPage.tsx projectCostFromSnapshot() — reads snap.cost_ladder_by_tenant[activeTenant] when non-default; borrows model_default from the legacy block (v6 by-tenant block omits it — router-side, not per-tenant).
  • CostStatePage.tsx — fetches /api/cost-state?tenant_id=<activeTenant>; surfaces "Viewing tenant: X" badge for non-default; explains All-tenants fallback inline.
  • Audit + promotion pages — Probe Investigations, Alert Investigations, Email Intent / Outbound / Autofix logs, Kora Actions, Promotion Review all forward ?tenant_id=. BE-side filter lands with CC#1 fix: add missing re.DOTALL flag to DeepSeek V3 tool call parser NousResearch/hermes-agent#444; FE wires the param + graceful fallback today (extra query params are no-ops under FastAPI).

Wizard (B)

  • web/src/pages/WizardPage.tsxsessionStorage[\"kora_wizard_state\"] persists {config: stripCreds(config), stepIdx, v: 1} on every change. On mount, if stepIdx > 0, render Resume? card. Yes → restore + jump; No → clear + fresh. Cleared on wizard complete + skip.
  • stripCreds() removes anthropicApiKey, substrateServiceRoleKey, slackBotToken AND all three validation results — a "success" badge with no key to back it is misleading; force re-validation.
  • Schema version v: 1 in the persisted blob — refuse stale shapes on future config-shape change.

Tests

  • tests/test_tenants_endpoint.py — endpoint contract + drift-guard pin asserting TENANT_ID_QUERY_PARAM_NAME == \"tenant_id\" AND grepping the FE source for the matching literal. Repo has no vitest in web/, so the FE side gets pinned via Python-side source grep.

Drift-guard pins

  • TENANT_ID_QUERY_PARAM_NAME (BE: kora_cli/web_server.py) ↔ TENANT_ID_QUERY_PARAM (FE: web/src/hooks/useActiveTenant.ts) — both must equal \"tenant_id\".
  • TENANT_PICKER_STORAGE_KEY = \"kora_active_tenant\" (FE) — asserted by Python-side source grep.
  • DEFAULT_TENANT_ID = \"default\" mirrored both sides.
  • WIZARD_RESUME_STORAGE_KEY = \"kora_wizard_state\" + schema v: 1 exported from WizardPage.tsx.

PM bucket note

The bucket spec says CostTelemetryPage consumes snapshot.cost_ladder — in HEAD it doesn't (it reads /api/cost_telemetry, which is per-route, not per-tenant). The actual cost_ladder consumers are DashboardPage CostCardBody + CostStatePage. A.4 applied to the right consumers; CostTelemetryPage left untouched (its data isn't tenant-scoped at the telemetry-singleton layer today — a follow-on bucket if/when telemetry shards per tenant).

Build

  • tsc -b && vite build ✓ clean (one pre-existing chunk-size warning; not introduced by this bucket).
  • python3 -m py_compile ✓ for tests/test_tenants_endpoint.py + kora_cli/web_server.py. Pytest runtime not available in CC#2 worktree (no .venv); CI will execute on PR.

Test plan

  • Single-tenant deployment renders no picker chrome (default tenant only)
  • After init_cost_holder(tenant_id=\"marvin\", ...) boot path, picker appears with [default, marvin] options
  • CostStatePage switches holders when operator picks marvin (URL /cost-state?tenant=marvin deep-link also works)
  • DashboardPage CostCard re-projects from cost_ladder_by_tenant.marvin on tenant switch
  • Audit pages forward ?tenant_id=marvin (Network tab); pages don't crash if BE ignores the param
  • Wizard: fill step 1 + 2 → close tab → reopen → Resume? prompt offers step 3
  • Resume Yes → tenant_id + IsoKron URL + Slack user_id restored; Anthropic key / IsoKron service-role / Slack bot token fields are empty; validation badges cleared
  • Resume No → fresh start; sessionStorage cleared
  • Wizard complete + skip both clear sessionStorage
  • pytest tests/test_tenants_endpoint.py — 5 tests pass (3 endpoint, 2 drift-guard pins)

Recommendation for next CC#2 dispatch

Two natural follow-ons surfaced while implementing this bucket:

  1. Per-tenant audit BE filter (depends on CC#1 fix: add missing re.DOTALL flag to DeepSeek V3 tool call parser NousResearch/hermes-agent#444) — once CC#1 lands the ?tenant_id= BE-side filter on the audit endpoints, add a thin verification bucket: assert each FE audit page renders the correct subset when the BE actually filters. ~1 day.
  2. Aggregate cost panel for the "All tenants" picker option — CostStatePage currently falls back to default for All; the snapshot cost_ladder_by_tenant block is the right cross-tenant surface. A small page rendering one card per tenant (tier badge + spend / pool / pct-used) closes the All-tenants UX gap. ~½ day.

Lighter-weight option for the next CC#2 lane: per-tenant audit panel deep-link/probe-investigations?tenant=marvin shareable links rendered with the active-tenant badge in the page header. Tiny ergonomics pass; ~2 hours.

🤖 Generated with Claude Code

…lti-tenant cockpit chrome + wizard resume

Deliverable A — tenant-picker cockpit chrome:
  * /api/tenants/list BE endpoint (wraps list_cost_holder_tenants;
    always default-first, synthesizes default when registry is
    named-only)
  * useActiveTenant() hook with URL ?tenant= → localStorage →
    "default" resolution; cross-tab + in-tab event-driven re-render
  * TenantPicker dropdown in sidebar header chrome; renders nothing
    when <2 tenants observed (A.6 single-tenant degradation)
  * /api/cost-state extended with optional ?tenant_id= → per-tenant
    holder lookup; CostStatePage + DashboardPage CostCardBody read
    snap.cost_ladder_by_tenant[activeTenant] when non-default
  * Audit + promotion pages forward ?tenant_id= (Probe + Alert
    Investigations, Email Intent + Outbound + Autofix logs, Kora
    Actions, PromotionReviewPage); BE-side filter lands with CC#1
    NousResearch#444 — FE wires the param + graceful fallback today
  * Drift-guard pins: TENANT_ID_QUERY_PARAM_NAME (BE) ↔
    TENANT_ID_QUERY_PARAM (FE) cross-stack test in
    tests/test_tenants_endpoint.py + storage-key + default-id pins

Deliverable B — wizard sessionStorage resume:
  * sessionStorage["kora_wizard_state"] persists step + non-cred
    config on every change
  * On mount past step 0, render Resume? prompt (yes restores +
    jumps to saved step; no clears + fresh start)
  * Credentials NEVER stored (anthropicApiKey,
    substrateServiceRoleKey, slackBotToken stripped before
    persist); validation results also reset on resume so the
    operator re-validates re-entered creds
  * Cleared on wizard complete + skip (terminal states)

Note: PM bucket attributed cost_ladder consumption to
CostTelemetryPage; in HEAD the cost_ladder block is consumed by
DashboardPage CostCardBody + CostStatePage (CostTelemetryPage
reads per-route /api/cost_telemetry, not per-tenant cost_ladder).
A.4 applied to the actual cost_ladder consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit 2eabc3f into feature/phase2-upgrades May 24, 2026
2 of 4 checks passed
@rafe-walker rafe-walker deleted the feat/kora-KR-FE-TENANT-PICKER-COCKPIT-CHROME-AND-WIZARD-RESUME-MEGABUCKET branch May 24, 2026 18:20
rafe-walker added a commit that referenced this pull request May 24, 2026
…ITLE — picker keyboard nav + URL-toggle + tab-title prefix (#210)

Deliverable A — TenantPicker keyboard nav:
  * Trigger: Enter/Space/ArrowDown opens the picker
  * Listbox: ArrowUp/ArrowDown wrap-around nav, Enter selects,
    Escape closes + returns focus to the trigger, Home/End jump
    to first/last
  * Letter-jump: any single-char key jumps to the first matching
    tenant; repeated press on the same letter cycles through
    matches (resolves dup-prefix §4 STOP-ASK in favor of cycling)
  * Picker opens with highlight on the currently-active tenant
    so arrow-nav starts from where the operator already is
  * Highlighted option scrolls into view (large tenant lists)
  * TENANT_PICKER_KEYBOARD_SHORTCUTS exported constant — pinned
    by the cross-stack drift-guard test

Deliverable B — opt-in URL-toggle in picker:
  * "Also update URL" checkbox at picker footer; localStorage
    persisted under kora_tenant_picker_update_url; default off
    (preserves #207 picker-vs-URL precedence for everyone who
    doesn't opt in)
  * useTenantUrlToggle hook reads + writes the preference, with
    cross-tab + in-tab sync via custom + storage events
  * When on, setActiveTenant also mirrors the pick into the URL
    via history.replaceState (preserves every other query param;
    "default" removes the ?tenant param entirely so the URL
    stays clean)

Deliverable C — active-tenant tab-title prefix:
  * PageHeaderProvider now syncs document.title from the in-page
    displayTitle + active-tenant prefix
  * formatBrowserTabTitle exported pure helper:
    - single-tenant or default-active → "<title> · Hermes Agent"
    - tenant-active → "[marvin] <title> · Hermes Agent"
    - aggregate-active → "[all tenants] <title> · Hermes Agent"
  * Lets operators distinguish ProbeInvestigations-for-Kora vs
    ProbeInvestigations-for-Marvin tabs at a glance in their
    browser tab strip

Drift-guard extension (tests/test_tenants_endpoint.py):
  * TENANT_PICKER_KEYBOARD_SHORTCUTS literal pin + ArrowUp/Down/
    Enter/Escape implementation grep
  * cycleLetterJump grep (asserts letter-cycling behavior chose
    over first-match-only)
  * TENANT_PICKER_URL_TOGGLE_STORAGE_KEY = "kora_tenant_picker_update_url"
    pin + useTenantUrlToggle existence in both hook + picker
  * TAB_TITLE_SUFFIX = "Hermes Agent" pin +
    formatBrowserTabTitle existence + "[all tenants]" format pin

Co-authored-by: CC#2 Kora Web <kora-pm@stormhavenenterprises.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant