feat: tabbed agent detail with unified global sidebar#2150
feat: tabbed agent detail with unified global sidebar#2150guillaumegay13 wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
1 issue found across 14 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
22001c0 to
1186bc0
Compare
There was a problem hiding this comment.
Three things on this PR:
-
Each child page (Overview, Routing, etc.) still renders its own
<h1>. Combined with the agent name heading in theAgentDetailshell, 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
/messagespage still has no Agent column in the table. The renderer and type exist butDETAILED_COLUMNSdoesn'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?
|
- 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.
4b7acbb to
e89acd2
Compare
- 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.
) * 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>
✨ What changed
AgentOverview(mirrorsGlobalOverview)./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
next(integration branch); stacked on feat: add global Overview, Messages and Agents navigation #2149 which is already merged intonext.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
AgentDetailtabbed shell with Overview / Routing / Limits / Settings and a back-link; agent Overview renamed toAgentOverview./agents/:name/*; sidebar is hidden only on the root redirect/./agents/:name/limits→/agents/:name/guardrailsand/agents/:name/messages→/messages.Bug Fixes
Overview,Routing,Limits, andSettingsso the tabbed shell owns the heading./guardrails) and removed the default underline on tabs.Written for commit 6185d87. Summary will update on new commits.