diff --git a/.agent-maintainer/dashboard-source-baseline.json b/.agent-maintainer/dashboard-source-baseline.json index 01fcb7c3..740e07b1 100644 --- a/.agent-maintainer/dashboard-source-baseline.json +++ b/.agent-maintainer/dashboard-source-baseline.json @@ -2,23 +2,23 @@ "exceptions": { "frontend/dashboard/src/App.live-refresh.test.tsx": { "group": "tests", - "nonblank": 543, - "physical": 575 + "nonblank": 553, + "physical": 587 }, "frontend/dashboard/src/App.tsx": { "group": "source", - "nonblank": 703, - "physical": 747 + "nonblank": 708, + "physical": 751 }, "frontend/dashboard/src/api/client.ts": { "group": "source", - "nonblank": 765, - "physical": 825 + "nonblank": 769, + "physical": 829 }, "frontend/dashboard/src/api/types.ts": { "group": "source", - "nonblank": 422, - "physical": 448 + "nonblank": 431, + "physical": 458 }, "frontend/dashboard/src/app/zh-Hans/advancedPages.ts": { "group": "source", @@ -47,8 +47,8 @@ }, "frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx": { "group": "source", - "nonblank": 473, - "physical": 502 + "nonblank": 480, + "physical": 510 }, "frontend/dashboard/src/features/diagnostics/diagnosticSnapshotCards.ts": { "group": "source", @@ -60,6 +60,11 @@ "nonblank": 639, "physical": 683 }, + "frontend/dashboard/src/features/threads/ThreadsPage.tsx": { + "group": "source", + "nonblank": 450, + "physical": 476 + }, "frontend/dashboard/src/styles/context-evidence.css": { "group": "styles", "nonblank": 352, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4923348..27c452e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: cache-dependency-path: package-lock.json - name: Install dashboard tooling run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium - name: Dashboard lint run: npm run dashboard:lint - name: Dashboard type check @@ -39,6 +41,8 @@ jobs: run: npm run dashboard:governance - name: Dashboard bundle budget run: node scripts/check-dashboard-bundles.mjs + - name: Dashboard release candidate + run: npm run dashboard:release-candidate hardening_routes: name: Hardening / Dashboard route budget @@ -268,6 +272,15 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.14" + - uses: actions/setup-node@v6.4.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: package-lock.json + - name: Install dashboard tooling + run: npm ci + - name: Verify packaged dashboard assets + run: npm run dashboard:assets:check - name: Install build tooling run: | python -m pip install --upgrade pip @@ -278,3 +291,5 @@ jobs: run: python -m twine check dist/* - name: Verify built wheel run: python scripts/check_release.py --dist + - name: Smoke installed wheel + run: python scripts/smoke_installed_package.py diff --git a/config/vulture-whitelist.py b/config/vulture-whitelist.py index e49da481..fe732204 100644 --- a/config/vulture-whitelist.py +++ b/config/vulture-whitelist.py @@ -78,6 +78,7 @@ _._handle_open_investigator _._handle_health _._handle_status +_._handle_readiness _._handle_dedupe_diagnostics _._handle_calls _._handle_call diff --git a/dependency-cruiser.config.cjs b/dependency-cruiser.config.cjs index 83889363..fce9b14c 100644 --- a/dependency-cruiser.config.cjs +++ b/dependency-cruiser.config.cjs @@ -25,7 +25,7 @@ module.exports = { from: { path: '^src/features/' }, to: { path: '^src/app/', - pathNot: '^src/app/(i18nContext|shellUrl)\\.tsx?$' + pathNot: '^src/app/(dashboardTargets|i18nContext|shellUrl)\\.tsx?$' } }, { diff --git a/docs/architecture.md b/docs/architecture.md index 9537fc92..3e8981e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ Shareable outputs remain aggregate-first and must omit indexed/raw content unles - `context.py` is the normal path for explicit selected-call raw context. It reads one selected source file on demand, applies redaction and size limits, omits tool output by default, and keeps full serialized group analysis explicit. - `diagnostic_snapshots.py` owns persisted diagnostic snapshot refresh/load orchestration. Snapshot modules should stay synthetic-testable and avoid raw transcript persistence in aggregate diagnostic facts. - `dashboard.py` builds aggregate-first static dashboard payloads and writes HTML/assets. `server.py` adds localhost refresh, compatibility `/api/usage`, SQL-backed live API slices, and explicit lazy context loading. -- `frontend/dashboard/` owns the React dashboard. It should render server/API payloads rather than becoming an independent source of usage calculations. +- `frontend/dashboard/` owns the React dashboard. The packaged React HTML embeds only a database-free boot payload (authentication, localization, and data-scope defaults), then hydrates aggregate data through the localhost APIs. It should render server/API payloads rather than becoming an independent source of usage calculations. - `plugin_installer.py`, `.mcp.json`, `skills/`, `src/codex_usage_tracker/plugin_data/skills/`, and `scripts/check_release.py` own install and packaging behavior. - `scripts/benchmark_synthetic_history.py` owns broad generated large-history query timing checks. `scripts/benchmark_dashboard_routes.py` owns deterministic cold/warm route budgets for the query pipeline. Both must stay synthetic-only and must not read real Codex logs. diff --git a/docs/cli-json-schemas.md b/docs/cli-json-schemas.md index 3768f34d..fab10b27 100644 --- a/docs/cli-json-schemas.md +++ b/docs/cli-json-schemas.md @@ -88,6 +88,7 @@ Tracked schema ids: | `codex-usage-tracker-dashboard-v1` | CLI `dashboard --json`, MCP `generate_usage_dashboard()` | | `codex-usage-tracker-open-dashboard-v1` | CLI `open-dashboard --json` | | `codex-usage-tracker-serve-dashboard-v1` | CLI `serve-dashboard --json` startup payload, including preferred React `dashboard_url` and legacy `legacy_dashboard_url` fallback | +| `codex-usage-tracker-dashboard-target-v1` | Privacy-safe agent/dashboard handoff with a cataloged view, normalized reviewed selectors, and deterministic React URL | | `codex-usage-tracker-pricing-coverage-v1` | CLI `pricing-coverage --json`, MCP `usage_pricing_coverage(response_format="json")` | | `codex-usage-tracker-source-coverage-v1` | CLI `source-coverage --json`, MCP `usage_source_coverage(response_format="json")` | | `codex-usage-tracker-content-search-v1` | MCP `usage_content_search(...)`; explicit local content-index search, may include indexed snippets | @@ -112,6 +113,37 @@ Tracked schema ids: | `codex-usage-tracker-init-projects-v1` | CLI `init-projects --json` | | `codex-usage-tracker-support-bundle-v1` | CLI `support-bundle --json` | +## Dashboard Targets + +`codex-usage-tracker-dashboard-target-v1` is the privacy-safe handoff contract +for agent-created dashboard links and copied prompts. It accepts only cataloged +views, canonical route identifiers, the normalized `active` or `all` history +scope, and filters reviewed for that destination. Unknown search fields, API +tokens, raw or indexed text, raw-context controls, and local paths are omitted. +Query parameters are sorted so equivalent targets serialize identically. +The dashboard route catalog keeps the full supported deep-link parameter set +separate from the smaller handoff-safe set used by this contract. + +```json +{"schema":"codex-usage-tracker-dashboard-target-v1","view":"call","record_id":"record-123","filters":{},"history":"active","privacy_mode":"strict","relative_url":"/react-dashboard.html?record=record-123&view=call","absolute_url":null,"fallback_instruction":"codex-usage-tracker serve-dashboard --open"} +``` + +`absolute_url` is populated only when a healthy persistent dashboard service or +an explicitly active loopback `serve-dashboard` origin is known. Otherwise, +clients use `relative_url` and show `fallback_instruction`. `record_id`, +`thread_key`, `diagnostic_fact`, and `limit_evidence` are optional because each +is valid only for its corresponding destination. +Canonical identifiers follow their producer formats: record ids are lowercase +SHA-256 hex (with `record-` reserved for documented synthetic examples), +session thread keys contain a UUID, diagnostic keys contain a cataloged fact +type plus the parser's structured fact name, and limit evidence is `stable` or +`decreased`. Normal mode may carry the parser's explicit `thread:` +key; redacted and strict modes reject that label-bearing form and require a +`session:` key. Reports accept only the verified dashboard report ids. +Other string filters use documented enums, while numeric filters use per-key +ranges and fixed non-scientific serialization. Boolean query values serialize +as lowercase `true` or `false` in both the Python and TypeScript builders. + ## Shared Error Codes CLI failures print a stable code in stderr: diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2076d761..875ab4a7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -352,6 +352,30 @@ the URL. - Evidence rows open the same Call Investigator used by Calls, Threads, Limits, and Diagnostics. +## Release-Candidate Route And Readiness Evidence + +Release N keeps the existing primary navigation unchanged. The Advanced +setting stores `Show experimental dashboard features` for this browser origin, +but the discoverability change is reserved for Release N+1. Direct links remain +valid throughout the transition, Diagnostics stays visible, and every +experimental or transitioning workspace carries an explicit maturity banner. + +The synthetic screenshot manifest covers each lifecycle category: + +- stable primary routes: Overview and Calls +- experimental routes: Investigate, Compression Lab, and always-visible + Diagnostics +- transitioning routes: Cache And Context Lab and Reports +- contextual routes: Call Investigator, including its Calls or Threads return + target + +It also captures the `ready`, `restart-required`, `unavailable`, and `unknown` +analysis-readiness states. Every state keeps recovery guidance and the manual +fallback list for Calls, Threads, Limits, Diagnostics, and Advanced experimental +controls visible. Run `npm run dashboard:screenshots` only against the documented +synthetic fixture server; never use a dashboard containing real session data for +documentation images. + ## Settings View `Settings` is a read-only explanation of the authoritative local configuration, diff --git a/docs/development.md b/docs/development.md index a668aef0..43cf3b5d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -333,6 +333,13 @@ record independent cold samples and warm hits. Thresholds live in `config/dashboard-route-budgets.json`; change them only with repeatable synthetic evidence. The benchmark never reads local Codex logs. +The packaged React HTML response is deliberately database-free: it embeds only +the boot token, localization, and data-scope defaults, then the browser hydrates +through `/api/usage`. The focused server regression test injects a one-second +aggregate-shell delay and requires the HTML response in under 500 ms, preventing +whole-history queries or MCP readiness checks from returning to the first-byte +path. + ## Release Checklist Use a release branch only for version/changelog/pinning/publish prep. It should include release-specific changes such as version bumps, `CHANGELOG.md`, install/version wording, runtime package pins, publish workflow tweaks, release notes, and final smoke-test fixes. It should not include unrelated features. diff --git a/docs/first-five-minutes.md b/docs/first-five-minutes.md index 65e12974..0dc03acb 100644 --- a/docs/first-five-minutes.md +++ b/docs/first-five-minutes.md @@ -72,6 +72,12 @@ codex-usage-tracker install-plugin Then restart Codex and start a fresh Codex session. +A healthy local installation or dashboard service does not prove that the current +Codex task loaded MCP tools. Check the tools exposed to the current task. When an MCP +result includes a dashboard evidence target, open its absolute localhost URL when +present; otherwise use the relative target only after following its launch guidance +(`codex-usage-tracker serve-dashboard --open`). + ## What To Attach To Issues For public GitHub issues, prefer a strict support bundle: diff --git a/docs/mcp.md b/docs/mcp.md index a13d0499..090ec6a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -182,6 +182,37 @@ Dashboard-shaped MCP tools return JSON dictionaries that reuse the same aggregat - `usage_report_pack(...)` mirrors `/api/reports/pack`. - `usage_dashboard_recommendations(...)` returns dashboard recommendation payloads. +Selected MCP results include an additive `dashboard_target` without changing their +existing schema name, fields, row order, filters, or query semantics. `usage_status` +links to Overview; Calls rows and call detail link to Call Investigator; Threads rows +link only when their producer supplied a canonical `thread_key`; and canonical +`usage_investigate` evidence rows link to matching Call Investigator or Threads +evidence. Each finding inherits a copy of its first successfully linked evidence +target; findings do not encode an ordinal Investigator route. Thread annotation never +substitutes a display name when `thread_key` is absent. If a canonical identifier is +not valid for the selected privacy mode, that row simply omits the target. + +Targets also preserve the MCP query's history scope. Calls, Threads, status, and +investigation results requested with `include_archived=true` emit `history="all"` and +an explicit `history=all` URL parameter. Active-only requests emit `history="active"`. +Call detail uses a reliable producer `is_archived` flag, when present, to keep an +archived selected record reachable in all-history scope. + +Each tool checks the persistent dashboard service once. A reachable service produces +a loopback `absolute_url`; an unreachable service or a local status-inspection error +keeps the MCP result successful and returns a relative target with +`codex-usage-tracker serve-dashboard --open` fallback guidance. These links are +navigation metadata only: target presence does not upgrade conversational readiness, +index freshness, MCP discovery, current-task tool exposure, or access to raw context. +Use the existing readiness and recovery fields from `usage_status` for those claims. + +Clients should present **Open evidence** with `dashboard_target.absolute_url` when +that value is present. Otherwise they should show `relative_url` together with the +exact `fallback_instruction`; they must not invent a host or infer that a dashboard +service is running. A target, installed skill, or healthy persistent service never +proves task-level MCP tool exposure. Inspect the current task's tool inventory before +claiming MCP availability. + `refresh_usage_index()` indexes aggregate usage rows plus the local content index by default. Use `refresh_usage_index(aggregate_only=True)` when the user wants the older aggregate-only SQLite posture. Use `include_archived=True` only when the user explicitly wants all history; the dashboard defaults to active sessions so older work does not inflate current usage. ## Allowance Intelligence diff --git a/docs/superpowers/plans/2026-07-19-mcp-first-dashboard-transition.md b/docs/superpowers/plans/2026-07-19-mcp-first-dashboard-transition.md new file mode 100644 index 00000000..0c217c29 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-mcp-first-dashboard-transition.md @@ -0,0 +1,755 @@ +# MCP-First Dashboard Transition Implementation Plan + +> **For Codex:** Execute this plan task by task with `superpowers:executing-plans` or `superpowers:subagent-driven-development`. Release N and Release N+1 are separate releases. + +**Goal:** Make MCP the primary analysis surface and the live React dashboard its focused evidence companion, without regressing Calls, Threads, Limits, Call Investigator, Diagnostics, or Settings. + +**Architecture:** Introduce one exhaustive route catalog, one shell-owned per-origin experimental preference, a locally provable conversational-readiness model, and a renderer-independent dashboard-target contract. Release N lands the bridge with navigation unchanged. Release N+1 derives a six-tab default navigation only after replacement, privacy, package, and browser gates pass. + +**Tech Stack:** Python 3.10+, FastMCP, stdlib HTTP server, React 19, TypeScript, TanStack Router/Query/Table, Vitest, Testing Library, Playwright/Axe, pytest, Vite, setuptools. + +**Approved design:** [`docs/superpowers/specs/2026-07-19-mcp-first-dashboard-transition-design.md`](../specs/2026-07-19-mcp-first-dashboard-transition-design.md) + +--- + +## Non-Negotiable Contracts + +- Release N preserves the current React navigation and every existing route ID. +- Release N+1 defaults to exactly Overview, Calls, Threads, Limits, Diagnostics Notebook, and Settings. +- Call Investigator remains contextual and functionally unchanged. +- Limits keeps status, history, and statistical analysis. +- Diagnostics remains visible and receives a **Highly experimental** banner. +- Investigate and Compression Lab become opt-in experimental navigation entries; direct URLs always work. +- Cache And Context and Reports become direct-only transitioning routes only after job parity is signed off. +- Files, Commands, and Models aliases disappear only in Release N+1. +- No React route is deleted and no legacy static entry point changes in these releases. +- The experimental preference is shell-owned, browser-local, per origin, and off by default. +- Targets and copied prompts exclude API tokens, raw/indexed text, local paths, raw context, and privacy-disallowed labels. +- Reuse existing components, CSS tokens, Lucide icons, fixtures, and localization. Add no UI framework. +- Use synthetic data in tests, captures, docs, and release evidence. + +## Delivery Map + +| PR | Release | Outcome | Merge gate | +| --- | --- | --- | --- | +| 1 | N | Route catalog, preference, banners; navigation unchanged | Frontend coherence, unit, lint, typecheck | +| 2 | N | Readiness and MCP-to-dashboard evidence bridge | Python/TS schema, privacy, MCP, server tests | +| 3 | N | Fresh packaged assets, installed-wheel smoke, required browser gate | Clean asset diff, build, Playwright/Axe | +| 4 | N+1 | Six-tab default, experimental group, direct transitioning routes | Job parity, unit, browser, localization | +| 5 | N+1 | Focused Overview and MCP-first docs | Task walkthrough and full release gate | + +Record the Release N tag and commit after PR 3. Release N+1 must be revertible to it without a data migration. + +--- + +## Release N: Foundation Without Navigation Changes + +### Task 1: Centralize route metadata and lock the baseline + +**Files:** + +- Create: `frontend/dashboard/src/app/routeCatalog.ts` +- Create: `frontend/dashboard/src/app/routeCatalog.test.ts` +- Modify: `frontend/dashboard/src/app/navigation.ts` +- Modify: `frontend/dashboard/src/routes/dashboardSearch.ts` +- Modify: `frontend/dashboard/src/routes/DashboardRouteView.tsx` +- Modify: `frontend/dashboard/src/app/shellUrl.ts` +- Modify: `frontend/dashboard/src/app/shellUrl.test.ts` +- Modify: `frontend/dashboard/src/app/currentViewExport.ts` + +**Step 1: Write failing coherence tests.** Assert every `DashboardViewId` is cataloged and rendered once, IDs and labels are unique, `call` is contextual, foundation navigation matches the current ten primary entries, and return labels resolve from the full catalog rather than visible navigation. + +```ts +expect(new Set(routeCatalog.map(route => route.id))).toEqual(new Set(dashboardViewIds)); +expect(navigationForPhase('foundation').map(route => route.id)).toEqual([ + 'overview', 'investigator', 'compression-lab', 'calls', 'threads', + 'usage-drain', 'cache-context', 'diagnostics', 'reports', 'settings', +]); +expect(routeDefinition('diagnostics')).toMatchObject({ + maturity: 'experimental', placement: 'primary', lifecycle: 'active', +}); +expect(routeDefinition('cache-context')).toMatchObject({ + maturity: 'experimental', placement: 'hidden', lifecycle: 'transitioning', +}); +``` + +Run `npm --workspace frontend/dashboard test -- routeCatalog.test.ts shellUrl.test.ts`. Expected: FAIL because the catalog does not exist. + +**Step 2: Implement the exhaustive catalog.** Export `dashboardViewIds` from `dashboardSearch.ts` and define: + +```ts +type RouteMaturity = 'stable' | 'experimental'; +type RoutePlacement = 'primary' | 'contextual' | 'hidden'; +type RouteLifecycle = 'active' | 'transitioning' | 'deprecated'; +type DashboardExposurePhase = 'foundation' | 'simplified'; + +type DashboardRouteDefinition = { + id: DashboardViewId; + label: string; + description: string; + icon: LucideIcon; + maturity: RouteMaturity; + placement: RoutePlacement; + lifecycle: RouteLifecycle; + navigationGroup: 'primary' | 'experimental' | null; + experimentalNavigationEligible: boolean; + capabilities: { refresh: boolean; export: boolean; copyLink: boolean }; + safeParams: readonly string[]; +}; +``` + +Classify stable/primary/active: Overview, Calls, Threads, Limits, Settings; experimental/primary/active: Diagnostics; experimental/hidden/active: Investigate and Compression Lab; experimental/hidden/transitioning: Cache And Context and Reports; stable/contextual/active: Call Investigator. Keep placement independent from discoverability: only Investigate and Compression Lab set `experimentalNavigationEligible: true`. Files, Commands, and Models remain separate navigation aliases classified stable/hidden/deprecated until Task 12 removes them. + +**Step 3: Move all route consumers.** Derive Release N `navItems` from `navigationForPhase('foundation')`; retain `secondaryNavItems`. Make return labels, keyboard labels, export labels, and refresh/export checks use the full catalog. Keep rendering exhaustive. Add `thread_key` to Threads URL cleanup. + +**Step 4: Verify.** Run: + +```bash +npm --workspace frontend/dashboard test -- routeCatalog.test.ts shellUrl.test.ts dashboardRouter.test.tsx App.shell.test.tsx +npm run dashboard:typecheck +npm run dashboard:lint +``` + +Expected: PASS with unchanged visible navigation. + +**Step 5: Commit.** + +```bash +git add -- frontend/dashboard/src/app/routeCatalog.ts frontend/dashboard/src/app/routeCatalog.test.ts frontend/dashboard/src/app/navigation.ts frontend/dashboard/src/routes/dashboardSearch.ts frontend/dashboard/src/routes/DashboardRouteView.tsx frontend/dashboard/src/app/shellUrl.ts frontend/dashboard/src/app/shellUrl.test.ts frontend/dashboard/src/app/currentViewExport.ts +git commit -m "refactor: centralize dashboard route metadata" +``` + +### Task 2: Add the shell-owned experimental preference + +**Files:** + +- Create: `frontend/dashboard/src/app/useExperimentalDashboardFeatures.ts` +- Create: `frontend/dashboard/src/app/useExperimentalDashboardFeatures.test.tsx` +- Modify: `frontend/dashboard/src/App.tsx` +- Modify: `frontend/dashboard/src/routes/DashboardRouteView.tsx` +- Modify: `frontend/dashboard/src/features/settings/useSettingsSection.ts` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.tsx` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.module.css` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.test.tsx` + +**Step 1: Write failing tests.** Cover default-off, immediate update, remount, malformed values, restricted storage, and the per-origin explanation. Use key `codex-usage-dashboard-show-experimental-v1` with JSON booleans. + +**Step 2: Implement one owner in `App.tsx`.** The hook catches read/write failures but preserves current-session state: + +```ts +export function useExperimentalDashboardFeatures() { + const [showExperimental, setShowExperimental] = useState(readPreference); + useEffect(() => safelyWrite(showExperimental), [showExperimental]); + return { showExperimental, setShowExperimental }; +} +``` + +Pass value and setter through `DashboardRouteView` to Settings. Do not duplicate state in Settings and do not change navigation in Release N. + +**Step 3: Add Settings > Advanced.** Append `advanced` to `settingsSections`; add the native checkbox **Show experimental dashboard features**; explain browser-origin scope, direct-link availability, and that Diagnostics stays visible. + +**Step 4: Verify.** Run `npm --workspace frontend/dashboard test -- useExperimentalDashboardFeatures.test.tsx SettingsPage.test.tsx App.shell.test.tsx` and `npm run dashboard:typecheck`. Expected: PASS; navigation remains unchanged. + +**Step 5: Commit.** + +```bash +git add -- frontend/dashboard/src/app/useExperimentalDashboardFeatures.ts frontend/dashboard/src/app/useExperimentalDashboardFeatures.test.tsx frontend/dashboard/src/App.tsx frontend/dashboard/src/routes/DashboardRouteView.tsx frontend/dashboard/src/features/settings/useSettingsSection.ts frontend/dashboard/src/features/settings/SettingsPage.tsx frontend/dashboard/src/features/settings/SettingsPage.module.css frontend/dashboard/src/features/settings/SettingsPage.test.tsx +git commit -m "feat: add experimental dashboard preference" +``` + +### Task 3: Add shared maturity and transition banners + +**Files:** + +- Create: `frontend/dashboard/src/components/FeatureMaturityBanner.tsx` +- Create: `frontend/dashboard/src/components/FeatureMaturityBanner.module.css` +- Create: `frontend/dashboard/src/components/FeatureMaturityBanner.test.tsx` +- Modify: `frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx` +- Modify: `frontend/dashboard/src/features/diagnostics/DiagnosticsPage.query.test.tsx` +- Modify: `frontend/dashboard/src/features/investigator/InvestigatorPage.tsx` +- Modify: `frontend/dashboard/src/features/compression-lab/CompressionLabPage.tsx` +- Modify: `frontend/dashboard/src/features/cache-context/CacheContextPage.tsx` +- Modify: `frontend/dashboard/src/features/reports/ReportsPage.tsx` + +**Step 1: Write failing semantic tests.** Require `role="note"`, an accessible label, title, description, and optional replacement action. Assert Diagnostics says **Highly experimental** independently of the preference. + +**Step 2: Implement one component.** Reuse product tokens and render: + +```tsx + +``` + +Add experimental banners to Investigate and Compression Lab. Add transition-capable banners to Cache And Context and Reports, but keep neutral copy while they remain visible in Release N. + +**Step 3: Verify.** Run `npm --workspace frontend/dashboard test -- FeatureMaturityBanner.test.tsx DiagnosticsPage.query.test.tsx dashboardRouter.test.tsx`, `npm run dashboard:stylelint`, and `npm run dashboard:typecheck`. Expected: PASS and all direct routes render. + +**Step 4: Commit.** + +```bash +git add -- frontend/dashboard/src/components/FeatureMaturityBanner.tsx frontend/dashboard/src/components/FeatureMaturityBanner.module.css frontend/dashboard/src/components/FeatureMaturityBanner.test.tsx frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx frontend/dashboard/src/features/diagnostics/DiagnosticsPage.query.test.tsx frontend/dashboard/src/features/investigator/InvestigatorPage.tsx frontend/dashboard/src/features/compression-lab/CompressionLabPage.tsx frontend/dashboard/src/features/cache-context/CacheContextPage.tsx frontend/dashboard/src/features/reports/ReportsPage.tsx +git commit -m "feat: label dashboard feature maturity" +``` + +### Task 4: Report locally provable conversational readiness + +**Files:** + +- Create: `src/codex_usage_tracker/core/conversational_readiness.py` +- Create: `tests/core/test_conversational_readiness.py` +- Modify: `src/codex_usage_tracker/server/status.py` +- Modify: `src/codex_usage_tracker/server/dashboard_shell.py` +- Modify: `src/codex_usage_tracker/server/handler.py` +- Modify: `src/codex_usage_tracker/server/dashboard_pages.py` +- Modify: `src/codex_usage_tracker/cli/mcp_dashboard.py` +- Modify: `tests/server/test_server_status.py` +- Modify: `tests/server/test_server_dashboard_shell.py` +- Modify: `tests/dashboard/test_dashboard_server.py` +- Modify: `tests/cli/test_mcp_integration.py` +- Modify: `frontend/dashboard/src/api/types.ts` +- Create: `frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.tsx` +- Create: `frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.module.css` +- Create: `frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.test.tsx` +- Modify: `frontend/dashboard/src/features/overview/OverviewPage.tsx` +- Modify: `frontend/dashboard/src/features/overview/OverviewPage.test.tsx` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.tsx` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.test.tsx` + +**Step 1: Write failing backend contract tests.** Cover fresh install before restart, valid generated wrapper, missing/malformed config, failed launcher import, and uninspectable/static state. Define: + +```python +class ConversationalReadiness(TypedDict): + schema: Literal["codex-usage-tracker-conversational-readiness-v1"] + state: Literal["ready", "restart-required", "unavailable", "unknown"] + summary: str + next_action: str | None + evidence: list[str] +``` + +`ready` means only that local installation and launcher checks pass. Never claim the current Codex task loaded MCP tools. Reuse bounded pure doctor checks; do not run the full doctor on every status request. + +**Step 2: Add optional `conversational_analysis` to live status and shell boot payloads.** Thread `codex_home` explicitly through `handler._handle_status` → `handle_status_request` → `status_payload`, and through `dashboard_pages._dashboard_shell_payload` → `dashboard_shell_payload`. MCP `usage_status` passes `mcp_dashboard.DEFAULT_CODEX_HOME`. Static exports omit the field and normalize to `unknown`; no helper silently falls back to the process home. + +Add server and MCP integration tests using a temporary custom Codex home. Prove the status endpoint and React shell report that fixture's readiness rather than the developer machine's default home. + +**Step 3: Render the same recovery card in Overview and Settings.** Ready shows local checks passed; restart-required gives restart/fresh-task action; unavailable gives the exact setup/doctor action; unknown explains the limitation. Always expose Calls, Threads, Limits, Diagnostics, and Advanced experimental controls as manual fallback. + +**Step 4: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_conversational_readiness.py tests/server/test_server_status.py tests/server/test_server_dashboard_shell.py tests/dashboard/test_dashboard_server.py tests/cli/test_mcp_integration.py -q +npm --workspace frontend/dashboard test -- ConversationalAnalysisStatus.test.tsx OverviewPage.test.tsx SettingsPage.test.tsx +npm run dashboard:typecheck +``` + +Expected: PASS across all four states and static fallback. + +**Step 5: Commit.** + +```bash +git add -- src/codex_usage_tracker/core/conversational_readiness.py tests/core/test_conversational_readiness.py src/codex_usage_tracker/server/status.py src/codex_usage_tracker/server/dashboard_shell.py src/codex_usage_tracker/server/handler.py src/codex_usage_tracker/server/dashboard_pages.py src/codex_usage_tracker/cli/mcp_dashboard.py tests/server/test_server_status.py tests/server/test_server_dashboard_shell.py tests/dashboard/test_dashboard_server.py tests/cli/test_mcp_integration.py frontend/dashboard/src/api/types.ts frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.tsx frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.module.css frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.test.tsx frontend/dashboard/src/features/overview/OverviewPage.tsx frontend/dashboard/src/features/overview/OverviewPage.test.tsx frontend/dashboard/src/features/settings/SettingsPage.tsx frontend/dashboard/src/features/settings/SettingsPage.test.tsx +git commit -m "feat: report conversational analysis readiness" +``` + +### Task 5: Implement the privacy-safe dashboard-target contract + +**Files:** + +- Create: `src/codex_usage_tracker/core/dashboard_targets.py` +- Create: `tests/core/test_dashboard_targets.py` +- Modify: `src/codex_usage_tracker/core/json_contract_server.py` +- Modify: `tests/core/test_json_contracts.py` +- Modify: `src/codex_usage_tracker/dashboard_service.py` +- Modify: `tests/cli/test_dashboard_service.py` +- Create: `frontend/dashboard/src/app/dashboardTargets.ts` +- Create: `frontend/dashboard/src/app/dashboardTargets.test.ts` +- Modify: `frontend/dashboard/src/app/routeCatalog.ts` +- Modify: `docs/cli-json-schemas.md` + +**Step 1: Write failing contract tests.** Require schema `codex-usage-tracker-dashboard-target-v1`, stable IDs, normalized history/filters, privacy mode, relative URL, optional absolute URL, and fallback instruction. Test normal, redacted, and strict modes. Reject API tokens, raw/indexed text, paths, raw-context fields, and unreviewed search text. + +```python +target = build_dashboard_target( + view="call", + record_id="record-123", + privacy_mode="strict", + service_origin="http://127.0.0.1:47821", +) +assert target["relative_url"] == "/react-dashboard.html?view=call&record=record-123" +``` + +**Step 2: Implement pure builders and register the stable schema.** Accept only cataloged destinations and canonical selectors: `record_id`, `thread_key`, `diagnostic_fact`, `limit_evidence`, `history`, and per-view allowlisted filters. Sort parameters. Return an absolute URL only for an explicitly active loopback origin or healthy persistent service. Add `DashboardServiceStatus.react_url` without changing existing `url` root semantics. + +Register `codex-usage-tracker-dashboard-target-v1` in `SERVER_JSON_PAYLOAD_CONTRACTS`. Validate common required keys (`view`, normalized `filters`, `history`, `privacy_mode`, and `relative_url`) plus nullable `absolute_url` and `fallback_instruction`. Route-specific canonical identifiers remain optional by destination and receive explicit builder tests because the current registry validates required fields only. Add `core/dashboard_targets.py` to `RUNTIME_SCHEMA_SOURCE_PATHS` so emitted-schema coverage and the documented-schema equality test remain authoritative. + +Resolution order: reachable persistent service, known active `serve-dashboard` origin, then relative URL plus `codex-usage-tracker serve-dashboard --open`. + +**Step 3: Mirror the contract in TypeScript.** Build links and copied prompts from reviewed fields only; drop unknown fields. Keep catalog `safeParams` authoritative. + +**Step 4: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_dashboard_targets.py tests/core/test_json_contracts.py tests/cli/test_dashboard_service.py -q +npm --workspace frontend/dashboard test -- dashboardTargets.test.ts routeCatalog.test.ts +``` + +Expected: PASS with deterministic URLs and no forbidden fixture value. + +**Step 5: Commit.** + +```bash +git add -- src/codex_usage_tracker/core/dashboard_targets.py tests/core/test_dashboard_targets.py src/codex_usage_tracker/core/json_contract_server.py tests/core/test_json_contracts.py src/codex_usage_tracker/dashboard_service.py tests/cli/test_dashboard_service.py frontend/dashboard/src/app/dashboardTargets.ts frontend/dashboard/src/app/dashboardTargets.test.ts frontend/dashboard/src/app/routeCatalog.ts docs/cli-json-schemas.md +git commit -m "feat: define privacy-safe dashboard targets" +``` + +### Task 6: Attach evidence targets to MCP results + +**Files:** + +- Modify: `src/codex_usage_tracker/cli/mcp_dashboard.py` +- Modify: `src/codex_usage_tracker/cli/mcp_investigations.py` +- Modify: `tests/cli/test_mcp_integration.py` +- Modify: `tests/cli/test_cli_release.py` +- Modify: `tests/core/test_json_contracts.py` +- Modify: `docs/mcp.md` + +**Step 1: Write failing integrations.** Assert Overview targets on `usage_status`; Call Investigator targets on call rows/detail; Threads targets by `thread_key`; matching targets on evidence findings; and relative fallback when service is unreachable. Assert public MCP tool names stay unchanged. + +**Step 2: Add one annotation helper.** Wrap existing payload builders after return. Preserve query semantics, order, schemas, and fields. Resolve service status once per tool call. Emit targets only with canonical IDs; never substitute a display name for missing `thread_key`. + +```python +payload = calls_payload(...) +return attach_call_targets(payload, privacy_mode=privacy_mode, origin=resolved_origin) +``` + +**Step 3: Document additive fields and readiness limits.** + +**Step 4: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/cli/test_mcp_integration.py tests/cli/test_cli_release.py tests/core/test_json_contracts.py tests/core/test_dashboard_targets.py -q +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py +``` + +Expected: PASS; tool names unchanged. + +**Step 5: Commit.** + +```bash +git add -- src/codex_usage_tracker/cli/mcp_dashboard.py src/codex_usage_tracker/cli/mcp_investigations.py tests/cli/test_mcp_integration.py tests/cli/test_cli_release.py tests/core/test_json_contracts.py docs/mcp.md +git commit -m "feat: link MCP findings to dashboard evidence" +``` + +### Task 7: Make thread targets canonical and add evidence actions + +**Files:** + +- Modify: `frontend/dashboard/src/api/types.ts` +- Modify: `frontend/dashboard/src/api/client.ts` +- Modify: `frontend/dashboard/src/features/threads/threadsUrlState.ts` +- Modify: `frontend/dashboard/src/features/threads/threadsUrlState.test.ts` +- Modify: `frontend/dashboard/src/features/threads/ThreadsPage.tsx` +- Create: `frontend/dashboard/src/features/threads/ThreadsPage.test.tsx` +- Create: `frontend/dashboard/src/components/DashboardEvidenceActions.tsx` +- Create: `frontend/dashboard/src/components/DashboardEvidenceActions.test.tsx` +- Modify: `frontend/dashboard/src/features/investigator/InvestigatorPage.tsx` +- Modify: `frontend/dashboard/src/features/reports/ReportsPage.tsx` + +**Step 1: Write failing tests.** `?view=threads&thread_key=thread-abc` selects by key when its display label changes. Existing `?thread=Display%20Name` remains compatible. New links emit `thread_key`. Add `threadKey?: string` to `ThreadRow` and map API `thread_key`. + +**Step 2: Implement key-first selection.** Match by key before name, clear both selectors when leaving Threads, and never expose raw names in strict mode. + +**Step 3: Add shared actions.** + +```ts +type DashboardEvidenceActionsProps = { + target: DashboardTarget; + question: string; + onStatus: (message: string) => void; +}; +``` + +**Open evidence** uses a safe same-origin/loopback link with `rel="noopener noreferrer"`. **Copy investigation prompt** includes only a concise question, aggregate IDs, scope, and target. With no origin, show/copy launch guidance. Add actions to Investigate and Reports; leave Call Investigator unchanged. + +**Step 4: Verify.** + +```bash +npm --workspace frontend/dashboard test -- threadsUrlState.test.ts ThreadsPage.test.tsx DashboardEvidenceActions.test.tsx dashboardTargets.test.ts +npm run dashboard:typecheck +``` + +Expected: PASS; clipboard fixtures contain no tokens, paths, raw text, or redacted labels. + +**Step 5: Commit.** + +```bash +git add -- frontend/dashboard/src/api/types.ts frontend/dashboard/src/api/client.ts frontend/dashboard/src/features/threads/threadsUrlState.ts frontend/dashboard/src/features/threads/threadsUrlState.test.ts frontend/dashboard/src/features/threads/ThreadsPage.tsx frontend/dashboard/src/features/threads/ThreadsPage.test.tsx frontend/dashboard/src/components/DashboardEvidenceActions.tsx frontend/dashboard/src/components/DashboardEvidenceActions.test.tsx frontend/dashboard/src/features/investigator/InvestigatorPage.tsx frontend/dashboard/src/features/reports/ReportsPage.tsx +git commit -m "feat: open canonical dashboard evidence" +``` + +### Task 8: Localize Release N copy and synchronize bundled skills + +**Files:** + +- Modify: `skills/codex-usage-tracker/SKILL.md` +- Modify: `skills/codex-usage-api/SKILL.md` +- Modify: `src/codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md` +- Modify: `src/codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md` +- Modify: `frontend/dashboard/src/app/i18n.ts` +- Modify: `frontend/dashboard/src/features/settings/SettingsPage.tsx` +- Modify: `frontend/dashboard/src/components/FeatureMaturityBanner.tsx` +- Modify: `frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.tsx` +- Modify: `frontend/dashboard/src/components/DashboardEvidenceActions.tsx` +- Modify: `src/codex_usage_tracker/plugin_data/dashboard/locales/*.json` +- Modify: `tests/core/test_i18n.py` +- Modify: `tests/playwright/dashboard-release-candidate.spec.mjs` +- Modify: `tests/cli/test_cli_release.py` +- Modify: `docs/mcp.md` +- Modify: `docs/first-five-minutes.md` + +**Step 1: Add failing Release N localization coverage.** Require supported-locale keys for the experimental toggle and origin scope, Diagnostics and workbench maturity wording, all readiness/recovery states, Open evidence, Copy investigation prompt, and fallback launch guidance. Add a browser locale case proving the new shell copy renders without falling back to English. + +**Step 2: Route all new Release N copy through the existing i18n layer.** Update every supported packaged locale in the same release as the components. Do not ship literal-only English for the toggle, banners, readiness card, or evidence actions. + +**Step 3: Update source skills, then package copies byte-for-byte.** Surface **Open evidence** with an absolute target; otherwise show the relative target and launch instruction. Never infer task-level MCP availability. + +**Step 4: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_i18n.py tests/cli/test_cli_release.py -q +npm --workspace frontend/dashboard test -- SettingsPage.test.tsx FeatureMaturityBanner.test.tsx ConversationalAnalysisStatus.test.tsx DashboardEvidenceActions.test.tsx +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py +``` + +Expected: PASS across every supported locale with byte-identical source/package skill copies. + +**Step 5: Commit.** + +```bash +git add -- skills/codex-usage-tracker/SKILL.md skills/codex-usage-api/SKILL.md src/codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md src/codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md frontend/dashboard/src/app/i18n.ts frontend/dashboard/src/features/settings/SettingsPage.tsx frontend/dashboard/src/components/FeatureMaturityBanner.tsx frontend/dashboard/src/features/shared/ConversationalAnalysisStatus.tsx frontend/dashboard/src/components/DashboardEvidenceActions.tsx src/codex_usage_tracker/plugin_data/dashboard/locales tests/core/test_i18n.py tests/playwright/dashboard-release-candidate.spec.mjs tests/cli/test_cli_release.py docs/mcp.md docs/first-five-minutes.md +git commit -m "feat: localize MCP evidence bridge" +``` + +### Task 9: Make packaged React assets deterministic + +**Files:** + +- Modify: `package.json` +- Modify: `.github/workflows/ci.yml` +- Modify: `scripts/check_release.py` +- Modify: `scripts/smoke_installed_package.py` +- Modify: `tests/cli/test_cli_release.py` + +**Step 1: Add a failing asset-sync check.** + +```json +"dashboard:assets:check": "npm run dashboard:build && git diff --exit-code -- src/codex_usage_tracker/plugin_data/dashboard/react" +``` + +Require it and require the package job to build React before `python -m build`. + +**Step 2: Harden packaging.** Set up Node 22, run `npm ci`, build React, then build wheel/sdist. After `python scripts/check_release.py --dist`, the package CI job must run `python scripts/smoke_installed_package.py`. Extend that smoke so the isolated installation serves `/react-dashboard.html`, `/dashboard.html`, and referenced React assets. + +**Step 3: Verify.** + +```bash +npm run dashboard:assets:check +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python -m build +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py --dist +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/smoke_installed_package.py +``` + +Expected: PASS and no generated diff; the smoke installs a built wheel into an isolated environment and serves both React and legacy entry points. + +**Step 4: Commit.** + +```bash +git add -- package.json .github/workflows/ci.yml scripts/check_release.py scripts/smoke_installed_package.py tests/cli/test_cli_release.py src/codex_usage_tracker/plugin_data/dashboard/react +git commit -m "build: verify packaged dashboard assets" +``` + +### Task 10: Require the release-candidate browser matrix + +**Files:** + +- Modify: `package.json` +- Modify: `.github/workflows/ci.yml` +- Modify: `tests/playwright/dashboard-release-candidate.spec.mjs` +- Modify: `tests/playwright/dashboard-react.spec.mjs` +- Modify: `scripts/capture_dashboard_screenshots.mjs` +- Modify: `docs/dashboard-guide.md` + +**Step 1: Add command and required Chromium CI gate.** + +```json +"dashboard:release-candidate": "REACT_DASHBOARD_WEB_SERVER=1 DASHBOARD_BASE_URL=http://127.0.0.1:5173 playwright test tests/playwright/dashboard-release-candidate.spec.mjs --project=chromium-desktop" +``` + +The same CI job that runs this command must first run `npx playwright install --with-deps chromium`; installation in the separate visualization job does not carry across GitHub runners. + +**Step 2: Expand Release N coverage.** Test preference persistence, direct experimental/transitioning routes, Diagnostics banner, Call Investigator returns, all readiness states, manual fallback, desktop/tablet/mobile, 200% zoom, keyboard/focus, reduced motion, Axe serious/critical findings, and console errors. Assert baseline navigation remains unchanged. + +**Step 3: Capture synthetic evidence** for every route category and readiness state. + +**Step 4: Verify.** + +```bash +npm run dashboard:release-candidate +npm run dashboard:verify +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_i18n.py -q +git diff --check +``` + +Expected: PASS; clean assets; no serious/critical accessibility issue. + +**Step 5: Commit and ship Release N.** + +```bash +git add -- package.json .github/workflows/ci.yml tests/playwright/dashboard-release-candidate.spec.mjs tests/playwright/dashboard-react.spec.mjs scripts/capture_dashboard_screenshots.mjs docs/dashboard-guide.md src/codex_usage_tracker/plugin_data/dashboard/react +git commit -m "test: gate dashboard release candidates" +``` + +Create the Release N PR and record its merge commit/package version. Do not start Task 11 until Release N is shipped and installed-wheel smoke is green. + +--- + +## Release N+1: Change Default Discoverability + +### Task 11: Prove Cache/Reports replacement parity before hiding them + +**Files:** + +- Create: `docs/dashboard-sunset-job-parity.md` +- Create: `tests/cli/test_dashboard_sunset_parity.py` +- Modify: `src/codex_usage_tracker/cli/mcp_investigations.py` +- Modify: `src/codex_usage_tracker/cli/mcp_dashboard.py` +- Modify: `frontend/dashboard/src/features/cache-context/CacheContextPage.tsx` +- Modify: `frontend/dashboard/src/features/reports/ReportsPage.tsx` +- Modify: `frontend/dashboard/src/features/cache-context/CacheContextPage.test.tsx` +- Modify: `frontend/dashboard/src/features/reports/ReportsPage.test.tsx` +- Modify: `docs/mcp.md` +- Modify: `docs/dashboard-guide.md` + +**Step 1: Turn the approved parity matrix into failing acceptance tests.** For synthetic fixtures, prove: + +- cache trend/context-pressure scope, caveats, and aggregate supporting Calls are available through Overview plus MCP; +- cross-thread cache/cold-resume ranking and selected evidence are available through MCP plus Threads/Calls; +- each cache heatmap job is either represented by a tested experimental viewer or explicitly recorded as intentionally removed; +- report intent selection, methodology, confidence, caveats, evidence targets, and export are reachable without undocumented CLI knowledge; +- every replacement target resolves to the same canonical call/thread used by the legacy page fixture. + +**Step 2: Fill only parity gaps.** Add missing aggregate fields, target annotations, or copy to existing MCP/report builders. Do not redesign the stable dashboard. Keep payload changes additive. + +**Step 3: Add transition notices while routes remain visible.** Cache And Context points to Overview, Threads, Calls, and the named MCP investigation. Reports points to the report/action brief, evidence targets, and existing export. Notices must include direct recovery when MCP is unavailable. + +**Step 4: Sign the decision record.** For each job record replacement, acceptance test, owner, result, intentional removals, and Release N baseline. Any row not PASS blocks Task 12. + +**Step 5: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/cli/test_dashboard_sunset_parity.py tests/cli/test_mcp_integration.py -q +npm --workspace frontend/dashboard test -- CacheContextPage.test.tsx ReportsPage.test.tsx DashboardEvidenceActions.test.tsx +``` + +Expected: PASS for every matrix row. Stop if any replacement is missing or points at different evidence. + +**Step 6: Commit.** + +```bash +git add -- docs/dashboard-sunset-job-parity.md tests/cli/test_dashboard_sunset_parity.py src/codex_usage_tracker/cli/mcp_investigations.py src/codex_usage_tracker/cli/mcp_dashboard.py frontend/dashboard/src/features/cache-context/CacheContextPage.tsx frontend/dashboard/src/features/reports/ReportsPage.tsx frontend/dashboard/src/features/cache-context/CacheContextPage.test.tsx frontend/dashboard/src/features/reports/ReportsPage.test.tsx docs/mcp.md docs/dashboard-guide.md +git commit -m "test: prove dashboard sunset job parity" +``` + +### Task 12: Switch to the six-tab default navigation + +**Files:** + +- Modify: `frontend/dashboard/src/app/navigation.ts` +- Modify: `frontend/dashboard/src/app/routeCatalog.ts` +- Modify: `frontend/dashboard/src/app/routeCatalog.test.ts` +- Modify: `frontend/dashboard/src/App.tsx` +- Modify: `frontend/dashboard/src/App.shell.test.tsx` +- Modify: `frontend/dashboard/src/app/shellUrl.test.ts` +- Modify: `frontend/dashboard/src/styles/shell.css` +- Modify: `frontend/dashboard/src/app/RowLimitControl.tsx` +- Create: `frontend/dashboard/src/components/AdvancedShellControls.tsx` +- Create: `frontend/dashboard/src/components/AdvancedShellControls.test.tsx` + +**Step 1: Change the tests first.** Preference off must show exactly: + +```ts +['Overview', 'Calls', 'Threads', 'Limits', 'Diagnostics Notebook', 'Settings'] +``` + +Preference on adds a visually separate **Experimental** group with Investigate and Compression Lab. Cache And Context and Reports are absent from navigation but direct links render transition notices. Files, Commands, and Models do not render. Toggling updates immediately without reload and visiting a direct route does not persist the preference. + +Also test Call Investigator returns to Reports/Cache correctly even though those labels are not visible. + +**Step 2: Activate simplified exposure.** In `App.tsx`, use `navigationForPhase('simplified', showExperimental)`. Remove `secondaryNavItems` rendering and imports. Keep all route IDs, lazy components, URL cleanup, and direct-route rendering. + +**Step 3: Reduce shell density.** Keep global search, history/time scope, refresh, and copy-link visible. Move `RowLimitControl` and other technical loading controls into an accessible topbar `AdvancedShellControls` disclosure. This is separate from Settings > Advanced because it controls the current data load. Persist no new state. + +**Step 4: Verify focused shell behavior.** + +```bash +npm --workspace frontend/dashboard test -- routeCatalog.test.ts App.shell.test.tsx shellUrl.test.ts AdvancedShellControls.test.tsx +npm run dashboard:typecheck +npm run dashboard:lint +npm run dashboard:stylelint +``` + +Expected: PASS with six default tabs, two optional Labs, and every direct route intact. + +**Step 5: Commit.** + +```bash +git add -- frontend/dashboard/src/app/navigation.ts frontend/dashboard/src/app/routeCatalog.ts frontend/dashboard/src/app/routeCatalog.test.ts frontend/dashboard/src/App.tsx frontend/dashboard/src/App.shell.test.tsx frontend/dashboard/src/app/shellUrl.test.ts frontend/dashboard/src/styles/shell.css frontend/dashboard/src/app/RowLimitControl.tsx frontend/dashboard/src/components/AdvancedShellControls.tsx frontend/dashboard/src/components/AdvancedShellControls.test.tsx +git commit -m "feat: simplify dashboard navigation" +``` + +### Task 13: Refine the stable first-run experience + +**Files:** + +- Modify: `frontend/dashboard/src/features/overview/OverviewPage.tsx` +- Modify: `frontend/dashboard/src/features/overview/OverviewPage.module.css` +- Modify: `frontend/dashboard/src/features/overview/OverviewPage.test.tsx` +- Modify: `frontend/dashboard/src/features/overview/overviewModel.ts` +- Modify: `frontend/dashboard/src/features/overview/overviewModel.test.ts` +- Modify: `frontend/dashboard/src/App.shell.test.tsx` + +**Step 1: Lock stable-flow tests before visual changes.** Assert one bounded summary group, one primary usage trend, bounded findings, conversational readiness/recovery, recent calls, and direct actions to Calls/Threads/Limits/Diagnostics. Assert Call Investigator and Limits tests/snapshots remain unchanged. + +**Step 2: Remove Overview duplication.** Keep only metrics that answer current usage, trend, what needs attention, and where evidence lives. Do not move Cache/Reports complexity into Overview. Put Usage Constellation behind the experimental preference or omit it from default Overview based on the approved design's “evaluate” clause. + +**Step 3: Keep manual recovery obvious.** Non-ready/unknown states expose the stable manual workflow and one action to enable experimental workbenches for this origin. Ready states lead with an MCP investigation prompt and evidence links. + +**Step 4: Verify.** + +```bash +npm --workspace frontend/dashboard test -- OverviewPage.test.tsx overviewModel.test.ts App.shell.test.tsx LimitsPage.test.tsx callInvestigatorState.test.ts callInvestigatorReadout.test.ts +npm run dashboard:typecheck +``` + +Expected: PASS; Limits and Call Investigator behavior equal the Release N baseline. + +**Step 5: Commit.** + +```bash +git add -- frontend/dashboard/src/features/overview/OverviewPage.tsx frontend/dashboard/src/features/overview/OverviewPage.module.css frontend/dashboard/src/features/overview/OverviewPage.test.tsx frontend/dashboard/src/features/overview/overviewModel.ts frontend/dashboard/src/features/overview/overviewModel.test.ts frontend/dashboard/src/App.shell.test.tsx +git commit -m "refactor: focus the dashboard overview" +``` + +### Task 14: Localize Release N+1 changes and document the MCP-first product contract + +**Files:** + +- Modify: `README.md` +- Modify: `docs/first-five-minutes.md` +- Modify: `docs/dashboard-guide.md` +- Modify: `docs/mcp.md` +- Modify: `docs/privacy.md` +- Modify: `docs/cli-json-schemas.md` +- Modify: `src/codex_usage_tracker/plugin_data/dashboard/locales/*.json` +- Modify: `tests/core/test_i18n.py` +- Modify: `tests/playwright/dashboard-release-candidate.spec.mjs` +- Modify: `scripts/capture_dashboard_screenshots.mjs` +- Modify: `docs/assets/*` + +**Step 1: Extend localization coverage for Release N+1 only.** Release N already covers the toggle, banners, readiness, and evidence actions in Task 8. Here require keys for the six-tab default navigation, Experimental group, Advanced shell disclosure, Overview refinements, and Cache/Reports transition notices across every supported locale. + +**Step 2: Rewrite the journey.** README and first five minutes lead with MCP conversational analysis; live React is the evidence companion. Clearly distinguish `serve-dashboard --open` live React behavior, generated/open static legacy behavior, MCP-unavailable fallback, experimental access, and transitioning direct routes. + +**Step 3: Document privacy.** List target allowlists and exclusions, explain local clipboard behavior, and state that no browser-to-task invocation occurs. + +**Step 4: Regenerate only synthetic screenshots.** Capture the six-tab default at desktop/mobile, experimental group enabled, Diagnostics banner, readiness recovery, and one canonical evidence target. Scan images/copy for paths, names, tokens, and real records. + +**Step 5: Verify.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_i18n.py tests/cli/test_cli_release.py -q +npm run dashboard:release-candidate +npx markdownlint-cli2 README.md "docs/**/*.md" +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py +``` + +Expected: PASS in every supported locale with synthetic-only evidence. + +**Step 6: Commit.** + +```bash +git add -- README.md docs/first-five-minutes.md docs/dashboard-guide.md docs/mcp.md docs/privacy.md docs/cli-json-schemas.md src/codex_usage_tracker/plugin_data/dashboard/locales tests/core/test_i18n.py tests/playwright/dashboard-release-candidate.spec.mjs scripts/capture_dashboard_screenshots.mjs docs/assets +git commit -m "docs: position MCP as the primary analysis surface" +``` + +### Task 15: Run the task evaluation and Release N+1 gate + +**Files:** + +- Create: `docs/mcp-first-dashboard-release-evaluation.md` +- Modify: `docs/dashboard-sunset-job-parity.md` +- Modify: `CHANGELOG.md` +- Modify: `src/codex_usage_tracker/plugin_data/dashboard/react/*` + +**Step 1: Run six synthetic walkthroughs.** Record happy and recovery paths for: + +1. determine recent usage drivers and open exact evidence; +2. find the heaviest thread and inspect one contributing call; +3. inspect Limits and understand statistical evidence; +4. complete a token-waste investigation with MCP ready; +5. complete useful manual analysis with MCP unavailable/unknown; +6. open Diagnostics and correctly identify it as highly experimental. + +For each, record start state, steps, wrong turns, target expected/actual IDs, recovery, and PASS/FAIL. Any dead end, mismatched target, privacy issue, or more than one wrong turn blocks release. + +**Step 2: Run focused regression gates.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/core/test_dashboard_targets.py tests/cli/test_mcp_integration.py tests/cli/test_dashboard_sunset_parity.py tests/server/test_server_open_investigator.py -q +npm --workspace frontend/dashboard test -- routeCatalog.test.ts App.shell.test.tsx dashboardTargets.test.ts DashboardEvidenceActions.test.tsx threadsUrlState.test.ts OverviewPage.test.tsx +npm run dashboard:release-candidate +``` + +**Step 3: Run the complete repository gates.** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/ruff check . +PATH="$PWD/.venv/bin:$PATH" .venv/bin/mypy +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest +npm run dashboard:verify +npm run dashboard:assets:check +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python -m build +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/check_release.py --dist +PATH="$PWD/.venv/bin:$PATH" .venv/bin/python scripts/smoke_installed_package.py +git diff --check +``` + +Expected: all PASS; committed React assets match source; installed React and legacy entry points both smoke successfully. + +**Step 4: Perform the final review.** Inspect `git status --short --branch`, `git diff --stat`, the full diff, and staged files for real/private data. Confirm no route deletion, no legacy behavior change, and no unintended Call Investigator or Limits diff. Update the evaluation and changelog with exact check results and the Release N rollback version/commit. + +**Step 5: Commit and open the Release N+1 PR.** + +```bash +git add -- docs/mcp-first-dashboard-release-evaluation.md docs/dashboard-sunset-job-parity.md CHANGELOG.md src/codex_usage_tracker/plugin_data/dashboard/react +git commit -m "docs: record MCP-first dashboard release evidence" +``` + +The PR description must list: Release N dependency, parity sign-off, exact checks, task-evaluation results, screenshots, rollback commit/version, and the explicit statement that route deletion and legacy removal remain deferred. + +--- + +## Deferred Follow-Up: Release N+2 or 1.0 + +Do not include these actions in this plan's implementation PRs: + +- deleting Cache And Context or Reports routes; +- removing or redirecting legacy static entry points; +- graduating or deleting Investigate, Compression Lab, or Diagnostics; +- redesigning Call Investigator or removing Limits statistical analysis. + +After at least two compatible minor releases, use support evidence plus the Task 15 evaluation to write a separate decision/design document for each graduation, removal, or legacy-entry-point change. diff --git a/docs/superpowers/specs/2026-07-19-mcp-first-dashboard-transition-design.md b/docs/superpowers/specs/2026-07-19-mcp-first-dashboard-transition-design.md new file mode 100644 index 00000000..4fb0e45a --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-mcp-first-dashboard-transition-design.md @@ -0,0 +1,506 @@ +# MCP-First Dashboard Transition Design + +**Date:** 2026-07-19 + +**Status:** Revised after independent product, architecture, and release review; +implementation planning pending maintainer review + +**Target:** Pre-1.0 product simplification + +## Context + +Codex Usage Tracker has grown into two overlapping products: + +1. an increasingly capable MCP and companion-skill investigation system; and +2. a broad dashboard with many top-level workspaces, experiments, reports, and + technical controls. + +The MCP direction is the stronger differentiator. It can choose investigations, +explain evidence, recommend actions, and guide verification without requiring a +user to understand the tracker's internal report taxonomy. The dashboard remains +valuable for quick status, manual evidence browsing, configuration, and focused +drill-downs, but its current navigation presents mature and experimental surfaces +as peers. That density makes the product harder to understand and creates a +pre-1.0 quality promise the project does not need to make. + +This transition makes MCP the primary analysis product and narrows the default +live React dashboard to the surfaces that are already useful and coherent. It +preserves advanced work rather than deleting it prematurely, while making maturity +explicit and maintaining a complete path for users whose MCP setup is unavailable. + +## Product Decision + +Dashboard policy uses three independent dimensions. They must not be collapsed +into one exposure enum: + +- **Maturity:** `stable` or `experimental`. +- **Placement:** `primary`, `contextual`, or `hidden`. +- **Lifecycle:** `active`, `transitioning`, or `deprecated`. + +This represents important combinations without exceptions. Diagnostics Notebook +is `experimental + primary + active`. Call Investigator is +`stable + contextual + active`. Cache And Context is +`experimental + hidden + transitioning` during the sunset window. + +The live React dashboard remains a useful product, but it will no longer attempt +to expose every analytical capability. MCP owns diagnosis, orchestration, +recommendation, simulation, and most report generation. The dashboard owns quick +status, manual evidence browsing, configuration, and focused verification. + +## Default Live Dashboard Contract + +The default navigation of the live React dashboard contains: + +1. **Overview** +2. **Calls** +3. **Threads** +4. **Limits** +5. **Diagnostics Notebook** +6. **Settings** + +Call Investigator remains fully supported as a contextual route opened from +Overview, Calls, Threads, Diagnostics, Limits evidence, or MCP-produced targets. +It remains largely unchanged during this transition. + +Diagnostics Notebook remains in the default navigation because it is useful for +technical exploration and support. Its page header clearly states that it is +highly experimental and may change before 1.0. This label communicates maturity +without making the workspace undiscoverable. + +Limits remains a core workspace, including its statistical analysis. Remaining +usage, resets, allowance history, and evidence-backed interpretation are recurring +user questions. The statistical material may be refined later, but it is not +hidden merely because it is advanced. + +The six-destination contract applies to the live React surface only. Static legacy +HTML remains a frozen compatibility product during this transition and is not +silently redefined as the simplified React dashboard. + +## Feature Disposition + +| Tab or area | Feature | Maturity | Placement | Lifecycle | Product treatment | +| --- | --- | --- | --- | --- | --- | +| Overview | Headline metrics | Stable | Primary | Active | Keep four or five trustworthy metrics with clear scope and timestamps. | +| Overview | Primary usage trend | Stable | Primary | Active | Keep one dominant trend visualization rather than several competing summaries. | +| Overview | High-confidence findings | Stable | Primary | Active | Show at most three findings with evidence and an MCP-oriented follow-up action. | +| Overview | Recent calls | Stable | Primary | Active | Keep a short path into Calls and Call Investigator. | +| Overview | 3D Usage Constellation | Experimental | Hidden | Transitioning | Reveal only through experimental exposure; remove before 1.0 if it does not prove a unique decision-making benefit. | +| Calls | Table, filters, sorting, and export | Stable | Primary | Active | Continue treating Calls as the principal manual evidence browser. | +| Calls | Selected-call summary rail | Stable | Primary | Active | Preserve essential accounting and access to Call Investigator. | +| Call Investigator | Aggregate call accounting and deep dive | Stable | Contextual | Active | Keep largely unchanged; do not place behind an experimental preference. | +| Call Investigator | Explicit raw/local context controls | Stable | Contextual | Active | Preserve the privacy permission and explicit local-action requirements. | +| Threads | Ranking, grouping, and call expansion | Stable | Primary | Active | Keep the conversation-level evidence workflow. | +| Threads | Secondary visualizations | Experimental | Contextual | Active | Avoid expanding them without a distinct user question. | +| Limits | Remaining usage, resets, and history | Stable | Primary | Active | Preserve the recurring allowance-status workflow. | +| Limits | Statistical analysis and evidence | Stable | Primary | Active | Keep visible; refine clarity and trust language in later work. | +| Diagnostics Notebook | Technical reports and evidence | Experimental | Primary | Active | Keep the tab and add a highly experimental page header. | +| Investigate | Agentic investigation workbench | Experimental | Hidden | Active | MCP becomes the primary interface; retain the page for opted-in users and direct links. | +| Investigate | Manual file, command, and churn exploration | Experimental | Hidden | Active | Preserve MCP/report services; stop expanding the manual UI. | +| Compression Lab | Profile, candidates, and simulation | Experimental | Hidden | Active | MCP owns orchestration; retain the dashboard as an optional visual workbench. | +| Cache And Context | Standalone lab | Experimental | Hidden | Transitioning | Preserve direct access during the compatibility window and replace each supported job explicitly. | +| Reports | Standalone report library | Experimental | Hidden | Transitioning | Preserve direct access and export behavior until job-level replacements pass. | +| Settings | Privacy, language, data scope, and status | Stable | Primary | Active | Keep as the dashboard's trust and configuration surface. | +| Settings | Experimental-feature control | Stable | Primary | Active | Place under Advanced and let users reveal hidden experimental navigation. | +| Navigation | Files, Commands, and Models aliases | Stable | Hidden | Deprecated | Remove shortcuts that redirect to unrelated destinations; preserve their destination capabilities. | +| Navigation | Search, time scope, and refresh | Stable | Primary | Active | Keep compact, broadly useful controls. | +| Navigation | Row limits and technical loading controls | Stable | Contextual | Active | Retain under an Advanced disclosure. | +| Legacy dashboard | Static compatibility surface | Stable | Contextual | Transitioning | Freeze feature work and retain through an explicit pre-1.0 removal decision. | +| Localization | Default dashboard and transition copy | Stable | Primary | Active | Maintain full locale coverage for stable navigation, maturity labels, notices, and Call Investigator. | +| Localization | Experimental page body copy | Experimental | Hidden | Active | Allow documented English fallback until a surface graduates. | + +## Shipped Entry-Point Contract + +The project currently ships multiple dashboard entry points. Simplification must +not pretend they are one surface. + +| Entry point | Current/transition owner | Transition behavior | Removal or change gate | +| --- | --- | --- | --- | +| `codex-usage-tracker serve-dashboard --open` | Live React | Remains the recommended interactive/default command and opens `/react-dashboard.html`. | None; this is the stable live entry point. | +| `/react-dashboard.html` on an active server | Live React | Receives the six-destination default navigation and experimental preference. | Stable contract. | +| Persistent dashboard service canonical URL | Live React | Documentation and open actions must target `/react-dashboard.html`, including custom configured ports. | Service contract test and installed-package smoke must pass. | +| `/` on the localhost server | Legacy compatibility during the transition | Continues current behavior until a separately approved redirect decision. It must identify the recommended live React URL. | No redirect in the navigation simplification release. | +| `/dashboard.html` or configured legacy filename | Legacy static HTML | Frozen compatibility route with replacement notice and live React link when served. | Retained for at least two minor releases and until a 1.0 removal decision. | +| `codex-usage-tracker dashboard` | Legacy static generator | Continues generating frozen static HTML; no new dashboard features. | A static React replacement requires its own design. | +| `codex-usage-tracker open-dashboard` | Legacy static generator/opener | Continues opening frozen static HTML and clearly labels it static. First-run docs prefer `serve-dashboard`. | A static React replacement requires its own design. | +| MCP `generate_usage_dashboard()` | Legacy static generator | Remains compatibility output; it must not be described as the live simplified dashboard. | Separate MCP deprecation decision only. | +| MCP-produced evidence destination | Relative React target plus optional resolved origin | Uses the versioned destination contract below. | Must exist before any dashboard navigation is hidden. | +| Installed wheel | Both packaged surfaces | Must contain synchronized React assets and the frozen legacy surface. | Clean-build and installed-wheel checks are release blockers. | + +Static mode remains useful for offline snapshots, but it does not gain the six-tab +React contract in this project. Static users receive accurate capability wording +and a documented route to the live dashboard when they need refresh, MCP bridges, +or contextual evidence loading. + +## Experimental Preference And Route Catalog + +Use one client-side setting named **Show experimental dashboard features** under +Settings > Advanced. + +- It defaults to off for new browser origins. +- A shell-level application hook owns its in-memory state and persistence. Settings + receives the current value and setter; Settings-local state is not the owner. +- Toggling it updates navigation immediately without a reload. +- It is stored locally per browser origin. Ad hoc ports, the persistent-service + port, and file/static origins may therefore have independent values; the UI and + documentation state this explicitly. +- When enabled, navigation adds Investigate and Compression Lab under a visually + separated **Experimental** group. +- Direct links to experimental or transitioning routes continue to work while the + preference is off. The destination shows its maturity or transition banner, but + visiting a link does not persist the preference. +- Existing route identifiers remain valid throughout the compatibility window. + +Diagnostics Notebook does not depend on this preference. It remains visible and +uses the shared experimental-banner component so its maturity is unmistakable. + +Create one route catalog rather than only a visible-navigation registry. Every +route entry declares: + +- route identifier and canonical label; +- `maturity`, `placement`, and `lifecycle`; +- navigation group and eligibility; +- optional banner and replacement copy; +- return-label behavior; +- refresh, export, and copy-link capabilities; and +- privacy-safe destination parameters. + +Route validation and rendering remain exhaustive consumers, but a coherence test +ensures every valid route is rendered and cataloged. Visible navigation is derived +from the catalog. URL cleanup, Call Investigator return labels, exports, keyboard +shortcuts, and refresh exceptions must not infer labels only from visible items. + +## MCP Readiness And Manual Fallback + +MCP-first positioning is valid only when users can discover whether conversational +analysis is ready and recover when it is not. + +Overview and Settings expose a stable **Conversational analysis** status with these +states: + +- **Ready:** local setup/runtime checks prove the plugin and MCP launcher are + configured. The UI must not claim that the current Codex task has loaded the MCP + tools when that fact cannot be observed. +- **Restart required:** setup is installed, but the user still needs to restart + Codex or open a fresh task for plugin discovery. +- **Unavailable:** a local configuration or runtime check failed. +- **Unknown:** the static dashboard or current server payload cannot prove status. + +Every non-ready state provides the exact next action: run setup or doctor, restart +Codex/open a fresh task, or open Settings for details. Overview retains a visible +manual analysis path while MCP is unavailable: Calls, Threads, Limits, Diagnostics, +and one action to reveal experimental workbenches for the current browser origin. + +Acceptance covers fresh install before restart, successful discovery setup, +discovery/runtime failure, unknown status, static dashboard, and unavailable live +service. The UI never claims access to OpenAI account state or a live MCP session it +cannot verify. + +## Dashboard Destination And Privacy Contract + +Define `codex-usage-tracker-dashboard-target-v1` before hiding any route. It is a +renderer-independent target descriptor shared by MCP recommendations and dashboard +copy-link actions. + +Required fields: + +- `view`; +- canonical aggregate identifiers such as `record_id`, `thread_key`, diagnostic + fact key, or Limits evidence key; +- normalized, allowlisted filters and history scope; +- `privacy_mode`; +- `relative_url`; +- optional `absolute_url`; and +- a fallback launch/open instruction when no live origin is reachable. + +Origin resolution follows this order: + +1. use a configured persistent service only after its loopback health endpoint + proves reachable, including its custom port; +2. use an explicitly known active `serve-dashboard` origin when available; or +3. return the relative target with `codex-usage-tracker serve-dashboard --open` + guidance instead of fabricating an absolute URL. + +Thread destinations use `thread_key` as the canonical identifier. Existing +display-name `thread` URLs remain compatibility inputs, but new MCP targets and +copy-link actions do not depend on mutable or privacy-transformed labels. + +Every destination type has a parameter allowlist. Links and copied prompts never +include API tokens, raw/indexed text, local paths, raw-context entries, project +labels disallowed by the selected privacy mode, or unreviewed free-form search +text. Normal, redacted, and strict modes have synthetic contract tests. + +**Open evidence** resolves and opens a dashboard target. **Ask Codex** means a +user-initiated **Copy investigation prompt** action until Codex provides a supported +browser-to-task invocation protocol. The clipboard payload contains only aggregate +identifiers, scope, a concise question, and the dashboard target. It never +transmits data or includes raw/indexed content automatically. + +## Sunset Job-Parity Contract + +No tab leaves default navigation until its supported user jobs have a documented +replacement and the replacement passes its acceptance check. + +| Existing job | Replacement | Required parity before hiding/removal | +| --- | --- | --- | +| Cache trend and context-pressure summary | Overview summary plus MCP `cache_failure` investigation | Same scope, caveats, and supporting aggregate Calls must be reachable. | +| Cross-thread cache/cold-resume comparison | MCP investigation plus Threads/filtered Calls evidence | Ranking, selected-thread evidence, and methodology wording must remain available. | +| Cache heatmap | Experimental contextual viewer or intentional removal | A decision record must show that the heatmap answers a unique user question; otherwise mark it intentionally removed. | +| Selected cache/thread evidence | Threads and Call Investigator | The same canonical thread/call records must open through dashboard targets. | +| Report selection/library | MCP investigation suggestions and report pack | Users can select the same supported report intent without undocumented CLI knowledge. | +| Report explanation and caveats | MCP report/action brief | Methodology, scope, confidence, and caveats remain visible. | +| Report evidence opening | Dashboard target contract | Every included evidence row can open the matching Call Investigator or contextual view. | +| Report export/artifact | Existing MCP/CLI export contract | Export remains available and documented before the Reports route is removed. | + +During the transition, Cache And Context and Reports remain directly reachable with +replacement notices. Route removal is a separate decision after at least two minor +releases, successful parity checks, and explicit maintainer approval. + +## Transition Releases And Phases + +Phases are grouped into release boundaries so the replacement exists before +discoverability changes. + +### Release N: Foundation Only + +Release N does not hide or remove any navigation item. + +#### Phase 0: Lock Contracts And Baselines + +- Approve this revised design and create the detailed implementation plan. +- Add the entry-point, route-catalog, destination, privacy, and sunset-parity + contracts to repository documentation. +- Capture current route, navigation, entry-point, and generated-asset behavior in + tests before changing it. +- Record the known-good package version and commit used for rollback. + +#### Phase 1: Establish Route And Exposure Infrastructure + +- Add the complete route catalog with independent maturity, placement, and + lifecycle fields. +- Add the shell-owned experimental preference and Settings > Advanced control. +- Add shared experimental and transition banners. +- Keep existing navigation visibility unchanged in Release N. +- Cover immediate toggle behavior, reload, restricted/malformed storage, + per-origin semantics, direct links, return labels, exports, keyboard navigation, + and localization. + +#### Phase 2: Establish Readiness And Destination Bridges + +- Add the conversational-analysis readiness model and recovery card. +- Implement `codex-usage-tracker-dashboard-target-v1` with stable identifiers, + privacy allowlists, and origin resolution. +- Add `thread_key` route selection while retaining display-name compatibility. +- Implement **Open evidence** and privacy-safe **Copy investigation prompt**. +- Update MCP verification payloads and the companion skill to use the shared + target descriptor. +- Prove MCP-unavailable manual fallback and MCP-to-evidence completion before any + route is hidden. + +#### Phase 3: Harden Packaging And Release Gates + +- Build React deterministically from a clean checkout. +- Assert no generated diff under the packaged React asset directory. +- Build the wheel and smoke both React and legacy entry points from the installed + package. +- Make the release-candidate browser suite a required gate. +- Capture a synthetic baseline of every default, contextual, experimental, and + transitioning route. + +### Release N+1: Change Default Discoverability + +Release N+1 ships only after all Release N bridge and packaging gates pass. + +#### Phase 4: Simplify Navigation And Shell Density + +- Reduce default React navigation to the six approved tabs. +- Hide Investigate and Compression Lab behind the experimental preference. +- Remove Files, Commands, and Models aliases. +- Move technical row-loading controls into Advanced. +- Keep search, time scope, history scope, and refresh easy to find. +- Add the highly experimental header to Diagnostics while keeping it primary. +- Remove Cache And Context and Reports from navigation only after their job-parity + checks pass; retain direct routes and transition notices. + +#### Phase 5: Refine The Stable First-Run Experience + +- Simplify Overview to a small set of metrics, one main trend, bounded findings, + readiness/recovery, and recent calls. +- Evaluate Usage Constellation behind experimental exposure. +- Keep Calls, Threads, Limits, Call Investigator, and core Settings behavior + functionally stable. +- Improve labels and empty states only where the transition exposes confusion; + avoid unrelated redesign work. + +#### Phase 6: Reposition Documentation + +- Lead README and first-run guidance with MCP and conversational analysis. +- Describe the live React dashboard as the evidence companion. +- Document MCP-unavailable fallback, experimental access, static legacy behavior, + and sunset replacements. +- Regenerate screenshots from synthetic data using the default live React + navigation. +- Preserve full supported-locale coverage for stable/default-shell transition copy. + +### Release N+2 Or 1.0 Review: Removal And Graduation Decisions + +- Run the task-based evaluation and review support/issue evidence. +- Graduate, retain, or remove each experimental surface using explicit evidence. +- Consider deleting Cache And Context or Reports routes only after two minor + compatibility releases and maintainer approval. +- Consider redirecting or removing legacy entry points only in a separate design + and release. +- Never combine route deletion, legacy removal, and the initial navigation change + into one release. + +## Verification And Release Gates + +Every release boundary has explicit entry and exit criteria. + +| Boundary | Entry criteria | Exit criteria | Rollback trigger | +| --- | --- | --- | --- | +| Release N foundation | Approved design and clean baseline | Route catalog, readiness states, target/privacy contract, package sync, and installed-wheel tests pass with navigation unchanged | Any stable route, privacy, package, or entry-point regression | +| Release N+1 discoverability | Release N shipped successfully; all replacement bridges operational | Six-tab default, experimental preference, transition notices, task evaluation, accessibility, localization, and full release gate pass | Broken Call Investigator or Limits flow; dead evidence link; failed MCP-unavailable recovery; packaged UI mismatch; serious privacy/accessibility regression | +| Release N+2/1.0 decision | At least two compatible minor releases and completed parity checks | Explicit maintainer decision for each graduation, route removal, and legacy action | Missing parity evidence, unresolved support need, or any stable contract dependency | + +Required technical gates: + +- dashboard lint, typecheck, unit tests, dependency/governance checks, and build; +- route-catalog coherence and all destination/privacy contract tests; +- deterministic React build followed by a clean generated-asset diff assertion; +- wheel/sdist build plus installed-package React and legacy smoke tests; +- release-candidate browser matrix for preference off/on, direct experimental and + transitioning routes, Call Investigator returns, mobile, compact desktop, + keyboard, focus, reduced motion, 200% zoom, and serious/critical Axe findings; +- supported-locale coverage for navigation, toggle, Diagnostics banner, maturity + wording, readiness/recovery, and transition notices; +- synthetic documentation screenshots and privacy scan; and +- existing full Python/release checks for MCP, CLI, HTTP, schema, and packaging + compatibility. + +## Product Success Measures + +Because the project does not add telemetry or hosted analytics, success is measured +with synthetic dogfood runs, documented maintainer walkthroughs, issue/support +evidence, and optional moderated usability checks. + +Representative tasks: + +1. determine what drove usage recently and open its evidence; +2. identify the heaviest thread and inspect one contributing call; +3. check remaining Limits and understand the statistical evidence; +4. complete a token-waste investigation with MCP available; +5. recover or complete a useful manual investigation when MCP is unavailable; and +6. open Diagnostics and correctly recognize its experimental maturity. + +Release N+1 requires: + +- every task to have a documented happy path and non-ready/error path; +- zero dead-end primary navigation destinations; +- exact evidence-target success for every tested MCP finding; +- no more than one navigation wrong turn in a moderated first-use walkthrough; +- no participant mistaking Diagnostics or hidden Labs for stable analysis promises; +- Call Investigator and Limits completion equal to the current baseline; and +- all privacy, accessibility, localization, and installed-package gates passing. + +Rollback is triggered when any privacy or stable-contract failure occurs, when an +installed wheel contains stale navigation assets, when any evidence link resolves +to the wrong record, or when more than one representative task lacks a working +happy path or recovery path. + +## Success Criteria + +The transition is successful when: + +- a new live React user sees exactly the six approved default destinations; +- stable and experimental maturity is obvious without external documentation; +- Call Investigator remains fully reachable and functionally unchanged; +- Limits retains status, history, and statistical analysis; +- Diagnostics remains primary and visibly labeled highly experimental; +- Investigate and Compression Lab remain available without appearing stable by + default; +- Cache And Context, Reports, and navigation aliases no longer compete for primary + attention after job parity is proven; +- users have an actionable path when MCP is ready, unavailable, or unknown; +- MCP answers can target the exact supporting dashboard evidence using canonical + identifiers; +- no default/shareable link or copied prompt weakens the privacy boundary; +- static legacy behavior and live React behavior are accurately distinguished; +- packaged React assets match reviewed source; +- stable MCP, CLI, HTTP, and JSON contracts remain compatible; and +- README and first-run guidance consistently present MCP as the primary analysis + experience. + +## Non-Goals + +- Deleting MCP, HTTP, CLI, or report contracts merely because a tab is hidden. +- Redesigning Call Investigator. +- Removing Limits statistical analysis. +- Hiding Diagnostics Notebook from normal navigation. +- Replacing the dashboard with a chat UI. +- Inventing a browser-to-Codex invocation protocol. +- Adding telemetry or hosted analytics to measure adoption. +- Reworking the entire visual system during the navigation transition. +- Building a static React dashboard in this project. +- Removing or redirecting the legacy dashboard in the same release as simplified + navigation. + +## Risks And Mitigations + +### Hidden functionality appears deleted + +Preserve direct routes for two minor releases, document experimental access, and +add transition notices with job-level replacement workflows. + +### MCP is unavailable during first use + +Expose conservative readiness states, exact recovery steps, and stable manual +paths. Do not claim current-task MCP discovery when it cannot be observed. + +### Evidence links open the wrong data or leak private metadata + +Use canonical identifiers, a versioned target schema, per-destination allowlists, +privacy-mode transformations, loopback health checks, and synthetic negative tests. + +### Entry points promise different products + +Maintain the explicit surface matrix and test every command/URL from the installed +wheel. First-run guidance names live React as the interactive default and static +legacy as compatibility output. + +### Experimental labels become permanent excuses + +Assign each experimental surface a Release N+2/1.0 graduation or removal decision +with task and parity evidence. + +### Stable pages absorb all removed complexity + +Move only essential summaries. Do not recreate Reports, Cache And Context, or the +Investigator workbench inside Overview. + +### Experimental preference becomes another fragmented flag system + +Use one shell-owned browser-local preference and document per-origin behavior. Do +not add server, CLI, environment, and database variants for the same choice. + +### Published wheels contain stale dashboard assets + +Rebuild from a clean checkout, assert the generated diff is clean, and smoke the +installed wheel's React and legacy entry points before release. + +### Localization or accessibility degrades on hidden routes + +Require stable transition copy in every supported locale and run the release- +candidate accessibility matrix in both preference states and on direct routes. + +## Rollback + +Release N records the known-good package version and commit. Release N+1 can be +rolled back by restoring that reviewed version or by shipping a focused metadata +reversion that restores prior navigation visibility. Routes and backend contracts +remain available, so rollback requires no data migration or contract restoration. + +Any privacy regression, wrong-record evidence link, broken Call Investigator or +Limits path, stale packaged UI, or failed MCP-unavailable recovery blocks release +or triggers rollback. Direct-route deletion and legacy-dashboard removal occur only +after the separate Release N+2/1.0 decision and therefore are not part of the +initial rollback surface. diff --git a/frontend/dashboard/src/App.live-refresh.test.tsx b/frontend/dashboard/src/App.live-refresh.test.tsx index 41761af0..f855293a 100644 --- a/frontend/dashboard/src/App.live-refresh.test.tsx +++ b/frontend/dashboard/src/App.live-refresh.test.tsx @@ -1,5 +1,6 @@ import { act, App, describe, expect, fireEvent, installAppTestHooks, it, render, screen, vi, waitFor } from './test-utils/appTestHarness'; import { shouldAutoRefreshUsageView } from './App'; +import * as routeCatalogModule from './app/routeCatalog'; describe('React dashboard live refresh and row loading', () => { installAppTestHooks(); @@ -13,6 +14,17 @@ it('pauses automatic index refresh on evidence-heavy views', () => { expect(shouldAutoRefreshUsageView('investigator')).toBe(false); }); +it('uses route catalog refresh capability at runtime', () => { + const overview = routeCatalogModule.routeDefinition('overview'); + const routeDefinition = vi.spyOn(routeCatalogModule, 'routeDefinition').mockReturnValue({ + ...overview, + capabilities: { ...overview.capabilities, refresh: false }, + }); + + expect(shouldAutoRefreshUsageView('overview')).toBe(false); + routeDefinition.mockRestore(); +}); + it('auto refreshes live dashboards immediately and on interval', async () => { vi.useFakeTimers(); window.__CODEX_USAGE_BOOT__ = { diff --git a/frontend/dashboard/src/App.shell.live.test.tsx b/frontend/dashboard/src/App.shell.live.test.tsx index 8f07d78f..687ac53d 100644 --- a/frontend/dashboard/src/App.shell.live.test.tsx +++ b/frontend/dashboard/src/App.shell.live.test.tsx @@ -4,13 +4,15 @@ describe('React dashboard shell live loading', () => { installAppTestHooks(); it('auto-loads live rows for shell boot payloads without showing fixture rows', async () => { - window.__CODEX_USAGE_BOOT__ = { + const embedded = document.createElement('script'); + embedded.id = 'usage-data'; + embedded.type = 'application/json'; + embedded.textContent = JSON.stringify({ api_token: 'shell-load-token', context_api_enabled: true, shell_boot: true, + readiness_deferred: true, loaded_row_count: 0, - total_available_rows: 2, - active_available_rows: 2, default_load_window: 'all', load_window: 'rows', limit: 500, @@ -23,10 +25,23 @@ it('auto-loads live rows for shell boot payloads without showing fixture rows', total_tokens: 1_250, estimated_cost_usd: 0.12, }, - }; + }); + document.body.append(embedded); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { void init; const url = String(input); + if (url.includes('/api/readiness')) { + return { + ok: true, + json: async () => ({ + schema: 'codex-usage-tracker-conversational-readiness-v1', + state: 'ready', + summary: 'Deferred MCP readiness passed.', + next_action: null, + evidence: ['MCP runtime: pass'], + }), + } as Response; + } if (!url.includes('/api/usage?')) { throw new Error(`Unexpected request: ${url}`); } @@ -63,13 +78,16 @@ it('auto-loads live rows for shell boot payloads without showing fixture rows', expect(screen.queryByText('thread-9f3a1c')).not.toBeInTheDocument(); expect(await screen.findByText('real-live-thread')).toBeInTheDocument(); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); expect(String(fetchMock.mock.calls[0][0])).toContain('refresh=0'); expect(String(fetchMock.mock.calls[0][0])).toContain('limit=500'); expect(String(fetchMock.mock.calls[0][0])).toContain('load_window=all'); expect(screen.getByRole('button', { name: 'All time' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByText('2 calls analyzed · 1 detail row cached')).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Analysis scope' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(await screen.findByText('Deferred MCP readiness passed.')).toBeInTheDocument(); + embedded.remove(); }); it('loads more all-time evidence without switching to recent rows', async () => { diff --git a/frontend/dashboard/src/App.threads-live.test.tsx b/frontend/dashboard/src/App.threads-live.test.tsx index 880be7c2..9c20a5c7 100644 --- a/frontend/dashboard/src/App.threads-live.test.tsx +++ b/frontend/dashboard/src/App.threads-live.test.tsx @@ -51,7 +51,8 @@ describe('React dashboard threads live queries', () => { expect(await screen.findByRole('row', { name: /Collapse calls for page-two-thread/i })).toBeInTheDocument(); expect(screen.getByRole('region', { name: /Calls for page-two-thread/i })).toBeInTheDocument(); expect(await screen.findByText('page-two-model / high')).toBeInTheDocument(); - expect(new URLSearchParams(window.location.search).get('thread')).toBe('page-two-thread'); + expect(new URLSearchParams(window.location.search).get('thread_key')).toBe('page-two-thread'); + expect(new URLSearchParams(window.location.search).has('thread')).toBe(false); expect(fetchMock.mock.calls.some(([input]) => new URL(String(input), window.location.origin).searchParams.get('offset') === '1')).toBe(true); }); @@ -229,13 +230,14 @@ describe('React dashboard threads live queries', () => { it('keeps boot calls as a labelled snapshot and retries an initial call failure', async () => { let callAttempts = 0; const threadName = fixtureModel.threads[0].name; + const threadKey = fixtureModel.threads[0].threadKey; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = new URL(String(input), window.location.origin); if (url.pathname === '/api/threads') { return new Response(JSON.stringify({ schema: 'codex-usage-tracker-threads-v1', rows: [{ - thread_key: threadName, + thread_key: threadKey, thread_label: threadName, first_event_timestamp: '2026-07-01T12:00:00Z', latest_event_timestamp: '2026-07-01T12:02:00Z', @@ -271,7 +273,7 @@ describe('React dashboard threads live queries', () => { if (callAttempts <= 2) return new Response('temporary call failure', { status: 500 }); return new Response(JSON.stringify({ schema: 'codex-usage-tracker-thread-calls-v1', - thread_key: threadName, + thread_key: threadKey, rows: [], row_count: 0, total_matched_rows: 0, @@ -282,7 +284,7 @@ describe('React dashboard threads live queries', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); vi.stubGlobal('fetch', fetchMock); - window.history.replaceState(null, '', `/?view=threads&thread=${encodeURIComponent(threadName)}`); + window.history.replaceState(null, '', `/?view=threads&thread_key=${encodeURIComponent(threadKey || '')}`); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( { await waitFor(() => { const params = new URLSearchParams(window.location.search); expect(params.get('view')).toBe('threads'); - expect(params.get('thread')).toBe('thread-9f3a'); + expect(params.get('thread_key')).toBe('fixture-thread-key-0'); + expect(params.has('thread')).toBe(false); expect(params.get('detail')).toBeNull(); }); }); @@ -109,7 +110,8 @@ describe('React dashboard threads workspace', () => { await waitFor(() => { const params = new URLSearchParams(window.location.search); - expect(params.get('thread')).toBe('thread-9f3a'); + expect(params.get('thread_key')).toBe('fixture-thread-key-0'); + expect(params.has('thread')).toBe(false); expect(params.get('expand')).toBeNull(); }); }); @@ -151,7 +153,8 @@ it('expands, switches, and collapses one thread inline', () => { fireEvent.click(first); expect(first).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByRole('region', { name: /Calls for thread-9f3a/i })).toBeInTheDocument(); - expect(window.location.search).toContain('thread=thread-9f3a'); + expect(window.location.search).toContain('thread_key=fixture-thread-key-0'); + expect(new URLSearchParams(window.location.search).has('thread')).toBe(false); fireEvent.click(second); expect(second).toHaveAttribute('aria-expanded', 'true'); @@ -186,7 +189,8 @@ it('copies a child call link with the selected thread return state', async () => expect(copied.searchParams.get('view')).toBe('call'); expect(copied.searchParams.get('record')).toBe('fixture-call-0'); expect(copied.searchParams.get('return')).toBe('threads'); - expect(copied.searchParams.get('thread')).toBe('thread-9f3a'); + expect(copied.searchParams.get('thread_key')).toBe('fixture-thread-key-0'); + expect(copied.searchParams.has('thread')).toBe(false); }); it('shows and sorts all selected thread calls inline', () => { @@ -372,7 +376,8 @@ expect(row).not.toBeNull(); fireEvent.click(row as HTMLTableRowElement); expect(window.location.search).toContain('view=threads'); - expect(window.location.search).toContain('thread=thread-9f3a'); + expect(window.location.search).toContain('thread_key=fixture-thread-key-0'); + expect(new URLSearchParams(window.location.search).has('thread')).toBe(false); expect(screen.getByRole('button', { name: /Open investigator for thread call thread-9f3a1c codex-1/i })).toBeInTheDocument(); }); diff --git a/frontend/dashboard/src/App.tsx b/frontend/dashboard/src/App.tsx index e8daf268..861c554b 100644 --- a/frontend/dashboard/src/App.tsx +++ b/frontend/dashboard/src/App.tsx @@ -8,6 +8,8 @@ import { createShellI18n, initialDashboardLanguage, storeDashboardLanguage } fro import { LocalizedShellI18nProvider } from './app/DocumentLocalizationBridge'; import { modelWithLegacyShellFilters } from './app/legacyShellFilters'; import { navItems, secondaryNavItems, type ViewId } from './app/navigation'; +import { routeDefinition } from './app/routeCatalog'; +import { useConversationalReadiness, useExperimentalDashboardFeatures } from './app/dashboardHooks'; import { RowLimitControl } from './app/RowLimitControl'; import { ShellGlobalFilters } from './app/ShellGlobalFilters'; import { @@ -59,10 +61,8 @@ const keyboardShortcutViews: Record = { '3': 'threads', '4': 'diagnostics', }; -const autoRefreshSkippedViews = new Set(['call', 'compression-lab', 'diagnostics', 'investigator']); - export function shouldAutoRefreshUsageView(view: ViewId): boolean { - return !autoRefreshSkippedViews.has(view); + return routeDefinition(view).capabilities.refresh; } function isKeyboardShortcutTarget(target: EventTarget | null) { @@ -71,6 +71,7 @@ function isKeyboardShortcutTarget(target: EventTarget | null) { } export function App() { + const { showExperimental, setShowExperimental } = useExperimentalDashboardFeatures(); const searchInputRef = useRef(null); const initialPayload = useMemo(() => readBootPayload(), []); const [dashboardPayload, setDashboardPayload] = useState(initialPayload); @@ -99,7 +100,7 @@ const initialLiveLoadAttempted = useRef(false); const [loadWindow, setLoadWindow] = useState(() => initialLoadWindowFromPayload(initialPayload)); const [historyScope, setHistoryScope] = useState(() => historyScopeFromUrl(historyScopeFromPayload(initialPayload))); const [contextApiEnabled, setContextApiEnabled] = useState(model.contextRuntime.contextApiEnabled); -const canUseLiveApi = Boolean(dashboardPayload?.api_token); +const { canUseLiveApi, conversationalAnalysis } = useConversationalReadiness(initialPayload, dashboardPayload); const sourceIdentity = useMemo(() => dashboardSourceIdentityFromPayload(dashboardPayload), [dashboardPayload]); const shellI18n = useMemo(() => createShellI18n(dashboardPayload, language), [dashboardPayload, language]); const contextRuntime = useMemo( @@ -357,7 +358,7 @@ useEffect(() => { if ( !canUseLiveApi || (!needsSessionRestore && hasLoadedRows) || - availableRows <= 0 || + (dashboardPayload?.shell_boot !== true && availableRows <= 0) || refreshing || initialLiveLoadAttempted.current ) { @@ -699,6 +700,7 @@ aria-label="History scope" } backFromCallInvestigator={backFromCallInvestigator} dashboardPayload={dashboardPayload} + conversationalAnalysis={conversationalAnalysis} sourceIdentity={sourceIdentity} historyScope={historyScope} loadWindow={loadWindow} @@ -708,6 +710,8 @@ aria-label="History scope" totalAvailableRows={totalAvailableRows} canUseLiveApi={canUseLiveApi} autoRefreshEnabled={autoRefreshEnabled} applicationI18n={shellI18n} + showExperimental={showExperimental} + setShowExperimental={setShowExperimental} refreshing={refreshing} hasMoreRows={hasMoreRows} canLoadAllRows={canLoadAllRows} diff --git a/frontend/dashboard/src/api/client.ts b/frontend/dashboard/src/api/client.ts index 12f3fb2f..c89db341 100644 --- a/frontend/dashboard/src/api/client.ts +++ b/frontend/dashboard/src/api/client.ts @@ -674,14 +674,17 @@ function getThreadLabel(row: UsageRow): string { export function buildThreads(calls: CallRow[]): ThreadRow[] { const grouped = new Map(); for (const call of calls) { - grouped.set(call.thread, [...(grouped.get(call.thread) ?? []), call]); + const identity = call.threadKey ? `key:${call.threadKey}` : `name:${call.thread}`; + grouped.set(identity, [...(grouped.get(identity) ?? []), call]); } return [...grouped.entries()] - .map(([name, rows]) => { + .map(([, rows]) => { const turns = rows.length; const sortedRows = [...rows].sort((left, right) => callTimestamp(right) - callTimestamp(left)); const latestCall = sortedRows[0] ?? null; + const threadKey = sortedRows.find(row => row.threadKey)?.threadKey; + const name = latestCall?.thread ?? rows[0]?.thread ?? 'Untitled thread'; const latestActivityRaw = latestCall?.rawTime || latestCall?.time || ''; const totalTokens = rows.reduce((sum, row) => sum + row.totalTokens, 0); const cachedInput = rows.reduce((sum, row) => sum + row.cachedInput, 0); @@ -703,6 +706,7 @@ export function buildThreads(calls: CallRow[]): ThreadRow[] { const coldResumeRisk = cachePct < 25 ? 'High' : cachePct < 45 ? 'Medium' : 'Low'; return { name, + ...(threadKey ? { threadKey } : {}), latestCallId: latestCall?.id ?? '', latestActivity: formatShortDate(latestActivityRaw), latestActivityRaw, diff --git a/frontend/dashboard/src/api/readiness.ts b/frontend/dashboard/src/api/readiness.ts new file mode 100644 index 00000000..b2f7dad3 --- /dev/null +++ b/frontend/dashboard/src/api/readiness.ts @@ -0,0 +1,10 @@ +import type { ConversationalReadiness } from './types'; + +export async function loadConversationalReadiness( + signal?: AbortSignal, +): Promise { + const response = await fetch('/api/readiness', { cache: 'no-store', signal }); + const payload = await response.json() as ConversationalReadiness & { error?: string }; + if (!response.ok) throw new Error(payload.error || `MCP readiness request failed (${response.status})`); + return payload; +} diff --git a/frontend/dashboard/src/api/types.ts b/frontend/dashboard/src/api/types.ts index 75435744..c7c8cc50 100644 --- a/frontend/dashboard/src/api/types.ts +++ b/frontend/dashboard/src/api/types.ts @@ -78,6 +78,7 @@ export type UsageRow = UsageBillingFields & UsageServiceTierFields & { }; export type DashboardBootPayload = DashboardDataScope & { + conversational_analysis?: ConversationalReadiness; api_token?: string; context_api_enabled?: boolean; refresh_jobs_available?: boolean; @@ -112,7 +113,15 @@ rows?: UsageRow[]; dedupe?: DedupeSummary; project_metadata_privacy?: ProjectMetadataPrivacy; privacy_mode?: string; -shell_boot?: boolean; +shell_boot?: boolean; readiness_deferred?: boolean; +}; + +export type ConversationalReadiness = { + schema: 'codex-usage-tracker-conversational-readiness-v1'; + state: 'ready' | 'restart-required' | 'unavailable' | 'unknown'; + summary: string; + next_action: string | null; + evidence: string[]; }; type DedupeSummary = { @@ -355,6 +364,7 @@ export type CallRow = CallBillingFields & CallServiceTierFields & { export type ThreadRow = { name: string; + threadKey?: string; latestCallId: string; latestActivity: string; latestActivityRaw: string; diff --git a/frontend/dashboard/src/app/currentViewExport.ts b/frontend/dashboard/src/app/currentViewExport.ts index 8c37a135..c937e999 100644 --- a/frontend/dashboard/src/app/currentViewExport.ts +++ b/frontend/dashboard/src/app/currentViewExport.ts @@ -4,6 +4,7 @@ import { callCsvColumns } from '../features/shared/tables'; import type { ContextRuntime, DashboardModel } from '../api/types'; import { loadWindowLabel, type LoadWindow } from '../data/dataScope'; import type { ViewId } from './navigation'; +import { routeDefinition } from './routeCatalog'; import { rowLimitNoCap } from './rowLimit'; import type { HistoryScope } from './shellUrl'; @@ -34,6 +35,9 @@ export async function currentViewCsvExport( globalQuery = '', activePreset = '', ): Promise { + if (!routeDefinition(activeView).capabilities.export) { + throw new Error(`CSV export is not available for ${routeDefinition(activeView).label}.`); + } const stamp = csvDateStamp(); switch (activeView) { case 'threads': { diff --git a/frontend/dashboard/src/app/dashboardHooks.ts b/frontend/dashboard/src/app/dashboardHooks.ts new file mode 100644 index 00000000..1d675c43 --- /dev/null +++ b/frontend/dashboard/src/app/dashboardHooks.ts @@ -0,0 +1,2 @@ +export { useConversationalReadiness } from './useConversationalReadiness'; +export { useExperimentalDashboardFeatures } from './useExperimentalDashboardFeatures'; diff --git a/frontend/dashboard/src/app/dashboardTargets.test.ts b/frontend/dashboard/src/app/dashboardTargets.test.ts new file mode 100644 index 00000000..e9c447a6 --- /dev/null +++ b/frontend/dashboard/src/app/dashboardTargets.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDashboardTarget, dashboardTargetPrompt } from './dashboardTargets'; + +describe('dashboard targets', () => { + it.each(['normal', 'redacted', 'strict'] as const)('builds a deterministic %s target', privacy_mode => { + const target = buildDashboardTarget({ + view: 'call', record_id: 'record-123', history: 'all', privacy_mode, + service_origin: 'http://127.0.0.1:47821', + filters: { mode: 'full', call_q: 'forbidden-fixture-value', api_token: 'forbidden-fixture-value' }, + }); + + expect(target.relative_url).toBe('/react-dashboard.html?history=all&mode=full&record=record-123&view=call'); + expect(target.absolute_url).toBe(`http://127.0.0.1:47821${target.relative_url}`); + expect(target.filters).toEqual({ mode: 'full' }); + expect(JSON.stringify(target)).not.toContain('forbidden-fixture-value'); + expect(dashboardTargetPrompt(target)).not.toContain('forbidden-fixture-value'); + }); + + it('uses a relative target and fallback when no active origin is known', () => { + const target = buildDashboardTarget({ view: 'overview' }); + expect(target.absolute_url).toBeNull(); + expect(target.fallback_instruction).toBe('codex-usage-tracker serve-dashboard --open'); + expect(dashboardTargetPrompt(target)).toContain(target.relative_url); + }); + + it('maps only canonical selectors for their cataloged destinations', () => { + const sessionKey = 'session:019e374d-c19f-7da3-a44f-8de043a7a64e'; + expect(buildDashboardTarget({ view: 'threads', thread_key: sessionKey }).relative_url) + .toBe('/react-dashboard.html?thread_key=session%3A019e374d-c19f-7da3-a44f-8de043a7a64e&view=threads'); + expect(buildDashboardTarget({ view: 'diagnostics', diagnostic_fact: 'activity:search_read_command' }).relative_url) + .toBe('/react-dashboard.html?diagnostic_fact=activity%3Asearch_read_command&view=diagnostics'); + for (const diagnosticFact of ['skill:codex-usage-tracker', 'skill:brooks-test']) { + expect(buildDashboardTarget({ view: 'diagnostics', diagnostic_fact: diagnosticFact }).diagnostic_fact) + .toBe(diagnosticFact); + } + expect(buildDashboardTarget({ view: 'usage-drain', limit_evidence: 'stable' }).relative_url) + .toBe('/react-dashboard.html?limit_hypothesis=stable&view=usage-drain'); + expect(buildDashboardTarget({ view: 'call', record_id: 'a'.repeat(64) }).record_id) + .toBe('a'.repeat(64)); + expect(() => buildDashboardTarget({ view: 'overview', record_id: 'private' })).toThrow(); + }); + + it('rejects non-loopback origins and uncataloged views', () => { + expect(() => buildDashboardTarget({ view: 'overview', service_origin: 'https://example.com' })).toThrow(/loopback/); + expect(() => buildDashboardTarget({ view: 'overview', service_origin: 'http://secret@localhost:47821' })).toThrow(/credentials/); + expect(() => buildDashboardTarget({ view: 'secrets' })).toThrow(/dashboard view/); + }); + + it.each(['normal', 'redacted', 'strict'] as const)('rejects unsafe selectors in %s mode', privacy_mode => { + const cases = [ + ['call', 'record_id'], ['threads', 'thread_key'], + ['diagnostics', 'diagnostic_fact'], ['usage-drain', 'limit_evidence'], + ] as const; + const unsafeValues = [ + 'forbidden-fixture-value', 'sk-' + 'abcdefghijklmnopqrstuvwxyz123456', + '/Users/private/project', 'name with spaces', 'line\nbreak', 'folder\\name', + 'value?token=secret', 'value#fragment', + '{"raw":"text"}', 'raw-context-fragment', 'indexed-prompt-fragment', + 'project:private-label', 'a'.repeat(130), + 'xox' + 'b-1234567890-abcdefghijklmnop', 'summarize-my-bank-account', + 'client-acme-production', + ]; + cases.forEach(([view, selector]) => unsafeValues.forEach(value => { + expect(() => buildDashboardTarget({ view, privacy_mode, [selector]: value })).toThrow(); + })); + }); + + it.each(['normal', 'redacted', 'strict'] as const)('drops invalid handoff filters in %s mode', privacy_mode => { + const cases = [ + ['investigator', 'finding'], ['calls', 'explore'], ['calls', 'detail'], + ['calls', 'source'], ['calls', 'sort'], ['calls', 'direction'], + ['calls', 'density'], ['calls', 'page'], ['call', 'return'], ['call', 'mode'], + ['threads', 'expand'], ['threads', 'risk'], ['threads', 'thread_call_sort'], + ['threads', 'thread_call_page'], ['usage-drain', 'usage_plan'], + ['usage-drain', 'usage_effort'], ['usage-drain', 'usage_subagents'], + ['usage-drain', 'usage_sample'], ['usage-drain', 'usage_confidence'], + ['usage-drain', 'limit_window'], ['diagnostics', 'diagnostic_source'], + ['reports', 'report'], + ]; + cases.forEach(([view, filterKey]) => { + const target = buildDashboardTarget({ + view, privacy_mode, filters: { [filterKey]: 'forbidden-fixture-value' }, + }); + expect(target.filters).toEqual({}); + expect(JSON.stringify(target)).not.toContain('forbidden-fixture-value'); + expect(target.relative_url).not.toContain('forbidden-fixture-value'); + expect(target.absolute_url).toBeNull(); + expect(dashboardTargetPrompt(target)).not.toContain('forbidden-fixture-value'); + }); + }); + + it('matches Python boolean and numeric query normalization', () => { + const target = buildDashboardTarget({ + view: 'usage-drain', + filters: { usage_subagents: false, usage_sample: 80, usage_confidence: 0.55 }, + }); + expect(target.relative_url).toBe( + '/react-dashboard.html?usage_confidence=0.55&usage_sample=80&usage_subagents=false&view=usage-drain', + ); + }); + + it('applies the label-bearing identifier rule by privacy mode', () => { + expect(buildDashboardTarget({ + view: 'threads', thread_key: 'thread:Project Alpha', privacy_mode: 'normal', + }).thread_key).toBe('thread:Project Alpha'); + for (const privacy_mode of ['redacted', 'strict'] as const) { + expect(() => buildDashboardTarget({ + view: 'threads', thread_key: 'thread:Project Alpha', privacy_mode, + })).toThrow(/thread_key/); + } + }); + + it.each(['normal', 'redacted', 'strict'] as const)('catalogs reports in %s mode', privacy_mode => { + for (const value of [ + 'xox' + 'b-1234567890-abcdefghijklmnop', 'summarize-my-bank-account', 'client-acme-production', + ]) { + const target = buildDashboardTarget({ view: 'reports', privacy_mode, filters: { report: value } }); + expect(target.filters).toEqual({}); + expect(JSON.stringify(target)).not.toContain(value); + expect(dashboardTargetPrompt(target)).not.toContain(value); + } + }); + + it('accepts only the verified report catalog', () => { + for (const report of [ + 'fast-mode-proxy', 'cost-curves', 'usage-remaining', + 'allowance-change', 'weekly-credits', 'usage-drain-model', + ]) { + expect(buildDashboardTarget({ view: 'reports', filters: { report } }).filters) + .toEqual({ report }); + } + }); + + it.each(['normal', 'redacted', 'strict'] as const)('keeps session keys in %s mode', privacy_mode => { + const thread_key = 'session:019e374d-c19f-7da3-a44f-8de043a7a64e'; + expect(buildDashboardTarget({ view: 'threads', thread_key, privacy_mode }).thread_key) + .toBe(thread_key); + }); + + it.each([ + [0, '0'], [-0, '0'], [1, '1'], [0.55, '0.55'], [1e-7, '0.0000001'], + ] as const)('canonically serializes numeric filter %s', (value, serialized) => { + expect(buildDashboardTarget({ + view: 'usage-drain', filters: { usage_confidence: value }, + }).relative_url).toBe( + `/react-dashboard.html?usage_confidence=${serialized}&view=usage-drain`, + ); + }); + + it.each([-0.01, 1.01, Number.POSITIVE_INFINITY, Number.NaN])('drops invalid number %s', value => { + expect(buildDashboardTarget({ + view: 'usage-drain', filters: { usage_confidence: value }, + }).filters).toEqual({}); + }); + + it.each(['http://localhost', 'http://localhost:80', 'http://localhost:65536'])( + 'requires a valid non-privileged service port for %s', origin => { + expect(() => buildDashboardTarget({ view: 'overview', service_origin: origin })).toThrow(); + }, + ); +}); diff --git a/frontend/dashboard/src/app/dashboardTargets.ts b/frontend/dashboard/src/app/dashboardTargets.ts new file mode 100644 index 00000000..23a5b9bc --- /dev/null +++ b/frontend/dashboard/src/app/dashboardTargets.ts @@ -0,0 +1,211 @@ +import { isDashboardViewId, type DashboardViewId } from '../routes/dashboardSearch'; +import { routeDefinition } from './routeCatalog'; + +type DashboardPrivacyMode = 'normal' | 'redacted' | 'strict'; +type DashboardHistory = 'active' | 'all'; + +export type DashboardTarget = { + schema: 'codex-usage-tracker-dashboard-target-v1'; + view: DashboardViewId; + record_id?: string; + thread_key?: string; + diagnostic_fact?: string; + limit_evidence?: string; + filters: Record; + history: DashboardHistory; + privacy_mode: DashboardPrivacyMode; + relative_url: string; + absolute_url: string | null; + fallback_instruction: string | null; +}; + +export type DashboardTargetInput = { + view: string; + record_id?: unknown; + thread_key?: unknown; + diagnostic_fact?: unknown; + limit_evidence?: unknown; + filters?: Record; + history?: unknown; + privacy_mode?: unknown; + service_origin?: string | null; +}; + +const fallbackInstruction = 'codex-usage-tracker serve-dashboard --open'; +const canonicalParams = new Set(['history', 'record', 'thread_key', 'diagnostic_fact', 'limit_hypothesis']); +const recordId = /^(?:[0-9a-f]{64}|record-[0-9]{1,10})$/; +const sessionThreadKey = /^session:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const diagnosticFact = /^(?:activity|command_family|compaction|function|loop|mcp_server|mcp_tool|outcome|skill|tool):[a-z0-9_.:-]{1,80}$/; +const limitEvidence = new Set(['stable', 'decreased']); +const reportIds = new Set([ + 'fast-mode-proxy', 'cost-curves', 'usage-remaining', + 'allowance-change', 'weekly-credits', 'usage-drain-model', +]); +const enumFilters: Record> = { + explore: new Set(['calls', 'tools', 'files']), + detail: new Set(['first']), + source: new Set(['all', 'project', 'session', 'git', 'source-file', 'missing']), + sort: new Set(['time', 'duration', 'gap', 'attention', 'thread', 'initiator', 'model', 'effort', 'total', 'cached', 'uncached', 'output', 'reasoning', 'cost', 'usage', 'cache', 'context']), + direction: new Set(['asc', 'desc']), density: new Set(['dense', 'roomy']), + return: new Set(['overview', 'investigator', 'compression-lab', 'calls', 'threads', 'usage-drain', 'cache-context', 'diagnostics', 'reports', 'settings']), + mode: new Set(['summary', 'full']), expand: new Set(['first', 'all']), + risk: new Set(['all', 'Low', 'Medium', 'High']), + thread_call_sort: new Set(['newest', 'duration', 'gap', 'initiator', 'model', 'effort', 'tokens', 'cached', 'uncached', 'output', 'reasoning', 'cost', 'cache']), + usage_plan: new Set(['Weekly', 'weekly', 'five_hour']), + usage_effort: new Set(['low', 'medium', 'high']), + limit_window: new Set(['weekly', 'five_hour']), + diagnostic_source: new Set(['facts', 'tools', 'compactions']), +}; +const integerFilters: Record = { + finding: [1, 10_000], page: [1, 10_000], thread_call_page: [1, 10_000], usage_sample: [1, 10_000], +}; + +export function buildDashboardTarget(input: DashboardTargetInput): DashboardTarget { + if (!isDashboardViewId(input.view)) throw new Error(`Unknown dashboard view: ${input.view}`); + const privacyMode = normalizePrivacyMode(input.privacy_mode); + const history = normalizeHistory(input.history); + const handoffParams = new Set(routeDefinition(input.view).handoffParams); + const filters = normalizeFilters(input.filters ?? {}, handoffParams); + const query = new Map([['view', input.view]]); + const target: DashboardTarget = { + schema: 'codex-usage-tracker-dashboard-target-v1', + view: input.view, + filters, + history, + privacy_mode: privacyMode, + relative_url: '', + absolute_url: null, + fallback_instruction: fallbackInstruction, + }; + + addSelector(target, query, handoffParams, input.view, 'record_id', 'record', input.record_id, 'call', privacyMode); + addSelector(target, query, handoffParams, input.view, 'thread_key', 'thread_key', input.thread_key, 'threads', privacyMode); + addSelector(target, query, handoffParams, input.view, 'diagnostic_fact', 'diagnostic_fact', input.diagnostic_fact, 'diagnostics', privacyMode); + addSelector(target, query, handoffParams, input.view, 'limit_evidence', 'limit_hypothesis', input.limit_evidence, 'usage-drain', privacyMode); + Object.entries(filters).forEach(([key, value]) => query.set(key, queryValue(value))); + if (history !== 'active') query.set('history', history); + + const params = new URLSearchParams([...query.entries()].sort(([left], [right]) => left.localeCompare(right))); + target.relative_url = `/react-dashboard.html?${params.toString()}`; + const origin = normalizeLoopbackOrigin(input.service_origin); + if (origin) { + target.absolute_url = `${origin}${target.relative_url}`; + target.fallback_instruction = null; + } + return target; +} + +export function dashboardTargetPrompt(target: DashboardTarget): string { + const destination = target.absolute_url ?? target.relative_url; + const fallback = target.fallback_instruction ? ` If needed, run: ${target.fallback_instruction}.` : ''; + return `Open the ${target.view} usage dashboard at ${destination}.${fallback}`; +} + +function normalizeFilters( + input: Record, + safeParams: ReadonlySet, +): Record { + const normalized: Record = {}; + Object.keys(input).sort().forEach(key => { + if (!safeParams.has(key) || canonicalParams.has(key)) return; + const value = normalizeFilterValue(key, input[key]); + if (value !== null) normalized[key] = value; + }); + return normalized; +} + +function addSelector( + target: DashboardTarget, + query: Map, + safeParams: ReadonlySet, + view: DashboardViewId, + targetKey: 'record_id' | 'thread_key' | 'diagnostic_fact' | 'limit_evidence', + queryKey: string, + value: unknown, + requiredView: DashboardViewId, + privacyMode: DashboardPrivacyMode, +): void { + if (value === undefined || value === null) return; + if (view !== requiredView || !safeParams.has(queryKey)) throw new Error(`${targetKey} is not allowed for ${view}`); + const normalized = normalizeIdentifier(value, targetKey, privacyMode); + if (!normalized) throw new Error(`${targetKey} must be a bounded privacy-safe identifier`); + target[targetKey] = normalized; + query.set(queryKey, normalized); +} + +function normalizeFilterValue(key: string, value: unknown): string | number | boolean | null { + const allowed = enumFilters[key]; + if (allowed) { + const candidate = typeof value === 'string' ? value.trim() : ''; + return allowed.has(candidate) ? candidate : null; + } + const range = integerFilters[key]; + if (range) return integerInRange(value, range[0], range[1]); + if (key === 'usage_subagents') { + if (typeof value === 'boolean') return value; + return integerInRange(value, 0, 100); + } + if (key === 'usage_confidence') { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) return null; + return Object.is(value, -0) || Number.isInteger(value) ? Math.trunc(value) : value; + } + if (key === 'report') return typeof value === 'string' && reportIds.has(value) ? value : null; + return null; +} + +function integerInRange(value: unknown, minimum: number, maximum: number): number | null { + return typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum ? value : null; +} + +function normalizeIdentifier( + value: unknown, + targetKey: 'record_id' | 'thread_key' | 'diagnostic_fact' | 'limit_evidence', + privacyMode: DashboardPrivacyMode, +): string | null { + if (typeof value !== 'string') return null; + if (targetKey === 'record_id') return recordId.test(value) ? value : null; + if (targetKey === 'thread_key') { + if (sessionThreadKey.test(value)) return value; + return privacyMode === 'normal' && normalThreadKey(value) ? value : null; + } + if (targetKey === 'diagnostic_fact') return diagnosticFact.test(value) ? value : null; + return limitEvidence.has(value) ? value : null; +} + +function normalThreadKey(value: string): boolean { + if (!value.startsWith('thread:')) return false; + const label = value.slice('thread:'.length); + return label.length > 0 && label.length <= 80 && !/[\r\n\t/\\?#{}[\]]/.test(label); +} + +function queryValue(value: string | number | boolean): string { + if (typeof value !== 'number' || Number.isInteger(value)) return String(value); + return value.toFixed(15).replace(/0+$/, '').replace(/\.$/, '') || '0'; +} + +function normalizePrivacyMode(value: unknown): DashboardPrivacyMode { + if (value === undefined) return 'normal'; + if (value === 'normal' || value === 'redacted' || value === 'strict') return value; + throw new Error(`Unknown privacy mode: ${String(value)}`); +} + +function normalizeHistory(value: unknown): DashboardHistory { + if (value === undefined) return 'active'; + if (value === 'active' || value === 'all') return value; + throw new Error(`Unknown dashboard history: ${String(value)}`); +} + +function normalizeLoopbackOrigin(value: string | null | undefined): string | null { + if (!value) return null; + const origin = new URL(value); + if (origin.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)) { + throw new Error('Dashboard service origin must be loopback HTTP'); + } + if (origin.username || origin.password) throw new Error('Dashboard service origin must not include credentials'); + const port = Number(origin.port); + if (!origin.port || !Number.isInteger(port) || port < 1024 || port > 65535) { + throw new Error('Dashboard service origin must include port 1024 through 65535'); + } + if (origin.pathname !== '/' || origin.search || origin.hash) throw new Error('Dashboard service origin must be an origin'); + return origin.origin; +} diff --git a/frontend/dashboard/src/app/i18n.ts b/frontend/dashboard/src/app/i18n.ts index 13a464a3..80934816 100644 --- a/frontend/dashboard/src/app/i18n.ts +++ b/frontend/dashboard/src/app/i18n.ts @@ -47,6 +47,47 @@ const fallbackTranslations: Record = { 'option.all_history': 'All history', 'status.paused': 'Paused', 'status.static': 'Static', + 'settings.experimental.title': 'Experimental Features', + 'settings.experimental.subtitle': 'Browser-local preference', + 'settings.experimental.toggle': 'Show experimental dashboard features', + 'settings.experimental.origin_scope': 'This preference is stored for this browser origin. Experimental workspaces remain available from direct links, and Diagnostics stays visible.', + 'maturity.aria': 'Feature maturity: {title}', + 'maturity.highly_experimental': 'Highly experimental', + 'maturity.highly_experimental_description': 'Useful for technical exploration; methods and presentation may change.', + 'maturity.available_transition': 'Available during transition', + 'maturity.transition_description': 'This workspace remains available in Release N while its future placement is prepared.', + 'readiness.eyebrow': 'Conversational analysis', + 'readiness.title': 'Analysis readiness', + 'readiness.state.ready': 'Ready', + 'readiness.state.restart_required': 'Restart required', + 'readiness.state.unavailable': 'Unavailable', + 'readiness.state.unknown': 'Unknown', + 'readiness.unknown_summary': 'Conversational analysis readiness could not be determined from this static payload.', + 'readiness.summary.unavailable_not_configured': 'Conversational analysis is not locally configured.', + 'readiness.summary.unavailable_failed': 'Conversational analysis failed a local configuration or launcher check.', + 'readiness.summary.unknown_local_files': 'Conversational analysis readiness could not be determined from local files.', + 'readiness.summary.ready_local_checks': 'Local installation and launcher checks passed; current task tool exposure is not verified.', + 'readiness.summary.restart_fresh_task': 'The local launcher is installed; a fresh Codex task is required for discovery.', + 'readiness.guidance.ready': 'Local checks passed. This does not verify that the current task loaded MCP tools.', + 'readiness.guidance.restart_required': 'Restart Codex and open a fresh task to load the local plugin tools.', + 'readiness.guidance.unavailable': 'Run `codex-usage-tracker setup`, then `codex-usage-tracker doctor`.', + 'readiness.guidance.unknown': 'Readiness could not be determined from this payload.', + 'readiness.manual_fallback': 'Manual fallback', + 'readiness.fallback.calls': 'Calls', + 'readiness.fallback.threads': 'Threads', + 'readiness.fallback.limits': 'Limits', + 'readiness.fallback.diagnostics': 'Diagnostics', + 'readiness.fallback.advanced': 'Advanced experimental controls', + 'evidence.actions.aria': 'Dashboard evidence actions', + 'evidence.open': 'Open evidence', + 'evidence.copy_prompt': 'Copy investigation prompt', + 'evidence.prompt_copied': 'Investigation prompt copied', + 'evidence.prompt_copy_failed': 'Unable to copy investigation prompt', + 'evidence.launch_guidance': 'Start the local dashboard first: {instruction}', + 'evidence.question.default': 'Investigate the selected aggregate usage evidence.', + 'evidence.aggregate_selection': 'aggregate selection', + 'evidence.prompt.template': '{question} Evidence: {identifiers}. Scope: history={history}, privacy_mode={privacy}. Target: {target}.{launch}', + 'evidence.prompt.launch': ' Launch locally with: {instruction}.', }; const navTranslationKeys: Partial> = { diff --git a/frontend/dashboard/src/app/navigation.ts b/frontend/dashboard/src/app/navigation.ts index 07edb6df..512914eb 100644 --- a/frontend/dashboard/src/app/navigation.ts +++ b/frontend/dashboard/src/app/navigation.ts @@ -1,20 +1,16 @@ import { Activity, - BarChart3, - BookOpen, - BrainCircuit, Boxes, - Database, FileText, - FlaskConical, - Home, - Settings, - Table2, - TimerReset, - Workflow, type LucideIcon, } from 'lucide-react'; import { isDashboardViewId, type DashboardViewId } from '../routes/dashboardSearch'; +import { + navigationForPhase, + type RouteLifecycle, + type RouteMaturity, + type RoutePlacement, +} from './routeCatalog'; export type ViewId = DashboardViewId; @@ -25,23 +21,23 @@ export type NavItem = { icon: LucideIcon; }; -export const navItems: NavItem[] = [ - { id: 'overview', label: 'Overview', description: 'High-level telemetry', icon: Home }, - { id: 'investigator', label: 'Investigate', description: 'Root-cause evidence', icon: FlaskConical }, - { id: 'compression-lab', label: 'Compression Lab', description: 'Context savings', icon: BrainCircuit }, - { id: 'calls', label: 'Calls', description: 'Model-call table', icon: Table2 }, - { id: 'threads', label: 'Threads', description: 'Thread efficiency', icon: Workflow }, - { id: 'usage-drain', label: 'Limits', description: 'Allowance intelligence', icon: TimerReset }, - { id: 'cache-context', label: 'Cache And Context', description: 'Cache and cold resumes', icon: Database }, - { id: 'diagnostics', label: 'Diagnostics Notebook', description: 'Technical report', icon: BookOpen }, - { id: 'reports', label: 'Reports', description: 'Generated analyses', icon: BarChart3 }, - { id: 'settings', label: 'Settings', description: 'Local configuration', icon: Settings }, -]; +export const navItems: NavItem[] = navigationForPhase('foundation').map(({ id, label, description, icon }) => ({ + id, label, description, icon, +})); + +type SecondaryNavItem = { + label: string; + icon: LucideIcon; + target: ViewId; + maturity: RouteMaturity; + placement: RoutePlacement; + lifecycle: RouteLifecycle; +}; -export const secondaryNavItems: Array<{ label: string; icon: LucideIcon; target: ViewId }> = [ - { label: 'Files', icon: FileText, target: 'settings' }, - { label: 'Commands', icon: Activity, target: 'investigator' }, - { label: 'Models', icon: Boxes, target: 'calls' }, +export const secondaryNavItems: SecondaryNavItem[] = [ + { label: 'Files', icon: FileText, target: 'settings', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, + { label: 'Commands', icon: Activity, target: 'investigator', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, + { label: 'Models', icon: Boxes, target: 'calls', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, ]; export function isViewId(value: string | null): value is ViewId { diff --git a/frontend/dashboard/src/app/routeCatalog.test.ts b/frontend/dashboard/src/app/routeCatalog.test.ts new file mode 100644 index 00000000..6056dcd7 --- /dev/null +++ b/frontend/dashboard/src/app/routeCatalog.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { dashboardRenderedViewIds } from '../routes/DashboardRouteView'; +import { dashboardViewIds } from '../routes/dashboardSearch'; +import { secondaryNavItems } from './navigation'; +import { navigationForPhase, routeCatalog, routeDefinition } from './routeCatalog'; + +describe('dashboard route catalog', () => { + it('catalogs and renders every dashboard view exactly once', () => { + expect(new Set(routeCatalog.map(route => route.id))).toEqual(new Set(dashboardViewIds)); + expect(dashboardRenderedViewIds).toHaveLength(dashboardViewIds.length); + expect(new Set(dashboardRenderedViewIds)).toEqual(new Set(dashboardViewIds)); + }); + + it('keeps route IDs and labels unique', () => { + expect(new Set(routeCatalog.map(route => route.id))).toHaveLength(routeCatalog.length); + expect(new Set(routeCatalog.map(route => route.label))).toHaveLength(routeCatalog.length); + }); + + it('classifies contextual and transitioning routes independently from navigation exposure', () => { + expect(routeDefinition('call')).toMatchObject({ + maturity: 'stable', placement: 'contextual', lifecycle: 'active', + }); + expect(routeDefinition('diagnostics')).toMatchObject({ + maturity: 'experimental', placement: 'primary', lifecycle: 'active', + }); + expect(routeDefinition('cache-context')).toMatchObject({ + maturity: 'experimental', placement: 'hidden', lifecycle: 'transitioning', + }); + }); + + it('limits experimental navigation eligibility to the two approved routes', () => { + expect(routeCatalog.filter(route => route.experimentalNavigationEligible).map(route => route.id)).toEqual([ + 'investigator', + 'compression-lab', + ]); + }); + + it('locks the Release N foundation navigation baseline', () => { + expect(navigationForPhase('foundation').map(route => route.id)).toEqual([ + 'overview', 'investigator', 'compression-lab', 'calls', 'threads', + 'usage-drain', 'cache-context', 'diagnostics', 'reports', 'settings', + ]); + }); + + it('keeps legacy quick-link aliases hidden and deprecated', () => { + expect(secondaryNavItems.map(({ label, maturity, placement, lifecycle }) => ({ + label, maturity, placement, lifecycle, + }))).toEqual([ + { label: 'Files', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, + { label: 'Commands', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, + { label: 'Models', maturity: 'stable', placement: 'hidden', lifecycle: 'deprecated' }, + ]); + }); + + it('preserves the full Task 1 route params separately from handoff params', () => { + expect(Object.fromEntries(routeCatalog.map(route => [route.id, route.safeParams]))).toEqual({ + overview: [], investigator: ['finding'], 'compression-lab': [], + calls: ['explore', 'detail', 'call_q', 'source', 'sort', 'direction', 'density', 'page'], + call: ['record', 'return', 'mode', 'max_entries', 'max_chars', 'include_tool_output', 'include_compaction_history'], + threads: ['thread', 'thread_key', 'expand', 'threads', 'thread_q', 'risk', 'thread_call_sort', 'thread_call_page'], + 'usage-drain': ['usage_plan', 'usage_effort', 'usage_subagents', 'usage_sample', 'usage_confidence', 'limit_window', 'limit_hypothesis'], + 'cache-context': ['cache_thread'], diagnostics: ['diagnostic_source', 'diagnostic_fact'], + reports: ['report'], settings: [], + }); + expect(Object.fromEntries(routeCatalog.map(route => [route.id, route.handoffParams]))).toEqual({ + overview: [], investigator: ['finding'], 'compression-lab': [], + calls: ['explore', 'detail', 'source', 'sort', 'direction', 'density', 'page'], + call: ['record', 'return', 'mode'], + threads: ['thread_key', 'expand', 'risk', 'thread_call_sort', 'thread_call_page'], + 'usage-drain': ['usage_plan', 'usage_effort', 'usage_subagents', 'usage_sample', 'usage_confidence', 'limit_window', 'limit_hypothesis'], + 'cache-context': [], diagnostics: ['diagnostic_source', 'diagnostic_fact'], + reports: ['report'], settings: [], + }); + }); +}); diff --git a/frontend/dashboard/src/app/routeCatalog.ts b/frontend/dashboard/src/app/routeCatalog.ts new file mode 100644 index 00000000..959690ed --- /dev/null +++ b/frontend/dashboard/src/app/routeCatalog.ts @@ -0,0 +1,122 @@ +import { + BarChart3, + BookOpen, + BrainCircuit, + Database, + FlaskConical, + Home, + Search, + Settings, + Table2, + TimerReset, + Workflow, + type LucideIcon, +} from 'lucide-react'; + +import type { DashboardViewId } from '../routes/dashboardSearch'; + +export type RouteMaturity = 'stable' | 'experimental'; +export type RoutePlacement = 'primary' | 'contextual' | 'hidden'; +export type RouteLifecycle = 'active' | 'transitioning' | 'deprecated'; +export type DashboardExposurePhase = 'foundation' | 'simplified'; + +export type DashboardRouteDefinition = { + id: DashboardViewId; + label: string; + description: string; + icon: LucideIcon; + maturity: RouteMaturity; + placement: RoutePlacement; + lifecycle: RouteLifecycle; + navigationGroup: 'primary' | 'experimental' | null; + experimentalNavigationEligible: boolean; + capabilities: { refresh: boolean; export: boolean; copyLink: boolean }; + safeParams: readonly string[]; + handoffParams: readonly string[]; +}; + +const capabilities = (refresh: boolean) => ({ refresh, export: true, copyLink: true }); + +export const routeCatalog = [ + { + id: 'overview', label: 'Overview', description: 'High-level telemetry', icon: Home, + maturity: 'stable', placement: 'primary', lifecycle: 'active', navigationGroup: 'primary', + experimentalNavigationEligible: false, capabilities: capabilities(true), safeParams: [], handoffParams: [], + }, + { + id: 'investigator', label: 'Investigate', description: 'Root-cause evidence', icon: FlaskConical, + maturity: 'experimental', placement: 'hidden', lifecycle: 'active', navigationGroup: 'experimental', + experimentalNavigationEligible: true, capabilities: capabilities(false), safeParams: ['finding'], handoffParams: ['finding'], + }, + { + id: 'compression-lab', label: 'Compression Lab', description: 'Context savings', icon: BrainCircuit, + maturity: 'experimental', placement: 'hidden', lifecycle: 'active', navigationGroup: 'experimental', + experimentalNavigationEligible: true, capabilities: capabilities(false), safeParams: [], handoffParams: [], + }, + { + id: 'calls', label: 'Calls', description: 'Model-call table', icon: Table2, + maturity: 'stable', placement: 'primary', lifecycle: 'active', navigationGroup: 'primary', + experimentalNavigationEligible: false, capabilities: capabilities(true), + safeParams: ['explore', 'detail', 'call_q', 'source', 'sort', 'direction', 'density', 'page'], + handoffParams: ['explore', 'detail', 'source', 'sort', 'direction', 'density', 'page'], + }, + { + id: 'call', label: 'Call Investigator', description: 'Selected call evidence', icon: Search, + maturity: 'stable', placement: 'contextual', lifecycle: 'active', navigationGroup: null, + experimentalNavigationEligible: false, capabilities: capabilities(false), + safeParams: ['record', 'return', 'mode', 'max_entries', 'max_chars', 'include_tool_output', 'include_compaction_history'], + handoffParams: ['record', 'return', 'mode'], + }, + { + id: 'threads', label: 'Threads', description: 'Thread efficiency', icon: Workflow, + maturity: 'stable', placement: 'primary', lifecycle: 'active', navigationGroup: 'primary', + experimentalNavigationEligible: false, capabilities: capabilities(true), + safeParams: ['thread', 'thread_key', 'expand', 'threads', 'thread_q', 'risk', 'thread_call_sort', 'thread_call_page'], + handoffParams: ['thread_key', 'expand', 'risk', 'thread_call_sort', 'thread_call_page'], + }, + { + id: 'usage-drain', label: 'Limits', description: 'Allowance intelligence', icon: TimerReset, + maturity: 'stable', placement: 'primary', lifecycle: 'active', navigationGroup: 'primary', + experimentalNavigationEligible: false, capabilities: capabilities(true), + safeParams: ['usage_plan', 'usage_effort', 'usage_subagents', 'usage_sample', 'usage_confidence', 'limit_window', 'limit_hypothesis'], + handoffParams: ['usage_plan', 'usage_effort', 'usage_subagents', 'usage_sample', 'usage_confidence', 'limit_window', 'limit_hypothesis'], + }, + { + id: 'cache-context', label: 'Cache And Context', description: 'Cache and cold resumes', icon: Database, + maturity: 'experimental', placement: 'hidden', lifecycle: 'transitioning', navigationGroup: null, + experimentalNavigationEligible: false, capabilities: capabilities(true), safeParams: ['cache_thread'], handoffParams: [], + }, + { + id: 'diagnostics', label: 'Diagnostics Notebook', description: 'Technical report', icon: BookOpen, + maturity: 'experimental', placement: 'primary', lifecycle: 'active', navigationGroup: 'experimental', + experimentalNavigationEligible: false, capabilities: capabilities(false), + safeParams: ['diagnostic_source', 'diagnostic_fact'], + handoffParams: ['diagnostic_source', 'diagnostic_fact'], + }, + { + id: 'reports', label: 'Reports', description: 'Generated analyses', icon: BarChart3, + maturity: 'experimental', placement: 'hidden', lifecycle: 'transitioning', navigationGroup: null, + experimentalNavigationEligible: false, capabilities: capabilities(true), safeParams: ['report'], handoffParams: ['report'], + }, + { + id: 'settings', label: 'Settings', description: 'Local configuration', icon: Settings, + maturity: 'stable', placement: 'primary', lifecycle: 'active', navigationGroup: 'primary', + experimentalNavigationEligible: false, capabilities: capabilities(true), safeParams: [], handoffParams: [], + }, +] as const satisfies readonly DashboardRouteDefinition[]; + +const foundationRouteIds: readonly DashboardViewId[] = [ + 'overview', 'investigator', 'compression-lab', 'calls', 'threads', + 'usage-drain', 'cache-context', 'diagnostics', 'reports', 'settings', +]; + +export function routeDefinition(view: DashboardViewId): DashboardRouteDefinition { + const definition = routeCatalog.find(route => route.id === view); + if (!definition) throw new Error(`Unknown dashboard route: ${view}`); + return definition; +} + +export function navigationForPhase(phase: DashboardExposurePhase): DashboardRouteDefinition[] { + if (phase === 'foundation') return foundationRouteIds.map(routeDefinition); + return routeCatalog.filter(route => route.placement === 'primary').map(route => routeDefinition(route.id)); +} diff --git a/frontend/dashboard/src/app/shellUrl.test.ts b/frontend/dashboard/src/app/shellUrl.test.ts index 8c764e2f..e94b300d 100644 --- a/frontend/dashboard/src/app/shellUrl.test.ts +++ b/frontend/dashboard/src/app/shellUrl.test.ts @@ -46,14 +46,15 @@ it('maps legacy Insights route params to the renamed Overview workspace', () => expect(activeUrl.searchParams.get('view')).toBe('threads'); }); - it('labels return views from primary and secondary shell navigation', () => { + it('labels return views from the full route catalog', () => { expect(callReturnViewLabel('calls')).toBe('Calls'); expect(callReturnViewLabel('investigator')).toBe('Investigate'); + expect(callReturnViewLabel('cache-context')).toBe('Cache And Context'); }); it('clears inactive workspace URL state while preserving shared shell filters', () => { const url = new URL( - 'https://example.test/react-dashboard.html?view=overview&finding=2&thread=thread-a&expand=all&threads=thread-a,thread-b&thread_q=cache&risk=Low&thread_call_sort=total&thread_call_page=2&cache_thread=Thread%20Alpha&report=weekly-credits&usage_plan=Weekly&usage_effort=high&usage_subagents=0&usage_sample=80&usage_confidence=0.55&diagnostic_source=tools&diagnostic_fact=tool:read&record=fixture-call-0&return=calls&mode=full&max_entries=50&max_chars=0&include_tool_output=1&include_compaction_history=true&detail=first&call_q=cache&source=missing&sort=total&direction=asc&density=roomy&page=3&model=gpt-5&effort=high&confidence=cost-estimated&date=last-7-days&history=all&q=cache', + 'https://example.test/react-dashboard.html?view=overview&finding=2&thread=thread-a&thread_key=thread-key-a&expand=all&threads=thread-a,thread-b&thread_q=cache&risk=Low&thread_call_sort=total&thread_call_page=2&cache_thread=Thread%20Alpha&report=weekly-credits&usage_plan=Weekly&usage_effort=high&usage_subagents=0&usage_sample=80&usage_confidence=0.55&diagnostic_source=tools&diagnostic_fact=tool:read&record=fixture-call-0&return=calls&mode=full&max_entries=50&max_chars=0&include_tool_output=1&include_compaction_history=true&detail=first&call_q=cache&source=missing&sort=total&direction=asc&density=roomy&page=3&model=gpt-5&effort=high&confidence=cost-estimated&date=last-7-days&history=all&q=cache', ); clearInactiveViewSearchParams(url, 'overview'); @@ -61,6 +62,7 @@ it('clears inactive workspace URL state while preserving shared shell filters', for (const name of [ 'finding', 'thread', + 'thread_key', 'expand', 'threads', 'thread_q', diff --git a/frontend/dashboard/src/app/shellUrl.ts b/frontend/dashboard/src/app/shellUrl.ts index aff93bbb..6b322495 100644 --- a/frontend/dashboard/src/app/shellUrl.ts +++ b/frontend/dashboard/src/app/shellUrl.ts @@ -1,4 +1,5 @@ -import { isViewId, navItems, secondaryNavItems, type ViewId } from './navigation'; +import { isViewId, type ViewId } from './navigation'; +import { routeDefinition } from './routeCatalog'; import type { HistoryScope } from '../data/dataScope'; export type { HistoryScope } from '../data/dataScope'; @@ -23,7 +24,7 @@ export function hasCallReturnViewParam(search = window.location.search): boolean } export function callReturnViewLabel(view: ViewId): string { - return navItems.find(item => item.id === view)?.label ?? secondaryNavItems.find(item => item.target === view)?.label ?? 'Calls'; + return routeDefinition(view).label; } export function historyScopeFromUrl(fallback: HistoryScope, search = window.location.search): HistoryScope { @@ -42,7 +43,7 @@ export function historyScopeUrl(historyScope: HistoryScope, href = window.locati const inactiveViewSearchParams: Partial> = { investigator: ['finding'], - threads: ['thread', 'expand', 'threads', 'thread_q', 'risk', 'thread_call_sort', 'thread_call_page'], + threads: ['thread', 'thread_key', 'expand', 'threads', 'thread_q', 'risk', 'thread_call_sort', 'thread_call_page'], 'cache-context': ['cache_thread'], reports: ['report'], 'usage-drain': [ diff --git a/frontend/dashboard/src/app/useConversationalReadiness.ts b/frontend/dashboard/src/app/useConversationalReadiness.ts new file mode 100644 index 00000000..45e99e87 --- /dev/null +++ b/frontend/dashboard/src/app/useConversationalReadiness.ts @@ -0,0 +1,21 @@ +import { useEffect, useRef, useState } from 'react'; +import { loadConversationalReadiness } from '../api/readiness'; +import type { ConversationalReadiness, DashboardBootPayload } from '../api/types'; + +export function useConversationalReadiness( + initialPayload: DashboardBootPayload | null, + dashboardPayload: DashboardBootPayload | null, +): { canUseLiveApi: boolean; conversationalAnalysis: ConversationalReadiness | undefined } { + const canUseLiveApi = Boolean(dashboardPayload?.api_token); + const attempted = useRef(false); + const [readiness, setReadiness] = useState(initialPayload?.conversational_analysis); + useEffect(() => { + if (readiness || initialPayload?.readiness_deferred !== true || !canUseLiveApi || + dashboardPayload?.shell_boot === true || attempted.current) return; + attempted.current = true; + const controller = new AbortController(); + void loadConversationalReadiness(controller.signal).then(setReadiness).catch(() => undefined); + return () => controller.abort(); + }, [canUseLiveApi, dashboardPayload?.shell_boot, initialPayload?.readiness_deferred, readiness]); + return { canUseLiveApi, conversationalAnalysis: readiness }; +} diff --git a/frontend/dashboard/src/app/useExperimentalDashboardFeatures.test.tsx b/frontend/dashboard/src/app/useExperimentalDashboardFeatures.test.tsx new file mode 100644 index 00000000..f94f760a --- /dev/null +++ b/frontend/dashboard/src/app/useExperimentalDashboardFeatures.test.tsx @@ -0,0 +1,78 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + experimentalDashboardFeaturesStorageKey, + useExperimentalDashboardFeatures, +} from './useExperimentalDashboardFeatures'; + +beforeEach(() => vi.stubGlobal('localStorage', memoryStorage())); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('useExperimentalDashboardFeatures', () => { + it('defaults experimental dashboard features off', () => { + const { result } = renderHook(() => useExperimentalDashboardFeatures()); + expect(result.current.showExperimental).toBe(false); + }); + + it('updates immediately and stores a JSON boolean', async () => { + const { result } = renderHook(() => useExperimentalDashboardFeatures()); + + act(() => result.current.setShowExperimental(true)); + + expect(result.current.showExperimental).toBe(true); + await waitFor(() => expect(localStorage.getItem(experimentalDashboardFeaturesStorageKey)).toBe('true')); + }); + + it('restores the preference after remounting', async () => { + const first = renderHook(() => useExperimentalDashboardFeatures()); + act(() => first.result.current.setShowExperimental(true)); + await waitFor(() => expect(localStorage.getItem(experimentalDashboardFeaturesStorageKey)).toBe('true')); + first.unmount(); + + const second = renderHook(() => useExperimentalDashboardFeatures()); + expect(second.result.current.showExperimental).toBe(true); + }); + + it.each(['{broken', 'null', '"true"', '1'])('treats malformed stored value %s as off', stored => { + localStorage.setItem(experimentalDashboardFeaturesStorageKey, stored); + const { result } = renderHook(() => useExperimentalDashboardFeatures()); + expect(result.current.showExperimental).toBe(false); + }); + + it('preserves in-session state when storage access is restricted', () => { + vi.stubGlobal('localStorage', restrictedStorage()); + const { result } = renderHook(() => useExperimentalDashboardFeatures()); + + act(() => result.current.setShowExperimental(true)); + + expect(result.current.showExperimental).toBe(true); + }); +}); + +function memoryStorage(): Storage { + const values = new Map(); + return { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: key => values.get(key) ?? null, + key: index => [...values.keys()][index] ?? null, + removeItem: key => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +} + +function restrictedStorage(): Storage { + const denied = () => { throw new DOMException('Storage denied', 'SecurityError'); }; + return { + get length() { return denied(); }, + clear: denied, + getItem: denied, + key: denied, + removeItem: denied, + setItem: denied, + }; +} diff --git a/frontend/dashboard/src/app/useExperimentalDashboardFeatures.ts b/frontend/dashboard/src/app/useExperimentalDashboardFeatures.ts new file mode 100644 index 00000000..d1474696 --- /dev/null +++ b/frontend/dashboard/src/app/useExperimentalDashboardFeatures.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +export const experimentalDashboardFeaturesStorageKey = 'codex-usage-dashboard-show-experimental-v1'; + +function storage(): Storage | undefined { + try { + return typeof globalThis.localStorage === 'undefined' ? undefined : globalThis.localStorage; + } catch { + return undefined; + } +} + +function readPreference(): boolean { + try { + const stored = JSON.parse(storage()?.getItem(experimentalDashboardFeaturesStorageKey) ?? 'false') as unknown; + return typeof stored === 'boolean' ? stored : false; + } catch { + return false; + } +} + +function safelyWrite(showExperimental: boolean): void { + try { + storage()?.setItem(experimentalDashboardFeaturesStorageKey, JSON.stringify(showExperimental)); + } catch { + // Restricted storage must not prevent in-session preference changes. + } +} + +export function useExperimentalDashboardFeatures() { + const [showExperimental, setShowExperimental] = useState(readPreference); + + useEffect(() => safelyWrite(showExperimental), [showExperimental]); + + return { showExperimental, setShowExperimental }; +} diff --git a/frontend/dashboard/src/components/DashboardEvidenceActions.test.tsx b/frontend/dashboard/src/components/DashboardEvidenceActions.test.tsx new file mode 100644 index 00000000..86c2cfba --- /dev/null +++ b/frontend/dashboard/src/components/DashboardEvidenceActions.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildDashboardTarget, type DashboardTarget } from '../app/dashboardTargets'; +import { investigatorEvidenceDashboardTarget } from '../features/investigator/InvestigatorPage'; +import { DashboardEvidenceActions } from './DashboardEvidenceActions'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('DashboardEvidenceActions', () => { + it('opens only a loopback evidence target and copies a strict aggregate-only prompt', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const onStatus = vi.fn(); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const target = buildDashboardTarget({ + view: 'call', + record_id: 'record-123', + history: 'all', + privacy_mode: 'strict', + service_origin: 'http://127.0.0.1:47821', + }); + + render( + , + ); + + const link = screen.getByRole('link', { name: 'Open evidence' }); + expect(link).toHaveAttribute('href', target.absolute_url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + + fireEvent.click(screen.getByRole('button', { name: 'Copy investigation prompt' })); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + const prompt = String(writeText.mock.calls[0]?.[0]); + expect(prompt).toContain('record-123'); + expect(prompt).toContain('history=all'); + expect(prompt).toContain(target.relative_url); + expect(prompt).not.toMatch(/sk-|\/Users\/private|client-name|\[redacted/i); + expect(onStatus).toHaveBeenCalledWith('Investigation prompt copied'); + }); + + it('blocks non-loopback absolute targets and gives launch guidance', () => { + const onStatus = vi.fn(); + const target = { + ...buildDashboardTarget({ view: 'reports' }), + absolute_url: 'https://example.com/private', + } satisfies DashboardTarget; + + render(); + + expect(screen.queryByRole('link', { name: 'Open evidence' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open evidence' })); + expect(onStatus).toHaveBeenCalledWith( + 'Start the local dashboard first: codex-usage-tracker serve-dashboard --open', + ); + }); + + it('keeps ordinary product vocabulary in a non-strict prompt', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const target = buildDashboardTarget({ view: 'reports', privacy_mode: 'normal' }); + + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Copy investigation prompt' })); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('avoidable token waste')); + }); + + it.each([ + 'Review /home/private/report.txt', + 'Review /tmp/private-report.txt', + `Review ${['g', 'hp_', 'abcdefghijklmnopqrstuvwxyz1234567890'].join('')}`, + `authorization: ${['Bear', 'er ', 'abcdefghijklmnopqrstuvwxyz123456'].join('')}`, + `access_token=${['private', '-value-1234567890'].join('')}`, + ])('drops unsafe non-strict caller text: %s', async question => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const target = buildDashboardTarget({ view: 'reports', privacy_mode: 'normal' }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Copy investigation prompt' })); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(String(writeText.mock.calls[0]?.[0])).not.toContain(question); + }); + + it('does not claim an unstable finding ordinal when canonical evidence is unavailable', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const target = investigatorEvidenceDashboardTarget(undefined, false, null); + + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Copy investigation prompt' })); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(target.relative_url).not.toContain('finding='); + expect(String(writeText.mock.calls[0]?.[0])).not.toContain('finding='); + }); +}); diff --git a/frontend/dashboard/src/components/DashboardEvidenceActions.tsx b/frontend/dashboard/src/components/DashboardEvidenceActions.tsx new file mode 100644 index 00000000..bd74072b --- /dev/null +++ b/frontend/dashboard/src/components/DashboardEvidenceActions.tsx @@ -0,0 +1,162 @@ +import { Clipboard, ExternalLink } from 'lucide-react'; + +import type { DashboardTarget } from '../app/dashboardTargets'; +import type { ShellI18n } from '../app/i18n'; +import { useShellI18n } from '../app/i18nContext'; +import { Button } from '../design'; +import primitiveStyles from '../design/primitives/primitives.module.css'; +import { copyText } from '../features/shared/copyText'; + +type DashboardEvidenceActionsProps = { + target: DashboardTarget; + question: string; + onStatus: (message: string) => void; +}; + +const fallbackQuestion = 'Investigate the selected aggregate usage evidence.'; +type EvidenceI18n = Pick; + +export function DashboardEvidenceActions({ + target, + question, + onStatus, +}: DashboardEvidenceActionsProps) { + const i18n = useShellI18n(); + const href = safeEvidenceHref(target); + + async function copyPrompt() { + try { + const copied = await copyText(buildInvestigationPrompt(target, question, i18n)); + onStatus(copied + ? i18n.t('evidence.prompt_copied', 'Investigation prompt copied') + : i18n.t('evidence.prompt_copy_failed', 'Unable to copy investigation prompt')); + } catch { + onStatus(i18n.t('evidence.prompt_copy_failed', 'Unable to copy investigation prompt')); + } + } + + function showLaunchGuidance() { + onStatus(i18n.formatText( + i18n.t('evidence.launch_guidance', 'Start the local dashboard first: {instruction}'), + { instruction: target.fallback_instruction ?? 'codex-usage-tracker serve-dashboard --open' }, + )); + } + + return ( +
+ {href ? ( + + + ) : ( + + )} + +
+ ); +} + +function buildInvestigationPrompt( + target: DashboardTarget, + question: string, + i18n?: EvidenceI18n, +): string { + const translate = i18n ?? englishEvidenceI18n; + const safeQuestion = target.privacy_mode === 'strict' + ? translate.t('evidence.question.default', fallbackQuestion) + : sanitizeQuestion(question, translate.t('evidence.question.default', fallbackQuestion)); + const identifiers = [ + target.record_id && `record_id=${target.record_id}`, + target.thread_key && `thread_key=${target.thread_key}`, + target.diagnostic_fact && `diagnostic_fact=${target.diagnostic_fact}`, + target.limit_evidence && `limit_evidence=${target.limit_evidence}`, + ].filter(Boolean).join(', ') || translate.t('evidence.aggregate_selection', 'aggregate selection'); + const launch = target.absolute_url + ? '' + : translate.formatText( + translate.t('evidence.prompt.launch', ' Launch locally with: {instruction}.'), + { instruction: target.fallback_instruction ?? 'codex-usage-tracker serve-dashboard --open' }, + ); + return translate.formatText( + translate.t( + 'evidence.prompt.template', + '{question} Evidence: {identifiers}. Scope: history={history}, privacy_mode={privacy}. Target: {target}.{launch}', + ), + { + question: safeQuestion, + identifiers, + history: target.history, + privacy: target.privacy_mode, + target: target.relative_url, + launch, + }, + ); +} + +const englishEvidenceI18n: EvidenceI18n = { + t: (_key, fallback) => fallback ?? _key, + formatText: (template, values) => template.replace( + /\{([A-Za-z][A-Za-z0-9_]*)\}/gu, + (token, key) => String(values[key] ?? token), + ), +}; + +function sanitizeQuestion(question: string, defaultQuestion: string): string { + const trimmed = question.trim(); + if (!trimmed + || trimmed.length > 160 + || !/^[\p{L}\p{N} .,?!:'"()_%=-]+$/u.test(trimmed) + || /(?:^|\s)\/\S+/u.test(trimmed) + || /(?:sk-|xox[a-z]-|gh[pousr]_|github_pat_|\[redacted|bearer\s+\S{8,}|(?:api[_ -]?key|access[_ -]?token|token|secret|credential|authorization)\s*[:=]\s*\S+)/i.test(trimmed) + || /[\r\n\t]/.test(trimmed)) { + return defaultQuestion; + } + return trimmed; +} + +function safeEvidenceHref(target: DashboardTarget): string | null { + try { + if (target.absolute_url) { + const absolute = new URL(target.absolute_url); + if (absolute.protocol !== 'http:' || !isLoopback(absolute.hostname) || absolute.username || absolute.password) { + return null; + } + return absolute.toString(); + } + const page = new URL(window.location.href); + if (!['http:', 'https:'].includes(page.protocol)) return null; + const relative = new URL(target.relative_url, page); + return relative.origin === page.origin ? relative.toString() : null; + } catch { + return null; + } +} + +function isLoopback(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; +} + +export function currentDashboardServiceOrigin(href = window.location.href): string | null { + try { + const url = new URL(href); + const port = Number(url.port); + return url.protocol === 'http:' + && isLoopback(url.hostname) + && Number.isInteger(port) + && port >= 1024 + && port <= 65535 + ? url.origin + : null; + } catch { + return null; + } +} diff --git a/frontend/dashboard/src/components/FeatureMaturityBanner.module.css b/frontend/dashboard/src/components/FeatureMaturityBanner.module.css new file mode 100644 index 00000000..0f33f324 --- /dev/null +++ b/frontend/dashboard/src/components/FeatureMaturityBanner.module.css @@ -0,0 +1,52 @@ +.root { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3) var(--space-4); + color: var(--ink-strong); + background: var(--surface-subtle); + border: 1px solid var(--line-strong); + border-radius: var(--radius-panel); +} + +.root[data-kind='experimental'] { + background: var(--signal-caution-subtle); + border-color: var(--signal-caution-line); +} + +.icon { + width: 20px; + height: 20px; + color: var(--signal-context); +} + +.root[data-kind='experimental'] .icon { color: var(--signal-caution); } + +.copy { display: grid; gap: var(--space-1); min-width: 0; } +.copy strong { font-size: 13px; } +.copy p { margin: 0; color: var(--ink-muted); font-size: 12px; line-height: 1.45; } + +.action { + display: inline-flex; + gap: var(--space-2); + align-items: center; + padding: 7px 10px; + color: var(--ink-strong); + font: inherit; + font-size: 12px; + font-weight: 700; + background: var(--surface-panel); + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + cursor: pointer; +} + +.action:hover { border-color: var(--signal-context); } +.action:focus-visible { outline: 2px solid var(--signal-context); outline-offset: 2px; } +.action svg { width: 15px; height: 15px; } + +@media (max-width: 640px) { + .root { grid-template-columns: auto minmax(0, 1fr); } + .action { grid-column: 2; justify-self: start; } +} diff --git a/frontend/dashboard/src/components/FeatureMaturityBanner.test.tsx b/frontend/dashboard/src/components/FeatureMaturityBanner.test.tsx new file mode 100644 index 00000000..b5d7f77a --- /dev/null +++ b/frontend/dashboard/src/components/FeatureMaturityBanner.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { FeatureMaturityBanner } from './FeatureMaturityBanner'; + +describe('FeatureMaturityBanner', () => { + it('exposes experimental maturity details as an accessible note', () => { + render( + , + ); + + const note = screen.getByRole('note', { name: 'Feature maturity: Highly experimental' }); + expect(within(note).getByText('Highly experimental')).toBeInTheDocument(); + expect(within(note).getByText( + 'Useful for technical exploration; methods and presentation may change.', + )).toBeInTheDocument(); + expect(within(note).queryByRole('button')).not.toBeInTheDocument(); + }); + + it('offers an optional replacement action for transitioning features', () => { + const onSelect = vi.fn(); + render( + , + ); + + const note = screen.getByRole('note', { name: 'Feature maturity: Transition planning' }); + fireEvent.click(within(note).getByRole('button', { name: 'Open replacement' })); + expect(onSelect).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/dashboard/src/components/FeatureMaturityBanner.tsx b/frontend/dashboard/src/components/FeatureMaturityBanner.tsx new file mode 100644 index 00000000..47ac4a3d --- /dev/null +++ b/frontend/dashboard/src/components/FeatureMaturityBanner.tsx @@ -0,0 +1,52 @@ +import { ArrowRight, FlaskConical, Waypoints } from 'lucide-react'; + +import { useShellI18n } from '../app/i18nContext'; +import styles from './FeatureMaturityBanner.module.css'; + +type FeatureMaturityBannerProps = { + kind: 'experimental' | 'transitioning'; + title: string; + description: string; + className?: string; + replacementAction?: { + label: string; + onSelect: () => void; + }; +}; + +export function FeatureMaturityBanner({ + kind, + title, + description, + className, + replacementAction, +}: FeatureMaturityBannerProps) { + const i18n = useShellI18n(); + const Icon = kind === 'experimental' ? FlaskConical : Waypoints; + const rootClassName = className ? `${styles.root} ${className}` : styles.root; + const localizedTitle = i18n.translateText(title); + const localizedDescription = i18n.translateText(description); + + return ( + + ); +} diff --git a/frontend/dashboard/src/features/cache-context/CacheContextPage.tsx b/frontend/dashboard/src/features/cache-context/CacheContextPage.tsx index 88be5f54..07537d00 100644 --- a/frontend/dashboard/src/features/cache-context/CacheContextPage.tsx +++ b/frontend/dashboard/src/features/cache-context/CacheContextPage.tsx @@ -5,6 +5,7 @@ import type { CallRow, ContextRuntime, DashboardModel, HeatmapRow, ThreadRow } f import { useShellI18n } from '../../app/i18nContext'; import { LineChart } from '../../charts/LineChart'; import { DataTable } from '../../components/DataTable'; +import { FeatureMaturityBanner } from '../../components/FeatureMaturityBanner'; import { MetricCard } from '../../components/MetricCard'; import { Panel } from '../../components/Panel'; import { StatusBadge } from '../../components/StatusBadge'; @@ -78,6 +79,13 @@ return ( + + + + {running && runProgress?.progress ? ( { const client = createDashboardQueryClient(); const first = renderDiagnostics(client); + expect(screen.getByRole('note', { name: 'Feature maturity: Highly experimental' })).toHaveTextContent( + 'Useful for technical exploration; methods and presentation may change.', + ); const modules = await screen.findByLabelText('Loading diagnostic snapshots modules'); await waitFor(() => { expect(within(modules).getByText('Overview ready')).toBeInTheDocument(); diff --git a/frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx b/frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx index 00c592aa..50d79eb5 100644 --- a/frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx +++ b/frontend/dashboard/src/features/diagnostics/DiagnosticsPage.tsx @@ -9,6 +9,7 @@ import { import type { CallRow, ContextRuntime, DashboardModel, DiagnosticSection } from '../../api/types'; import { LineChart } from '../../charts/LineChart'; import { Panel } from '../../components/Panel'; +import { FeatureMaturityBanner } from '../../components/FeatureMaturityBanner'; import { StatusBadge } from '../../components/StatusBadge'; import { PageLoadProgress } from '../../design'; import { formatCompact, money, pct } from '../shared/format'; @@ -183,6 +184,13 @@ export function DiagnosticsPage({ + + row.recordId)?.recordId; + const evidenceTarget = useMemo( + () => investigatorEvidenceDashboardTarget( + selectedRecordId, + includeArchived, + currentDashboardServiceOrigin(), + ), + [includeArchived, selectedRecordId], + ); const walkQuery = useQuery({ ...investigatorWalkQueryOptions({ runtime: contextRuntime, @@ -185,6 +201,11 @@ export function InvestigatorPage({

Ranked waste signals, linked evidence, and one explicit verification path.

+
+ + module.status === 'loading' || module.status === 'updating'))} completed={refreshProgress?.completed_units ?? evidence.progress.ready} @@ -320,6 +347,26 @@ export function InvestigatorPage({ ); } +export function investigatorEvidenceDashboardTarget( + recordId: string | undefined, + includeArchived: boolean, + serviceOrigin: string | null, +) { + const common = { + history: includeArchived ? 'all' as const : 'active' as const, + privacy_mode: 'strict' as const, + service_origin: serviceOrigin, + }; + if (recordId) { + try { + return buildDashboardTarget({ ...common, view: 'call', record_id: recordId }); + } catch { + // Legacy aggregate rows may not yet carry a canonical record ID. + } + } + return buildDashboardTarget({ ...common, view: 'investigator' }); +} + function TraceResults({ branches }: { branches: InvestigationWalkBranch[] }) { const supported = branches.filter(branch => branch.status !== 'no_evidence'); return supported.length ? ( diff --git a/frontend/dashboard/src/features/overview/OverviewPage.test.tsx b/frontend/dashboard/src/features/overview/OverviewPage.test.tsx index 787416b9..a0d04d2b 100644 --- a/frontend/dashboard/src/features/overview/OverviewPage.test.tsx +++ b/frontend/dashboard/src/features/overview/OverviewPage.test.tsx @@ -206,6 +206,7 @@ describe('Overview focused evidence flow', () => { ); expect(screen.getByRole('progressbar', { name: 'Loading overview evidence' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Analysis readiness' })).toBeInTheDocument(); await waitFor(() => expect(screen.getByText('Focused endpoints')).toBeInTheDocument()); expect(screen.queryByRole('progressbar', { name: 'Loading overview evidence' })).not.toBeInTheDocument(); expect(screen.getByText('Total Calls')).toBeInTheDocument(); diff --git a/frontend/dashboard/src/features/overview/OverviewPage.tsx b/frontend/dashboard/src/features/overview/OverviewPage.tsx index f306c12d..683266ac 100644 --- a/frontend/dashboard/src/features/overview/OverviewPage.tsx +++ b/frontend/dashboard/src/features/overview/OverviewPage.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { RefreshCw } from 'lucide-react'; import { useMemo, type ReactNode } from 'react'; -import type { ContextRuntime, DashboardModel } from '../../api/types'; +import type { ContextRuntime, ConversationalReadiness, DashboardModel } from '../../api/types'; import type { LoadWindow } from '../../data/dataScope'; import { dashboardModuleProgress, @@ -18,6 +18,7 @@ import { Visualization } from '../../visualization'; import { OverviewMetrics } from './OverviewMetrics'; import styles from './OverviewPage.module.css'; import { OverviewRecentCalls } from './OverviewRecentCalls'; +import { ConversationalAnalysisStatus } from '../shared/ConversationalAnalysisStatus'; import { buildOverviewViewModel } from './overviewModel'; import type { OverviewNavigationTarget } from './overviewNavigation'; @@ -33,6 +34,7 @@ type OverviewRuntime = { }; type OverviewPageProps = { + conversationalAnalysis?: ConversationalReadiness; model: DashboardModel; contextRuntime: ContextRuntime; sourceRevision: string; @@ -142,6 +144,8 @@ export function OverviewPage(props: OverviewPageProps) { updating={modules.some(module => module.status === 'updating')} /> + + buildDashboardTarget({ + view: 'reports', + filters: active ? { report: reportKey(active) } : {}, + history: includeArchived ? 'all' : 'active', + privacy_mode: 'strict', + service_origin: currentDashboardServiceOrigin(), + }), [active, includeArchived]); const details = reportDetails(active, evidenceCalls); const source = pack ? 'Live localhost report pack' : 'Loaded dashboard aggregates'; const generated = pack?.generatedAt || (pack ? 'Server timestamp unavailable' : 'Current dashboard snapshot'); @@ -138,6 +151,11 @@ export function ReportsPage({

Focused local research snapshots with their methods, limits, and investigator-linked evidence.

+ @@ -155,6 +173,12 @@ export function ReportsPage({
+ + { it('shows all categories and their focused content', () => { renderPage(); const navigation = screen.getByRole('navigation', { name: 'Settings sections' }); - for (const label of ['Data', 'Estimates', 'Content Access', 'Application', 'Source Health']) { + for (const label of ['Data', 'Estimates', 'Content Access', 'Application', 'Source Health', 'Advanced']) { expect(navigation).toHaveTextContent(label); } expect(screen.getByText('Data window')).toBeInTheDocument(); expect(screen.getByText('Evidence rows')).toBeInTheDocument(); + expect(screen.getByText('Local checks passed.')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Estimates' })); expect(screen.getByText('custom-pricing.json')).toBeInTheDocument(); @@ -63,11 +64,31 @@ describe('SettingsPage', () => { renderPage({ language: 'en', direction: 'ltr', languages: [] }); expect(screen.getByText('English (en)')).toBeInTheDocument(); }); + + it('controls experimental features from Advanced with the complete scope explanation', () => { + const setShowExperimental = vi.fn(); + window.localStorage.setItem(settingsSectionStorageKey, '"advanced"'); + renderPage(undefined, false, setShowExperimental); + + const checkbox = screen.getByRole('checkbox', { name: 'Show experimental dashboard features' }); + expect(checkbox).not.toBeChecked(); + expect(screen.getByText( + 'This preference is stored for this browser origin. Experimental workspaces remain available from direct links, and Diagnostics stays visible.', + )).toBeInTheDocument(); + + fireEvent.click(checkbox); + expect(setShowExperimental).toHaveBeenCalledWith(true); + }); }); -function renderPage(applicationI18n = { language: 'en', direction: 'ltr' as const, languages: [{ code: 'en' }, { code: 'es' }] }) { +function renderPage( + applicationI18n = { language: 'en', direction: 'ltr' as const, languages: [{ code: 'en' }, { code: 'es' }] }, + showExperimental = false, + setShowExperimental = vi.fn(), +) { return render( , ); } const payload: DashboardBootPayload = { + conversational_analysis: { + schema: 'codex-usage-tracker-conversational-readiness-v1', + state: 'ready', + summary: 'Local checks passed.', + next_action: null, + evidence: ['MCP config: pass'], + }, shell_boot: true, language: 'en', language_direction: 'ltr', diff --git a/frontend/dashboard/src/features/settings/SettingsPage.tsx b/frontend/dashboard/src/features/settings/SettingsPage.tsx index f6707e3b..41f4ecb3 100644 --- a/frontend/dashboard/src/features/settings/SettingsPage.tsx +++ b/frontend/dashboard/src/features/settings/SettingsPage.tsx @@ -9,7 +9,8 @@ import { ShieldCheck, } from 'lucide-react'; -import type { DashboardBootPayload, DashboardLanguage, DashboardModel } from '../../api/types'; +import type { ConversationalReadiness, DashboardBootPayload, DashboardLanguage, DashboardModel } from '../../api/types'; +import { useShellI18n } from '../../app/i18nContext'; import { Panel } from '../../components/Panel'; import { StatusBadge } from '../../components/StatusBadge'; import { loadWindowLabel, type LoadWindow } from '../../data/dataScope'; @@ -21,6 +22,7 @@ import { sourceLabel, } from './settingsModel'; import { settingsSections, useSettingsSection } from './useSettingsSection'; +import { ConversationalAnalysisStatus } from '../shared/ConversationalAnalysisStatus'; import styles from './SettingsPage.module.css'; type HistoryScope = 'active' | 'all'; @@ -32,6 +34,7 @@ type ApplicationI18n = { }; type SettingsPageProps = { + conversationalAnalysis?: ConversationalReadiness; model: DashboardModel; payload: DashboardBootPayload | null; historyScope: HistoryScope; @@ -44,6 +47,8 @@ type SettingsPageProps = { autoRefreshEnabled: boolean; refreshState: string; applicationI18n: ApplicationI18n; + showExperimental: boolean; + setShowExperimental: (showExperimental: boolean) => void; }; const sectionCopy = { @@ -52,6 +57,7 @@ const sectionCopy = { content: ['Content Access', 'Privacy boundaries and explicit raw-context gates.'], application: ['Application', 'Dashboard refresh, API, and language visibility.'], sources: ['Source Health', 'Configuration and parser diagnostics.'], + advanced: ['Advanced', 'Local preferences for experimental dashboard surfaces.'], } as const; export function SettingsPage(props: SettingsPageProps) { @@ -73,6 +79,8 @@ export function SettingsPage(props: SettingsPageProps) { + +