From ccb869705ba9f31970f37cbe84b2b4b58015e94f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 24 Sep 2026 13:34:18 +0000 Subject: [PATCH 01/34] Redesign the Web console as an operations back office (WIP) Turn the administrator console into a back office for a self-hosted Agents API deployment, and add the Skills, Files, full template configuration and per-Agent usage pages from the Web specs. - Navigation: Monitor (Overview, Agent metrics, Sandbox metrics, Session log), Resources (Agents, Skills, Environment templates, Vaults, Files), Platform (Nodes, API keys), Playground (API workbench, getting started). The Session console is a secondary page of the Session log; the System page is removed. - Shared page grammar in components/console-ui (page header, help tips, icon refresh, metrics, meters, status dots) and a canvas/card surface. - Overview: health, Session, capacity and attention figures, 24-hour Session activity, fleet with node detail, Sessions needing attention. - Agent and Sandbox metrics computed in the browser from existing reads, with honest coverage; missing values never render as zero. Backend requirements for aggregate endpoints are documented. - API keys as a table with a create dialog; the one-time secret survives closing the dialog. API workbench builds fixed-format requests (curl and JSON) and can send them or look objects up by ID. - First-run introduction follows one request path; the first request uses the builder's form and request preview layout. - agents-client: Skills (11 operations), Files list, and full Environment template projection with per-template unrecognized configuration. - Agents page: table with per-Agent usage (sessions, tokens, coverage); starter templates removed. - Chinese UI keeps API terms in English (Agent, Session, Turn, Skill, Vault). Work in progress: remaining design feedback (list toolbar unification, API workbench fields, first-run visual direction) and the Playwright specs are not updated or run yet. Web typecheck and 904 unit tests pass. --- CONTRIBUTING.md | 17 +- apps/web/.impeccable/design.json | 692 +++++ apps/web/.impeccable/surfaces/src-app-tsx.md | 28 + apps/web/DESIGN.md | 330 +++ apps/web/PRODUCT.md | 112 + apps/web/e2e/admin-console.spec.ts | 387 +++ apps/web/e2e/agent-usage.spec.ts | 132 + apps/web/e2e/agents-lifecycle.spec.ts | 660 ++--- apps/web/e2e/core-connection.spec.ts | 2 +- apps/web/e2e/environment-templates.spec.ts | 197 +- apps/web/e2e/files.spec.ts | 187 ++ apps/web/e2e/first-run.spec.ts | 8 +- apps/web/e2e/fixture-core.mjs | 209 +- apps/web/e2e/fixture-skills.mjs | 354 +++ apps/web/e2e/sandbox-manager.spec.ts | 38 +- apps/web/e2e/sandbox-setup.spec.ts | 25 +- apps/web/e2e/skills-lifecycle.spec.ts | 223 ++ apps/web/e2e/vault-credentials.spec.ts | 36 +- apps/web/e2e/vault-oauth-metadata.spec.ts | 2 +- apps/web/src/App.tsx | 291 ++- apps/web/src/components/ConsoleSidebar.tsx | 132 + apps/web/src/components/CreateMenu.test.tsx | 25 - apps/web/src/components/CreateMenu.tsx | 125 - .../src/components/ProductNavigation.test.tsx | 40 - apps/web/src/components/ProductNavigation.tsx | 47 - apps/web/src/components/SystemNavigation.tsx | 14 - .../src/components/charts/TimeSeriesChart.tsx | 291 +++ apps/web/src/components/charts/chart-scale.ts | 75 + apps/web/src/components/console-ui.test.tsx | 139 + apps/web/src/components/console-ui.tsx | 309 +++ apps/web/src/features/agents/AgentCatalog.css | 277 +- .../src/features/agents/AgentCatalog.test.tsx | 95 +- apps/web/src/features/agents/AgentCatalog.tsx | 294 +-- apps/web/src/features/agents/AgentForm.tsx | 2 +- .../features/agents/AgentSetupView.test.tsx | 23 - .../src/features/agents/AgentSetupView.tsx | 12 +- apps/web/src/features/agents/AgentUsage.css | 169 ++ .../src/features/agents/AgentUsage.test.tsx | 171 ++ apps/web/src/features/agents/AgentUsage.tsx | 303 +++ .../src/features/agents/AgentsView.test.tsx | 34 +- apps/web/src/features/agents/AgentsView.tsx | 195 +- .../features/agents/agent-templates.test.ts | 41 - .../src/features/agents/agent-templates.ts | 59 - .../src/features/agents/agent-usage.test.ts | 295 +++ apps/web/src/features/agents/agent-usage.ts | 294 +++ .../src/features/agents/use-agent-usage.ts | 148 ++ .../web/src/features/api-keys/ApiKeyPanel.tsx | 126 +- .../web/src/features/api-keys/ApiKeysView.tsx | 193 ++ apps/web/src/features/api-keys/api-keys.css | 48 +- .../web/src/features/api-keys/use-api-keys.ts | 135 + .../src/features/dashboard/DashboardView.css | 2301 ----------------- .../features/dashboard/DashboardView.test.tsx | 394 --- .../src/features/dashboard/DashboardView.tsx | 482 ---- .../dashboard/RuntimeObservabilityContent.tsx | 40 +- .../dashboard/RuntimeTrendCharts.test.tsx | 40 +- .../features/dashboard/RuntimeTrendCharts.tsx | 92 +- .../features/dashboard/RuntimeTrendPanel.tsx | 35 +- .../src/features/dashboard/dashboard-model.ts | 2 +- .../dashboard/runtime-observability.css | 1019 ++++++++ .../EnvironmentTemplatesView.css | 231 +- .../EnvironmentTemplatesView.test.tsx | 132 +- .../EnvironmentTemplatesView.tsx | 231 +- .../environment-templates/TemplateDetail.tsx | 249 ++ .../environment-templates/TemplateForm.tsx | 54 +- .../template-editor.test.ts | 70 +- .../environment-templates/template-editor.ts | 69 +- apps/web/src/features/files/CopyableId.tsx | 38 + .../web/src/features/files/FilesView.test.tsx | 144 ++ apps/web/src/features/files/FilesView.tsx | 457 ++++ .../features/files/file-operations.test.ts | 102 + .../web/src/features/files/file-operations.ts | 89 + apps/web/src/features/files/files.css | 107 + .../features/first-run/ConsoleNavigation.tsx | 17 - .../src/features/first-run/FirstRequest.tsx | 37 +- .../src/features/first-run/FirstRunHome.css | 164 +- .../src/features/first-run/FirstRunHome.tsx | 110 +- .../src/features/fleet/use-sandbox-fleet.ts | 98 + .../src/features/metrics/AgentMetricsView.tsx | 371 +++ apps/web/src/features/metrics/MetricsView.css | 16 + .../features/metrics/SandboxMetricsView.tsx | 167 ++ .../features/metrics/agent-metrics-loader.ts | 196 ++ .../features/metrics/agent-metrics.test.ts | 308 +++ .../web/src/features/metrics/agent-metrics.ts | 404 +++ .../src/features/metrics/use-stable-colors.ts | 16 + .../src/features/overview/OverviewView.css | 459 ++++ .../src/features/overview/OverviewView.tsx | 539 ++++ .../features/overview/overview-model.test.ts | 159 ++ .../src/features/overview/overview-model.ts | 187 ++ .../features/resources/SessionsLogView.tsx | 182 ++ .../features/sandbox/SandboxManagerView.css | 9 +- .../features/sandbox/SandboxManagerView.tsx | 48 +- .../src/features/sandbox/SandboxTopology.css | 27 +- .../src/features/sandbox/SandboxTopology.tsx | 5 +- .../src/features/sessions/SessionsView.tsx | 9 +- .../sessions/create/SessionStartDialog.tsx | 2 +- .../environment/EnvironmentPanel.test.tsx | 13 + .../sessions/environment/EnvironmentPanel.tsx | 7 +- .../environment/environment-templates.test.ts | 28 + .../environment/environment-templates.ts | 31 +- .../src/features/skills/SkillDetail.test.tsx | 186 ++ apps/web/src/features/skills/SkillDetail.tsx | 582 +++++ .../src/features/skills/SkillUploadDialog.tsx | 307 +++ .../src/features/skills/SkillsView.test.tsx | 122 + apps/web/src/features/skills/SkillsView.tsx | 344 +++ .../src/features/skills/skill-bundle.test.ts | 236 ++ apps/web/src/features/skills/skill-bundle.ts | 356 +++ .../features/skills/skill-operations.test.ts | 176 ++ .../src/features/skills/skill-operations.ts | 179 ++ apps/web/src/features/skills/skill-parts.tsx | 50 + apps/web/src/features/skills/skills.css | 357 +++ .../src/features/system/SourceFilesPanel.css | 222 -- .../features/system/SourceFilesPanel.test.tsx | 61 - .../src/features/system/SourceFilesPanel.tsx | 454 ---- apps/web/src/features/system/SystemView.css | 222 -- .../src/features/system/SystemView.test.tsx | 159 -- apps/web/src/features/system/SystemView.tsx | 195 -- apps/web/src/features/vaults/VaultsView.tsx | 11 +- .../features/workbench/ApiWorkbenchView.tsx | 276 ++ .../workbench/workbench-requests.test.ts | 58 + .../features/workbench/workbench-requests.ts | 118 + apps/web/src/features/workbench/workbench.css | 153 ++ apps/web/src/i18n/i18n.test.tsx | 4 +- apps/web/src/i18n/locales/en/agents.ts | 29 +- apps/web/src/i18n/locales/en/app.ts | 27 + apps/web/src/i18n/locales/en/common.ts | 14 +- apps/web/src/i18n/locales/en/dashboard.ts | 2 +- apps/web/src/i18n/locales/en/files.ts | 80 + apps/web/src/i18n/locales/en/metrics.ts | 129 + apps/web/src/i18n/locales/en/navigation.ts | 35 +- apps/web/src/i18n/locales/en/overview.ts | 139 + apps/web/src/i18n/locales/en/pages.ts | 18 +- apps/web/src/i18n/locales/en/resources.ts | 53 + apps/web/src/i18n/locales/en/sessions.ts | 5 +- apps/web/src/i18n/locales/en/skills.ts | 188 ++ apps/web/src/i18n/locales/en/system.ts | 63 - apps/web/src/i18n/locales/en/templates.ts | 81 +- apps/web/src/i18n/locales/zh-CN/agents.ts | 37 +- apps/web/src/i18n/locales/zh-CN/app.ts | 31 +- apps/web/src/i18n/locales/zh-CN/common.ts | 14 +- apps/web/src/i18n/locales/zh-CN/dashboard.ts | 30 +- apps/web/src/i18n/locales/zh-CN/files.ts | 84 + apps/web/src/i18n/locales/zh-CN/metrics.ts | 129 + apps/web/src/i18n/locales/zh-CN/navigation.ts | 35 +- apps/web/src/i18n/locales/zh-CN/overview.ts | 139 + apps/web/src/i18n/locales/zh-CN/pages.ts | 24 +- apps/web/src/i18n/locales/zh-CN/resources.ts | 53 + apps/web/src/i18n/locales/zh-CN/sessions.ts | 171 +- apps/web/src/i18n/locales/zh-CN/skills.ts | 188 ++ apps/web/src/i18n/locales/zh-CN/system.ts | 63 - apps/web/src/i18n/locales/zh-CN/templates.ts | 84 +- apps/web/src/i18n/locales/zh-CN/vaults.ts | 12 +- apps/web/src/i18n/resources.ts | 24 +- apps/web/src/lib/api-key-strings.ts | 9 + .../web/src/lib/collection-pagination.test.ts | 20 +- apps/web/src/lib/collection-pagination.ts | 29 + apps/web/src/lib/console-routes.test.ts | 83 + apps/web/src/lib/console-routes.ts | 72 + apps/web/src/lib/core-readiness.ts | 2 +- apps/web/src/lib/first-run-strings.ts | 4 +- apps/web/src/lib/format.ts | 80 + apps/web/src/lib/locale-strings.ts | 13 +- apps/web/src/main.tsx | 1 + apps/web/src/styles/console.css | 1152 +++++++++ docs/web/README.md | 223 +- docs/web/README.zh-CN.md | 128 +- .../web/admin-metrics-backend-requirements.md | 125 + ...dmin-metrics-backend-requirements.zh-CN.md | 88 + docs/web/images/agent-metrics.png | Bin 0 -> 125158 bytes docs/web/images/agents.png | Bin 107555 -> 0 bytes docs/web/images/dashboard.png | Bin 94969 -> 0 bytes docs/web/images/overview.png | Bin 0 -> 146430 bytes docs/web/images/runtime-dashboard.png | Bin 78256 -> 0 bytes docs/web/images/sessions.png | Bin 67703 -> 0 bytes docs/web/images/system.png | Bin 51861 -> 0 bytes docs/web/protocol-coverage.md | 41 +- packages/agents-client/src/client.ts | 392 ++- .../src/environment-template-projection.ts | 224 ++ .../src/environment-templates.test.ts | 223 +- .../environment-templates.json | 550 ++++ .../src/fixtures/parsar-d3f55046/skills.json | 331 +++ .../parsar-d3f55046/source-files-list.json | 252 ++ packages/agents-client/src/index.ts | 2 + .../agents-client/src/skill-projection.ts | 264 ++ packages/agents-client/src/skills.test.ts | 348 +++ .../src/source-files-list.test.ts | 138 + packages/agents-client/src/types.ts | 247 +- 186 files changed, 22773 insertions(+), 7105 deletions(-) create mode 100644 apps/web/.impeccable/design.json create mode 100644 apps/web/.impeccable/surfaces/src-app-tsx.md create mode 100644 apps/web/DESIGN.md create mode 100644 apps/web/PRODUCT.md create mode 100644 apps/web/e2e/admin-console.spec.ts create mode 100644 apps/web/e2e/agent-usage.spec.ts create mode 100644 apps/web/e2e/files.spec.ts create mode 100644 apps/web/e2e/fixture-skills.mjs create mode 100644 apps/web/e2e/skills-lifecycle.spec.ts create mode 100644 apps/web/src/components/ConsoleSidebar.tsx delete mode 100644 apps/web/src/components/CreateMenu.test.tsx delete mode 100644 apps/web/src/components/CreateMenu.tsx delete mode 100644 apps/web/src/components/ProductNavigation.test.tsx delete mode 100644 apps/web/src/components/ProductNavigation.tsx delete mode 100644 apps/web/src/components/SystemNavigation.tsx create mode 100644 apps/web/src/components/charts/TimeSeriesChart.tsx create mode 100644 apps/web/src/components/charts/chart-scale.ts create mode 100644 apps/web/src/components/console-ui.test.tsx create mode 100644 apps/web/src/components/console-ui.tsx create mode 100644 apps/web/src/features/agents/AgentUsage.css create mode 100644 apps/web/src/features/agents/AgentUsage.test.tsx create mode 100644 apps/web/src/features/agents/AgentUsage.tsx delete mode 100644 apps/web/src/features/agents/agent-templates.test.ts delete mode 100644 apps/web/src/features/agents/agent-templates.ts create mode 100644 apps/web/src/features/agents/agent-usage.test.ts create mode 100644 apps/web/src/features/agents/agent-usage.ts create mode 100644 apps/web/src/features/agents/use-agent-usage.ts create mode 100644 apps/web/src/features/api-keys/ApiKeysView.tsx create mode 100644 apps/web/src/features/api-keys/use-api-keys.ts delete mode 100644 apps/web/src/features/dashboard/DashboardView.css delete mode 100644 apps/web/src/features/dashboard/DashboardView.test.tsx delete mode 100644 apps/web/src/features/dashboard/DashboardView.tsx create mode 100644 apps/web/src/features/dashboard/runtime-observability.css create mode 100644 apps/web/src/features/environment-templates/TemplateDetail.tsx create mode 100644 apps/web/src/features/files/CopyableId.tsx create mode 100644 apps/web/src/features/files/FilesView.test.tsx create mode 100644 apps/web/src/features/files/FilesView.tsx create mode 100644 apps/web/src/features/files/file-operations.test.ts create mode 100644 apps/web/src/features/files/file-operations.ts create mode 100644 apps/web/src/features/files/files.css delete mode 100644 apps/web/src/features/first-run/ConsoleNavigation.tsx create mode 100644 apps/web/src/features/fleet/use-sandbox-fleet.ts create mode 100644 apps/web/src/features/metrics/AgentMetricsView.tsx create mode 100644 apps/web/src/features/metrics/MetricsView.css create mode 100644 apps/web/src/features/metrics/SandboxMetricsView.tsx create mode 100644 apps/web/src/features/metrics/agent-metrics-loader.ts create mode 100644 apps/web/src/features/metrics/agent-metrics.test.ts create mode 100644 apps/web/src/features/metrics/agent-metrics.ts create mode 100644 apps/web/src/features/metrics/use-stable-colors.ts create mode 100644 apps/web/src/features/overview/OverviewView.css create mode 100644 apps/web/src/features/overview/OverviewView.tsx create mode 100644 apps/web/src/features/overview/overview-model.test.ts create mode 100644 apps/web/src/features/overview/overview-model.ts create mode 100644 apps/web/src/features/resources/SessionsLogView.tsx create mode 100644 apps/web/src/features/skills/SkillDetail.test.tsx create mode 100644 apps/web/src/features/skills/SkillDetail.tsx create mode 100644 apps/web/src/features/skills/SkillUploadDialog.tsx create mode 100644 apps/web/src/features/skills/SkillsView.test.tsx create mode 100644 apps/web/src/features/skills/SkillsView.tsx create mode 100644 apps/web/src/features/skills/skill-bundle.test.ts create mode 100644 apps/web/src/features/skills/skill-bundle.ts create mode 100644 apps/web/src/features/skills/skill-operations.test.ts create mode 100644 apps/web/src/features/skills/skill-operations.ts create mode 100644 apps/web/src/features/skills/skill-parts.tsx create mode 100644 apps/web/src/features/skills/skills.css delete mode 100644 apps/web/src/features/system/SourceFilesPanel.css delete mode 100644 apps/web/src/features/system/SourceFilesPanel.test.tsx delete mode 100644 apps/web/src/features/system/SourceFilesPanel.tsx delete mode 100644 apps/web/src/features/system/SystemView.css delete mode 100644 apps/web/src/features/system/SystemView.test.tsx delete mode 100644 apps/web/src/features/system/SystemView.tsx create mode 100644 apps/web/src/features/workbench/ApiWorkbenchView.tsx create mode 100644 apps/web/src/features/workbench/workbench-requests.test.ts create mode 100644 apps/web/src/features/workbench/workbench-requests.ts create mode 100644 apps/web/src/features/workbench/workbench.css create mode 100644 apps/web/src/i18n/locales/en/files.ts create mode 100644 apps/web/src/i18n/locales/en/metrics.ts create mode 100644 apps/web/src/i18n/locales/en/overview.ts create mode 100644 apps/web/src/i18n/locales/en/resources.ts create mode 100644 apps/web/src/i18n/locales/en/skills.ts delete mode 100644 apps/web/src/i18n/locales/en/system.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/files.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/metrics.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/overview.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/resources.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/skills.ts delete mode 100644 apps/web/src/i18n/locales/zh-CN/system.ts create mode 100644 apps/web/src/lib/console-routes.test.ts create mode 100644 apps/web/src/lib/console-routes.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/styles/console.css create mode 100644 docs/web/admin-metrics-backend-requirements.md create mode 100644 docs/web/admin-metrics-backend-requirements.zh-CN.md create mode 100644 docs/web/images/agent-metrics.png delete mode 100644 docs/web/images/agents.png delete mode 100644 docs/web/images/dashboard.png create mode 100644 docs/web/images/overview.png delete mode 100644 docs/web/images/runtime-dashboard.png delete mode 100644 docs/web/images/sessions.png delete mode 100644 docs/web/images/system.png create mode 100644 packages/agents-client/src/environment-template-projection.ts create mode 100644 packages/agents-client/src/fixtures/parsar-d3f55046/environment-templates.json create mode 100644 packages/agents-client/src/fixtures/parsar-d3f55046/skills.json create mode 100644 packages/agents-client/src/fixtures/parsar-d3f55046/source-files-list.json create mode 100644 packages/agents-client/src/skill-projection.ts create mode 100644 packages/agents-client/src/skills.test.ts create mode 100644 packages/agents-client/src/source-files-list.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7adab337e..62467d31d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,22 @@ repeat independent review when a correction materially changes the design or ris Fix in-scope blockers before delivery. Do not use `codex exec` as a substitute reviewer. The Core Web is an administrator console for execution and resource operations; -business collaboration remains in Parsar. Environment Template management shares +business collaboration remains in Parsar. Its navigation leads with operations: Monitor +(Overview, Agent metrics, Sandbox metrics, Session log), Resources, Infrastructure +and Settings; building Agents and chatting in Sessions are Playground tools for +verifying the deployment, not the landing surface. Operations pages use the shared +page grammar in `apps/web/src/components/console-ui.tsx` and `styles/console.css`; +older feature pages share its header, help-tip and refresh conventions. Keep +explanations behind help tips rather than lines of small print, but keep errors, +warnings and safety notices visible. +Browser-derived metrics state their coverage on screen, keep missing values +visibly missing, bound their fan-out and time (Agent metrics reads at most 200 +recently active Sessions per load, 15 s per Session, 45 s per load), report a +failed read as failed rather than as an empty result, and never imply +deployment-wide or billing totals. +Aggregate endpoints that would replace this work are console-only `/core/v1` +routes, never additions to the public `/v1` contract; see +`docs/web/admin-metrics-backend-requirements.md`. Environment Template management shares the Session creation catalog and uses the existing public client operations. Patch only edited fields, confirm deletion, and never automatically retry an uncertain write. When Core refuses to delete a busy Session, offer an explicit Cancel work diff --git a/apps/web/.impeccable/design.json b/apps/web/.impeccable/design.json new file mode 100644 index 000000000..9cd52cf93 --- /dev/null +++ b/apps/web/.impeccable/design.json @@ -0,0 +1,692 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-09-24T09:59:21.057068+00:00", + "title": "Design System: Parsar Core Console", + "extensions": { + "colorMeta": { + "ink": { + "role": "neutral", + "displayName": "Ledger Ink", + "canonical": "#37352f", + "cssVar": "--fg", + "dark": "#d4d4d4", + "tonalRamp": [ + "#292823", + "#4a483f", + "#6b675c", + "#8c8778", + "#a8a499", + "#c4c2ba", + "#e0dfdb", + "#f3f3f1" + ] + }, + "ink-muted": { + "role": "neutral", + "displayName": "Graphite", + "canonical": "#787774", + "cssVar": "--fg-muted", + "dark": "#9b9b9b", + "tonalRamp": [ + "#272726", + "#464544", + "#656462", + "#848380", + "#a2a19f", + "#c0c0be", + "#dededd", + "#f2f2f2" + ] + }, + "ink-subtle": { + "role": "neutral", + "displayName": "Pencil", + "canonical": "#9b9a97", + "cssVar": "--fg-subtle", + "dark": "#7b7b7b", + "tonalRamp": [ + "#272726", + "#464644", + "#656462", + "#848380", + "#a2a29f", + "#c0c0be", + "#dededd", + "#f2f2f2" + ] + }, + "sidebar-ink": { + "role": "neutral", + "displayName": "Sidebar Ink", + "canonical": "#5f5e5a", + "cssVar": "--sidebar-fg", + "dark": "#bdbdbd", + "tonalRamp": [ + "#272725", + "#474643", + "#666561", + "#85847f", + "#a3a29e", + "#c1c0be", + "#dfdedd", + "#f3f2f2" + ] + }, + "surface": { + "role": "neutral", + "displayName": "Paper", + "canonical": "#ffffff", + "cssVar": "--surface", + "dark": "#191919", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "surface-subtle": { + "role": "neutral", + "displayName": "Margin Gray", + "canonical": "#fafafa", + "cssVar": "--surface-subtle", + "dark": "#202020", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "surface-muted": { + "role": "neutral", + "displayName": "Well Gray", + "canonical": "#f1f1f0", + "cssVar": "--surface-muted", + "dark": "#2a2a2a", + "tonalRamp": [ + "#282825", + "#474742", + "#676760", + "#86867e", + "#a4a49d", + "#c1c1bd", + "#dfdfdd", + "#f3f3f2" + ] + }, + "line": { + "role": "neutral", + "displayName": "Hairline", + "canonical": "#e9e9ec", + "cssVar": "--line", + "dark": "rgb(255 255 255 / 9%)", + "tonalRamp": [ + "#232329", + "#40404a", + "#5c5c6b", + "#79798b", + "#9a9aa8", + "#bbbbc4", + "#dbdbe0", + "#f1f1f3" + ] + }, + "line-muted": { + "role": "neutral", + "displayName": "Faint Rule", + "canonical": "#efeff1", + "cssVar": "--line-muted", + "dark": "rgb(255 255 255 / 6%)", + "tonalRamp": [ + "#242429", + "#404049", + "#5d5d6a", + "#7a7a8a", + "#9a9aa7", + "#bbbbc3", + "#dcdce0", + "#f1f1f3" + ] + }, + "line-strong": { + "role": "neutral", + "displayName": "Firm Rule", + "canonical": "#d6d7dc", + "cssVar": "--line-strong", + "dark": "rgb(255 255 255 / 16%)", + "tonalRamp": [ + "#232429", + "#3f414a", + "#5c5e6b", + "#787b8c", + "#999ca8", + "#babcc4", + "#dbdce0", + "#f1f2f3" + ] + }, + "hover": { + "role": "neutral", + "displayName": "Hover Wash", + "canonical": "rgb(55 53 47 / 3%)", + "cssVar": "--hover", + "dark": "rgb(255 255 255 / 5.5%)", + "tonalRamp": [] + }, + "pressed": { + "role": "neutral", + "displayName": "Pressed Wash", + "canonical": "rgb(55 53 47 / 6%)", + "cssVar": "--pressed", + "dark": "rgb(255 255 255 / 10%)", + "tonalRamp": [] + }, + "tile": { + "role": "neutral", + "displayName": "Tile Wash", + "canonical": "rgb(55 53 47 / 7%)", + "cssVar": "--tile", + "dark": "rgb(255 255 255 / 10%)", + "tonalRamp": [] + }, + "accent": { + "role": "primary", + "displayName": "Parsar Indigo", + "canonical": "#4f46e5", + "cssVar": "--accent", + "dark": "#8b90f6", + "tonalRamp": [ + "#0d0943", + "#171179", + "#2119ae", + "#2f24e0", + "#625ae8", + "#958fef", + "#c8c5f7", + "#eae9fc" + ] + }, + "accent-emphasis": { + "role": "primary", + "displayName": "Pressed Indigo", + "canonical": "#4338ca", + "cssVar": "--accent-emphasis", + "dark": "#a5a9f8", + "tonalRamp": [ + "#13103c", + "#231d6d", + "#332a9d", + "#453aca", + "#726ad7", + "#a09ae4", + "#cecbf1", + "#ecebfa" + ] + }, + "accent-fg": { + "role": "primary", + "displayName": "On Indigo", + "canonical": "#ffffff", + "cssVar": "--accent-fg", + "dark": "#14142b", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "success": { + "role": "signal", + "displayName": "Healthy Green", + "canonical": "#16a34a", + "cssVar": "--success", + "dark": "#3fb950", + "tonalRamp": [ + "#09431f", + "#107937", + "#18af50", + "#23e169", + "#59e98e", + "#8ff0b3", + "#c5f7d7", + "#e9fcf0" + ] + }, + "warning": { + "role": "signal", + "displayName": "Caution Amber", + "canonical": "#d97706", + "cssVar": "--warning", + "dark": "#f5a524", + "tonalRamp": [ + "#4a2902", + "#864904", + "#c26a05", + "#f88a0c", + "#faa747", + "#fcc483", + "#fde0be", + "#fef3e6" + ] + }, + "danger": { + "role": "signal", + "displayName": "Fault Red", + "canonical": "#dc2626", + "cssVar": "--danger", + "dark": "#f05252", + "tonalRamp": [ + "#420b0b", + "#771313", + "#ab1c1c", + "#dc2828", + "#e55d5d", + "#ed9191", + "#f6c6c6", + "#fbe9e9" + ] + }, + "status-queued": { + "role": "signal", + "displayName": "Queued Gray", + "canonical": "#9a9ca4", + "cssVar": "--status-queued", + "dark": "#71737b", + "tonalRamp": [ + "#242528", + "#414348", + "#5e6069", + "#7c7e89", + "#9c9ea6", + "#bcbdc3", + "#dcdde0", + "#f2f2f3" + ] + }, + "status-idle": { + "role": "signal", + "displayName": "Idle Gray", + "canonical": "#b4b4b9", + "cssVar": "--status-idle", + "dark": "#5e5e62", + "tonalRamp": [ + "#252528", + "#424247", + "#606067", + "#7e7e86", + "#9d9da4", + "#bdbdc1", + "#dddddf", + "#f2f2f3" + ] + }, + "series-1": { + "role": "data", + "displayName": "Cobalt", + "canonical": "#2a78d6", + "cssVar": "--series-1", + "dark": "#3987e5", + "tonalRamp": [ + "#0c2440", + "#164173", + "#205da7", + "#2d7ad7", + "#619be1", + "#94bbea", + "#c7dcf4", + "#eaf1fb" + ] + }, + "series-2": { + "role": "data", + "displayName": "Persimmon", + "canonical": "#eb6834", + "cssVar": "--series-2", + "dark": "#d95926", + "tonalRamp": [ + "#461907", + "#7d2c0c", + "#b54012", + "#e9561c", + "#ee7f53", + "#f4a98b", + "#f9d2c3", + "#fdeee8" + ] + }, + "series-3": { + "role": "data", + "displayName": "Jade", + "canonical": "#1baf7a", + "cssVar": "--series-3", + "dark": "#199e70", + "tonalRamp": [ + "#0a422e", + "#127753", + "#1bac78", + "#27de9c", + "#5ce6b4", + "#91eecd", + "#c6f6e5", + "#e9fcf5" + ] + }, + "series-4": { + "role": "data", + "displayName": "Saffron", + "canonical": "#eda100", + "cssVar": "--series-4", + "dark": "#c98500", + "tonalRamp": [ + "#4c3400", + "#8a5e00", + "#c78700", + "#ffaf05", + "#ffc242", + "#ffd680", + "#ffeabd", + "#fff7e5" + ] + }, + "series-5": { + "role": "data", + "displayName": "Rose", + "canonical": "#e87ba4", + "cssVar": "--series-5", + "dark": "#d55181", + "tonalRamp": [ + "#410b20", + "#751439", + "#a91e52", + "#da2a6c", + "#e35e90", + "#ec92b4", + "#f5c7d8", + "#fbe9f0" + ] + }, + "series-6": { + "role": "data", + "displayName": "Forest", + "canonical": "#008300", + "cssVar": "--series-6", + "dark": "#008300", + "tonalRamp": [ + "#004c00", + "#008a00", + "#00c700", + "#05ff05", + "#42ff42", + "#80ff80", + "#bdffbd", + "#e5ffe5" + ] + }, + "series-other": { + "role": "data", + "displayName": "Series Other", + "canonical": "#a3a3a8", + "cssVar": "--series-other", + "dark": "#6d6d72", + "tonalRamp": [ + "#252527", + "#434347", + "#616166", + "#7f7f86", + "#9e9ea3", + "#bdbdc1", + "#dddddf", + "#f2f2f3" + ] + }, + "meter-track": { + "role": "data", + "displayName": "Meter Track", + "canonical": "rgb(55 53 47 / 8%)", + "cssVar": "--meter-track", + "dark": "rgb(255 255 255 / 9%)", + "tonalRamp": [] + } + }, + "typographyMeta": { + "display": { + "displayName": "KPI Figure", + "purpose": "KPI strip values only; tabular numerals." + }, + "headline": { + "displayName": "Page Title", + "purpose": "One per page in the 64px header." + }, + "title": { + "displayName": "Section Title", + "purpose": "Section headings; chart captions step down to 13px/600." + }, + "body": { + "displayName": "Body", + "purpose": "Table cells, controls, fields; document base is 14px/20px." + }, + "label": { + "displayName": "Label", + "purpose": "KPI labels, status labels, text actions, segmented options." + }, + "mono": { + "displayName": "Mono", + "purpose": "Identifiers and code in Graphite." + } + }, + "shadows": [ + { + "name": "shadow-control", + "value": "0 1px 2px rgb(0 0 0 / 6%)", + "dark": "0 1px 2px rgb(0 0 0 / 40%)", + "purpose": "Raised controls only: primary/outline buttons, fields, active segment, active filter tab, selected fleet target." + }, + { + "name": "shadow-floating", + "value": "0 1px 2px rgb(24 24 27 / 4%), 0 8px 24px -12px rgb(24 24 27 / 18%)", + "dark": "0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px -12px rgb(0 0 0 / 55%)", + "purpose": "Help-tip popovers, chart tooltips, menus and dialogs." + } + ], + "motion": [ + { + "name": "ease-settle", + "value": "cubic-bezier(0.22, 1, 0.36, 1)", + "purpose": "Default easing for colour, background and opacity state changes (150ms)." + }, + { + "name": "ease-spring", + "value": "cubic-bezier(0.34, 1.56, 0.64, 1)", + "purpose": "Button press scale to 0.97 (120ms)." + }, + { + "name": "page-in", + "value": "opacity 0 to 1, 160ms ease-settle", + "purpose": "Page change settle; no choreography. Disabled under reduced motion." + }, + { + "name": "help-tip-in", + "value": "opacity 0 to 1, 120ms ease-settle", + "purpose": "Help-tip popover appearance." + }, + { + "name": "refetch-dim", + "value": "opacity 0.62, 180ms ease-settle", + "purpose": "Page body while refetching." + } + ], + "breakpoints": [ + { + "name": "min-desktop", + "value": "960px" + }, + { + "name": "stack-charts", + "value": "1180px" + } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "The one filled action on a page; indigo is reserved for this and selection.", + "html": "", + "css": ".ds-btn { display:inline-flex; height:28px; align-items:center; gap:6px; padding:0 10px; font:500 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; border:1px solid transparent; border-radius:6px; cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1), border-color 150ms cubic-bezier(0.22,1,0.36,1), transform 120ms cubic-bezier(0.34,1.56,0.64,1); } .ds-btn-primary { color: var(--accent-fg, #fff); background: var(--accent, #4f46e5); border-color: var(--accent, #4f46e5); box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-btn-primary:hover { background: var(--accent-emphasis, #4338ca); border-color: var(--accent-emphasis, #4338ca); } .ds-btn:focus-visible { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); } .ds-btn:active { transform: scale(0.97); }" + }, + { + "name": "Outline Button (Refresh)", + "kind": "button", + "refersTo": "button-outline", + "description": "Default page-header action; Refresh keeps its last-updated time in the tooltip.", + "html": "", + "css": ".ds-btn-outline { display:inline-flex; height:28px; align-items:center; gap:6px; padding:0 10px; color: var(--fg, #37352f); font:500 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--surface, #fff); border:1px solid var(--line-strong, #d6d7dc); border-radius:6px; box-shadow: 0 1px 2px rgb(0 0 0 / 6%); cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-outline:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-btn-outline:focus-visible { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "Text Field", + "kind": "input", + "refersTo": "input-field", + "description": "28px form field with a firm rule border and indigo focus ring.", + "html": "", + "css": ".ds-input { width:240px; min-height:28px; padding:4px 8px; color: var(--fg, #37352f); font:400 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--surface, #fff); border:1px solid var(--line-strong, #d6d7dc); border-radius:6px; box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-input:focus { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "Segmented Range Control", + "kind": "chip", + "refersTo": "segmented-option", + "description": "The single style for time ranges and mode switches.", + "html": "
", + "css": ".ds-seg { display:inline-flex; gap:2px; padding:2px; background: var(--surface-muted, #f1f1f0); border-radius:7px; } .ds-seg button { height:24px; padding:0 10px; color: var(--fg-muted, #787774); font:500 12.5px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Segoe UI', sans-serif; background:transparent; border:0; border-radius:5px; cursor:pointer; transition: color 150ms cubic-bezier(0.22,1,0.36,1), background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-seg button:hover { color: var(--fg, #37352f); } .ds-seg button[aria-checked='true'] { color: var(--fg, #37352f); background: var(--surface, #fff); box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-seg button:focus-visible { outline:2px solid var(--accent, #4f46e5); outline-offset:2px; }" + }, + { + "name": "Sidebar Navigation", + "kind": "nav", + "refersTo": "nav-item", + "description": "Grouped sidebar items on the subtle ground; active item takes the pressed wash.", + "html": "", + "css": ".ds-nav { width:212px; padding:10px; color: var(--sidebar-fg, #5f5e5a); background: var(--surface-subtle, #fafafa); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-nav-label { margin:0; padding:14px 8px 4px; color: var(--fg-muted, #787774); font-size:12px; line-height:16px; } .ds-nav button { display:flex; align-items:center; gap:8px; width:100%; height:30px; margin:1px 0; padding:0 8px; color:inherit; font-size:14px; background:transparent; border:0; border-radius:6px; cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-nav button:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-nav button.active { color: var(--fg, #37352f); font-weight:500; background: var(--pressed, rgb(55 53 47 / 6%)); } .ds-nav button:focus-visible { outline:none; box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "KPI Strip", + "kind": "card", + "refersTo": "kpi-cell", + "description": "One hairline frame of figures divided by internal rules, each label with a help tip.", + "html": "
Service ?
Degraded
Sandbox slots
12 / 16
Reported tokens
4.16M
P95 latency
—
", + "css": ".ds-kpis { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); margin:0; overflow:hidden; border:1px solid var(--line, #e9e9ec); border-radius:8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-kpi { display:flex; flex-direction:column; gap:6px; padding:14px 16px 16px; box-shadow: inset -1px 0 var(--line, #e9e9ec); } .ds-kpi dt { display:flex; align-items:center; gap:4px; color: var(--fg-muted, #787774); font-size:12.5px; line-height:18px; } .ds-q { display:inline-grid; place-items:center; width:13px; height:13px; border:1.2px solid var(--fg-subtle, #9b9a97); border-radius:50%; color: var(--fg-subtle, #9b9a97); font-size:9px; } .ds-kpi dd { display:flex; align-items:center; gap:7px; margin:0; color: var(--fg, #37352f); font-size:24px; font-weight:600; line-height:28px; letter-spacing:-0.02em; font-variant-numeric: tabular-nums; } .ds-tone { width:8px; height:8px; border-radius:50%; } .ds-tone.warn { background: var(--warning, #d97706); }" + }, + { + "name": "Status Dot", + "kind": "custom", + "refersTo": "status-dot", + "description": "A 7px dot plus a plain label; never colour alone, never a pill.", + "html": "Running Needs action Failed Idle", + "css": ".ds-status { display:inline-flex; align-items:center; gap:6px; margin-right:12px; color: var(--fg, #37352f); font:400 12.5px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-status i { width:7px; height:7px; border-radius:50%; background: var(--fg-subtle, #9b9a97); } .ds-status.ok i { background: var(--success, #16a34a); } .ds-status.warn i { background: var(--warning, #d97706); } .ds-status.danger i { background: var(--danger, #dc2626); } .ds-status.idle i { background: var(--status-idle, #b4b4b9); }" + }, + { + "name": "Meter", + "kind": "custom", + "refersTo": "meter", + "description": "Ratio against a limit; series fill until 80%, amber to 95%, red beyond.", + "html": "
Active sandboxes12 / 16
gpu-worker-027 / 8
", + "css": ".ds-meter-row { display:grid; grid-template-columns: 120px 1fr auto; align-items:center; gap:12px; margin:6px 0; color: var(--fg-muted, #787774); font:400 12.5px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-meter-row b { color: var(--fg, #37352f); font-weight:500; font-variant-numeric: tabular-nums; } .ds-meter { position:relative; display:block; height:6px; overflow:hidden; background: var(--meter-track, rgb(55 53 47 / 8%)); border-radius:999px; } .ds-meter > span { position:absolute; inset:0 auto 0 0; background: var(--meter-fill, #2a78d6); border-radius:999px; } .ds-meter.warn > span { background: var(--warning, #d97706); }" + }, + { + "name": "Data Table", + "kind": "custom", + "refersTo": "table-row", + "description": "Hairline-framed table with sticky subtle header, 44px rows, right-aligned tabular numerics.", + "html": "
AgentRequestsError rate
Contract checker571.8%
Code reviewer20—
", + "css": ".ds-table { overflow:auto; border:1px solid var(--line, #e9e9ec); border-radius:8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-table table { width:100%; border-collapse:collapse; font-size:13px; line-height:18px; } .ds-table th { height:34px; padding:0 12px; color: var(--fg-muted, #787774); font-size:12px; font-weight:500; text-align:left; background: var(--surface-subtle, #fafafa); border-bottom:1px solid var(--line, #e9e9ec); } .ds-table td { height:44px; padding:6px 12px; color: var(--fg, #37352f); border-bottom:1px solid var(--line-muted, #efeff1); } .ds-table tr:last-child td { border-bottom:0; } .ds-table tbody tr:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-table td strong { font-weight:500; } .ds-table tbody tr:hover strong { color: var(--accent, #4f46e5); } .ds-table .n { text-align:right; font-variant-numeric: tabular-nums; }" + } + ], + "narrative": { + "northStar": "The Operator's Ledger", + "overview": "The console is an operations back office, not a developer showroom. Every screen reads like a ledger page: one header rule, one strip of figures, then ruled sections of evidence (charts, bar lists, tables) sitting on white. Structure comes from 1px hairlines and whitespace, never from floating cards; colour is spent on data and state, almost never on decoration. The one indigo accent means \"you selected this\" or \"this is the primary action,\" and nothing else.\n\nDensity is deliberately high and calm: 13px body, 44px table rows, 28px controls, tabular figures in every column. The system is bilingual (zh-CN and English) and ships light and dark themes on the same token names; dark swaps values, never structure. It honours reduced motion and treats keyboard focus as a first-class state (2px indigo outline).\n\nThe data contract is part of the look. Core reports only what it observes, so the interface shows absence honestly: an em dash, a gap in a line, the word \"Unavailable\". An explained figure keeps its explanation one click away behind a circled question mark, so the page stays a ledger rather than a leaflet.", + "keyCharacteristics": [ + "Neutral warm-gray ink on white, one indigo accent for selection and primary actions only.", + "Hairline frames divided by internal rules; no nested cards, no decorative shadows.", + "A six-slot categorical palette for data, bound to the entity, not its rank.", + "Tabular numerals everywhere a number can line up.", + "Status is always a dot plus a plain-language label.", + "Explanations live behind \"?\" help tips; errors, warnings and safety notices stay visible." + ], + "rules": [ + { + "name": "The One Voice Rule", + "body": "Indigo is for selection and primary actions only. Data never wears the accent; charts, bar lists and meters draw from `--series-1..6`, `--series-other` and `--meter-fill`.", + "section": "colors" + }, + { + "name": "The Entity Owns Its Colour Rule", + "body": "A categorical colour follows the entity (model, tool, node), never its rank. An entity keeps its slot while visible; only slots of entities that left the view are reused. Anything beyond six series collapses into Series Other.", + "section": "colors" + }, + { + "name": "The Signal Is Not Decoration Rule", + "body": "Green, amber and red appear only when they report a state. A healthy meter stays in the series ramp; it turns amber or red only when the value crosses its threshold.", + "section": "colors" + }, + { + "name": "The Columns Line Up Rule", + "body": "Every figure that can share a column uses tabular numerals (`font-variant-numeric: tabular-nums`): KPI values, numeric table cells (right-aligned), bar-list values, axis ticks, tooltip values, counts.", + "section": "typography" + }, + { + "name": "The Honest Figure Rule", + "body": "Missing data renders as \"—\", a gap in the line, or \"Unavailable\"; never as 0. Compact numbers keep two decimals only when the integer part is a single digit (\"1.04M\"), otherwise one (\"415.7万\"); values under 10,000 print whole. Durations read \"850 ms / 12.4 s / 4m 12s / 3h 5m\".", + "section": "typography" + }, + { + "name": "The Plain Vocabulary Rule", + "body": "zh-CN copy uses one term per concept: 沙箱 (sandbox), 运行时 (runtime), 已上报 (reported), 活跃 (active), 提供方 (provider). Time ranges read \"1 小时 / 6 小时 / 24 小时 / 7 天\" (English \"1h / 6h / 24h / 7d\"), always in the one segmented control style.", + "section": "typography" + }, + { + "name": "The One Page Grammar Rule", + "body": "Every page, new or legacy, uses PageHeader, PageBody and Section. No page invents its own header height, gutter or section rhythm.", + "section": "layout" + }, + { + "name": "The One Frame Rule", + "body": "Charts and KPI figures sit in one hairline frame divided by internal rules (inset 1px lines), never in nested cards. A frame never contains another bordered, shadowed container.", + "section": "elevation" + } + ], + "dos": [ + "Do put every explanation of a figure, section or page behind a circled \"?\" help tip; keep errors, warnings and safety notices (deletion confirmation, uncertain writes, secrets) visible on the page.", + "Do reserve Parsar Indigo for selection, focus and primary actions; draw data from `--series-1..6` and `--series-other`, and meters from `--meter-fill`.", + "Do place charts and KPI figures in one hairline frame (8px) divided by 1px internal rules.", + "Do render missing data as \"—\", a chart gap, or \"Unavailable\".", + "Do show status as a 7px dot plus a plain label.", + "Do use tabular numerals for every aligned figure and right-align numeric columns.", + "Do use the one segmented control for time ranges, labelled \"1 小时 / 6 小时 / 24 小时 / 7 天\".", + "Do keep compact numbers at two decimals only when the integer part is one digit.", + "Do build every page from PageHeader, PageBody and Section with the 24px gutter and 28px section gap." + ], + "donts": [ + "Don't add lines of small explanatory print under headings, KPIs or charts.", + "Don't colour data, bars, lines or meters with the indigo accent.", + "Don't nest cards inside frames or lift sections with shadows; shadows are for raised controls and floating layers only.", + "Don't render an unreported value as 0 or draw a missing interval as a zero line.", + "Don't use coloured status pills or colour-only status.", + "Don't reassign a categorical colour by rank when data re-sorts.", + "Don't add uppercase letter-spaced micro-labels or eyebrow lines above headings; a section is named by its title alone.", + "Don't mix synonyms in zh-CN copy (for example alternating 沙盒 with 沙箱)." + ] + } +} \ No newline at end of file diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md new file mode 100644 index 000000000..bddb0ab5d --- /dev/null +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -0,0 +1,28 @@ +--- +version: 1 +slug: "src-app-tsx" +primary_target: "src/App.tsx" +related_targets: [] +--- + +# Administrator console (operate) + +Scope: the signed-in console shell and every page behind it. Visitor mode: Operate. +Audience: the operator of one Parsar Core deployment. Task: judge health, capacity, +usage and failures; inspect resources; manage nodes, keys and configuration. +Constraints: existing public client and console routes only; browser aggregation is +bounded and labelled; missing data stays visibly missing; Parsar identity retained. + +Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, +Sessions log) · Resources (Agents, Environment templates, Vaults) · Infrastructure +(Nodes) · Settings (API keys, System) · Playground (Session console, Agent builder, +Getting started). + +## Direction contract + +THESIS: The home is the fleet, not a builder: one health strip, the deployment's nodes and sandboxes on the left, evidence for the selected target on the right. Refuses the card-grid dashboard with create shortcuts. +OWN-WORLD: Parsar neutrals on white, one indigo accent for selection and primary actions only, hairline rules instead of cards, tabular numerals, status dots, dense rows, system sans. +STORY: The operator sees in one glance whether Core, nodes and agents are healthy, drills from a node to its sandboxes and failing Sessions, and opens deeper metrics pages. +FIRST VIEWPORT: Page header; full-width KPI strip; fleet list at one third; selected-target metrics and allocations at two thirds; attention table beneath. +FORM: Fleet split-pane, sixth of seven grounded structures; seed e20a0bc1. +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md new file mode 100644 index 000000000..4299c607a --- /dev/null +++ b/apps/web/DESIGN.md @@ -0,0 +1,330 @@ +--- +name: Parsar Core Console +description: The operator's back office for one self-hosted Parsar Core deployment; health, capacity, usage and failures on hairline-ruled white. +colors: + ink: "#37352f" + ink-muted: "#787774" + ink-subtle: "#9b9a97" + sidebar-ink: "#5f5e5a" + surface: "#ffffff" + surface-subtle: "#fafafa" + surface-muted: "#f1f1f0" + line: "#e9e9ec" + line-muted: "#efeff1" + line-strong: "#d6d7dc" + hover: "rgb(55 53 47 / 3%)" + pressed: "rgb(55 53 47 / 6%)" + tile: "rgb(55 53 47 / 7%)" + accent: "#4f46e5" + accent-emphasis: "#4338ca" + accent-fg: "#ffffff" + success: "#16a34a" + warning: "#d97706" + danger: "#dc2626" + status-queued: "#9a9ca4" + status-idle: "#b4b4b9" + series-1: "#2a78d6" + series-2: "#eb6834" + series-3: "#1baf7a" + series-4: "#eda100" + series-5: "#e87ba4" + series-6: "#008300" + series-other: "#a3a3a8" + meter-track: "rgb(55 53 47 / 8%)" +typography: + display: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "24px" + fontWeight: 600 + lineHeight: "28px" + letterSpacing: "-0.02em" + fontFeature: "\"tnum\"" + headline: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "20px" + fontWeight: 600 + lineHeight: "26px" + letterSpacing: "-0.02em" + title: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "15px" + fontWeight: 600 + lineHeight: "22px" + letterSpacing: "-0.01em" + body: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "13px" + fontWeight: 400 + lineHeight: "18px" + label: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "12.5px" + fontWeight: 500 + lineHeight: "18px" + mono: + fontFamily: "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", \"Noto Sans Mono\", monospace" + fontSize: "11.5px" + fontWeight: 400 +rounded: + hairline: "4px" + control: "6px" + control-inner: "5px" + segment: "7px" + frame: "8px" + pill: "999px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + gutter: "24px" + section: "28px" +components: + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.accent-fg}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + typography: "{typography.label}" + button-primary-hover: + backgroundColor: "{colors.accent-emphasis}" + button-outline: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + button-outline-hover: + backgroundColor: "{colors.hover}" + button-secondary: + backgroundColor: "{colors.surface-muted}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + button-ghost: + textColor: "{colors.ink-muted}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + input-field: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "4px 8px" + height: "28px" + segmented-track: + backgroundColor: "{colors.surface-muted}" + rounded: "{rounded.segment}" + padding: "2px" + segmented-option: + textColor: "{colors.ink-muted}" + rounded: "{rounded.control-inner}" + padding: "0 10px" + height: "24px" + segmented-option-active: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + nav-item: + textColor: "{colors.sidebar-ink}" + rounded: "{rounded.control}" + padding: "0 8px" + height: "30px" + nav-item-active: + backgroundColor: "{colors.pressed}" + textColor: "{colors.ink}" + kpi-cell: + textColor: "{colors.ink}" + typography: "{typography.display}" + padding: "14px 16px 16px" + table-header: + backgroundColor: "{colors.surface-subtle}" + textColor: "{colors.ink-muted}" + padding: "0 12px" + height: "34px" + table-row: + textColor: "{colors.ink}" + typography: "{typography.body}" + padding: "6px 12px" + height: "44px" + help-tip: + textColor: "{colors.ink-subtle}" + rounded: "{rounded.pill}" + size: "18px" + meter: + backgroundColor: "{colors.meter-track}" + rounded: "{rounded.pill}" + height: "6px" +--- + +# Design System: Parsar Core Console + +## Overview + +**Creative North Star: "The Operator's Ledger"** + +The console is an operations back office, not a developer showroom. Every screen reads like a ledger page: one header rule, one strip of figures, then ruled sections of evidence (charts, bar lists, tables) sitting on white. Structure comes from 1px hairlines and whitespace, never from floating cards; colour is spent on data and state, almost never on decoration. The one indigo accent means "you selected this" or "this is the primary action," and nothing else. + +Density is deliberately high and calm: 13px body, 44px table rows, 28px controls, tabular figures in every column. The system is bilingual (zh-CN and English) and ships light and dark themes on the same token names; dark swaps values, never structure. It honours reduced motion and treats keyboard focus as a first-class state (2px indigo outline). + +The data contract is part of the look. Core reports only what it observes, so the interface shows absence honestly: an em dash, a gap in a line, the word "Unavailable". An explained figure keeps its explanation one click away behind a circled question mark, so the page stays a ledger rather than a leaflet. + +**Key Characteristics:** +- Neutral warm-gray ink on white, one indigo accent for selection and primary actions only. +- Hairline frames divided by internal rules; no nested cards, no decorative shadows. +- A six-slot categorical palette for data, bound to the entity, not its rank. +- Tabular numerals everywhere a number can line up. +- Status is always a dot plus a plain-language label. +- Explanations live behind "?" help tips; errors, warnings and safety notices stay visible. + +## Colors + +A restrained neutral ledger with one indigo voice, three semantic signal colours, and a separate categorical palette that belongs to data alone. + +### Primary +- **Parsar Indigo** (accent): keyboard focus outlines and rings, primary buttons, link hover on table names and text actions, text selection wash. Deepens to **Pressed Indigo** (accent-emphasis) on primary hover. It is the brand colour shared with the public Parsar landing. + +### Neutral +- **Ledger Ink** (ink): all primary text, figures, table cells, headings. +- **Graphite** (ink-muted): secondary text, column headers, KPI labels, axis ticks, inactive controls. +- **Pencil** (ink-subtle): help-tip glyphs, crosshairs, untoned KPI dots; the quietest legible mark. +- **Sidebar Ink** (sidebar-ink): navigation text on the sidebar's subtle ground. +- **Paper** (surface): page ground, popovers, the active segment's raised face. +- **Margin Gray** (surface-subtle): sidebar, table header band, the fleet list pane, coverage notes. +- **Well Gray** (surface-muted): segmented-control track, secondary buttons. +- **Hairline** (line): every frame border and internal divider; chart gridlines. +- **Faint Rule** (line-muted): row dividers inside tables. +- **Firm Rule** (line-strong): control borders (inputs, outline buttons, active filter tab, selected fleet target), dashed empty-state border. +- **Hover / Pressed / Tile washes**: translucent ink at 3% / 6% / 7% for hover, active nav item, and count pills. + +### Signal +- **Healthy Green** (success), **Caution Amber** (warning), **Fault Red** (danger): used only for status dots, KPI tone dots, meter fills past thresholds (warning at 80%, danger at 95%), error text, and error notices. **Queued Gray** (status-queued) is the pending tone dot in the KPI strip; **Idle Gray** (status-idle) marks idle and neutral status dots. + +### Data (categorical) +- **Series 1-6** (series-1 Cobalt, series-2 Persimmon, series-3 Jade, series-4 Saffron, series-5 Rose, series-6 Forest) and **Series Other** (series-other): chart lines, stacked bars, bar-list fills, legend keys. Series 1 doubles as the running-status colour and the default meter fill (`--meter-fill`, `--status-running`). Dark theme re-tunes each slot under the same name. +- **Meter Track** (meter-track): the empty rail under meters and bar-list fills. + +### Named Rules +**The One Voice Rule.** Indigo is for selection and primary actions only. Data never wears the accent; charts, bar lists and meters draw from `--series-1..6`, `--series-other` and `--meter-fill`. + +**The Entity Owns Its Colour Rule.** A categorical colour follows the entity (model, tool, node), never its rank. An entity keeps its slot while visible; only slots of entities that left the view are reused. Anything beyond six series collapses into Series Other. + +**The Signal Is Not Decoration Rule.** Green, amber and red appear only when they report a state. A healthy meter stays in the series ramp; it turns amber or red only when the value crosses its threshold. + +## Typography + +**Display Font:** system UI sans (-apple-system, Segoe UI, with PingFang SC / Microsoft YaHei / Noto Sans SC for Chinese) +**Body Font:** the same system stack +**Label/Mono Font:** ui-monospace / SF Mono / Menlo for identifiers and code + +**Character:** One quiet system sans in several weights, sized for dense reading; hierarchy comes from weight and a tight scale, not from a second typeface. Mono appears only for machine identifiers. + +### Hierarchy +- **Display** (600, 24px, 28px, -0.02em, tabular): KPI figures in the strip. The largest type on any console page. +- **Headline** (600, 20px, 26px, -0.02em): the page title in the 64px page header; one per page. +- **Title** (600, 15px, 22px, -0.01em): section headings. Chart captions and empty-state titles step down to 600 at 13-14px. +- **Body** (400, 13px, 18px): table cells, controls, form fields. The document base is 14px/20px for longer prose (help popovers run 12.5px/19px). +- **Label** (500, 12.5px, 18px): KPI labels (400), status labels, text actions, segmented options; column headers 500 at 12px; axis ticks 11px. +- **Mono** (400, 11.5px): IDs and code inside section headers and tables, in Graphite. + +### Named Rules +**The Columns Line Up Rule.** Every figure that can share a column uses tabular numerals (`font-variant-numeric: tabular-nums`): KPI values, numeric table cells (right-aligned), bar-list values, axis ticks, tooltip values, counts. + +**The Honest Figure Rule.** Missing data renders as "—", a gap in the line, or "Unavailable"; never as 0. Compact numbers keep two decimals only when the integer part is a single digit ("1.04M"), otherwise one ("415.7万"); values under 10,000 print whole. Durations read "850 ms / 12.4 s / 4m 12s / 3h 5m". + +**The Plain Vocabulary Rule.** zh-CN copy uses one term per concept: 沙箱 (sandbox), 运行时 (runtime), 已上报 (reported), 活跃 (active), 提供方 (provider). Time ranges read "1 小时 / 6 小时 / 24 小时 / 7 天" (English "1h / 6h / 24h / 7d"), always in the one segmented control style. + +## Layout + +A fixed sidebar (232px, Margin Gray, hairline right edge) beside a full-height page column. The desktop minimum is 960px. Every page uses the same frame: a 64px header (title, optional help tip, actions on the right: range control then Refresh) over a hairline rule, then a scrolling body padded `20px 24px 48px` with sections stacked 28px apart. Inside a section the heading row sits 12px above its content. + +The recurring shapes in the body are the full-width KPI strip (auto-fit columns, min 158px; three per row below 1180px), paired chart frames (two equal columns, or 2:3 when a bar list sits beside its trend; single column below 1180px), and full-width table frames. The Overview uses a fleet split-pane inside one frame: target list at roughly one third on the subtle ground, selected-target evidence at two thirds, an attention table spanning beneath. + +Spacing follows a 4px base: 4, 8, 12, 16, 24 (page gutter), 28 (section gap). Controls are 28px tall, segmented options 24px, table rows 44px (32px compact), headers 34px. + +**The One Page Grammar Rule.** Every page, new or legacy, uses PageHeader, PageBody and Section. No page invents its own header height, gutter or section rhythm. + +## Elevation & Depth + +The system is flat. Depth is conveyed by tone (subtle and muted grounds) and hairlines, not by shadow. Only two shadows exist, and each has a job. + +### Shadow Vocabulary +- **Control lift** (`box-shadow: 0 1px 2px rgb(0 0 0 / 6%)`): raised controls only: primary and outline buttons, inputs and selects, the active segment, the active filter tab, the selected fleet target. +- **Floating** (`box-shadow: 0 1px 2px rgb(24 24 27 / 4%), 0 8px 24px -12px rgb(24 24 27 / 18%)`): things that float above the page: help-tip popovers, chart tooltips, menus and dialogs. + +### Named Rules +**The One Frame Rule.** Charts and KPI figures sit in one hairline frame divided by internal rules (inset 1px lines), never in nested cards. A frame never contains another bordered, shadowed container. + +## Shapes + +Soft but disciplined corners: 8px on frames (KPI strip, chart grid, tables, empty states, notices, popovers), 6px on controls, 7px on the segmented track and fleet targets, 5px on inner segments, 4px on small inline marks and the chart plot, full pills for meters, bar tracks and count badges, circles for status dots (7px) and KPI tone dots (8px). Legend keys are 9px squares with 2px corners, or 12x2px strokes for line series. Borders are always 1px; the empty state alone uses a dashed Firm Rule. + +## Components + +### Buttons +Compact and quiet; the primary button is the only filled colour on a page header. +- **Shape:** gently rounded (6px), 28px tall, 0 10px padding, 13px/500 label, optional 14px Lucide icon at 1.5 stroke. +- **Primary:** Parsar Indigo fill, white text, control lift. Hover deepens to Pressed Indigo. +- **Outline:** Paper face, Firm Rule border, control lift; hover takes the ink wash. Refresh is always an outline button whose last-updated time lives in its tooltip, not on the page. +- **Secondary / Ghost:** Well Gray fill, or transparent with Graphite text; both darken on hover. +- **Focus / Press:** focus draws a 1px indigo border plus 1px indigo ring; press scales to 0.97 on the spring curve. +- **Text action:** borderless Graphite 12.5px/500 link with a trailing arrow, turning indigo on hover ("Sandbox metrics →"). + +### Segmented control +The single style for ranges and mode switches. A Well Gray track (2px padding, 7px corners) holds 24px options in Graphite; the chosen option rises to Paper with Ledger Ink text and control lift. It is a radiogroup with arrow-key movement. + +### Filter tabs and selects +Filter tabs are 28px transparent buttons that gain Paper, a Firm Rule border and control lift when active; counts beside them are tabular Graphite. Selects are 28px Paper fields with a Firm Rule border and a drawn chevron; focus swaps the border to indigo with a 1px ring. + +### Inputs / Fields +- **Style:** Paper ground, Firm Rule 1px border, 6px corners, 28px min height, 13px text, control lift. +- **Focus:** indigo border plus 1px indigo ring; no glow. +- **Error:** Fault Red text beneath, kept visible. + +### Navigation +Sidebar groups (Monitor, Resources, Infrastructure, Settings, Playground) with 12px Graphite group labels; items are 30px rows with a 16px outline icon and Sidebar Ink text. Hover takes the ink wash; the active item takes the pressed wash with Ledger Ink at 500. The Playground group sits below a hairline. The footer holds the Core connection switcher (status icon plus "API ready"), sign-out, and the language/theme menu. + +### KPI strip +One hairline frame of equal cells separated by inset rules. Each cell: a 12.5px Graphite label with an optional help tip, then the Display figure, optionally led by an 8px tone dot (ok, warning, danger, pending). Figures ellipsize rather than wrap. + +### Help tip +An 18px circular button holding a 13px circled "?" in Pencil; hover or open takes Ledger Ink on the ink wash. It opens on hover, focus or click (click pins it), closes on Escape, scroll or resize, and renders a 12.5px popover (Paper, Hairline, 8px, floating shadow, max 288px) in a portal. The text also exists in a visually hidden element for assistive technology. + +### Status dot +A 7px circle plus a plain label at 12.5px (ok green, warning amber, danger red, pending Series 1, neutral Idle Gray). Never a coloured pill, never colour alone. + +### Meter and bar list +A 6px pill rail in Meter Track with a fill in `--meter-fill`; the fill turns amber at 80% and red at 95% of its limit, and a nonzero ratio shows at least 3% width. Unknown ratios draw an empty rail. Bar lists use the same idea at 8px with categorical fills: label (with legend key) at ~42%, the track, then a right-aligned tabular value. + +### Charts +Time-series charts live in chart panels (caption 13px/600, legend, plot) inside one chart-grid frame. Lines are 2px round-joined with a surface-ringed end dot; gridlines are crisp Hairlines with 11px tabular ticks; hovering draws a Pencil crosshair, a hover-wash band and a floating tooltip. Missing buckets are gaps, not zeros. Every chart has a table toggle (26px icon button, top right) that reveals the numbers in a 220px scrolling table. + +### Tables +A hairline frame with a sticky 34px Margin Gray header in Graphite 12px/500, 44px rows divided by Faint Rules, hover wash, right-aligned tabular numerics, a 500-weight primary cell that turns indigo on hover when it links, and inline Fault Red error lines under the row title. + +### Notices and empty states +Coverage notes (Margin Gray, Hairline, 8px, 12.5px Graphite) state bounded aggregation; the error variant tints toward Fault Red. Partial-data chips are amber-tinted pills with a help tip. Empty states are dashed-border frames with a 20px outline icon, a 14px/600 title, one line of description and an optional action. + +## Do's and Don'ts + +### Do: +- **Do** put every explanation of a figure, section or page behind a circled "?" help tip; keep errors, warnings and safety notices (deletion confirmation, uncertain writes, secrets) visible on the page. +- **Do** reserve Parsar Indigo for selection, focus and primary actions; draw data from `--series-1..6` and `--series-other`, and meters from `--meter-fill`. +- **Do** place charts and KPI figures in one hairline frame (8px) divided by 1px internal rules. +- **Do** render missing data as "—", a chart gap, or "Unavailable". +- **Do** show status as a 7px dot plus a plain label. +- **Do** use tabular numerals for every aligned figure and right-align numeric columns. +- **Do** use the one segmented control for time ranges, labelled "1 小时 / 6 小时 / 24 小时 / 7 天". +- **Do** keep compact numbers at two decimals only when the integer part is one digit. +- **Do** build every page from PageHeader, PageBody and Section with the 24px gutter and 28px section gap. + +### Don't: +- **Don't** add lines of small explanatory print under headings, KPIs or charts. +- **Don't** colour data, bars, lines or meters with the indigo accent. +- **Don't** nest cards inside frames or lift sections with shadows; shadows are for raised controls and floating layers only. +- **Don't** render an unreported value as 0 or draw a missing interval as a zero line. +- **Don't** use coloured status pills or colour-only status. +- **Don't** reassign a categorical colour by rank when data re-sorts. +- **Don't** add uppercase letter-spaced micro-labels or eyebrow lines above headings; a section is named by its title alone. +- **Don't** mix synonyms in zh-CN copy (for example alternating 沙盒 with 沙箱). diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md new file mode 100644 index 000000000..25859261e --- /dev/null +++ b/apps/web/PRODUCT.md @@ -0,0 +1,112 @@ +# Product + + + +## Platform + +web + +## Users + +The primary user is the operator who deployed Parsar Core: a self-hosted, +OpenAI Agents API compatible execution service. After signing in to the paired +console they need to answer four questions quickly: is the service healthy, is +there enough sandbox capacity, how much is it being used, and where is work +failing. They also enroll execution nodes, issue and revoke Agent API keys and +keep Skills, templates and Vaults in order. + +API callers (application developers) use the Agents API from their own code, not +this console. The console is the operator's back office, comparable to what the +provider of a hosted API runs internally: it manages the service, it does not +build things on the callers' behalf. Parsar Core is open source and a small team +deploys one instance for itself, so the console serves one deployment and one +project; there is no tenant or customer concept. + +## Product Purpose + +An operations back office for one Parsar Core deployment, comparable in role to +the provider-side console behind a hosted API rather than the developer +platform in front of it. Success: the operator lands on health, capacity, usage +and failures; inspects Agents, Sessions and Turns as resources; manages nodes, +keys and configuration; and reaches the debugging playground only when needed. + +## Positioning + +The console runs beside the operator's own Core, with execution, files and +credentials on infrastructure they control. It shows only evidence Core +actually reports and never invents readiness, traffic or zero values for +missing data. + +## Operating Context + +- Paired console (`services/core-console`): single local administrator account, + forwards `/v1` with the console's project credential and allowlisted + `/core/v1/sandbox` admin routes with its server-side admin token. +- Web-only consoles may connect to an existing Core without sandbox admin or + key management capabilities (`/console/config`). +- Chinese and English UI; light and dark themes; reduced-motion honored. + +## Information Architecture + +Confirmed with the user on 2026-09-24 (top-level groups are theirs; the pages +inside each group were left to design): + +- Monitor: Overview (health, capacity, Session status, attention), Agent + metrics, Sandbox metrics, Session log. There is no System page and no + platform-facts strip; maintenance shows as a status beside the deployment. +- Resources: the objects callers use by ID in this single-project deployment — + Agents (list, create, edit), Skills, Environment templates, Vaults. +- Platform: Nodes, API keys (still called API keys; one key per caller name is + not enforced by Core). +- Playground: API workbench (fixed-format requests: form on the left, the exact + curl and JSON body on the right, copy or send, response below; also the + look-up-by-ID tool), Session console, and the first-run introduction. + Agents and Sessions created in the workbench carry + `metadata.created_by = console-playground`. +- Terminology: API terms stay in English in the Chinese UI (Agent, Session, + Turn, Skill, Vault, API key). +- First-run introduction: one continuous animation — the request path (your + code → Parsar Core → machines and sandboxes → Agent) is the progress + indicator; each step lights the next segment. + +## Capabilities and Constraints + +- Data comes only from the existing public client (`packages/agents-client`) + and console routes; Core has no aggregate or statistics endpoint. Project + totals are aggregated in the browser from complete paginated lists (bounded + at about 10,000 items). Deployment-wide data is limited to sandbox nodes, + allocations and deployment state. +- Runtime CPU and memory exist only for Core-managed hosted sandboxes; runtime + history is per Session and bounded. +- Metrics that need new Core endpoints are recorded as backend requirements for + discussion, not simulated in the browser. +- Preserve existing workflow safety: confirmed deletion, no automatic retry of + uncertain writes, connection-change discards, secrets never in browser storage. + +## Brand Commitments + +- Product name: Parsar Core. Parsar mark assets in `apps/web/public/`. +- Keep the existing Parsar visual identity shared with the public landing + (`site/`): neutral grays, violet/indigo accent, system UI fonts. Unify the + console within it rather than replacing it. + +## Evidence on Hand + +- Fixture Core for development and acceptance: `apps/web/e2e/fixture-core.mjs`. +- No customer data, benchmarks or usage claims exist; do not fabricate them. + +## Product Principles + +1. Operations first: health, capacity, usage and failures lead; building and + chatting are debugging tools. +2. Report evidence, not assumptions: missing data stays visibly missing. +3. One page grammar everywhere: the same header, actions, tables, metrics and + states on every screen. +4. Resources are inspected before they are edited. +5. Deployment-level truth (nodes, keys, configuration) is distinct from + project-level resources. + +## Accessibility & Inclusion + +Keyboard navigation with visible focus, reduced-motion support, and bilingual +Chinese/English copy through the existing i18n modules. diff --git a/apps/web/e2e/admin-console.spec.ts b/apps/web/e2e/admin-console.spec.ts new file mode 100644 index 000000000..f27345498 --- /dev/null +++ b/apps/web/e2e/admin-console.spec.ts @@ -0,0 +1,387 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +// Acceptance coverage for the administrator console information architecture: +// grouped navigation, the operations Overview, metrics, the resource tables and +// the Playground hand-offs, legacy hashes and the Chinese locale. + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +/** Fixture Session, Turn and Agent timestamps are anchored to this instant. */ +const fixtureBaseline = 1_789_438_800; + +const expectedGroups = [ + { name: "Monitor", views: ["Overview", "Agent metrics", "Sandbox metrics", "Session log"] }, + { name: "Resources", views: ["Agents", "Environment templates", "Vaults"] }, + { name: "Infrastructure", views: ["Nodes"] }, + { name: "Settings", views: ["API keys", "System"] }, + // "Getting started" joins this group only when the console has an account introduction. + { name: "Playground", views: ["Session console", "Agent builder"] }, +] as const; + +const expectedChineseGroups = [ + { name: "监控", views: ["总览", "智能体监控", "沙箱监控", "会话日志"] }, + { name: "资源", views: ["智能体", "环境模板", "凭据库"] }, + { name: "基础设施", views: ["节点"] }, + { name: "设置", views: ["API 密钥", "系统配置"] }, + { name: "调试台", views: ["会话调试", "智能体构建器"] }, +] as const; + +async function resetFixture(request: APIRequestContext) { + expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true); +} + +function consoleNav(page: Page, name = "Console navigation") { + return page.getByRole("navigation", { name, exact: true }); +} + +function navButton(page: Page, name: string) { + return consoleNav(page).getByRole("button", { name, exact: true }); +} + +async function openView(page: Page, name: string) { + await navButton(page, name).click(); + await expect(navButton(page, name)).toHaveAttribute("aria-current", "page"); +} + +/** Group label paragraphs and button names in DOM order, one entry per sidebar group. */ +async function sidebarStructure(page: Page, navigationName = "Console navigation") { + return consoleNav(page, navigationName).getByRole("group").evaluateAll((groups) => groups.map((group) => ({ + name: group.querySelector(".nav-label")?.textContent?.trim() ?? "", + views: [...group.querySelectorAll("button")].map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim() ?? ""), + }))); +} + +function overviewKpi(page: Page, label: string) { + return page.getByLabel("Deployment health", { exact: true }).locator(".kpi") + .filter({ has: page.locator("dt > span", { hasText: new RegExp(`^${label}$`) }) }) + .locator(".kpi-value"); +} + +function sessionLog(page: Page) { + return page.getByRole("region", { name: "Session log", exact: true }); +} + +function statusFilter(page: Page, label: string) { + return sessionLog(page).getByRole("group", { name: "Status", exact: true }) + .getByRole("button", { name: new RegExp(`^${label}\\s*\\d*$`) }); +} + +/** Collects uncaught page errors so each test can prove none occurred. */ +function collectPageErrors(page: Page) { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + return errors; +} + +async function createSecondAgentSession(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { + headers: { "OpenAI-Beta": "agents=v1", "Idempotency-Key": "admin-console-second-session" }, + data: { + agent_id: "agent_b", + environment: { type: "none" }, + metadata: { fixture: "admin-console" }, + input: "Summarize the deployment.", + stream: false, + vault_ids: [], + }, + }); + expect(response.status()).toBe(201); +} + +test.beforeEach(async ({ request }) => { + await resetFixture(request); +}); + +test("groups the sidebar by task and marks exactly the current page", async ({ page }) => { + const errors = collectPageErrors(page); + await page.goto("/"); + await expect(page.locator(".app-sidebar .brand-name")).toHaveText("Parsar Core"); + await expect(page.locator(".app-sidebar .brand-product")).toHaveText("Console"); + await expect(navButton(page, "Overview")).toBeVisible(); + expect(await sidebarStructure(page)).toEqual(expectedGroups); + + const current = consoleNav(page).locator('button[aria-current="page"]'); + await expect(current).toHaveCount(1); + await expect(navButton(page, "Overview")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "Overview", exact: true })).toBeVisible(); + + const pages: Array<{ view: string; hash: string; heading: string | RegExp }> = [ + { view: "Agent metrics", hash: "#agent-metrics", heading: "Agent metrics" }, + { view: "Sandbox metrics", hash: "#sandbox-metrics", heading: "Sandbox metrics" }, + { view: "Session log", hash: "#sessions", heading: "Session log" }, + { view: "Agents", hash: "#agents", heading: "Agents" }, + { view: "Environment templates", hash: "#templates", heading: "Environment Templates" }, + { view: "Vaults", hash: "#vaults", heading: "Vaults" }, + { view: "Nodes", hash: "#nodes", heading: "Nodes" }, + { view: "API keys", hash: "#api-keys", heading: "API keys" }, + { view: "System", hash: "#system", heading: "System" }, + { view: "Session console", hash: "#playground", heading: /^Session console\b/ }, + { view: "Agent builder", hash: "#builder", heading: "Agent builder" }, + ]; + for (const target of pages) { + await openView(page, target.view); + await expect(current).toHaveCount(1); + await expect(page).toHaveURL(new RegExp(`${target.hash}$`)); + await expect(page.locator("main").getByRole("heading", { level: 1, name: target.heading, exact: typeof target.heading === "string" })).toBeVisible(); + } + + await openView(page, "Overview"); + await expect(page).toHaveURL(/\/$/); + expect(errors).toEqual([]); +}); + +test("legacy Dashboard and Sandbox hashes open Overview and Nodes", async ({ page }) => { + await page.goto("/#dashboard"); + await expect(navButton(page, "Overview")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "Overview", exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/$/); + + await page.goto("/#sandbox"); + await expect(navButton(page, "Nodes")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "Nodes", exact: true })).toBeVisible(); + await expect(page).toHaveURL(/#nodes$/); + + await page.goto("/#not-a-console-page"); + await expect(navButton(page, "Overview")).toHaveAttribute("aria-current", "page"); +}); + +test("Overview shows the KPI strip and fleet, and a selected node lists its allocations", async ({ page }) => { + const errors = collectPageErrors(page); + await page.route("**/console/config", (route) => route.fulfill({ json: { sandbox_admin: true, node_installer: false } })); + await page.goto("/"); + + const overview = page.locator(".overview-page"); + const kpis = page.getByLabel("Deployment health", { exact: true }); + await expect(kpis.locator(".kpi dt > span:first-child")).toHaveText([ + "Service", + "Nodes online", + "Sandbox slots", + "Running Sessions", + "Needs attention", + "Tokens reported", + ]); + await expect(overviewKpi(page, "Nodes online")).toHaveText("1 / 2"); + await expect(overviewKpi(page, "Sandbox slots")).toHaveText("1 / 4"); + // One enrolled node is offline, so the deployment cannot report healthy. + await expect(overviewKpi(page, "Service")).toHaveText("Degraded"); + await expect(overviewKpi(page, "Running Sessions")).toHaveText("0"); + await expect(overviewKpi(page, "Needs attention")).toHaveText("0"); + + const fleet = overview.getByRole("list", { name: "Fleet", exact: true }); + const deployment = fleet.getByRole("button", { name: /Core deployment/ }); + const coreServer = fleet.getByRole("button", { name: /Core server/ }); + const offlineHost = fleet.getByRole("button", { name: /Offline host/ }); + await expect(fleet.getByRole("listitem")).toHaveCount(3); + await expect(deployment).toHaveAttribute("aria-pressed", "true"); + await expect(coreServer).toHaveAttribute("aria-pressed", "false"); + await expect(offlineHost.getByRole("img", { name: "Offline" })).toBeVisible(); + + await coreServer.click(); + await expect(coreServer).toHaveAttribute("aria-pressed", "true"); + await expect(deployment).toHaveAttribute("aria-pressed", "false"); + const detail = overview.locator(".fleet-detail"); + await expect(detail.getByRole("heading", { level: 2, name: "Core server", exact: true })).toBeVisible(); + await expect(detail.getByRole("heading", { name: "Allocations (1)", exact: true })).toBeVisible(); + const allocations = detail.getByRole("table"); + await expect(allocations.getByRole("row")).toHaveCount(2); + await expect(allocations).toContainText("Active"); + await expect(allocations).toContainText("Running"); + + await offlineHost.click(); + await expect(detail.getByRole("heading", { level: 2, name: "Offline host", exact: true })).toBeVisible(); + await expect(detail.getByRole("heading", { name: "Allocations (0)", exact: true })).toBeVisible(); + await expect(detail).toContainText("No sandboxes on this node."); + await expect(detail).toContainText("Host metrics are unavailable while the heartbeat is stale."); + + // An allocation that belongs to a loaded Session opens it in the Session console. + await coreServer.click(); + await allocations.getByRole("button", { name: "Lifecycle Agent", exact: true }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".session-row.active")).toContainText("Lifecycle Agent"); + expect(errors).toEqual([]); +}); + +test("Overview lists failed Sessions under Needs attention and opens them", async ({ page, request }) => { + await createSecondAgentSession(request); + expect((await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status: "failed" } })).ok()).toBe(true); + await page.goto("/"); + + await expect(overviewKpi(page, "Needs attention")).toHaveText("1"); + const attention = page.getByRole("region", { name: "Needs attention", exact: true }); + const rows = attention.getByRole("table").getByRole("row"); + await expect(rows).toHaveCount(2); + await expect(rows.nth(1)).toContainText("Lifecycle Agent"); + await expect(rows.nth(1)).toContainText("Failed"); + await expect(rows.nth(1)).toContainText("The execution could not complete."); + await expect(attention).not.toContainText("Second Agent"); + + await attention.getByRole("button", { name: "Session log", exact: true }).click(); + await expect(navButton(page, "Session log")).toHaveAttribute("aria-current", "page"); + await openView(page, "Overview"); + await rows.nth(1).getByRole("button", { name: "Lifecycle Agent", exact: true }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".session-row.active")).toContainText("Lifecycle Agent"); +}); + +test("Agent metrics reads the fixture Session and shows totals or an explicit empty range", async ({ page }) => { + const errors = collectPageErrors(page); + // Pin the browser clock beside the fixture's Turns so the shorter ranges include them. + await page.clock.setFixedTime(new Date((fixtureBaseline + 120) * 1_000)); + await page.goto("/#agent-metrics"); + + const metrics = page.locator(".metrics-page"); + await expect(metrics.getByRole("heading", { level: 1, name: "Agent metrics", exact: true })).toBeVisible(); + const ranges = metrics.getByRole("radiogroup", { name: "Time range", exact: true }); + await expect(ranges.getByRole("radio")).toHaveText(["1 hour", "6 hours", "24 hours", "7 days"]); + await expect(ranges.getByRole("radio", { name: "24 hours" })).toHaveAttribute("aria-checked", "true"); + + const totals = metrics.getByLabel("Agent run totals", { exact: true }); + const empty = metrics.locator(".console-empty").filter({ hasText: "No Agent runs in this range" }); + for (const range of ["1 hour", "6 hours", "24 hours", "7 days"]) { + await ranges.getByRole("radio", { name: range, exact: true }).click(); + await expect(ranges.getByRole("radio", { name: range, exact: true })).toHaveAttribute("aria-checked", "true"); + await expect(metrics.getByRole("status").filter({ hasText: "Reading recent Turns…" })).toHaveCount(0, { timeout: 15_000 }); + await expect(totals.or(empty)).toBeVisible(); + if (await totals.isVisible()) { + await expect(totals.locator(".kpi dt > span").first()).toHaveText("Requests"); + await expect(totals.locator(".kpi-value").first()).toHaveText(/^\d[\d,]*$/); + } else { + await expect(empty).toContainText(`No Turn was created in the last ${range}`); + } + await expect(metrics).not.toContainText("could not be loaded"); + await expect(metrics).not.toContainText("could not be read"); + } + expect(errors).toEqual([]); +}); + +test("Session log filters by status and opens a row in the Session console", async ({ page, request }) => { + const errors = collectPageErrors(page); + await createSecondAgentSession(request); + expect((await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status: "failed" } })).ok()).toBe(true); + await page.goto("/"); + await openView(page, "Session log"); + + const table = sessionLog(page).getByRole("table"); + await expect(statusFilter(page, "All")).toHaveAttribute("aria-pressed", "true"); + await expect(statusFilter(page, "All").locator(".filter-count")).toHaveText("2"); + await expect(statusFilter(page, "Failed").locator(".filter-count")).toHaveText("1"); + await expect(table.getByRole("row")).toHaveCount(3); + + await statusFilter(page, "Failed").click(); + await expect(statusFilter(page, "Failed")).toHaveAttribute("aria-pressed", "true"); + await expect(statusFilter(page, "All")).toHaveAttribute("aria-pressed", "false"); + await expect(table.getByRole("row")).toHaveCount(2); + const failedRow = table.getByRole("row").filter({ hasText: "Lifecycle Agent" }); + await expect(failedRow).toContainText("Failed"); + await expect(failedRow.getByRole("button", { name: "Error", exact: true })).toBeVisible(); + await expect(failedRow).toContainText("The execution could not complete."); + await expect(table).not.toContainText("Second Agent"); + await expect(sessionLog(page)).toContainText("Showing 1 of 1"); + + await statusFilter(page, "Running").click(); + await expect(table.getByRole("row")).toHaveCount(2); + await expect(table).toContainText("Second Agent"); + await expect(table).not.toContainText("Lifecycle Agent"); + + await statusFilter(page, "Idle").click(); + await expect(table).toHaveCount(0); + await expect(sessionLog(page)).toContainText("No matching Sessions"); + + await statusFilter(page, "All").click(); + await sessionLog(page).getByRole("searchbox", { name: "Search by ID, Agent, model or error" }).fill("could not complete"); + await expect(table.getByRole("row")).toHaveCount(2); + await expect(table).toContainText("Lifecycle Agent"); + + // The newest Session is selected by default, so opening the failed one proves the hand-off. + // Click a plain cell: the row itself is the target, not only its name button. + await failedRow.getByRole("cell", { name: "No environment", exact: true }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "Session console" })).toBeVisible(); + await expect(page.locator(".session-row.active")).toHaveCount(1); + await expect(page.locator(".session-row.active")).toContainText("Lifecycle Agent"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await openView(page, "Session log"); + await sessionLog(page).getByRole("button", { name: "Second Agent", exact: true }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".session-row.active")).toContainText("Second Agent"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + expect(errors).toEqual([]); +}); + +test("Agents table is read-only and Edit opens that Agent in the Agent builder", async ({ page, request }) => { + await page.goto("/"); + await openView(page, "Agents"); + const agents = page.getByRole("region", { name: "Agents", exact: true }); + const table = agents.getByRole("table"); + await expect(table.getByRole("row")).toHaveCount(4); + await expect(agents).toContainText("3 of 3 Agents"); + await expect(agents.getByRole("textbox")).toHaveCount(0); + await expect(agents.getByRole("button", { name: /^(Delete|Save)/ })).toHaveCount(0); + + const lifecycleRow = table.getByRole("row").filter({ has: page.getByRole("rowheader", { name: /Lifecycle Agent/ }) }); + await expect(lifecycleRow.getByRole("rowheader")).toHaveAttribute("title", "agent_a"); + await expect(lifecycleRow.getByRole("button", { name: "Sessions", exact: true })).toBeEnabled(); + + const secondRow = table.getByRole("row").filter({ has: page.getByRole("rowheader", { name: "Second Agent", exact: true }) }); + await expect(secondRow).toContainText("fixture/model-b"); + await expect(secondRow.getByRole("button", { name: "Sessions", exact: true })).toBeDisabled(); + const postsBefore = (await (await request.get(`${fixtureBaseUrl}/__fixture/requests`)).json() as Array<{ method: string }>) + .filter((entry) => entry.method !== "GET").length; + await secondRow.getByRole("button", { name: "Edit", exact: true }).click(); + + await expect(navButton(page, "Agent builder")).toHaveAttribute("aria-current", "page"); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); + await expect(setup.getByRole("region", { name: "Saved definition", exact: true })).toContainText("agent_b"); + await expect(page.getByLabel("Name")).toHaveValue("Second Agent"); + // Opening the editor reads the latest definition but never writes. + const writes = (await (await request.get(`${fixtureBaseUrl}/__fixture/requests`)).json() as Array<{ method: string }>) + .filter((entry) => entry.method !== "GET").length; + expect(writes).toBe(postsBefore); + + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); + + // "New in builder" hands one create request to the Agent builder; it is consumed once. + await openView(page, "Agents"); + await agents.getByRole("button", { name: "New in builder", exact: true }).click(); + await expect(navButton(page, "Agent builder")).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); + await expect(page.getByLabel("Name")).toHaveValue(""); + await openView(page, "Agents"); + await openView(page, "Agent builder"); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); + const writesAfterCreateRequest = (await (await request.get(`${fixtureBaseUrl}/__fixture/requests`)).json() as Array<{ method: string }>) + .filter((entry) => entry.method !== "GET").length; + expect(writesAfterCreateRequest).toBe(postsBefore); + + await openView(page, "Agents"); + await lifecycleRow.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(navButton(page, "Session log")).toHaveAttribute("aria-current", "page"); + await expect(sessionLog(page).getByRole("combobox").first()).toHaveValue("agent_a"); + await expect(sessionLog(page).getByRole("table")).toContainText("Lifecycle Agent"); +}); + +test("renders the grouped console in Chinese when the saved language is zh-CN", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("agents-core-web.language", "zh-CN")); + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await expect(page.locator(".app-sidebar .brand-product")).toHaveText("控制台"); + await expect(consoleNav(page)).toHaveCount(0); + const navigation = consoleNav(page, "控制台导航"); + await expect(navigation).toBeVisible(); + expect(await sidebarStructure(page, "控制台导航")).toEqual(expectedChineseGroups); + await expect(navigation.getByRole("button", { name: "总览", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "总览", exact: true })).toBeVisible(); + await expect(page.getByLabel("部署健康状况", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "需要处理", exact: true })).toBeVisible(); + + await navigation.getByRole("button", { name: "会话日志", exact: true }).click(); + await expect(navigation.getByRole("button", { name: "会话日志", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "会话日志", exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "会话日志", exact: true }).getByRole("group", { name: "状态", exact: true })).toBeVisible(); + await navigation.getByRole("button", { name: "节点", exact: true }).click(); + await expect(page.getByRole("heading", { level: 1, name: "节点", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "刷新沙箱状态", exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/agent-usage.spec.ts b/apps/web/e2e/agent-usage.spec.ts new file mode 100644 index 000000000..21b2e83dc --- /dev/null +++ b/apps/web/e2e/agent-usage.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; + +// Per-Agent usage on Resources › Agents: sums of reported Session usage, +// coverage that never turns missing usage into zero, an "Other Agents" group +// for deleted and inline Agents, and time ranges by Session creation time. + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +/** Fixture Session timestamps are anchored to this instant. */ +const fixtureBaseline = 1_789_438_800; + +async function resetFixture(request: APIRequestContext) { + expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true); +} + +async function controlFixture(request: APIRequestContext, values: Record) { + expect((await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: values })).ok()).toBe(true); +} + +function collectPageErrors(page: Page) { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + return errors; +} + +function agentRow(page: Page, name: string): Locator { + return page.getByRole("table", { name: "Agents", exact: true }).getByRole("row") + .filter({ has: page.getByRole("button", { name: new RegExp(`^Edit ${name} \\(`) }) }); +} + +/** Sessions, tokens and coverage of one saved Agent row. */ +async function expectAgentUsage(page: Page, name: string, [sessions, tokens, coverage]: [string, string, string]) { + // Cells: model, harness, tools, Sessions, tokens, coverage, last active, updated, actions. + const cells = agentRow(page, name).getByRole("cell"); + await expect(cells.nth(3)).toHaveText(sessions); + await expect(cells.nth(4)).toHaveText(tokens); + await expect(cells.nth(5)).toHaveText(coverage); +} + +function otherRow(page: Page, agentId: string): Locator { + return page.getByRole("region", { name: "Other Agents", exact: true }).getByRole("row").filter({ hasText: agentId }); +} + +async function expectOtherUsage(page: Page, agentId: string, [sessions, tokens, coverage]: [string, string, string]) { + const cells = otherRow(page, agentId).getByRole("cell"); + await expect(cells.nth(0)).toHaveText(sessions); + await expect(cells.nth(1)).toHaveText(tokens); + await expect(cells.nth(2)).toHaveText(coverage); +} + +test.beforeEach(async ({ request }) => { + await resetFixture(request); +}); + +test("sums reported usage per Agent, keeps missing usage out of totals and follows the creation-time range", async ({ page, request }) => { + const errors = collectPageErrors(page); + // Small pages make the usage reader follow Core's cursor across several requests. + await controlFixture(request, { usageScenario: 1, sessionListPageSize: 3 }); + await page.clock.setFixedTime(new Date((fixtureBaseline + 120) * 1_000)); + await page.goto("/#agents"); + + await expect(page.locator("main").getByRole("heading", { level: 1, name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByText( + "Data comes from cumulative Session usage reported by Core. It excludes unreported usage and is not a basis for billing.", + { exact: true }, + )).toBeVisible(); + const ranges = page.getByRole("radiogroup", { name: "Sessions created", exact: true }); + await expect(ranges.getByRole("radio")).toHaveText(["All time", "Last 7 days", "Last 30 days"]); + await expect(ranges.getByRole("radio", { name: "All time" })).toHaveAttribute("aria-checked", "true"); + + // All time: Lifecycle Agent has four Sessions, two with reported usage (140 + 10). + await expectAgentUsage(page, "Lifecycle Agent", ["4", "150", "50%"]); + await expectAgentUsage(page, "Second Agent", ["1", "60", "100%"]); + await expectAgentUsage(page, "Saved-only Tool Agent", ["0", "—", "—"]); + // Sessions of a deleted Agent and an inline Agent stay visible under their raw IDs. + await expectOtherUsage(page, "agent_deleted", ["2", "1,000", "50%"]); + await expectOtherUsage(page, "agent_inline_usage", ["1", "No data", "No data"]); + + await ranges.getByRole("radio", { name: "Last 7 days" }).click(); + await expectAgentUsage(page, "Lifecycle Agent", ["3", "140", "33%"]); + await expectAgentUsage(page, "Second Agent", ["0", "—", "—"]); + await expectOtherUsage(page, "agent_deleted", ["1", "1,000", "100%"]); + await expect(otherRow(page, "agent_inline_usage")).toHaveCount(0); + + await ranges.getByRole("radio", { name: "Last 30 days" }).click(); + await expectAgentUsage(page, "Lifecycle Agent", ["3", "140", "33%"]); + await expectAgentUsage(page, "Second Agent", ["1", "60", "100%"]); + await expectOtherUsage(page, "agent_deleted", ["1", "1,000", "100%"]); + + await ranges.getByRole("radio", { name: "All time" }).click(); + await expectAgentUsage(page, "Lifecycle Agent", ["4", "150", "50%"]); + + // The Agent detail view carries the complete breakdown for the same range. + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + const panel = page.getByRole("region", { name: "Usage", exact: true }); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("radio", { name: "All time" })).toHaveAttribute("aria-checked", "true"); + await expect(panel).toContainText("In progress 0"); + await expect(panel).toContainText("Requires action 0"); + await expect(panel).toContainText("Failed 1"); + await expect(panel).toContainText("Idle 3"); + const tokens = panel.locator(".agent-usage-tokens"); + await expect(tokens.locator("dt")).toHaveText(["Input", "Output", "Total", "Cached input", "Reasoning"]); + await expect(tokens.locator("dd")).toHaveText(["107", "43", "150", "20", "10"]); + await expect(panel).toContainText("2 of 4 Sessions reported usage"); + await expect(panel).toContainText("not a basis for billing"); + + await panel.getByRole("radio", { name: "Last 7 days" }).click(); + await expect(panel).toContainText("1 of 3 Sessions reported usage"); + await expect(tokens.locator("dd")).toHaveText(["100", "40", "140", "20", "10"]); + + expect(errors).toEqual([]); +}); + +test("shows a failed usage read with retry and keeps the Agent list usable", async ({ page, request }) => { + const errors = collectPageErrors(page); + await controlFixture(request, { usageScenario: 1 }); + await page.clock.setFixedTime(new Date((fixtureBaseline + 120) * 1_000)); + await page.goto("/#agents"); + await expectAgentUsage(page, "Lifecycle Agent", ["4", "150", "50%"]); + + // The next Session list read fails once; reloading usage reports it and a retry recovers. + await controlFixture(request, { sessionListStatus: 500 }); + await page.getByRole("button", { name: "Refresh Agents", exact: true }).click(); + const failure = page.getByRole("alert").filter({ hasText: "Couldn’t read Sessions." }); + await expect(failure).toBeVisible(); + await expect(agentRow(page, "Lifecycle Agent").getByRole("cell").nth(3)).toHaveText("—"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(failure).toHaveCount(0); + await expectAgentUsage(page, "Lifecycle Agent", ["4", "150", "50%"]); + + expect(errors).toEqual([]); +}); diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 545ff34c6..c1d05328e 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -18,6 +18,56 @@ async function resetFixture(request: APIRequestContext) { expect(response.ok()).toBe(true); } +function consoleNav(page: Page) { + return page.getByRole("navigation", { name: "Console navigation", exact: true }); +} + +function navButton(page: Page, name: string) { + return consoleNav(page).getByRole("button", { name, exact: true }); +} + +function sessionLog(page: Page) { + return page.getByRole("region", { name: "Session log", exact: true }); +} + +function sessionLogStatusFilter(page: Page, label: string) { + return sessionLog(page).getByRole("group", { name: "Status", exact: true }) + .getByRole("button", { name: new RegExp(`^${label}\\s*\\d*$`) }); +} + +/** The value cell of one Overview KPI, located by its visible label. */ +function overviewKpi(page: Page, label: string) { + return page.getByLabel("Deployment health", { exact: true }).locator(".kpi") + .filter({ has: page.locator("dt > span", { hasText: new RegExp(`^${label}$`) }) }) + .locator(".kpi-value"); +} + +/** The Sandbox metrics page, which hosts the runtime observability block formerly on the Dashboard. */ +function sandboxMetrics(page: Page) { + return page.locator(".metrics-page"); +} + +function pageRefresh(page: Page) { + return page.locator("main .page-header").getByRole("button", { name: "Refresh", exact: true }); +} + +/** The console's theme lives in the sidebar's language and appearance menu. */ +async function chooseDarkTheme(page: Page) { + await page.locator(".app-sidebar .appearance-menu-trigger").click(); + await page.getByRole("menuitemradio", { name: "Dark theme", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); +} + +/** Sampling provenance (sample count and last sample time) lives in the Help tip beside the trend status. */ +function runtimeSamplingHelp(scope: Locator) { + return scope.locator(".dashboard-runtime-live-controls").getByRole("button", { name: "Help", exact: true }); +} + +/** Opens a console page from the grouped sidebar, e.g. "Agent builder" or "Session console". */ +async function openView(page: Page, name: string) { + await navButton(page, name).click(); +} + async function controlFixture(request: APIRequestContext, control: Record) { const response = await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: control }); expect(response.ok()).toBe(true); @@ -130,8 +180,8 @@ function isEnvironmentResourcePath(path: string): boolean { async function openAgents(page: Page, request: APIRequestContext) { await resetFixture(request); await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await openView(page, "Agent builder"); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); } async function startSessionWithSecondAgent(page: Page) { @@ -154,11 +204,12 @@ async function openAdvancedSessionSettings(dialog: Locator) { } function environmentTrigger(page: Page) { - return page.getByRole("button", { name: /Environment|Connect environment/i }); + // Scoped to the page body: the sidebar's "Environment templates" item would otherwise match. + return page.locator("main").getByRole("button", { name: /Environment|Connect environment/i }); } function connectedLiveEvents(page: Page) { - return page.getByRole("status", { name: "Session live events: connected" }); + return page.getByRole("status", { name: "Session live events: Live events" }); } async function openEnvironmentDialog(page: Page) { @@ -193,16 +244,16 @@ async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, nam async function openSessionsFromHome(page: Page) { await page.goto("/"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); } -test("opens Dashboard as the default landing page", async ({ page, request }) => { +test("opens Overview as the default landing page", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); - await expect(page.getByRole("button", { name: "Dashboard", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); - await expect(page.locator(".dashboard-page")).toBeVisible(); + await expect(navButton(page, "Overview")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { level: 1, name: "Overview", exact: true })).toBeVisible(); + await expect(page.locator(".overview-page")).toBeVisible(); }); test("explains a 502 Core backend failure and opens copyable Docker recovery steps", async ({ page }) => { @@ -224,7 +275,7 @@ test("explains a 502 Core backend failure and opens copyable Docker recovery ste await expect(recovery).toContainText("HTTP 502"); await expect(page.getByText("Agents: Agent core request failed (502).", { exact: true })).toHaveCount(0); await expect(page.getByText( - "Agent Core backend is not ready. Open the Dashboard startup guide.", + "Agent Core backend is not ready. Open the Overview startup guide.", { exact: true }, )).toHaveCount(1); await expect(page.getByText("Agent core request failed (502).", { exact: true })).toHaveCount(0); @@ -256,7 +307,7 @@ test("retrieves the latest Agent and opens its validated edit setup directly", a await openAgents(page, request); await expect(page.getByText("Lifecycle Agent · stale list", { exact: true })).toBeVisible(); - await controlFixture(request, { retrieveDelayMs: 250 }); + await controlFixture(request, { retrieveDelayMs: 1_500 }); await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); const setup = page.locator(".agent-setup-page"); @@ -327,7 +378,7 @@ test("fails closed when the latest Agent response has a different identity", asy await editTrigger.click(); await expect(page.getByRole("alert")).toContainText("returned a different Agent than the one requested"); await expect(page.locator(".agent-setup-page")).toHaveCount(0); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await expect(editTrigger).toBeFocused(); const reads = (await fixtureRequests(request)).filter((entry) => ( @@ -443,39 +494,28 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP test("keeps Source Files controls out of the System status page", async ({ page, request }) => { await resetFixture(request); await openSessionsFromHome(page); - await page.getByRole("button", { name: "System", exact: true }).click(); + await openView(page, "System"); await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); await expect(page.locator(".system-page")).not.toContainText("Source Files"); }); -test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { +test("supports grouped console navigation and consumes Agent setup requests once", async ({ page, request }) => { await openAgents(page, request); const sidebar = page.locator(".app-sidebar"); - const productNavigation = sidebar.getByRole("navigation", { name: "Agents product" }); - await expect(productNavigation).toBeVisible(); - await expect(productNavigation.getByRole("button", { name: "Agents", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(page.locator(".product-header").getByRole("navigation", { name: "Agents product" })).toHaveCount(0); - const createMenu = page.getByRole("button", { name: "Create", exact: true }); - await createMenu.focus(); - await page.keyboard.press("Enter"); - await expect(page.getByRole("menuitem", { name: /^Agent\b/ })).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(createMenu).toBeFocused(); - await page.keyboard.press("ArrowDown"); - const createAgentItem = page.getByRole("menuitem", { name: /^Agent\b/ }); - const startSessionItem = page.getByRole("menuitem", { name: /^Start Session\b/ }); - await expect(createAgentItem).toBeFocused(); - await page.keyboard.press("ArrowDown"); - await expect(startSessionItem).toBeFocused(); - await page.keyboard.press("ArrowUp"); - await expect(createAgentItem).toBeFocused(); + await expect(sidebar.getByRole("navigation", { name: "Console navigation", exact: true })).toBeVisible(); + await expect(navButton(page, "Agent builder")).toHaveAttribute("aria-current", "page"); + await expect(navButton(page, "Agents")).not.toHaveAttribute("aria-current", "page"); + // The global Create menu is gone; Agents are created from the Agent builder catalog. + await expect(page.getByRole("button", { name: "Create", exact: true })).toHaveCount(0); + const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); + await catalogCreate.focus(); await page.keyboard.press("Enter"); await expect(page.getByLabel("Name")).toBeFocused(); await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await openView(page, "Session console"); + await openView(page, "Agent builder"); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); @@ -487,32 +527,7 @@ test("supports global Create keyboard navigation and consumes setup requests onc await expect(editTrigger).toBeFocused(); await expect(page.locator(".modal-backdrop")).toHaveCount(0); - await createMenu.click(); - await startSessionItem.click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); - await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); - await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); - await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); - const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - await sessionDialog.getByRole("button", { name: "Cancel" }).click(); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(sessionPostsBeforeCancel); - await expect(createMenu).toBeFocused(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Create a Session" })).toHaveCount(0); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await createMenu.click(); - await createAgentItem.click(); + await catalogCreate.click(); const createMetadata = page.locator(".agent-metadata-input"); const createName = page.locator('input[data-agent-initial-focus="true"]'); await expect(createName).toBeFocused(); @@ -523,17 +538,12 @@ test("supports global Create keyboard navigation and consumes setup requests onc await page.keyboard.press("Enter"); await expect(page.getByRole("status")).toContainText("Agent definition saved as"); - await createMenu.click(); - await createAgentItem.click(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await catalogCreate.click(); await expect(page.getByLabel("Name")).toHaveValue(""); await expect(page.getByLabel("Name")).toBeEnabled(); await expect(page.getByRole("status")).toHaveCount(0); await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(createMenu).toBeFocused(); - - const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); - await catalogCreate.click(); - await page.getByRole("button", { name: "Back to Agents" }).click(); await expect(catalogCreate).toBeFocused(); const requests = await fixtureRequests(request); @@ -549,6 +559,44 @@ test("supports global Create keyboard navigation and consumes setup requests onc expect(creates[0]?.body).not.toHaveProperty("reasoning"); }); +test("consumes Session setup requests once and returns focus to the Session console create button", async ({ page, request }) => { + await openAgents(page, request); + // A Session setup request from an Agent card opens once in the Session console. + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); + await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); + await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); + const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBeforeCancel); + await openView(page, "Agent builder"); + await openView(page, "Session console"); + await expect(page.getByRole("dialog", { name: "Create a Session" })).toHaveCount(0); + + // The Session console's own create button returns focus when cancelled. + const newSession = page.getByRole("button", { name: "New Session", exact: true }); + await newSession.click(); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(sessionDialog).toHaveCount(0); + await expect(newSession).toBeFocused(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBeforeCancel); +}); + test("continues from a default Agent definition into a Session with initial input", async ({ page, request }) => { await openAgents(page, request); await page.getByRole("button", { name: /^Create agent/ }).click(); @@ -575,7 +623,7 @@ test("continues from a default Agent definition into a Session with initial inpu await expect(sessionDialog.getByRole("textbox", { name: /^Additional metadata\b/u })).toHaveCount(0); await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); - await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); await expect(environmentTrigger(page)).toHaveCount(0); await expect( page.getByRole("tabpanel", { name: "Conversation" }) @@ -626,7 +674,7 @@ test("opens repair setup for a saved response while blocking an invalid Session test("starts only Agents that pass known Session admission", async ({ page, request }) => { await openAgents(page, request); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); const blockedStart = page.getByRole("button", { name: /^Start a Session with Lifecycle Agent/ }); @@ -662,7 +710,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ await expect(sessionDialog).toBeVisible(); await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); - await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -677,7 +725,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ test("serializes complete saved-Agent overrides with initial input", async ({ page, request }) => { await openAgents(page, request); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); await page.getByRole("button", { name: "New Session" }).click(); let dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings."); @@ -786,8 +834,8 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential } await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await openView(page, "Agent builder"); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); let dialog = page.getByRole("dialog", { name: "Create a Session" }); @@ -797,7 +845,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await dialog.getByRole("button", { name: "Create Session" }).click(); await expect(dialog).toHaveCount(0); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); @@ -807,7 +855,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await dialog.getByRole("button", { name: "Create Session" }).click(); await expect(dialog).toHaveCount(0); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); @@ -818,7 +866,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); await dialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); @@ -841,7 +889,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential ]); const createsBeforeCredentialDelete = creates.length; - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); @@ -880,7 +928,7 @@ test("clears manual Vault attachments when overrides remove HTTP MCP tools", asy expect(agentResponse.status()).toBe(201); await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Explain the Session without MCP tools."); @@ -976,7 +1024,7 @@ test("saves a reusable Environment Template and references it from a managed Ses await expect(templateSelect).toBeDisabled(); await expect(dialog).toContainText("This Core exposes no saved Templates"); - await dialog.getByRole("button", { name: "New Template" }).click(); + await dialog.getByRole("button", { name: "New Environment Template" }).click(); await dialog.getByLabel("Template name").fill("Restricted outbound access"); await dialog.getByLabel("Template network access").selectOption("disabled"); await dialog.getByRole("button", { name: "Save Template" }).click(); @@ -985,7 +1033,7 @@ test("saves a reusable Environment Template and references it from a managed Ses await expect(templateSelect).toHaveValue(/^4/); const selectedTemplateId = await templateSelect.inputValue(); await expect(dialog.getByLabel("Managed Environment network access")) - .toContainText("Inherit from Template (disabled)"); + .toContainText("Inherit from Template (Disabled)"); const templateCreates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/environments/templates" @@ -1022,14 +1070,14 @@ test("hides Template selection when the connected Core lacks the resource", asyn await resetFixture(request); await controlFixture(request, { environmentTemplateListStatus: 400 }); await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents" }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); await expect(dialog).toContainText("does not expose the Environment Template resource"); await expect(dialog.getByLabel("Reusable Environment Template")).toBeDisabled(); - await expect(dialog.getByRole("button", { name: "New Template" })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "New Environment Template" })).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); }); @@ -1042,7 +1090,7 @@ test("creates managed hosted profiles with JSON for empty input and SSE for init ] as const; for (const [index, profile] of profiles.entries()) { - if (index > 0) await page.getByRole("button", { name: "Agents", exact: true }).click(); + if (index > 0) await openView(page, "Agent builder"); if (index === 0) await controlFixture(request, { environmentEventStatus: 3, environmentEventCount: 1 }); await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); @@ -1122,8 +1170,8 @@ test("creates managed hosted profiles with JSON for empty input and SSE for init } } - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); + await openView(page, "Session log"); + await expect(sessionLog(page).getByRole("table")).toContainText("Hosted sandbox"); }); test("blocks hosted MCP before persistence while allowing a Function-only managed Session", async ({ page, request }) => { @@ -1151,7 +1199,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage expect(fn.status()).toBe(201); await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); const before = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; @@ -1165,7 +1213,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage ))).toHaveLength(before); await dialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Hosted Function Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); @@ -1184,20 +1232,20 @@ test("keeps managed Environment resource and terminal event states fail-closed", await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); await openSessionsFromHome(page); let trigger = environmentTrigger(page); - await expect(trigger).toContainText("Managed Environment expired"); + await expect(trigger).toContainText("Environment: Expired"); let opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment expired"); + await expect(opened.panel).toContainText(/Managed Environment expired/i); await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); await expect(opened.panel).not.toContainText("Connect Environment"); await opened.dialog.getByRole("button", { name: "Done" }).click(); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session log"); + await expect(sessionLog(page).getByRole("table")).toContainText("Hosted sandbox"); + await openView(page, "Session console"); await controlFixture(request, { environmentResourceStatus: "pending", environmentResourceVariant: "wrong_type" }); - await page.getByRole("button", { name: "Recover durable state" }).click(); + await page.getByRole("button", { name: "Refresh Sessions", exact: true }).click(); trigger = environmentTrigger(page); - await expect(trigger).toContainText("Managed Environment unavailable"); + await expect(trigger).toContainText("Environment: Unavailable"); opened = await openEnvironmentDialog(page); await expect(opened.panel).toContainText("Durable managed Environment status is unavailable"); await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); @@ -1209,9 +1257,9 @@ test("keeps managed Environment resource and terminal event states fail-closed", environmentEventCount: 1, }); await page.reload(); - await expect(environmentTrigger(page)).toContainText("Managed Environment failed"); + await expect(environmentTrigger(page)).toContainText("Environment: Failed"); opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel).toContainText(/Managed Environment failed/i); await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); await expect(opened.panel).not.toContainText("Connect Environment"); @@ -1227,7 +1275,7 @@ test("keeps managed Environment resource and terminal event states fail-closed", await expect(page.getByText('Failed to provision environment: script "setup_commands[0]" failed with exit code 3', { exact: true })).toBeVisible(); await expect(page.getByLabel("Message the Agent")).toBeDisabled(); opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel).toContainText(/Managed Environment failed/i); await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); await expect(opened.panel).not.toContainText("Connect Environment"); @@ -1252,7 +1300,7 @@ test("creates an inline Session without saved Agents and preserves ordered user- await page.getByRole("button", { name: "Refresh Agents" }).click(); await expect(page.getByText("No saved Agents", { exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); const newSession = page.getByRole("button", { name: "New Session" }); await expect(newSession).toBeEnabled(); await newSession.click(); @@ -1346,12 +1394,12 @@ test("keeps failures visible, rejects stale async continuations, and never retri await controlFixture(request, { retrieveDelayMs: 400 }); await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); await page.waitForTimeout(500); await expect(page.locator(".session-page")).toBeVisible(); await expect(page.locator(".agent-setup-page")).toHaveCount(0); - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); await expect(page.locator(".agent-setup-page")).toBeVisible(); await page.getByLabel("Name").fill("Preserved after failure"); @@ -1367,7 +1415,7 @@ test("keeps failures visible, rejects stale async continuations, and never retri await page.getByRole("button", { name: "Save changes" }).click(); await expect(page.getByRole("button", { name: "Back to Agents" })).toBeDisabled(); await page.waitForTimeout(100); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); await page.waitForTimeout(700); await expect(page.locator(".session-page")).toBeVisible(); await expect(page.locator(".agent-setup-page")).toHaveCount(0); @@ -1403,50 +1451,41 @@ test("requires delete confirmation, preserves failures, and keeps Session snapsh await dialog.getByRole("button", { name: "Delete Agent" }).click(); await expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); await expect(dialog).toHaveCount(0); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toHaveCount(0); - await page.getByRole("button", { name: "Sessions" }).click(); - await expect(page.getByRole("button", { name: /idle Lifecycle Agent/ })).toBeVisible(); + await openView(page, "Session console"); + await expect(page.getByRole("button", { name: /^Idle Lifecycle Agent/ })).toBeVisible(); requests = await fixtureRequests(request); expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); }); -test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { +test("keeps the Agent table, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await openAgents(page, request); - const agentList = page.getByRole("list", { name: "Agents", exact: true }); - await expect(agentList.getByRole("listitem")).toHaveCount(2); - await expect(agentList.getByRole("listitem").first()).toContainText("Create agent"); + // Every saved Agent is one table row; the table scrolls inside its frame instead of the page. + const agentTable = page.getByRole("table", { name: "Agents", exact: true }); + await expect(agentTable.locator("tbody tr")).toHaveCount(3); await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); - await expect(page.getByRole("button", { name: /^Edit Second Agent/ })).toHaveCount(0); - const showMore = page.getByRole("button", { name: "Show 2 more" }); - await expect(showMore).toHaveAttribute("aria-expanded", "false"); - await showMore.click(); - await expect(page.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true"); - await expect(agentList.getByRole("listitem")).toHaveCount(4); const secondAgent = page.getByRole("button", { name: /^Edit Second Agent/ }); await expect(secondAgent).toBeVisible(); await secondAgent.click(); await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Back to Agents" }).click(); await expect(secondAgent).toBeFocused(); - await expect(page.getByRole("button", { name: "Show less" })).toBeVisible(); const metrics = await page.evaluate(() => { const main = document.querySelector(".app-main")?.getBoundingClientRect(); const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Create agent"))?.getBoundingClientRect(); - const grid = document.querySelector(".agent-card-grid")?.getBoundingClientRect(); - const sessionTrigger = document.querySelector('button[aria-label^="Start a Session with Second Agent ("]')?.getBoundingClientRect(); + const frame = document.querySelector(".agent-table-frame")?.getBoundingClientRect(); return { innerWidth, documentScrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, main: main && { left: main.left, right: main.right, width: main.width }, trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, - grid: grid && { left: grid.left, right: grid.right, width: grid.width }, - sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, + frame: frame && { left: frame.left, right: frame.right, width: frame.width }, }; }); expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); @@ -1455,33 +1494,22 @@ test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px expect(metrics.main?.right).toBeLessThanOrEqual(390); expect(metrics.trigger?.left).toBeGreaterThanOrEqual(0); expect(metrics.trigger?.right).toBeLessThanOrEqual(390); - expect(metrics.grid?.left).toBeGreaterThanOrEqual(0); - expect(metrics.grid?.right).toBeLessThanOrEqual(390); - expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.grid?.left ?? 0); - expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.grid?.right ?? 390); + expect(metrics.frame?.left).toBeGreaterThanOrEqual(0); + expect(metrics.frame?.right).toBeLessThanOrEqual(390); await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); - await attachScreenshot(page, testInfo, "narrow-light-agent-card-grid"); + await attachScreenshot(page, testInfo, "narrow-light-agent-table"); await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); - const sessionsNavigation = page.getByRole("button", { name: "Sessions", exact: true }); + const sessionsNavigation = navButton(page, "Session console"); await sessionsNavigation.click(); await expect(sessionsNavigation).toHaveAttribute("aria-current", "page"); await expect(page.locator(".session-page")).toBeVisible(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - - const globalCreate = page.getByRole("button", { name: "Create", exact: true }); - await globalCreate.click(); - const createPanel = page.getByRole("menu", { name: "Create" }); - await expect(createPanel).toBeVisible(); - await expect(createPanel.getByRole("menuitem")).toHaveCount(2); - await expect(createPanel.getByRole("menuitem", { name: /Environment template/i })).toHaveCount(0); - await expect(createPanel.getByRole("menuitem", { name: /Environment key/i })).toHaveCount(0); - const createPanelBox = await createPanel.boundingBox(); - expect(createPanelBox).not.toBeNull(); - expect(createPanelBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect((createPanelBox?.x ?? 0) + (createPanelBox?.width ?? 0)).toBeLessThanOrEqual(390); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); - await createPanel.getByRole("menuitem", { name: /^Agent\b/ }).click(); + await openView(page, "Agent builder"); + + // Agents are created from the page header's Create agent action; the global Create menu was removed. + await expect(page.getByRole("button", { name: "Create", exact: true })).toHaveCount(0); + const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); + await catalogCreate.click(); const setup = page.locator(".agent-setup-page"); const box = await setup.boundingBox(); expect(box).not.toBeNull(); @@ -1496,10 +1524,9 @@ test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px await expect(setup).toContainText("Creation is locked to the current Session-compatible profile"); await attachScreenshot(page, testInfo, "narrow-light-agent-setup"); await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(globalCreate).toBeFocused(); + await expect(catalogCreate).toBeFocused(); - await page.getByRole("button", { name: "Dark theme" }).click(); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await chooseDarkTheme(page); await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); const editSetup = page.locator(".agent-setup-page"); await expect(editSetup).toBeVisible(); @@ -1577,7 +1604,7 @@ test("requires a first message without an Environment and preserves the draft ac test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { await openAgents(page, request); await startSessionWithSecondAgent(page); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Session console" })).toBeVisible(); const requests = await fixtureRequests(request); const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); @@ -1600,7 +1627,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async await expect(page.locator(".session-row")).toHaveCount(1); // These Sessions are deliberately created after the global snapshot loads. - // The Agent-scoped list must be able to select them without inflating Dashboard totals. + // The Agent-scoped list must be able to select them without inflating the Session log totals. await createFixtureSession(request, "first"); await createFixtureSession(request, "second"); @@ -1622,10 +1649,10 @@ test("filters every Session page by Agent and aborts a stale filter read", async new URLSearchParams(entry.query ?? "").has("after") ))).toBe(true); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session log"); + await expect(sessionLogStatusFilter(page, "All").locator(".filter-count")).toHaveText("1"); + await expect(sessionLog(page).getByRole("table").getByRole("row")).toHaveCount(2); + await openView(page, "Session console"); await expect(filter).toHaveValue("agent_b"); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); @@ -1635,7 +1662,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async entry.path === "/v1/agents/sessions" && new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" )).length; - await controlFixture(request, { sessionListDelayMs: 500 }); + await controlFixture(request, { sessionListDelayMs: 2_000 }); await filter.selectOption("agent_a"); await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( entry.method === "GET" && @@ -1649,7 +1676,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async await expect(page.locator(".session-row")).toHaveCount(2); await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - await page.waitForTimeout(550); + await page.waitForTimeout(2_050); await expect(filter).toHaveValue("agent_b"); await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); @@ -1661,11 +1688,12 @@ test("filters every Session page by Agent and aborts a stale filter read", async await filter.selectOption("agent_b"); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - const lifecycleRow = page.getByRole("table", { name: "Recent Sessions" }) + await openView(page, "Session log"); + const lifecycleRow = sessionLog(page).getByRole("table") .getByRole("row") .filter({ hasText: "Lifecycle Agent" }); - await lifecycleRow.getByRole("button").click(); + await lifecycleRow.getByRole("button", { name: "Lifecycle Agent", exact: true }).click(); + await expect(navButton(page, "Session console")).toHaveAttribute("aria-current", "page"); await expect(filter).toHaveValue(""); await expect(page.locator(".session-row")).toHaveCount(1); await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); @@ -1696,7 +1724,7 @@ test("fences the filtered workspace across loading, errors, unavailable Agents, await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); const deletedAgent = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); expect(deletedAgent.ok()).toBe(true); - await page.getByRole("button", { name: "Recover durable state" }).click(); + await page.getByRole("button", { name: "Refresh Sessions", exact: true }).click(); await expect(filter).toHaveValue("agent_b"); await expect(filter.locator("option:checked")).toHaveText("Unavailable Agent (not loaded)"); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); @@ -1719,7 +1747,7 @@ test("fences the filtered workspace across loading, errors, unavailable Agents, test("streams initial Session creation, captures early events, then hands off to one GET stream", async ({ page, request }) => { await openAgents(page, request); - await controlFixture(request, { sessionCreateStreamCloseDelayMs: 1_500 }); + await controlFixture(request, { sessionCreateStreamCloseDelayMs: 4_000 }); await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); await dialog.getByRole("textbox", { name: /^Title\b/u }).fill("Streamed creation"); @@ -1802,7 +1830,7 @@ for (const failure of [ expect((await fixtureState(request)).sessions).toHaveLength(2); await create.click(); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Session console" })).toBeVisible(); creates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )); @@ -2120,7 +2148,7 @@ test("deletes an inactive Session without disturbing the active composer or live await resetFixture(request); await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); + await openView(page, "Agent builder"); await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await expect(connectedLiveEvents(page)).toBeVisible(); @@ -2138,7 +2166,7 @@ test("deletes an inactive Session without disturbing the active composer or live const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await controlFixture(request, { sessionDeleteDelayMs: 1_500 }); + await controlFixture(request, { sessionDeleteDelayMs: 4_000 }); const deleteButton = dialog.locator(".modal-footer .button.danger"); const deleteClick = deleteButton.click(); await expect(deleteButton).toBeDisabled(); @@ -2224,7 +2252,7 @@ for (const pendingRead of [ await resetFixture(request); await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); + await openView(page, "Agent builder"); await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await controlFixture(request, { turnsScenario: 1 }); @@ -2270,7 +2298,7 @@ test("aborts a pending manual recovery read after deleting the selected Session" await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); await expectSelectedDeleteAbortsSessionRead(page, request, () => ( - page.getByRole("button", { name: "Recover durable state" }).click() + page.getByRole("button", { name: "Refresh Sessions", exact: true }).click() )); }); @@ -2279,7 +2307,7 @@ test("aborts a pending detail retry read after deleting the selected Session", a await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { sessionRetrieveStatus: 503 }); - await page.getByRole("button", { name: "Recover durable state" }).click(); + await page.getByRole("button", { name: "Refresh Sessions", exact: true }).click(); const detailError = page.locator(".session-detail-error"); await expect(detailError).toBeVisible(); await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); @@ -2293,12 +2321,12 @@ test("deletes the selected Session while its SSE is still connecting", async ({ await resetFixture(request); await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); + await openView(page, "Agent builder"); await startSessionWithSecondAgent(page); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await expect(connectedLiveEvents(page)).toBeVisible(); - await controlFixture(request, { streamOpenDelayMs: 3_000 }); + await controlFixture(request, { streamOpenDelayMs: 8_000 }); const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" )).length; @@ -2326,7 +2354,7 @@ test("keeps Session actions accessible and contained at 390 px in dark mode", as await resetFixture(request); await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Dark theme" }).click(); + await chooseDarkTheme(page); const manage = page.locator(".conversation-session-action"); await manage.focus(); await page.keyboard.press("Enter"); @@ -2360,7 +2388,7 @@ test("keeps Session actions accessible and contained at 390 px in dark mode", as await expect(manage).toBeFocused(); }); -test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { +test("presents Overview page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { await resetFixture(request); await openSessionsFromHome(page); await expect(connectedLiveEvents(page)).toBeVisible(); @@ -2376,19 +2404,15 @@ test("presents Dashboard page-chain results and System boundaries without extra entry.method === "GET" && entry.path === path )).length >= 2); }).toBe(true); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); - await expect(dashboard).toContainText("Latest complete paginated reads"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "In progress" })).toContainText("0"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Needs attention" })).toContainText("0"); - await expect(dashboard).not.toContainText("Reported aggregate tokens"); - await expect(dashboard).toContainText("No Sessions currently need attention."); - await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); - await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + await openView(page, "Overview"); + + const overview = page.locator(".overview-page"); + await expect(overview.getByRole("heading", { level: 1, name: "Overview", exact: true })).toBeVisible(); + await expect(overviewKpi(page, "Running Sessions")).toHaveText("0"); + await expect(overviewKpi(page, "Needs attention")).toHaveText("0"); + // No Session reports usage, so the Overview must not invent an aggregate. + await expect(overviewKpi(page, "Tokens reported")).toHaveText("—"); + await expect(overview.getByRole("region", { name: "Needs attention", exact: true })).toContainText("Nothing needs attention"); const before = await fixtureRequests(request); const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( @@ -2416,7 +2440,7 @@ test("presents Dashboard page-chain results and System boundaries without extra }); await route.continue(); }); - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + const refresh = overview.locator(".page-header").getByRole("button", { name: "Refresh", exact: true }); await refresh.click(); await sessionListStarted; await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); @@ -2430,10 +2454,10 @@ test("presents Dashboard page-chain results and System boundaries without extra expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 2); expect(count(after, "/v1/agents/runtime-observations")).toBe(count(before, "/v1/agents/runtime-observations") + 1); for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); - await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); + await attachScreenshot(page, testInfo, "desktop-overview-loaded-snapshot"); await page.setViewportSize({ width: 390, height: 844 }); - const dashboardBounds = await dashboard.evaluate((element) => { + const overviewBounds = await overview.evaluate((element) => { const box = element.getBoundingClientRect(); return { viewportWidth: innerWidth, @@ -2443,30 +2467,24 @@ test("presents Dashboard page-chain results and System boundaries without extra right: box.right, }; }); - expect(dashboardBounds.documentWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); - expect(dashboardBounds.bodyWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); - expect(dashboardBounds.left).toBeGreaterThanOrEqual(0); - expect(dashboardBounds.right).toBeLessThanOrEqual(390); - await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); - await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); - await attachScreenshot(page, testInfo, "narrow-dashboard-loaded-snapshot"); + expect(overviewBounds.documentWidth).toBeLessThanOrEqual(overviewBounds.viewportWidth); + expect(overviewBounds.bodyWidth).toBeLessThanOrEqual(overviewBounds.viewportWidth); + expect(overviewBounds.left).toBeGreaterThanOrEqual(0); + expect(overviewBounds.right).toBeLessThanOrEqual(390); + await attachScreenshot(page, testInfo, "narrow-overview-loaded-snapshot"); await page.setViewportSize({ width: 1280, height: 720 }); - await dashboard.getByRole("button", { name: /Create agent/ }).click(); - await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await dashboard.getByRole("button", { name: /Start session/ }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(sessionDialog).toBeVisible(); - await sessionDialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); + // Saved Agent and Session totals now live on the resource pages. + await openView(page, "Agents"); + await expect(page.getByRole("region", { name: "Agents", exact: true })).toContainText("3 of 3 Agents"); + await openView(page, "Session log"); + await expect(sessionLogStatusFilter(page, "All").locator(".filter-count")).toHaveText("1"); + await sessionLog(page).getByRole("table").getByRole("button", { name: "Lifecycle Agent", exact: true }).click(); const sessionPage = page.locator(".session-page"); await expect(sessionPage).toBeVisible(); await expect(sessionPage.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); - await page.getByRole("button", { name: "System", exact: true }).click(); + await openView(page, "System"); const system = page.locator(".system-page"); await expect(system.getByRole("listitem").filter({ hasText: "Daemon gateway" })).toContainText("Enabled"); await expect(system.getByRole("listitem").filter({ hasText: "Managed sandbox" })).toContainText("Docker"); @@ -2481,6 +2499,10 @@ test("presents Dashboard page-chain results and System boundaries without extra await expect(system).toContainText("Operator endpoint override: configured"); await expect(system).toContainText("Compiled into this build, but not enabled when this Core process started"); await expect(system).toContainText("Runtime connection, native binary availability, sandbox health, and model execution belong to the relevant Session or Environment"); + // The boundary note now lives behind the page header's Help tip; it must stay in the accessible description. + await expect(system.locator(".page-header").getByRole("button", { name: "Help", exact: true })).toHaveAccessibleDescription( + /Runtime connection, native binary availability, sandbox health, and model execution belong to the relevant Session or Environment/, + ); await expect(system).not.toContainText("Source Files"); await expect(system).not.toContainText("Public capability surface"); await expect(system).not.toContainText("Cannot be pre-checked"); @@ -2515,13 +2537,11 @@ test("presents Dashboard page-chain results and System boundaries without extra content: section.querySelector(".system-harness-grid")!.getBoundingClientRect().left, explanation: section.querySelector(".system-config-explanation")?.getBoundingClientRect().left ?? null, })), - boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, })); expect(compactSystemBounds.documentWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); expect(compactSystemBounds.bodyWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); expect(compactSystemBounds.right).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); expect(compactSystemBounds.sectionInset).toBe(24); - expect(compactSystemBounds.boundaryInset).toBe(24); for (const edge of compactSystemBounds.sectionEdges) { expect({ left: edge.left, right: edge.right }).toEqual({ left: 24, right: 24 }); expect(edge.header).toBe(edge.content); @@ -2534,9 +2554,9 @@ test("presents Dashboard page-chain results and System boundaries without extra return { viewportWidth: innerWidth, documentWidth: document.documentElement.scrollWidth, + headingInset: element.querySelector(".page-header h1")!.getBoundingClientRect().left - element.getBoundingClientRect().left, sectionInset: element.querySelector(".system-config-section")!.getBoundingClientRect().left - element.getBoundingClientRect().left, sectionRightInset: element.getBoundingClientRect().right - element.querySelector(".system-config-section")!.getBoundingClientRect().right, - boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, rowBounds: rows.map((row) => ({ top: row.getBoundingClientRect().top, bottom: row.getBoundingClientRect().bottom, @@ -2546,9 +2566,9 @@ test("presents Dashboard page-chain results and System boundaries without extra }; }); expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); - expect(systemBounds.sectionInset).toBe(12); - expect(systemBounds.sectionRightInset).toBe(12); - expect(systemBounds.boundaryInset).toBe(12); + // Sections align with the page heading and keep symmetric insets on narrow screens. + expect(systemBounds.sectionInset).toBe(systemBounds.headingInset); + expect(systemBounds.sectionRightInset).toBe(systemBounds.sectionInset); for (let index = 1; index < systemBounds.rowBounds.length; index += 1) { expect(systemBounds.rowBounds[index]!.top).toBeGreaterThanOrEqual(systemBounds.rowBounds[index - 1]!.bottom); } @@ -2675,31 +2695,32 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand }); }); - await page.goto("/"); - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); - await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("1 sample ·"); - await expect(dashboard.getByRole("heading", { name: "CPU usage" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Active sandboxes" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Token throughput" })).toBeVisible(); - await expect(dashboard.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible(); - const liveRange = dashboard.getByRole("group", { name: "Runtime live range" }); - await expect(liveRange.getByRole("button", { name: "1h" })).toHaveAttribute("aria-pressed", "true"); - await liveRange.getByRole("button", { name: "15m" }).click(); - await expect(liveRange.getByRole("button", { name: "15m" })).toHaveAttribute("aria-pressed", "true"); + await page.goto("/#sandbox-metrics"); + const metrics = sandboxMetrics(page); + await expect(metrics.getByRole("heading", { level: 1, name: "Sandbox metrics", exact: true })).toBeVisible(); + await expect(metrics.getByRole("heading", { name: "Hosted runtime", exact: true })).toBeVisible(); + const refresh = pageRefresh(page); + await expect(runtimeSamplingHelp(metrics)).toHaveAccessibleDescription(/^1 sample · /); + await expect(metrics.getByRole("heading", { name: "CPU usage" })).toBeVisible(); + await expect(metrics.getByRole("heading", { name: "Memory usage" })).toBeVisible(); + await expect(metrics.getByRole("heading", { name: "Active sandboxes" })).toBeVisible(); + await expect(metrics.getByRole("heading", { name: "Token throughput" })).toBeVisible(); + await expect(metrics.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible(); + const liveRange = metrics.getByRole("group", { name: "Runtime live range" }); + await expect(liveRange.getByRole("button", { name: "1 hour", exact: true })).toHaveAttribute("aria-pressed", "true"); + await liveRange.getByRole("button", { name: "15 minutes", exact: true }).click(); + await expect(liveRange.getByRole("button", { name: "15 minutes", exact: true })).toHaveAttribute("aria-pressed", "true"); await refresh.click(); - await expect(dashboard.getByLabel("CPU usage: 2 live samples")).toBeVisible(); + await expect(metrics.getByLabel("CPU usage: 2 live samples")).toBeVisible(); await refresh.click(); - await expect(dashboard.getByLabel("CPU usage: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Memory usage: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Active sandboxes: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Token throughput: 3 live samples")).toBeVisible(); - await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("3 samples"); - await expect(dashboard.getByText("CPU usage live trend available")).toBeAttached(); - await expect(dashboard).not.toContainText("Collecting live samples"); - const memoryCard = dashboard.getByRole("region", { name: "Memory usage live chart" }); + await expect(metrics.getByLabel("CPU usage: 3 live samples")).toBeVisible(); + await expect(metrics.getByLabel("Memory usage: 3 live samples")).toBeVisible(); + await expect(metrics.getByLabel("Active sandboxes: 3 live samples")).toBeVisible(); + await expect(metrics.getByLabel("Token throughput: 3 live samples")).toBeVisible(); + await expect(runtimeSamplingHelp(metrics)).toHaveAccessibleDescription(/^3 samples · /); + await expect(metrics.getByText("CPU usage live trend available")).toBeAttached(); + await expect(metrics).not.toContainText("Collecting live samples"); + const memoryCard = metrics.getByRole("region", { name: "Memory usage live chart" }); const memoryStrokePixelCount = () => memoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { const context = canvas.getContext("2d"); if (!context) return 0; @@ -2720,7 +2741,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand return pixels; }); expect(await memoryStrokePixelCount()).toBeGreaterThan(0); - const cpuChart = dashboard.getByLabel("CPU usage: 3 live samples"); + const cpuChart = metrics.getByLabel("CPU usage: 3 live samples"); const cpuCard = cpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); await cpuChart.focus(); await cpuChart.press("Enter"); @@ -2730,7 +2751,8 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toBeVisible(); await cpuChart.click({ position: { x: 260, y: 90 } }); await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("10%"); + // The first live sample has no CPU rate yet. It renders as a gap ("Unavailable"), never as a fabricated 0%. + await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Repository migration 1Unavailable"); await cpuChart.focus(); await cpuChart.press("ArrowRight"); await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); @@ -2741,8 +2763,8 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand await cpuSeriesToggle.click(); await expect(cpuSeriesToggle).toHaveAttribute("aria-pressed", "true"); - await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); - await expect(dashboard.locator(".dashboard-runtime-timeline")).toHaveCount(0); + await expect(metrics.locator('[data-chart-engine="uplot"]')).toHaveCount(4); + await expect(metrics.locator(".dashboard-runtime-timeline")).toHaveCount(0); const initialViewStart = Number(await cpuChart.getAttribute("data-view-start")); const initialViewEnd = Number(await cpuChart.getAttribute("data-view-end")); const plot = cpuCard.locator(".u-over"); @@ -2763,47 +2785,47 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand expect(keyboardSelectedAt).toBeGreaterThanOrEqual(zoomedViewStart); expect(keyboardSelectedAt).toBeLessThanOrEqual(zoomedViewEnd); for (const chartName of ["Memory usage", "Active sandboxes", "Token throughput"]) { - await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart)); - await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd)); + await expect(metrics.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart)); + await expect(metrics.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd)); } const resetZoom = cpuCard.getByRole("button", { name: "Reset zoom" }); await expect(resetZoom).toBeEnabled(); await resetZoom.click(); await expect(cpuChart).toHaveAttribute("data-view-start", String(initialViewStart)); await expect(cpuChart).toHaveAttribute("data-view-end", String(initialViewEnd)); - await expect(dashboard.getByRole("table", { name: "Runtime targets" })).not.toBeVisible(); + await expect(metrics.getByRole("table", { name: "Runtime targets" })).not.toBeVisible(); for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); await expect(page.getByRole("button", { name: "Close notification" })).toHaveCount(0); - await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard"); + await attachElementScreenshot(metrics.locator(".runtime-embed"), testInfo, "runtime-visual-dashboard"); - await dashboard.getByText("Runtime targets", { exact: true }).click(); - await expect(dashboard.getByRole("table", { name: "Runtime targets" })).toBeVisible(); - await expect(dashboard.getByText("Page 1 of 2")).toBeVisible(); - const runtimeSearch = dashboard.getByPlaceholder("Search Session, provider, or identity"); + await metrics.getByText("Runtime targets", { exact: true }).click(); + await expect(metrics.getByRole("table", { name: "Runtime targets" })).toBeVisible(); + await expect(metrics.getByText("Page 1 of 2")).toBeVisible(); + const runtimeSearch = metrics.getByPlaceholder("Search Session, provider, or identity"); await runtimeSearch.fill("Repository migration 12"); - await expect(dashboard.getByText("1 visible")).toBeVisible(); - await expect(dashboard.getByRole("table", { name: "Runtime targets" }).getByRole("row", { name: /Repository migration 12/ })).toBeVisible(); + await expect(metrics.getByText("1 visible")).toBeVisible(); + await expect(metrics.getByRole("table", { name: "Runtime targets" }).getByRole("row", { name: /Repository migration 12/ })).toBeVisible(); await runtimeSearch.fill(""); - await dashboard.getByRole("button", { name: "Tokens" }).click(); - await expect(dashboard.getByRole("columnheader", { name: "Tokens" })).toHaveAttribute("aria-sort", "descending"); - await dashboard.getByRole("button", { name: "Next" }).click(); - await expect(dashboard.getByText("Page 2 of 2")).toBeVisible(); + await metrics.getByRole("button", { name: "Tokens" }).click(); + await expect(metrics.getByRole("columnheader", { name: "Tokens" })).toHaveAttribute("aria-sort", "descending"); + await metrics.getByRole("button", { name: "Next" }).click(); + await expect(metrics.getByText("Page 2 of 2")).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); - await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); + await expect(metrics.getByRole("heading", { name: "Memory usage" })).toBeVisible(); await expect.poll(() => cpuChart.evaluate((element) => getComputedStyle(element).touchAction)).toBe("pan-y"); for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); - const widths = await dashboard.locator(".dashboard-runtime-panel").evaluate((element) => ({ + const widths = await metrics.locator(".runtime-embed").evaluate((element) => ({ viewport: innerWidth, document: document.documentElement.scrollWidth, panel: element.getBoundingClientRect().width, })); expect(widths.document).toBeLessThanOrEqual(widths.viewport); expect(widths.panel).toBeLessThanOrEqual(widths.viewport); - await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard-narrow"); + await attachElementScreenshot(metrics.locator(".runtime-embed"), testInfo, "runtime-visual-dashboard-narrow"); }); -test("restores retained Runtime history after a Dashboard reload", async ({ page }) => { +test("restores retained Runtime history after a Sandbox metrics reload", async ({ page }) => { const sessionId = "11111111-1111-4111-8111-111111111111"; const environmentId = "22222222-2222-4222-8222-222222222222"; const allocationId = "33333333-3333-4333-8333-333333333333"; @@ -2908,27 +2930,30 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page }); await page.goto("/"); - const dashboard = page.locator(".dashboard-runtime-panel"); - await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); - await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); - await expect(dashboard.getByLabel("Runtime durable-history charts")).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0); - await expect(dashboard.getByRole("heading", { name: "Active sandboxes", exact: true })).toBeVisible(); - await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); - await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); - await expect(dashboard).toContainText("120 buckets"); - await expect(dashboard).toContainText("119/120 observations"); - await expect(dashboard.getByText("Active sandboxes durable trend available")).toBeAttached(); - await expect(dashboard.getByText("Token throughput durable trend available")).toBeAttached(); - const durableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); - await expect(dashboard.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible(); + await openView(page, "Sandbox metrics"); + await expect(sandboxMetrics(page).getByRole("heading", { name: "Hosted runtime", exact: true })).toBeVisible(); + const runtimePanel = sandboxMetrics(page).locator(".runtime-embed"); + await expect(runtimePanel.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); + await expect(runtimePanel.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); + await expect(runtimePanel.getByLabel("Runtime durable-history charts")).toBeVisible(); + await expect(runtimePanel.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0); + await expect(runtimePanel.getByRole("heading", { name: "Active sandboxes", exact: true })).toBeVisible(); + await expect(runtimePanel.locator('[data-chart-engine="uplot"]')).toHaveCount(4); + await expect(runtimePanel.getByText("CPU usage durable trend available")).toBeAttached(); + await expect(runtimePanel).toContainText("120 buckets"); + await expect(runtimePanel).toContainText("119/120 observations"); + await expect(runtimePanel.getByText("Active sandboxes durable trend available")).toBeAttached(); + await expect(runtimePanel.getByText("Token throughput durable trend available")).toBeAttached(); + const durableCpuChart = runtimePanel.getByLabel("CPU usage: 120 retained buckets"); + await expect(runtimePanel.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible(); const durableCpuCard = durableCpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); await durableCpuChart.focus(); await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); + // The explicit gap bucket has no CPU observation; it must read "Unavailable", not an idle 0%. + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); - const durableMemoryCard = dashboard.getByRole("region", { name: "Memory usage durable history chart" }); + await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable"); + const durableMemoryCard = runtimePanel.getByRole("region", { name: "Memory usage durable history chart" }); const durableMemorySpan = await durableMemoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { const context = canvas.getContext("2d"); if (!context) return 0; @@ -2962,18 +2987,19 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page const durableZoomStart = Number(await durableCpuChart.getAttribute("data-view-start")); const durableZoomEnd = Number(await durableCpuChart.getAttribute("data-view-end")); await expect(durableCpuCard.getByRole("button", { name: "Reset zoom" })).toBeVisible(); - await page.getByRole("button", { name: "Refresh Dashboard snapshot" }).click(); - const refreshedDurableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); + await pageRefresh(page).click(); + const refreshedDurableCpuChart = runtimePanel.getByLabel("CPU usage: 120 retained buckets"); await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-start", String(durableZoomStart)); await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-end", String(durableZoomEnd)); await page.reload(); - await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); - await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); - await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); + await expect(navButton(page, "Sandbox metrics")).toHaveAttribute("aria-current", "page"); + await expect(runtimePanel.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); + await expect(runtimePanel.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); + await expect(runtimePanel.getByText("CPU usage durable trend available")).toBeAttached(); }); -test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { +test("publishes Overview and resource counts only after every top-level Agent and Session page loads", async ({ page, request }) => { await resetFixture(request); const agentAfters: Array = []; const sessionAfters: Array = []; @@ -3036,33 +3062,37 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa }); await page.goto("/"); - const dashboard = page.locator(".dashboard-page"); + const overview = page.locator(".overview-page"); // The synthetic second Session has no Runtime observation. Let that initial // snapshot finish before navigation, so requests cannot be aborted mid-chain. - await expect(dashboard.locator(".dashboard-source-badge").filter({ hasText: "Runtime" })).toContainText("Unavailable"); + await expect(overview).toContainText("Runtime observations unavailable"); expect(sessionAfters).toEqual([null, "session_snapshot", null, "session_snapshot"]); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openView(page, "Session console"); await expect.poll(() => sessionAfters.length).toBe(4); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); + await openView(page, "Overview"); + await expect(overviewKpi(page, "Running Sessions")).not.toHaveText("—"); + await openView(page, "Agents"); + await expect(page.getByRole("region", { name: "Agents", exact: true })).toContainText("3 of 3 Agents"); + await openView(page, "Session log"); + await expect(sessionLogStatusFilter(page, "All").locator(".filter-count")).toHaveText("2"); + await expect(sessionLog(page).getByRole("table").getByRole("row")).toHaveCount(3); expect(agentAfters).toEqual([null, "agent_b"]); - // The cached Dashboard and shared Session collection stay mounted across - // navigation, so no extra page-chain read occurs on either transition. + // The cached snapshot and shared Session collection stay mounted across + // navigation, so no extra page-chain read occurs on any transition. await expect.poll(() => sessionAfters).toEqual([ null, "session_snapshot", null, "session_snapshot", ]); }); -test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { +test("keeps the previous Agent result when pagination exceeds the safety limit", async ({ page, request }) => { await resetFixture(request); await openSessionsFromHome(page); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - const dashboard = page.locator(".dashboard-page"); - const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" }); - await expect(loadedAgents).toContainText("3"); + await openView(page, "Agents"); + const loadedAgents = page.getByRole("region", { name: "Agents", exact: true }); + await expect(loadedAgents).toContainText("3 of 3 Agents"); + await openView(page, "Overview"); + const overview = page.locator(".overview-page"); let template: Record | null = null; let reads = 0; @@ -3091,18 +3121,22 @@ test("keeps the previous Dashboard result when pagination exceeds the safety lim }); }); - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + const refresh = pageRefresh(page); await refresh.click(); - await expect.poll(() => reads).toBe(100); + // Every page passes through a routed handler, which is slow over a remote browser connection. + await expect.poll(() => reads, { timeout: 30_000 }).toBe(100); await expect(refresh).toBeEnabled(); - await expect(dashboard).toContainText("Snapshot incomplete"); - await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); - await expect(dashboard).toContainText("Sessions changed while Runtime observations were loading"); - await expect(loadedAgents).toContainText("3"); - await dashboard.getByRole("button", { name: "Connection settings" }).click(); + const collectionAlert = overview.getByRole("alert").filter({ hasText: "Agents:" }); + await expect(collectionAlert).toContainText("collection pagination exceeded the Web safety limit"); + await expect(overview).toContainText("Sessions changed while Runtime observations were loading"); + await collectionAlert.getByRole("button", { name: "Connection settings" }).click(); const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); await expect(connectionDialog).toBeVisible(); await connectionDialog.getByRole("button", { name: "Cancel" }).click(); + await openView(page, "Agents"); + await expect(loadedAgents).toContainText("3 of 3 Agents"); + await expect(loadedAgents).toContainText("Refresh failed; showing the last loaded Agents."); + await expect(loadedAgents).not.toContainText("Safety page"); expect(reads).toBe(100); }); @@ -3119,7 +3153,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(connectedLiveEvents(page)).toBeVisible(); const { dialog, panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment pending"); + await expect(trigger).toHaveAccessibleName("Environment: Pending"); await expect(panel).toContainText("Self-hosted Environment"); await expect(panel).toContainText("Pending"); await expect(panel).toContainText("environment_fixture"); @@ -3148,7 +3182,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(dialog).toHaveCount(0); await expect(trigger).toBeFocused(); await page.setViewportSize({ width: 390, height: 844 }); - await page.getByRole("button", { name: "Dark theme" }).click(); + await chooseDarkTheme(page); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); const { panel: narrowPanel } = await openEnvironmentDialog(page); const widths = await narrowPanel.evaluate((element) => { @@ -3174,8 +3208,8 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await controlFixture(request, { environmentScenario: 2, environmentEventStatus: 0 }); await page.reload(); const { panel: unknown, trigger: unknownTrigger } = await openEnvironmentDialog(page); - await expect(unknownTrigger).toHaveAccessibleName("Environment unavailable"); - await expect(unknown).toContainText("Environment unavailable"); + await expect(unknownTrigger).toHaveAccessibleName("Environment: Unavailable"); + await expect(unknown).toContainText(/Environment:? unavailable/i); await expect(unknown).toContainText("Unknown type"); await expect(unknown).not.toContainText("/must-not-render"); await expect(unknown.locator("a")).toHaveCount(0); @@ -3235,7 +3269,7 @@ test("lists Workspace file metadata explicitly, paginates, fails closed, and fen await fixtureRequests(request) ).filter((entry) => entry.path.endsWith("/files")).length).toBeGreaterThan(reads.length + 1); await controlFixture(request, { environmentScenario: 2 }); - await page.getByRole("button", { name: "Recover durable state" }).evaluate((button) => ( + await page.getByRole("button", { name: "Refresh Sessions", exact: true }).evaluate((button) => ( button as HTMLButtonElement ).click()); await expect(page.getByRole("region", { name: "Workspace files" })).toHaveCount(0); @@ -3253,9 +3287,9 @@ test("hydrates durable expired and unavailable Environment states without a writ await expect(connectedLiveEvents(page)).toBeVisible(); let { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(trigger).toHaveAccessibleName("Environment: Expired"); await expect(panel).toContainText("Expired"); - await expect(panel).toContainText("Environment expired"); + await expect(panel).toContainText(/Environment:? expired/i); await expect(panel).toContainText("no API-managed files, plugins, or skills"); await expect(page.getByLabel("Message the Agent")).toBeVisible(); @@ -3278,7 +3312,7 @@ test("hydrates durable expired and unavailable Environment states without a writ await controlFixture(request, { environmentRetrieveStatus: 503 }); await page.reload(); ({ panel, trigger } = await openEnvironmentDialog(page)); - await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(trigger).toHaveAccessibleName("Environment: Unavailable"); await expect(panel).toContainText("Unavailable"); await expect(panel).toContainText("conversation remains usable"); await expect(panel).not.toContainText("Expired"); @@ -3291,7 +3325,7 @@ test("hydrates durable expired and unavailable Environment states without a writ }); await page.reload(); ({ panel, trigger } = await openEnvironmentDialog(page)); - await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(trigger).toHaveAccessibleName("Environment: Unavailable"); await expect(panel).toContainText("Unavailable"); await expect(page.getByLabel("Message the Agent")).toBeVisible(); @@ -3311,7 +3345,7 @@ test("hydrates durable Environment state even when the live stream is rejected", await openSessionsFromHome(page); const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(trigger).toHaveAccessibleName("Environment: Expired"); await expect(panel).toContainText("Expired"); await expect(panel).toContainText("Status comes from the durable Environment resource"); await expect(page.getByText("Events unavailable", { exact: true })).toBeVisible(); @@ -3334,7 +3368,7 @@ test("keeps canonical Environment UUID identity across Session and resource proj await openSessionsFromHome(page); const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(trigger).toHaveAccessibleName("Environment: Connected"); await expect(panel).toContainText("Connected"); await expect(panel).toContainText("Status comes from the durable Environment resource"); await expect(panel).not.toContainText("Durable Environment status is unavailable"); @@ -3359,7 +3393,7 @@ test("applies a buffered live Environment event after an earlier durable snapsho }); await openSessionsFromHome(page); - await expect(environmentTrigger(page)).toHaveAccessibleName("Environment pending"); + await expect(environmentTrigger(page)).toHaveAccessibleName("Environment: Pending"); const streamError = page.locator(".session-stream-error"); await expect(streamError.getByText("Couldn’t open live events")).toBeVisible(); @@ -3372,7 +3406,7 @@ test("applies a buffered live Environment event after an earlier durable snapsho await streamError.getByRole("button", { name: "Retry" }).click(); const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(trigger).toHaveAccessibleName("Environment: Connected"); await expect(panel).toContainText("Connected"); await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); await expect(panel).not.toContainText("Pending"); @@ -3431,7 +3465,7 @@ test("loads every Turn page, reconciles terminal events, and keeps diagnostics o ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/turns")).length).toBeGreaterThan(readsBeforeTerminal.length); await controlFixture(request, { turnsRetrieveStatus: 503 }); - await page.getByRole("button", { name: "Recover durable state" }).click(); + await page.getByRole("button", { name: "Refresh Sessions", exact: true }).click(); await expect(trace.locator(".trace-load-error").filter({ hasText: "Turn history is incomplete" })).toBeVisible(); await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); await expect(timeline).toContainText("last observed Turn timeline remains visible"); @@ -3597,8 +3631,8 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques await diagnostics.locator("summary").click(); const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); await expect(timeline).toContainText("Loading every Turn page"); - await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await openView(page, "Agent builder"); + await expect(page.getByRole("table", { name: "Agents", exact: true })).toBeVisible(); await startSessionWithSecondAgent(page); await page.getByRole("tab", { name: "Trace" }).click(); @@ -3655,7 +3689,7 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow await attachScreenshot(page, testInfo, "desktop-light-parsar-diff"); await page.setViewportSize({ width: 390, height: 844 }); - await page.getByRole("button", { name: "Dark theme" }).click(); + await chooseDarkTheme(page); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); const widths = await viewer.evaluate((element) => { const box = element.getBoundingClientRect(); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index c61f531ac..c9edf3917 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -406,7 +406,7 @@ test("announces loading, authenticated access, and each safe failure state from methods.push(route.request().method()); const reply = replies.shift(); if (!reply) return route.abort("failed"); - await new Promise((resolve) => setTimeout(resolve, 150)); + await new Promise((resolve) => setTimeout(resolve, 1_000)); if ("abort" in reply) return route.abort("failed"); return route.fulfill({ status: reply.status, diff --git a/apps/web/e2e/environment-templates.spec.ts b/apps/web/e2e/environment-templates.spec.ts index d56661715..0a7d24bb0 100644 --- a/apps/web/e2e/environment-templates.spec.ts +++ b/apps/web/e2e/environment-templates.spec.ts @@ -7,9 +7,10 @@ const beta = { "OpenAI-Beta": "agents=v1" }; interface Template { id: string; name: string | null; - network: { access: "enabled" | "disabled"; allowed_domains: string[] }; + network: { access: "enabled" | "disabled" | "restricted"; allowed_domains: string[] }; created_at: number; updated_at: number; + [section: string]: unknown; } interface RecordedRequest { @@ -45,12 +46,29 @@ async function openTemplates(page: Page) { await expect(page.getByRole("heading", { name: "Environment Templates", exact: true })).toBeVisible(); } +function consoleNav(page: Page) { + return page.getByRole("navigation", { name: "Console navigation", exact: true }); +} + +async function openView(page: Page, name: string) { + await consoleNav(page).getByRole("button", { name, exact: true }).click(); +} + function manager(page: Page) { return page.locator(".templates-page"); } +/** A Template row's accessible open action; page-rooted so it also works inside `filter({ has })`. */ +function templateLink(page: Page, name: string) { + return page.getByRole("button", { name: `Open ${name}`, exact: true }); +} + +function emptyCatalog(page: Page) { + return manager(page).getByText("No Environment Templates yet", { exact: true }); +} + async function openSessionSelector(page: Page) { - await page.getByRole("button", { name: "Agents", exact: true }).click(); + await openView(page, "Agent builder"); await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session", exact: true }); await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); @@ -75,12 +93,12 @@ test.afterEach(async ({ request }) => { test("opens Templates by navigation and hash, with an explicit empty catalog", async ({ page }) => { await page.goto("/"); - await page.getByRole("button", { name: "Templates", exact: true }).click(); + await openView(page, "Environment templates"); await expect(page).toHaveURL(/#templates$/u); - await expect(manager(page).getByRole("heading", { name: "No Environment Templates yet" })).toBeVisible(); + await expect(emptyCatalog(page)).toBeVisible(); await expect(manager(page).getByRole("button", { name: "New Template", exact: true })).toBeEnabled(); await page.reload(); - await expect(page.getByRole("button", { name: "Templates", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(consoleNav(page).getByRole("button", { name: "Environment templates", exact: true })).toHaveAttribute("aria-current", "page"); await expect(manager(page).getByRole("heading", { name: "Environment Templates", exact: true })).toBeVisible(); }); @@ -88,14 +106,14 @@ test("loads every Template page and keeps pagination cursors distinct", async ({ const templates = await Promise.all(["First profile", "Second profile", "Third profile"].map((name) => seed(request, name))); await control(request, { environmentTemplatePageSize: 2 }); await openTemplates(page); - for (const template of templates) await expect(manager(page).getByRole("heading", { name: template.name!, exact: true })).toBeVisible(); + for (const template of templates) await expect(templateLink(page, template.name!)).toBeVisible(); await expect(manager(page)).toContainText("3 of 3 Templates"); const reads = (await requests(request)).filter((entry) => entry.method === "GET" && entry.path === collectionPath); expect(reads.some((entry) => new URLSearchParams(entry.query).has("after"))).toBe(true); expect(new Set(reads.map((entry) => entry.query)).size).toBeGreaterThanOrEqual(2); await manager(page).getByRole("searchbox", { name: "Filter Templates" }).fill("Third profile"); await expect(manager(page)).toContainText("1 of 3 Templates"); - await expect(manager(page).getByRole("heading", { name: "First profile", exact: true })).toHaveCount(0); + await expect(templateLink(page, "First profile")).toHaveCount(0); }); for (const status of [400, 404, 405, 501, 503]) { @@ -106,11 +124,11 @@ for (const status of [400, 404, 405, 501, 503]) { status === 503 ? "Environment Templates could not be loaded" : "Environment Templates are not available", { exact: true }, )).toBeVisible(); - await expect(manager(page).getByRole("heading", { name: "No Environment Templates yet" })).toHaveCount(0); + await expect(emptyCatalog(page)).toHaveCount(0); await expect(manager(page).getByRole("button", { name: "New Template", exact: true })).toBeDisabled(); await control(request, { environmentTemplateListStatus: 200 }); await manager(page).getByRole("button", { name: "Refresh", exact: true }).click(); - await expect(manager(page).getByRole("heading", { name: "No Environment Templates yet" })).toBeVisible(); + await expect(emptyCatalog(page)).toBeVisible(); }); } @@ -123,7 +141,7 @@ test("creates and edits basic Templates with minimal patches, then refreshes the const beforeCreate = (await requests(request)).filter((entry) => entry.method === "GET" && entry.path === collectionPath).length; await dialog.getByRole("button", { name: "Create Template", exact: true }).click(); await expect(dialog).toHaveCount(0); - await expect(manager(page).getByRole("heading", { name: "Outbound disabled", exact: true })).toBeVisible(); + await expect(templateLink(page, "Outbound disabled")).toBeVisible(); const writes = (await requests(request)).filter((entry) => entry.method === "POST" && entry.path === collectionPath); expect(writes).toHaveLength(1); expect(writes[0]!.body).toEqual({ name: "Outbound disabled", network: { access: "disabled" } }); @@ -161,7 +179,7 @@ test("confirms deletion, preserves a failed delete, and removes the selected cat await openTemplates(page); await manager(page).getByRole("button", { name: "Delete Delete candidate", exact: true }).click(); let dialog = page.getByRole("dialog", { name: "Delete Template?", exact: true }); - await expect(dialog).toContainText("Existing Sessions keep their configuration"); + await expect(dialog).toContainText("Existing Sessions are not affected"); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); expect((await requests(request)).filter((entry) => entry.method === "DELETE")).toEqual([]); @@ -176,25 +194,156 @@ test("confirms deletion, preserves a failed delete, and removes the selected cat await expect(manager(page).getByRole("button", { name: "Delete Delete candidate", exact: true })).toBeEnabled(); await manager(page).getByRole("button", { name: "Delete Delete candidate", exact: true }).click(); await page.getByRole("dialog", { name: "Delete Template?", exact: true }).getByRole("button", { name: "Delete Template", exact: true }).click(); - await expect(manager(page).getByRole("heading", { name: "No Environment Templates yet" })).toBeVisible(); + await expect(emptyCatalog(page)).toBeVisible(); const selector = await openSessionSelector(page); await expect(selector.locator(`option[value="${template.id}"]`)).toHaveCount(0); await expect(selector).toBeDisabled(); await page.keyboard.press("Escape"); }); -test("rejects advanced Template responses without offering destructive basic edits", async ({ page, request }) => { - await seed(request, "Advanced profile"); +const advancedBody = { + name: "Report builder", + network: { access: "restricted", allowed_domains: ["pypi.org", "files.pythonhosted.org"] }, + capability_directories: ["/workspace/capabilities"], + packages: { npm: ["typescript@5.8.3"], python: ["packaging==26.0"], system: ["jq"] }, + files: [ + { type: "inline", path: "/workspace/config/settings.json", data: Buffer.from("{\"mode\":\"report\"}").toString("base64") }, + { type: "file_id", path: "/workspace/data/input.csv", file_id: "file-2b7c9d10-4e5f-4a6b-8c7d-9e0f1a2b3c4d" }, + ], + skills: [ + { type: "skill_reference", skill_id: "skill_3f1c2a9e-7b4d-4e8a-9c21-5d6e7f8a9b0c" }, + { type: "skill_reference", skill_id: "skill_9d8c7b6a-5e4f-4d3c-8b2a-1f0e9d8c7b6a", version: "latest" }, + { type: "skill_reference", skill_id: "skill_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", version: "2" }, + { type: "inline", name: "triage", description: "Sort incoming issues.", source: { type: "base64", media_type: "application/zip", data: "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==" } }, + ], + plugins: [{ type: "inline", name: "release-notes", description: "Draft release notes from merged changes.", source: { type: "base64", media_type: "application/zip", data: "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==" } }], + env: { REPORT_TOKEN: "private-canary" }, + setup_commands: [{ command: "echo private-setup" }], +}; + +async function seedAdvanced(request: APIRequestContext, fileId?: string): Promise