Skip to content

feat: tabbed agent detail with unified global sidebar#2150

Open
guillaumegay13 wants to merge 9 commits into
mnfst:feat/global-dashboardfrom
guillaumegay13:feat/agent-tabbed-shell
Open

feat: tabbed agent detail with unified global sidebar#2150
guillaumegay13 wants to merge 9 commits into
mnfst:feat/global-dashboardfrom
guillaumegay13:feat/agent-tabbed-shell

Conversation

@guillaumegay13

@guillaumegay13 guillaumegay13 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

✨ What changed

  • Agent detail is now a horizontal-tabbed shell: Overview / Routing / Guardrails / Settings.
  • Sidebar unified to one global nav (Overview / Messages / Agents), rendered identically everywhere. Removed the per-agent MONITORING/MANAGE/RESOURCES section.
  • Renamed the agent Overview page to AgentOverview (mirrors GlobalOverview).
  • Redirects: /agents/:name/limits → Guardrails, /agents/:name/messages → global Messages.

💭 Why

Second slice of the #2061 navigation rebuild. Collapses the agent view into one consistent surface (tabs) and drops the duplicate sidebar nav.

👤 For users

Each agent's pages live behind tabs now, under a single global sidebar. Guardrails replaces Limits (same functionality).

📝 Notes


Summary by cubic

Switches agent details to a horizontal tabbed view and unifies navigation into one global sidebar across all authenticated routes. Restores a concise agent header with platform icon, display name, and tabs. Part of #2061 to make navigation consistent across the app.

  • New Features

    • Added AgentDetail tabbed shell with Overview / Routing / Limits / Settings and a back-link; agent Overview renamed to AgentOverview.
    • One global sidebar (Overview / Messages / Agents) rendered identically everywhere; removed agent-scoped MONITORING/MANAGE/RESOURCES; active state persists on /agents/:name/*; sidebar is hidden only on the root redirect /.
    • Backward-compatible redirects: /agents/:name/limits/agents/:name/guardrails and /agents/:name/messages/messages.
  • Bug Fixes

    • Restored agent header (platform icon + display name H1); browser tab title uses the resolved display name, falling back to the decoded slug.
    • Removed duplicate H1s and subtitles from Overview, Routing, Limits, and Settings so the tabbed shell owns the heading.
    • Renamed the tab label to “Limits” (path remains /guardrails) and removed the default underline on tabs.

Written for commit 6185d87. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 14 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/frontend/src/pages/AgentDetail.tsx Outdated

@SebConejo SebConejo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things on this PR:

  • Each child page (Overview, Routing, etc.) still renders its own <h1>. Combined with the agent name heading in the AgentDetail shell, that makes two <h1> on screen. The shell should be the only one showing the agent name, and child pages should skip their own title.

  • From the stacked PR #2149: the global /messages page still has no Agent column in the table. The renderer and type exist but DETAILED_COLUMNS doesn't include 'agent', so users can't see which agent each row belongs to.

  • The Limits page tab is labeled "Limits" but the table inside is titled "Guardrails" (as the former version of the UI).

  • The Overview and Messages pages are now global (all agents), But their content is not adapted to that situation. So as a user, I don't undestand my data as I could. Maybe it's related to the PR 4?

@SebConejo

SebConejo commented Jun 8, 2026

Copy link
Copy Markdown
Member
Screenshot 2026-06-08 at 15 11 34

guillaumegay13 added a commit to guillaumegay13/manifest that referenced this pull request Jun 8, 2026
- New agents now navigate to the Routing tab on create (they inherit all
  connected providers with auto-assigned routes, so routing is the useful
  first view) instead of Overview.
- Rename the agent detail tab labelled 'Guardrails' back to 'Limits' to match
  PR2 (mnfst#2150); the /guardrails route is unchanged.
Introduces AgentDetail layout with Overview/Routing/Guardrails/Settings
tabs, renames Overview to AgentOverview for symmetry with GlobalOverview,
adds backward-compat redirects (/limits→/guardrails, /messages→/messages).
Collapse the interim two-nav state: remove the agent-scoped
MONITORING/MANAGE/RESOURCES sidebar section so agent detail views show
only the horizontal tabs (PR2) and the single global nav
(Overview / Messages / Agents). Active-state uses an isGlobalActive
prefix helper so /agents stays highlighted on /agents/:name/* sub-paths.
App showSidebar() simplified to all authenticated routes (truthy for any
path other than the root redirect "/").
Remove the redundant <h1>{agentName} Overview</h1> and breadcrumb
from Overview.tsx — AgentDetail already renders the agent name H1 above
the tabs. Also remove the now-unused agentPlatformIcon import.
AgentDetail header and <Title> now use agentDisplayName() ?? agentName(),
matching the pattern used by Settings, Routing, Limits, and other pages.
Falls back to the decoded URL slug when no display name is loaded yet.
Removes the platform icon and H1 heading block from the agent detail
page so the header shows only the back-link and tab bar. Keeps the
browser tab title unchanged.
@guillaumegay13 guillaumegay13 force-pushed the feat/agent-tabbed-shell branch from 4b7acbb to e89acd2 Compare June 8, 2026 19:49
guillaumegay13 added a commit to guillaumegay13/manifest that referenced this pull request Jun 8, 2026
- New agents now navigate to the Routing tab on create (they inherit all
  connected providers with auto-assigned routes, so routing is the useful
  first view) instead of Overview.
- Rename the agent detail tab labelled 'Guardrails' back to 'Limits' to match
  PR2 (mnfst#2150); the /guardrails route is unchanged.
@guillaumegay13 guillaumegay13 requested a review from SebConejo June 9, 2026 13:09
SebConejo added a commit that referenced this pull request Jun 11, 2026
)

* feat: add global dashboard with Overview, Messages, and Agents navigation

Introduces tenant-wide analytics: GlobalOverview page (cost/token/message
charts keyed off has_data), global Messages route, Agents workspace at /agents,
RootRedirect from / to /overview, sidebar global nav section, and agent column
support in MessageTable for global mode. Settings and Header breadcrumb updated
to navigate to /agents after agent deletion.

* feat(messages): add agent filter dropdown to global Messages view

Adds an agent filter Select to the /messages global view (hidden in
agent-scoped /agents/:name/messages). Wires agentFilter into the
messages query (agent_name param), hasActiveFilters, clearFilters, and
the on([...]) page-reset deps, mirroring the providerFilter/tierFilter
pattern. Loads agent list via getAgents() on mount (global mode only).

9 new tests cover: filter renders in global mode, hidden in agent mode,
agent_name query param, All agents clears it, filtered-empty state,
clear-all, getAgents() error handling, and page reset on change.

* fix(messages): render Agent column in global mode and stop swallowing agent-fetch errors

columns() now inserts 'agent' before 'model' when !params.agentName so the Agent
column actually renders on the global Messages page. The agentList createResource
loader no longer catches and swallows errors; rejections now propagate to the
resource error state and surface via the component's ErrorState handling.

Tests added: Agent column header present/absent by mode, agent_name cell values in
global mode, error-surfacing assertion for getAgents() loader.

* fix(messages): global title and empty-state CTA (no agent context)

In global mode (no params.agentName), the <Title> was calling
decodeURIComponent(undefined) → "undefined Messages - Manifest".
The empty-state body rendered a "Set up agent" button that mounted
SetupModal with agentName="undefined" and navigated to
/agents/undefined/routing. Fix: Title conditionally renders
"Messages - Manifest" in global mode; SetupModal is gated on
!!params.agentName; global empty-state shows a "Go to Agents" link
instead of the agent-scoped setup flow.

* refactor(frontend): extract shared range + self-hosted logic into useOverviewRange/useOverviewColumns

Both GlobalOverview and Overview duplicated RANGE_STORAGE_KEY, VALID_RANGES,
range signal + persistence, and the isSelfHosted/columns guard. Extract them
into services/use-overview-range.ts so the two pages share a single source of
truth and drift is structurally prevented. 100% line coverage added.

* fix(frontend): only treat valid stored ranges as user selections in Overview

When localStorage holds an invalid range value, userSelectedRange was
incorrectly set to true, disabling the smart-range cascade even though
no valid selection existed. Fix: gate on VALID_RANGES.has() instead of
a plain boolean coercion. Add a regression test to pin the cascade
behaviour under invalid-range conditions.

* feat(agent): add horizontal-tabbed shell for agent detail view

Introduces AgentDetail layout with Overview/Routing/Guardrails/Settings
tabs, renames Overview to AgentOverview for symmetry with GlobalOverview,
adds backward-compat redirects (/limits→/guardrails, /messages→/messages).

* feat(nav): unify sidebar to global-only, remove agent sub-nav

Collapse the interim two-nav state: remove the agent-scoped
MONITORING/MANAGE/RESOURCES sidebar section so agent detail views show
only the horizontal tabs (PR2) and the single global nav
(Overview / Messages / Agents). Active-state uses an isGlobalActive
prefix helper so /agents stays highlighted on /agents/:name/* sub-paths.
App showSidebar() simplified to all authenticated routes (truthy for any
path other than the root redirect "/").

* fix(nav): remove default link underline on agent detail tabs

* fix(ui): drop duplicate agent H1 in Overview

Remove the redundant <h1>{agentName} Overview</h1> and breadcrumb
from Overview.tsx — AgentDetail already renders the agent name H1 above
the tabs. Also remove the now-unused agentPlatformIcon import.

* fix(agent-detail): show resolved display name instead of URL slug

AgentDetail header and <Title> now use agentDisplayName() ?? agentName(),
matching the pattern used by Settings, Routing, Limits, and other pages.
Falls back to the decoded URL slug when no display name is loaded yet.

* fix: rename guardrails tab to limits

* fix(ui): remove agent-name H1 from agent detail header

Removes the platform icon and H1 heading block from the agent detail
page so the header shows only the back-link and tab bar. Keeps the
browser tab title unchanged.

* fix(ui): restore agent detail header (platform icon + name H1)

* feat(global-providers): add AgentProviderAccess junction entity and LiftProvidersToUserLevel migration

Introduces the user-scoped provider model: user_providers rows now carry
user_id (nullable agent_id) and a new agent_provider_access junction table
grants per-agent access. Migration 1791000000000 lifts existing rows from
agent-scoped to user-scoped and backfills the junction.

* feat(global-providers): rewrite core services to user-scoped provider model

ProviderService, ProviderKeyService, TierService, TierAutoAssignService,
RoutingCacheService, and ResolveService now thread userId as the primary
scope key. user_providers are queried by user_id (not agent_id). Introduces
invalidateUser on RoutingCacheService; ModelDiscoveryService caches and
queries by userId. Adds Repository<Agent> and listOwnedAgentIds to
ProviderService for cross-agent operations.

* feat(global-providers): thread userId through all callers of core services

Updates every controller, proxy service, and OAuth service to pass userId
alongside agentId when calling ProviderService, ResolveService, and
ProviderKeyService. OAuth revoke endpoints now forward user.id as the
second arg to removeProvider. Proxy and fallback services use userId for
all providerKeyService lookups.

* feat(global-providers): add user-providers and agent-provider-access endpoints

New REST endpoints: GET /routing/user-providers (list all user-scoped
providers), POST/DELETE /routing/agents/:name/provider-access/:providerId
(grant/revoke per-agent access via the junction table).

* test(global-providers): update all specs to match user-scoped method signatures

Fixes every failing spec after the userId threading refactor: inserts userId
as the second argument in removeProvider, resolve, resolveForTier, and related
calls; updates provider repo assertions from agent_id to user_id; adds
invalidateUser to routingCache mocks; adds Repository<Agent> to ProviderService
constructor in tests; corrects resolveForTier heartbeat assertions in proxy
spec; fixes model-discovery cache invalidation to use user_id fixture field.

* fix(global-providers): thread userId through xai-oauth controller

revoke() was passing agent.id to getProviderKeys and omitting user.id from
removeProvider, shifting the authType argument into the label slot and
producing the TS2345 type error. Fixed to match the anthropic/openai pattern:
getProviderKeys(user.id, ...) and removeProvider(agent.id, user.id, ...).

Added xai-oauth.controller.spec.ts (24 tests, 100% controller coverage) and
test/agent-provider-access.e2e-spec.ts (11 e2e tests) proving per-agent
provider isolation: connect for A, B excluded, grant B, revoke A, B unaffected,
junction sparse, global key row intact.

* fix(global-providers): wire per-agent access filter into routing + grant-on-connect + disable/disconnect/oauth/migration fixes

- proxy/resolve/playground/model.controller/model-discovery now pass agentId for per-agent access filtering
- grant-on-connect centralized in provider.service (OAuth/Copilot/custom all grant via afterProviderChange)
- delete grants on full disconnect via deleteProviderAccess
- disable route-cleanup on deactivateAllProviders
- OAuth refresh keyed by provider/user/label
- migration IF NOT EXISTS + safer down()
- overview hasActiveProviders respects grants
- tier/header-tier/specificity/routing-invalidation edit paths all pass agentId

* test(global-providers): grant provider access in e2e setup

Update controller specs to match new userId-threaded signatures in
setOverride/setFallbacks. Fix e2e SQL seeds to use user_id (not agent_id,
which is now NULL after LiftProvidersToUserLevel migration) and pass userId
to TierAutoAssignService.recalculate which now requires both args.

* fix(routing-cache): clear custom-providers cache on invalidateAgent

Custom providers were cached by agentId in setCustomProviders() but
invalidateAgent() did not delete that entry — only invalidateUser()
did, which keyed by userId. Since agentId ≠ userId, a stale empty-[]
cache persisted across create/update/delete operations, causing
GET /custom-providers to return [] right after a POST and breaking
the url-validation beforeEach setup (409 Conflict on duplicate name).

Add `this.customProviders.delete(agentId)` to invalidateAgent() so
any write that funnels through afterProviderChange() correctly flushes
the agent-scoped custom-provider cache.

* test(e2e): fix test-setup gaps introduced by PR3 global-provider lift

Three suites seed data with the old agent-scoped model (agent_id-keyed
user_providers, TEST_TENANT_ID for user_id) instead of the PR3 model
(user_id-keyed, TEST_USER_ID, plus agent_provider_access grants).

proxy-fallback-auth / messages-cache-tokens:
- Change user_providers inserts from user_id=TEST_TENANT_ID to
  user_id=TEST_USER_ID so getProviders(userId) resolves the rows.
- Add agent_provider_access grants so filterProvidersForAgent() does
  not short-circuit to empty when the junction table has no rows.
- Change tier_assignments user_id from TEST_TENANT_ID to TEST_USER_ID
  to match the PR3 schema expectation.

multi-key-providers (PUT order / DELETE label):
- Use per-test-unique API key values (sk-order-*, sk-del-*) so the
  sameKey-reactivation shortcut added for OAuth reconnects does not
  confuse an inactive renamed row ('Office') with the freshly
  requested label ('Work'), causing wrong-label state and a 400 on
  the reorder endpoint.

* feat(global-providers): provider pages + agent Providers tab; drop agent H1

* fix(ui): drop redundant section H1 from agent tab pages

* feat(global-providers): symmetric provider↔agent auto-connect (new agent gets all providers; new provider enabled on all agents)

* fix(ui): open a provider's connect form directly from its row

Clicking Add (subscription/API key/local) on a specific provider row opened
the provider picker list instead of that provider's connection form. Pass a
providerDeepLink (provider id + the page's auth type) so the modal jumps
straight to the right provider AND the right mode — e.g. the Subscriptions
page now opens the OAuth/subscription flow rather than the API-key form for
providers that support both. The generic page-header Add button still opens
the picker.

* fix(ui): land new agents on Routing tab; rename agent Limits tab

- New agents now navigate to the Routing tab on create (they inherit all
  connected providers with auto-assigned routes, so routing is the useful
  first view) instead of Overview.
- Rename the agent detail tab labelled 'Guardrails' back to 'Limits' to match
  PR2 (#2150); the /guardrails route is unchanged.

* fix(global-providers): recalc all agents after model discovery on new-provider connect (sibling auto-routes were stale)

* test(ui): cover the Routing setup modal

* fix(global-providers): disable impact preview now reports auto-assigned routes

* fix(global-providers): add ON DELETE CASCADE FKs to agent_provider_access junction

The junction controls per-agent provider authorization; a dangling agent_id or
user_provider_id would be a silent access bug. Add DB-level FKs with ON DELETE
CASCADE (added after table creation, dropped in down) so grants disappear with
their owning agent/provider even if a future path bypasses service-layer
cleanup. Addresses cubic P1.

* test(ui): update Workspace-validation nav assertions for the Routing redirect

New agents navigate to /agents/<slug>/routing; this sibling test file still
asserted the old /agents/<slug> target (it wasn't gating CI). Append /routing.

* fix(agent-duplication): copy provider grants instead of cloning credential rows

Duplicating an agent cloned every user_providers row under the new agent_id.
Against the migrated schema this violates the user-scoped unique index
IDX_user_providers_user_provider_auth_label and copied no agent_provider_access
grants, so duplication crashed for any agent with a connected provider (and the
copy could not route). The e2e suite missed it because it builds schema with
synchronize:true, which never creates the migration-only unique index.

Duplication now copies the source agent's agent_provider_access grants (global
providers are shared, not re-credentialed); custom providers remain agent-scoped
so their companion user_providers row is still cloned and re-granted. getCopySummary
counts grants rather than credential rows.

Adds a migration-built-schema regression test (agent-duplication-migrations.e2e-spec)
so the constraint is actually exercised and this can't hide behind synchronize again.

* fix: remap duplicated custom provider routes

* feat(providers): lift custom providers to user-global

custom_providers were agent-scoped (agent_id, unique on (agent_id, name)).
This lifts them to tenant/user-global to match PR3's global provider model.

- Migration LiftCustomProvidersToUserLevel: drops agent_id, relabels colliding
  names within a user with a collision-safe suffix (never delete), adds unique
  index on (user_id, LOWER(name)), and backfills agent_provider_access grants so
  existing custom providers are usable on all of the owner's agents (matching
  newly-created ones, which grant every agent).
- Entity: drop agent_id + Agent relation; unique on (user_id, name).
- Service/controller: user-scoped (list/create/update/remove by user_id);
  the agent route stays only for authz.
- Caller sweep: model.controller + proxy-message-recorder pass userId.
- agent-duplication: custom providers are now shared via grants like any global
  provider, so per-agent custom cloning + remap is removed; duplication copies
  agent_provider_access grants verbatim (customProviders summary field dropped).
- Cache coherence: custom-provider mutations emit a user-level `routing` SSE and
  invalidate the whole frontend custom-provider cache (data is now global, so a
  change on one agent affects every agent's list).
- Frontend: duplicate summary drops the custom-provider line.

Adds custom-providers-lift-migrations e2e that RUNS the migration (the
synchronize-based e2e never does) and asserts the relabel/backfill against
real Postgres.

* fix(custom-providers): address cubic review findings

- Entity: drop the case-sensitive `@Index(['user_id','name'])` — the migration
  owns a case-insensitive unique index on (user_id, LOWER(name)), which a
  column-list @Index can't express, so the decorator only drifted synchronize
  from migrations (same pattern as user_providers).
- Migration down(): restore the original agent FK (ON DELETE CASCADE) so a
  rollback re-enforces referential integrity; documents why NOT NULL can't be
  restored (the lift discards each row's agent binding, nothing to backfill).
- Service remove(): invalidate the user-scoped custom-provider cache so a later
  list() doesn't serve the deleted provider from a warm cache (mirrors update()).

* feat(playground): global Playground backed by a reserved per-tenant agent

The Playground becomes a single global page that always runs under a reserved
per-tenant "Playground" agent, so runs record as `Playground` in global Messages
and route against the whole global provider pool (an improvement over picking a
user agent).

Backend:
- `is_system` flag on agents + `Playground` constant.
- Seed migration `SeedPlaygroundAgents`: adds the flag, frees the reserved name
  (relabels colliding user agents, never deletes), creates one keyless Playground
  agent per tenant, and grants it the tenant's whole provider pool.
- `PlaygroundAgentService.resolve` (lazy-creates the agent + grants on first use,
  race-safe); the run + history endpoints and a new `GET /playground/agent`
  resolve it (instead of a client-picked agentName).
- Reserve the name (block create/rename to the `playground` slug); hide system
  agents from the agent list/switcher; protect them from delete/rename; exclude
  them from telemetry counts.

Frontend:
- Playground page resolves the reserved agent via `GET /playground/agent` (no
  agent picker); history calls drop agentName; custom providers stay global (PR4).

Adds a real-DB migration test (runs the actual seed migration: per-tenant
creation, collision relabel, full-pool grant) since the synchronize e2e never
runs migrations.

* fix(playground): address Codex review findings on the reserved agent

- Lazy-create is now atomic: the reserved agent insert + its whole-pool grant run
  in one transaction, so a committed agent is always granted (no race-loser /
  crash-between-insert-and-grant window). Drops the separate provider-service call.
- The reserved system agent is no longer mutable via generic :agentName endpoints:
  ResolveAgentService.resolve rejects is_system agents by default (NotFound), with
  an allowSystem opt-in used only by the Playground's read paths (available-models,
  providers list, custom-providers list); rotate-key / get-key reject it too.
- Duplication honors the reserved name (POST /agents/:src/duplicate with
  name:"Playground" now 400s, matching create/rename).
- Frontend: model/provider/custom-provider resources are keyed on the RESOLVED
  agent name (undefined until GET /playground/agent creates the row), so they no
  longer fire against a not-yet-created agent and 404.

* fix(playground): address Codex round-2 findings

- Tenant bootstrap: the global Playground can be opened before the user creates
  any normal agent (so no tenant row exists yet). PlaygroundAgentService now
  creates the tenant if missing (race-safe) before resolving the reserved agent,
  so /playground/agent works standalone.
- Provider-access protection: AgentProviderAccessController resolves agents
  directly (not via ResolveAgentService), so its resolver now also excludes
  is_system agents — DELETE/PUT/GET .../provider-access can no longer strip or
  toggle the reserved Playground agent's whole-pool grants.

Note: existing user agents whose slug is already 'playground' are grandfathered
(they don't collide with the case-distinct reserved 'Playground' agent); the
reservation only blocks new ones.

* fix(playground): address Codex round-3 findings

- Tenant cache: invalidate the user's tenant-cache entry right after bootstrapping
  the tenant row, so the stale `null` doesn't 404 subsequent ResolveAgentService
  reads for the 5-minute TTL.
- Provider setup from the global Playground: the provider CONNECT and
  custom-provider CREATE endpoints accept the reserved agent (allowSystem) so a
  user can connect providers from the Playground; all destructive/config mutations
  (delete/disable/rename/rotate-key/tier/model-params) still reject it.
- Migration relabel uses a route-safe suffix (`name-<uuid>`) so a relabeled
  colliding agent stays usable by the `^[a-zA-Z0-9_-]+$` agentName routes.
- Duplication: the source resolver excludes is_system, so the reserved Playground
  agent can't be cloned via POST /agents/Playground/duplicate.

* fix(playground): address cubic findings

- PlaygroundAgentService: the lazy-create / tenant-bootstrap catch blocks now
  re-throw the original error when the failure isn't a creation race (re-find
  returns nothing), instead of masking real DB failures as a generic not-found.
- Clarify the getProviders allowSystem comment (additive connect is allowed for
  the reserved agent; destructive/config mutations stay blocked).

* feat(playground): add the Playground nav entry under a TOOLS section

The Playground page existed at /playground but had no sidebar link. Add a TOOLS
section with a Playground link (matching #2061's nav), so the global Playground
is reachable from the sidebar.

* fix(playground): register /playground as a global route

The Playground route was nested under /agents/:agentName, so the global sidebar
link to /playground had no matching route and rendered the client 404. The
Playground is global now, so move the route to the top level (alongside
/overview, /messages, /agents) and drop the dead agent-scoped one.

* fix(playground): surface reserved agent in Messages agent filter

The Messages 'Agents' filter is populated from GET /agents (getAgentList),
which hides is_system agents so the Workspace grid / switcher don't show the
reserved Playground agent. That also dropped Playground from the Messages
filter, so runs (recorded as agent_name='Playground') couldn't be filtered.

Add an includeSystem opt-in to getAgentList + GET /agents; the Messages
filter requests it (getAgents(true)) while Workspace/switcher keep the
default exclude. Cache key already varies on the query string.

* fix(playground): clear both /agents cache variants on mutation

cubic (conf 9): adding ?includeSystem=true created a second cached /agents
entry that agent create/rename/delete/duplicate never invalidated, so the
Messages filter could show a stale agent list. invalidateAgentListCache now
clears both the bare and ?includeSystem=true keys.

* feat(onboarding): extract AddAgentModal with provider-aware first-run flow

Pull the inline Connect Agent modal out of Workspace into a reusable
AddAgentModal component. After creating an agent, look up the tenant's
providers: when none are connected yet, navigate to Routing with
openProviders state so the user is nudged straight into connecting their
first provider. Add an /agents?add=true deep-link that auto-opens the
modal for onboarding entry points.

* feat(onboarding): add reusable ActionMenu dropdown component

Generic three-dot action menu (trigger + caller-supplied items, closes on
outside click) with its own stylesheet, so card and detail surfaces can
share one popover implementation instead of re-rolling it.

* refactor(onboarding): wire reusable ActionMenu into Workspace agent cards

Replace Workspace's inline AgentCardMenu with the shared ActionMenu
component (its intended #2061 role as the per-card kebab menu). Extend
ActionMenu minimally to cover every existing behavior: Escape-to-close,
a custom trigger aria-label, an --open root modifier, optional per-item
icons, and a class passthrough for the hover-reveal card variant. Move
the kebab styling into action-menu.css and drop the now-dead
agent-card__menu* rules. Tests updated/added for full line coverage.

* fix(playground): canonical agent-list cache key, bounded invalidation

cubic round 2 on the includeSystem cache fix:
- P2: URL-exact keys meant ?includeSystem=false (or any other query variant)
  could cache under a key invalidation never cleared. AgentListCacheInterceptor
  now collapses every variant onto one of two canonical keys
  (:system=true/:system=false) via agentListCacheKey, so the key set is bounded
  and invalidateAgentListCache enumerates it exhaustively.
- P3: the delete test now asserts both canonical variants are cleared.

* feat(analytics): add provider_rate_limits entity + migration

Introduces the ProviderRateLimit entity and the AddProviderRateLimits
migration (timestamp 1791500000000, after SeedPlaygroundAgents). Registers
the entity in the database module and the e2e test helper entity set.

* feat(proxy): capture provider rate-limit headers on each response

Adds RateLimitTrackerService which parses OpenAI/Anthropic rate-limit
headers off the upstream response, caches the latest snapshot in-memory
(60s TTL) and persists it fire-and-forget. Wired into ProxyService after
the forward call so it never blocks or fails the proxy path.

* feat(analytics): provider-analytics + rate-limits endpoints

Adds /provider-analytics (summary, per-agent token/message/cost
timeseries, agents-by-auth-type, connection-detail) and /rate-limits
controllers, plus per-agent/provider/model overview timeseries endpoints.
Extends AggregationService.getSummaryMetrics and the timeseries service
with auth_type/provider filters and pivoted per-key timeseries helpers.
The reserved Playground (is_system) agent is excluded from all aggregates.

* feat(frontend): provider-analytics API client + connection breadcrumb store

Adds the API client functions for the new provider-analytics, overview
per-* timeseries, connection-detail and rate-limits endpoints, plus the
connection breadcrumb store used by the connection detail view.

* fix(onboarding): reactive deep-link + open-gated menu listeners

Codex review on PR7:
- P2: Workspace ?add=true was read once at setup, so deep-linking while
  Workspace was already mounted didn't open the modal. Moved into a
  createEffect (clears the param with replace) so it reacts to every nav.
- P3: ActionMenu registered permanent document click/keydown listeners per
  card; gated them behind an open() effect with cleanup so listeners exist
  only while a menu is open.

* test(playground): assert both cache variants cleared on createAgent

cubic P3: the createAgent invalidation test asserted only the system=false
key; the implementation clears both, so assert system=true too (parity with
the rename/delete tests).

* fix(analytics): scope provider analytics + rate limits by full connection identity

Address four PR6 reviewer findings:

- Exclude the reserved Playground (is_system) agent from provider-analytics
  summary + timeseries aggregates via a shared excludeSystemAgents() helper
  (opt-in flag so PR1-5 overview/costs/tokens behavior is unchanged).
- Filter every connection-detail usage query by the connection's label
  (LOWER(COALESCE(provider_key_label,'Default'))), so two keys sharing
  provider+auth_type but differing by label no longer merge.
- Key the rate-limit tracker cache and the DB latest-snapshot lookup on the
  full (user, provider, auth_type, key_label, limit_type) tuple, and widen the
  provider_rate_limits index to match (edited PR6's own migration in place).
- Capture rate-limit headers from a fallback-success response too (was only
  tracking the failed primary), fire-and-forget like the primary path.

* fix(onboarding): cancel post-success nav on dismiss + honest ActionMenu a11y

AddAgentModal: track a `cancelled` flag (set on overlay/Escape dismiss and
onCleanup) and skip toast/markAgentCreated/navigate if the modal was dismissed
while createAgent or the providers lookup was still in flight. A clean success
still navigates; the flag resets at the start of each create.

ActionMenu: drop role="menu" / role="menuitem" / aria-haspopup="menu" — the
component implements no menu keyboard/focus semantics (roving focus), so those
roles misled assistive tech. Dropdown stays a list of buttons with aria-label
+ aria-expanded on the trigger.

* fix(analytics): join agents by id in timeseries to stop double-counting + filter system agent from per-provider/per-model

Finding 1 (P1): per-agent timeseries + excludeSystemAgents joined agents by
name+tenant. A soft-deleted agent reusing a slug made the LEFT JOIN one-to-many
and inflated every SUM. Switch to a.id = at.agent_id (one-to-(zero-or-one)).

Finding 2 (P2): per-provider and per-model timeseries had no is_system filter,
so Playground usage leaked in. Add the same identity-based excludeSystemAgents
join + guard to all provider/model token/message/cost variants.

Also switch getAgentNamesByAuthType to the id-based join for consistency.

* fix(analytics): id-based join in connection-detail agent breakdown

Closes the third instance of the name-join duplication (cubic P1 class): the
connection-detail 'agents using this provider' breakdown joined agents by
name+tenant, so a soft-deleted agent sharing a slug doubled its tokens/cost/
message counts. Join on a.id = at.agent_id instead. Added an e2e invariant:
the agent-breakdown token sum must equal the model-breakdown sum for the same
connection (a name join inflates the agent side to 2x).

* refactor(frontend): rename /agents routes to /harnesses with legacy redirects

Client router now serves the workspace and agent-detail views under
/harnesses (param still :agentName — data stays agent-based). Old
/agents and /agents/:agentName/* paths redirect to their /harnesses
equivalents so existing bookmarks keep working. useAgentName/agentPath
helpers updated to match the new prefix; API call paths (/api/v1/agents)
are untouched.

* refactor(frontend): rename user-facing "Agent" copy to "Harness"

Sidebar nav tab, page titles/headings, empty states, buttons, labels,
tooltips, and aria-labels now say Harness/Harnesses instead of
Agent/Agents. Code identifiers, types, props, API paths, and the
agent-framework integration names (Hermes Agent, Craft Agent, the
Agents setup tab) are left unchanged.

* test(frontend): update route + copy assertions for Harness rebrand

Port renamed expectations: /agents → /harnesses route/href assertions,
Harness copy in Sidebar, Workspace, MessageLog, Settings, Header and the
agent-detail pages, and the Harness column header in the message table.
API-endpoint assertions (/api/v1/agents) are left as-is.

* feat(frontend): list harnesses in the sidebar switcher

Replace the static Harnesses link with a collapsible HARNESSES section
that lists every (non-system) harness inline, ported from #2061. Each
item links to /harnesses/:name with its platform icon and an active
state for the current route. Adds a + create button wired to
AddAgentModal, a caret collapse toggle, and a no-harnesses empty state.
The list uses getAgents() with the default (system agents excluded) so
the reserved Playground harness never appears in the switcher.

Also fixes a stale post-rebrand navigation assertion in the
AddAgentModal test (/agents -> /harnesses).

* fix(analytics): exclude system agents by id OR name via NOT EXISTS semi-join

The id-only LEFT JOIN exclusion let Playground rows with a NULL/unmatched
agent_id (name only) leak into analytics totals: their is_system came back
NULL so they were wrongly included. Replace the join+guard in
excludeSystemAgents with a NOT EXISTS semi-join matching the reserved
Playground agent by sysag.id = at.agent_id OR sysag.name = at.agent_name.
A semi-join is a pure existence test, so it fixes the leak without
re-introducing the soft-deleted-slug duplication the id join prevented.

Switch every call site (per-agent/provider/model timeseries, summary
builders, getAgentNamesByAuthType, and the connection-detail
agent/model/recent-message breakdowns) to the shared predicate. The
connection-detail agent breakdown keeps its own a.id LEFT JOIN solely for
MAX(a.agent_platform). Add e2e proving a Playground row with NULL agent_id
but agent_name='Playground' is excluded from per-agent timeseries, summary,
per-provider/model timeseries and the connection breakdown.

* fix(harness): encode sidebar harness hrefs + preserve query/hash on bare /agents redirect

Codex on the harness rebrand:
- P2: the sidebar harness item href used the raw name, breaking names with
  /, %, ? etc. Wrap in encodeURIComponent (matches the agent-card link).
- P2: the bare /agents legacy redirect dropped query/hash (/agents?add=true
  became /harnesses). Build the target from window.location like the
  sub-path redirects already do.

* feat(frontend): add multi-harness analytics chart components

Port the stacked multi-series token/message/cost chart, the
provider/overview chart card, and the global overview skeleton from
the #2061 analytics surfaces. Add an analytics-overview stylesheet
holding the chart-tooltip, harness-filter, stat-card and scroll-panel
classes the analytics pages need but that were missing from this base.

100% line coverage via AnalyticsChartSurfaces.test.tsx.

* feat(frontend): full analytics Global Overview

Replace the lean overview with the full analytics dashboard: provider
category stat cards (subscriptions / BYOK / local / harnesses), the
multi-harness token/message/cost chart card with a per-harness or
per-provider grouping toggle and harness filter, recent messages, model
usage, provider connections, and harness usage tables. Wires to the PR6
analytics client (overview + per-agent/per-provider timeseries) and the
tenant-level providers endpoint. Routes and copy use /harnesses and
"Harness" to match this base.

The old lean-overview test is replaced by ProviderOverviewPages.test.tsx
in the following commit.

* feat(frontend): connection detail drill-down page + route

Add the per-connection analytics page at /providers/connections/:connectionId
(lazyReload route). Shows the connection header (status, models, first/last
used), the multi-harness chart card scoped to the connection, recent
messages, model usage, and per-harness usage. BYOK connections surface cost
columns. The breadcrumb store is updated for the header. Provider management
(rename / change key / disconnect) is delegated to ProviderSelectModal via the
Manage button; the unreachable inline manage/disconnect modals from #2061 were
dropped. Routes/copy use /harnesses and "Harness".

ProviderOverviewPages.test.tsx covers GlobalOverview + ConnectionDetail at
100% line coverage (loading / empty / error / data / filter states).

* fix(harness): a11y + dedup cleanups on sidebar HARNESSES section

- Make the collapse toggle a real <button> (label "HARNESSES" + caret,
  aria-expanded) with the + create button as a sibling button, not nested;
  remove the interactive clickable <div> and its CSS.
- Stop hover-gating the + create button so it is keyboard- and touch-reachable
  (visible on hover and :focus-visible).
- Gate the "No harnesses yet" empty state on the resource not loading so it no
  longer flashes during initial load.
- Drop the redundant bare /agents/:agentName redirect route (the *rest splat
  already matches zero trailing segments).
- Dedup harness-item CSS by sharing the sidebar__link base rule.

* fix(analytics-ui): scope connection charts by label, isolate overview series, handle missing connections

- ConnectionDetail charts/summary now pass the connection label to the
  provider-analytics queries so two connections sharing provider+auth_type
  but differing by label no longer show each other's usage. Backend
  per-agent/aggregate timeseries + summary gain an optional label filter
  (filterByKeyLabel, NULL->Default).
- GlobalOverview series selection is scoped per groupBy() and pruned against
  the live series, defaulting to all-selected when the intersection is empty,
  so switching provider/harness groupings no longer blanks the chart.
- ConnectionDetail renders a not-found state for a resolved-but-null
  connection instead of spinning on the loading fallback forever.
- A custom: provider deep link now opens the custom-provider editor for the
  matching id instead of falling through the standard provider deep link.

* fix(analytics-backend): cubic findings on rate-limit capture, cache cap, agent-scoping, connection-detail shape

- rate-limit capture (proxy primary + fallback): attribute headers to the
  canonical 'Default' connection label for unlabeled keys instead of a NULL
  that collides with a real 'Default' connection on read.
- rate-limit-tracker: enforce MAX_CACHE on the DB-repopulation path so the
  in-memory cache can't grow unbounded (shared setCapped helper).
- timeseries per-provider/per-model: scope an agent-filtered query to the LIVE
  agent id (filterByLiveAgentName), so a soft-deleted slug-sharing agent's rows
  don't leak in. Extracted the live-agent subquery into query-helpers.
- provider-analytics connection-detail: return a consistent shape (incl.
  model_usage: []) in every branch, and resolve the tenant via TenantCacheService
  instead of a per-request tenant query.
- overview controller: extract a shared deriveTimeseriesArgs helper for the
  per-* endpoints to remove range/hourly/tenant derivation drift.

* fix(analytics-ui): chart-component cubic findings (lazy uPlot, a11y tabs, per-index colors, sub-cent cost, dead props)

- ProviderChartCard: lazy-load MultiAgentTokenChart so uPlot stays out of the
  initial bundle (Suspense fallback); make the tab controls semantic <button>s
  for keyboard/a11y; drop the unused tokenUsage/messageChartData props (and the
  now-dead computation at the GlobalOverview call site).
- MultiAgentTokenChart: per-index series color fallback (AGENT_COLORS[i % len])
  keyed on the agent's original index so colors no longer all collapse onto
  index 0; colorMap still takes precedence. Cost y-axis uses formatCost so
  sub-cent values render '< $0.01' instead of rounding to '$0.00'.

* fix(analytics-ui): ConnectionDetail persisted range + genuine empty harness selection

- savedRange() now restores a persisted '24h' selection too (it was silently
  dropped and reset to the 7d default on reload).
- distinguish 'no persisted preference' (null → default to all selected) from a
  genuinely-empty selection (persisted []), so 'Unselect all' actually sticks
  and the chart renders its empty state instead of coercing back to all. Drops
  the dead tokenUsage/messageChartData wiring at the ProviderChartCard call site.

* feat(analytics): label-scoped queries + cubic backend fixes

Back-port of the analytics backend consolidated on the analytics-UI branch so
PR6 matches (single source for the analytics/rate-limit backend):
- connection-scoped label param on per-agent/summary/aggregate timeseries
- rate-limit capture uses the canonical connection label (no fabricated Default)
- rate-limit tracker caps the DB-repopulation path (no unbounded cache growth)
- agent-scoped provider/model timeseries constrains to the live agent id
- connection-detail returns a consistent shape (model_usage in every branch)
- getConnectionDetail uses TenantCacheService; overview endpoints share a helper

* fix(analytics): scope live-agent lookup by resolved tenant, not at.tenant_id

cubic P2: filterByLiveAgentName keyed the agent subquery off at.tenant_id, so
on the user_id fallback path (message rows with NULL tenant_id) it matched
nothing and returned empty timeseries. Scope the lookup by the resolved tenant
id when known, else resolve via tenant.name = userId — mirroring addTenantFilter.

* fix(analytics): scope live-agent lookup by resolved tenant, not at.tenant_id

cubic P2 on #2171: filterByLiveAgentName keyed the agent subquery off
at.tenant_id, blanking timeseries on the user_id fallback path (NULL
tenant_id rows). Scope by the resolved tenant id, else tenant.name = userId.
(Mirrors the PR6 backend fix so both branches stay in sync.)

* fix(rate-limits): sweep expired cache entries before capping; soften test assertion

cubic on #2168:
- P2: setCapped checked MAX_CACHE before reclaiming expired entries, so stale
  snapshots could evict a still-live entry. Sweep expired first, evict oldest
  only if still at capacity.
- P3: aggregation label assertion uses expect.objectContaining for resilience.

* fix(rate-limits): sweep expired cache entries before capping; soften test assertion

Sync of the PR6 cubic fixes (P2 cache sweep order, P3 test assertion) to keep
the analytics-UI backend identical to #2168.

* fix(setup): scroll the harness-framework tab row instead of overflowing the modal

Inherited from #2061: the setup framework tabs (flex-shrink: 0) only got
overflow-x handling in the mobile media query, so on desktop a full row of
frameworks overflowed past the modal edge. Add overflow-x: auto (scrollbar
hidden) at desktop too.

* fix(setup): scroll the harness-framework tab row instead of overflowing the modal

Inherited from #2061: the setup framework tabs (flex-shrink: 0) only got
overflow-x handling in the mobile media query, so on desktop a full row of
frameworks overflowed past the modal edge. Add overflow-x: auto at desktop too.

* fix(setup): persist setup-pending flag so the harness setup modal survives refresh

The setup modal previously opened only when isRecentlyCreated() was true, a
flag held in an in-memory Set that a page refresh dropped (and AgentGuard
cleared once the agent appeared in the list), so the modal never reopened.

Add a localStorage-backed setup-pending flag (markSetupPending / isSetupPending
/ clearSetupPending) set on create in AddAgentModal. Routing and Overview now
gate the modal on (isSetupPending || isRecentlyCreated) && !completed &&
!dismissed, and clear the pending flag on dismiss/done. The modal now reopens
across refreshes until the user dismisses or completes it.

* refactor(cache): share trackBy preconditions + assert both cache variants on duplicate

- Extract GET+user.id precondition gate into UserCacheInterceptor.resolveUserId
  and reuse it from AgentListCacheInterceptor.trackBy, removing the duplicated
  method-check/user-extraction that risked drift between the two interceptors.
- duplicateAgent controller test now asserts both system=false and system=true
  cache-clear calls, matching rename/delete/create and the interceptor's
  exhaustive two-variant invalidation.

* feat: enrich provider pages

* fix(messages): View more opens global log pre-filtered to the harness

The Recent Messages 'View more' link redirected to the global message log
but dropped the agent entirely. Carry it through as ?agent=<name> and seed
MessageLog's agent filter from the query param so the log opens scoped to
the harness the user came from.

* fix(messages): restore harness icons in global log

* fix(providers): block disabling a provider whose models are routed

Replace the 'this will remove your routing assignments' confirmation modal
with a hard block: if the provider's models are assigned to the harness's
routing, show an error toast and refuse to disable, instead of silently
stripping the assignments. Disables directly when nothing is routed; fails
safe (toast + abort) if the impact check errors.

* fix(routing): don't let auto-assigned routes block provider disable

The disable-impact check counted auto_assigned_route, but that is the
system's automatic pick — disable() already clears it and recalculateTiers()
recomputes it. Counting it meant a provider auto-assigned to a tier could
never be disabled, even after the user manually routed that tier elsewhere.
Only user-authored assignments (override_route + fallback_routes) block now.

* fix: refine provider row affordances

* fix(routing): make model routing user-controlled

* fix: remove duplicate agent detail child headings

* fix: remove workspace from agent header breadcrumb

* fix: route header docs link by current page

* fix: count legacy agent-name rows for limits

* test: make limits SQL assertion failure explicit

* fix: address aggregate stack review findings

* test: assert provider removal cache invalidation

* fix: target the visible provider key on edit and rename

- AddAnotherKeyAction's focus effect tracked the label signal, so every
  keystroke in the name field re-fired it and stole focus back to the
  API key input. Track only isOpen via on() and focus on open transition.
- handleUpdateKey sent no label, so the backend legacy path always wrote
  to the row labeled 'Default' regardless of which key was shown. Pin the
  update to the visible key's label.
- relabelOverrides only rewrote pinned routes for the renaming agent, but
  keys are user-global: stale labels on other agents made the proxy fall
  back silently to the first key. Relabel across all the user's agents
  and flush each mutated agent's routing cache.

* fix: strip native button chrome from chart stat tabs

The Cost/Messages/Token usage stats render as <button> for a11y, but the
UA default background/border made them light gray cards on dark theme.
Reset the button chrome and keep the themed divider border.

* fix: compute limit period boundaries in local time, not UTC

Token/cost limits silently never blocked (and threshold alerts never
fired) whenever the server process ran in a non-UTC timezone.

computePeriodBoundaries / computePeriodResetDate built their window with
Date.UTC + toSqlTimestamp (toISOString → UTC wall-clock), but
agent_messages.timestamp rows are stored in the process's LOCAL time (the
pg driver serialises Dates locally; the rest of the analytics layer
already compares with local cutoffs via computeCutoff/formatLocalIso).

On a non-UTC host the window's upper bound (periodEnd = UTC "now") sits
behind the local-time rows by the TZ offset, so getConsumption's SUM
matches ~0 rows. With actual < threshold the hard-limit check in
ProxyService.enforceLimits never trips and NotificationCronService never
alerts — reproduced on a UTC+2 host where a 1-token/day cap let a 522-token
agent through, and confirmed fixed (M200 block) after this change.

- Add toLocalSqlTimestamp (local sibling of toSqlTimestamp) in postgres-sql.
- Recompute both period helpers from local Date parts + toLocalSqlTimestamp.
- Update period.util / postgres-sql specs to assert local behaviour, plus a
  round-trip regression that has teeth on any non-UTC host.
- Make notification-cron edge-case boundary assertions timezone-independent.

* fix: show custom provider names in overview series and models

Provider-grouped chart series keyed custom providers as custom:<uuid>,
which leaked raw UUIDs into the filter dropdown, legend, and tooltip.
Remap series keys to the provider's display name once custom provider
data resolves. Recent Messages and Model usage now run model names
through getModelDisplayName so custom:<uuid>/model slugs render as the
bare model name.

* fix: resolve custom provider name + icon in the global Messages log

The Messages log's customProviders resource was keyed solely on
params.agentName. On the global ("All harnesses") view there is no route
agent, so the resource source was undefined, the fetcher never ran, and
custom:<uuid> models rendered as `custom:Custom/<model>` with a letter
badge instead of the provider's name and icon.

Custom providers are user-global, so fall back to the first available
agent (from the already-loaded agent list) when there's no route agent —
mirroring GlobalOverview's firstAgent() pattern — so the cell resolves the
provider name (e.g. "Hugging Face") and logo like every other provider.

Extends #2192 (custom provider names) to the Messages table; the
agent-scoped log path is unchanged. Adds a global-mode test.

* fix: refetch agent list on agent change so setup modal respects chosen harness

AgentGuard fetched the agent list with a source-less resource, so it never
refetched while staying mounted across /harnesses/:agentName navigations.
Creating a new agent from the always-present sidebar (while another agent was
open) redirected to the new agent but kept serving the first-mount list, which
omits it. agentExists() then fell through to setAgentPlatform(null), so the
post-create 'Set up harness' modal rendered its full Agents/Toolkits picker
instead of the harness the user just selected.

Key the resource on the agent param so the list refetches per agent, and keep
children mounted during the in-flight refetch so switching agents no longer
flashes an empty page. Also fixes the same staleness for the display name and
NotFound gate.

* docs: spec for custom provider display fix

* docs: fix timeseries fallback label in spec

* docs: implementation plan for custom provider display fix

* feat(analytics): resolve custom provider name in shared message-row projection

* feat(analytics): cost-by-model and per-provider charts resolve custom provider names

* feat(analytics): messages endpoint labels custom providers for the filter dropdown

* feat(providers): global providers endpoint resolves custom display names

* feat(frontend): ModelCell renders custom providers like built-in providers

* feat(frontend): CostByModelTable reads backend-resolved custom provider name

* refactor(frontend): drop per-page custom provider name lookups

* feat(frontend): GlobalOverview reads backend-resolved custom provider names

* test(analytics): register CustomProvider repo mock in cost-filter spec

* style: prettier formatting on touched src files

* chore: changeset for custom provider display fix

* fix: UI design anomalies — container widths, modal cleanup, naming, inline rename, no-connections cards

- Workspace and Limits pages use container--lg to match Subscriptions width
- Remove legacy "Connect providers" modal from Routing and Playground
- Remove top-level "Add" buttons from provider list pages
- Sidebar section renamed to "Provider Connections", normalize Local/BYOK labels
- Manage modal on ConnectionDetail: provider name title, X close button, rename field, model count, disconnect with error feedback
- Inline rename on provider list pages with pen icon on hover, Save/Cancel
- NoConnectionsPrompt component with colored auth-type icons (teal user, orange key, pink tent), left-aligned cards, primary "Connect provider" buttons — shared across Routing, Playground, and AgentProviders
- Routing tab layout: merged provider icons + buttons into single row, removed parasitic separator
- Supported providers default to grid view with grid toggle first
- Fix toast import and silent disconnect/rename/refresh when no agents exist

* docs: spec for dashboard fixes (filter redirect, local gating, savings removal)

* docs: implementation plan for dashboard fixes

* fix(messages): carry agent filter through the messages redirect

* fix(providers): hide local tab, route and overview card in cloud

* refactor: remove subscription savings (UI, API client, backend endpoints)

* docs: spec for per-agent provider-grouped chart (restore Seb's design)

* docs: implementation plan for per-agent provider chart

* feat(api): add per-agent per-provider cost timeseries client fn

* feat(overview): provider-grouped usage chart on the per-agent overview

* test(overview): adapt limits-page chart assertions to ProviderChartCard

* test(overview): full coverage for provider chart; chore: changeset

* fix: strip native button chrome from chart stat tabs

The Cost/Messages/Token usage stats render as <button> for a11y, but the
UA default background/border made them light gray cards on dark theme.
Reset the button chrome and keep the themed divider border.

* fix(seed): set provider on seeded agent messages so provider-grouped charts show demo data

* fix(charts): move agent-chart-tooltip styles into a component-owned stylesheet

The tooltip CSS lived in analytics-overview.css, imported only by
GlobalOverview and ConnectionDetail. Landing directly on the per-agent
Overview left .agent-chart-tooltip unstyled, so the hover tooltip
rendered as static text below the chart. MultiAgentTokenChart now
imports its own stylesheet, so every page using the chart gets it.

* refactor(frontend): extract shared FilterSelect from triplicated chart filter markup

The provider/harness multi-select dropdown was copy-pasted across the
agent Overview, GlobalOverview, and ConnectionDetail, and its styles
lived in analytics-overview.css — which the agent Overview never
imported, so the filter rendered as unstyled text there (not matching
the original design). FilterSelect now owns the markup, the open/
outside-click/Escape state, and its own stylesheet, so every consumer
renders identically.

* fix(seed): set provider on seeded agent messages so provider-grouped charts show demo data

* fix(charts): move agent-chart-tooltip styles into a component-owned stylesheet

The tooltip CSS lived in analytics-overview.css, imported only by
GlobalOverview and ConnectionDetail. Landing directly on the per-agent
Overview left .agent-chart-tooltip unstyled, so the hover tooltip
rendered as static text below the chart. MultiAgentTokenChart now
imports its own stylesheet, so every page using the chart gets it.

* refactor(frontend): extract shared FilterSelect from triplicated chart filter markup

The provider/harness multi-select dropdown was copy-pasted across the
agent Overview, GlobalOverview, and ConnectionDetail, and its styles
lived in analytics-overview.css — which the agent Overview never
imported, so the filter rendered as unstyled text there (not matching
the original design). FilterSelect now owns the markup, the open/
outside-click/Escape state, and its own stylesheet, so every consumer
renders identically.

* fix: bar charts on agent overview, simplified skeletons, openclaw SVG, routing layout fixes

- ChartCard now uses MultiAgentTokenChart (bar charts) instead of line charts
  for Messages, Cost, and Tokens views. Savings tab keeps its dedicated chart.
- RoutingLoadingSkeleton simplified to smooth beige rectangles (one per zone),
  no nested skeleton elements inside content areas.
- ConnectionDetail loading state: single 300px beige rectangle with pulse.
- Replace openclaw.png with openclaw.svg everywhere, remove the PNG asset.
- Remove margin-bottom on .routing-providers-info (now inside flex row).
- NoConnectionsPrompt cards max-width constrained to 1400px.

* test(seed): pin provider inference on seeded messages

* refactor(frontend): delete dead legacy chart components

ChartCard, CostChart, TokenChart, and SingleTokenChart have been
orphaned since the ProviderChartCard migration — no page imports them.
Also removes the chart-utils helpers only they consumed
(makeGradientFill, makeGradientFillFromVar, createCursorSnap,
formatLegendTimestamp, formatLegendCost, timeScaleRange,
sanitizeNumbers) and their tests.

* refactor(frontend): delete dead legacy chart components

ChartCard, CostChart, TokenChart, and SingleTokenChart have been
orphaned since the ProviderChartCard migration — no page imports them.
Also removes the chart-utils helpers only they consumed
(makeGradientFill, makeGradientFillFromVar, createCursorSnap,
formatLegendTimestamp, formatLegendCost, timeScaleRange,
sanitizeNumbers) and their tests.

* test: drop vi.mock stubs for the deleted legacy chart components

* test: drop vi.mock stubs for the deleted legacy chart components

* refactor(providers): drop Estimated savings from Subscriptions and Local pages

The savings concept was removed product-wide (see the Subscription
Savings removal); these two pages still showed an 'Estimated savings
(30d)' stat card and a per-row 'Savings (30d)' column. The cost metric
is now BYOK-only; subscriptions/local show usage without a synthetic
dollar figure. Also drops the page's overview fetch, which existed
solely to compute savings.

Test file gains the useNavigate/formatCost mocks the merged page
already required — fixes 8 pre-existing failures on this branch.

* refactor(providers): hide model counts on Subscriptions and BYOK pages

Model counts come from discovery for subscription/BYOK providers and can
be partial or stale, so the number is misleading there. Drops the Models
column from the connected and supported tables and the 'N models' label
from grid cards for those pages. The Local providers page keeps them —
its counts are read straight from the local server.

* refactor(providers): drop model counts on the Local page too

Same column on all three pages now that Subscriptions/BYOK lost it —
remove the kind gate and the dead modelCount helper for coherence.

* fix: naming, copy, icons, tables, modal cleanup, em-dash removal

- Sidebar: PROVIDERS (reverted from PROVIDER CONNECTIONS)
- BYOK renamed to "Usage-based" everywhere (sidebar, H1, back-links, cards)
- Subscriptions subtitle: "Use your current plans with any supported provider"
- Usage-based subtitle: "Connect providers you pay per token or per usage"
- Consistent headings: "My subscription/usage-based/local connections"
  and "Supported subscription/usage-based/local providers"
- All provider list buttons unified to "Connect"
- "Connect providers" (plural) changed to "Connect provider" (singular)
  across all detail views, modals, and buttons
- Remove list view with tabs from ProviderSelectContent (dead code after
  deep-link-only flow). Tab imports cleaned up.
- Back button in deep-link modal now closes instead of navigating to list
- Remove Models column from My connections and Supported providers tables;
  fix colgroup mismatches so tables fill 100% width
- Edit icon (pencil-in-frame) replaces old icons in provider list, routing
  tier cards, and header tier cards
- Provider detail: header align-items center, manage modal on inactive too
- ProviderDetailView: requirement note below provider name, not beside it
- Em dashes removed from all user-visible strings (13 files)
- AgentProviders sentence rewritten without dash

* refactor(frontend): delete four more unreferenced components

ApiKeyDisplay, EmailProviderSection, SetupStepProviders, and
RoutingComplexitySection lost their last importer during the
dashboard rework; only their own tests still referenced them.

* refactor(frontend): share the scroll-panel fade handler

The same six-line onScroll closure was inlined seven times across
GlobalOverview and ConnectionDetail; extract it to services/scroll-fade.

* refactor(frontend): delete four more unreferenced components

ApiKeyDisplay, EmailProviderSection, SetupStepProviders, and
RoutingComplexitySection lost their last importer during the
dashboard rework; only their own tests still referenced them.

* refactor(frontend): share the scroll-panel fade handler

The same six-line onScroll closure was inlined seven times across
GlobalOverview and ConnectionDetail; extract it to services/scroll-fade.

* fix: breadcrumbs, auth badge positioning, Manifest branding, tables, filters, tooltips

Header breadcrumbs:
- Harness pages: manifest / Harnesses / [icon] agent-name
- Connection pages: manifest / Usage-based / [icon] OpenAI Default
  (provider logo + name in bold, connection label in muted)
- Breadcrumb store extended with providerId and label fields
- Cleanup on unmount so breadcrumb clears on navigation

Auth badge icon positioning:
- Root cause: routing.css not imported on Overview, GlobalOverview, MessageLog
- All icon wrappers now have explicit width/height matching icon size
- Fixed CSS classes: .routing-card__override-icon, .fallback-list__icon,
  .routing-providers-info__icon (all get explicit dimensions)

Manifest as provider/model (DRY):
- getModelDisplayName('manifest') returns 'Manifest' (single source of truth)
- providerIcon('manifest') returns manifest.svg logo
- manifest.svg added to public/icons/
- Removed hardcoded manifest checks from individual pages

Tables and filters:
- Removed all colgroups from ProviderConnectionsPage (caused right whitespace)
- Added table-layout: auto to global .data-table CSS
- Select component supports optional icon per option
- Provider filters show provider logos, harness filters show platform icons
- Removed "Unselect all" from FilterSelect
- "BYOK" label changed to "Usage-based" in GlobalOverview stat card
- Overview + add buttons link directly to pages (no ?add=true modal)

Other fixes:
- ConnectionDetail: provider logo + connection label in page header
- ConnectionDetail: back link restored (was accidentally replaced by breadcrumb)
- ConnectionDetail: recent messages use getModelDisplayName, View more removed
- Overview + Limits: removed page-header separator border
- Capability badge tooltips: font-size from 10px to var(--tooltip-font-size)

* fix: include auto_assigned_route in baseline cost model collection

---------

Co-authored-by: Guillaume Gay <guillaume.gay@protonmail.com>
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