From d6ce7482a63a71f57745e3f070afa97b9618f622 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Tue, 7 Jul 2026 21:50:50 -0500 Subject: [PATCH 1/2] commit --- .ai/master-audit-report.md | 2 +- .gitignore | 9 +++++ README.md | 2 +- benchmarks/e2e-skill-metrics.ts | 3 +- docs/27_PROJECT_ROADMAP.md | 2 +- docs/dev/ARCHITECTURE_INTEGRITY_AUDIT.md | 42 +++++++++++------------ docs/dev/AUDIT_REPORT.md | 2 +- docs/dev/DOCS_AUDIT_REPORT.md | 2 +- packages/compliance/README.md | 2 ++ packages/config/README.md | 43 +++++++++++++++++------- packages/ui/README.md | 39 +++++++++++++++------ security-audit-report.md | 2 +- 12 files changed, 100 insertions(+), 50 deletions(-) diff --git a/.ai/master-audit-report.md b/.ai/master-audit-report.md index 3e33930..feaa2a9 100644 --- a/.ai/master-audit-report.md +++ b/.ai/master-audit-report.md @@ -3,7 +3,7 @@ **Date:** 2026-07-03 **Methodology:** Mixture of Agents (3 parallel subagents) **GitHub:** [MerverliPy/agent-workbench](https://github.com/MerverliPy/agent-workbench) -**Local Path:** `/home/calvin/agent-workbench` +**Local Path:** ```REPO_ROOT``` --- diff --git a/.gitignore b/.gitignore index 78976d1..e204fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,15 @@ audit-report.txt apps/mobile-web/e2e/pages/ apps/mobile-web/e2e/specs/ apps/mobile-web/e2e/utils/ +apps/mobile-web/e2e/*.spec.ts + +# Postinstall symlink artifacts +packages/auth/auth +packages/cache/cache +packages/compliance/compliance +packages/eval/eval +packages/planner/planner +packages/tools/tools # Benchmark test artifacts (pre-existing lint issues) benchmarks/e2e-flakiness-audit.mjs diff --git a/README.md b/README.md index 0f18126..4f9e1e0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ --- -> **Status:** All 30 phases complete · 564+ tests passing · Ready for production use +> **Status:** All 31 phases complete · 900+ tests passing · Ready for production use --- diff --git a/benchmarks/e2e-skill-metrics.ts b/benchmarks/e2e-skill-metrics.ts index dcd099f..c396eae 100644 --- a/benchmarks/e2e-skill-metrics.ts +++ b/benchmarks/e2e-skill-metrics.ts @@ -14,6 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "os"; // ── Cross-runtime __dirname ───────────────────────────────────────────────── const scriptDir = (() => { @@ -27,7 +28,7 @@ const scriptDir = (() => { const _ROOT = resolve(scriptDir, ".."); const SKILL_PATH = resolve( - process.env.HOME ?? "/home/calvin", + process.env.HOME ?? homedir(), ".hermes/skills/software-development/playwright-e2e-testing/SKILL.md", ); const OUTPUT_PATH = resolve(scriptDir, "e2e-skill-metrics.json"); diff --git a/docs/27_PROJECT_ROADMAP.md b/docs/27_PROJECT_ROADMAP.md index 46cd60f..46a6996 100644 --- a/docs/27_PROJECT_ROADMAP.md +++ b/docs/27_PROJECT_ROADMAP.md @@ -328,7 +328,7 @@ These are speculative directions that may become formal phases based on communit 1. **Pick a phase** from phases 19–30 2. **Read the exit gates** — they define what "done" means 3. **Follow the architecture boundaries** in `AGENTS.md` -4. **Write tests first** — 602 existing tests must continue to pass +4. **Write tests first** — 900+ existing tests must continue to pass 5. **Open a PR** with a phase plan doc in `docs/` 6. **Get community review** before implementation diff --git a/docs/dev/ARCHITECTURE_INTEGRITY_AUDIT.md b/docs/dev/ARCHITECTURE_INTEGRITY_AUDIT.md index fdec11b..6ab1a04 100644 --- a/docs/dev/ARCHITECTURE_INTEGRITY_AUDIT.md +++ b/docs/dev/ARCHITECTURE_INTEGRITY_AUDIT.md @@ -30,9 +30,9 @@ However, **5 new HIGH-severity issues** emerged: two dead packages (`packages/co ### H1 — `packages/config` is a dead shell with zero consumers 🔴 **Files:** -- `/home/calvin/agent-workbench/packages/config/src/index.ts` (line 1-2): `// Scaffold-only — no runtime implementation yet.` / `export {};` -- `/home/calvin/agent-workbench/packages/config/package.json`: Has `typecheck` script, no build script, no dependencies -- `/home/calvin/agent-workbench/packages/config/src/.gitkeep`: Empty marker file +- ```REPO_ROOT``/packages/config/src/index.ts` (line 1-2): `// Scaffold-only — no runtime implementation yet.` / `export {};` +- ```REPO_ROOT``/packages/config/package.json`: Has `typecheck` script, no build script, no dependencies +- ```REPO_ROOT``/packages/config/src/.gitkeep`: Empty marker file **Evidence:** - Zero `import ... from "@agent-workbench/config"` occurrences in any `.ts` file across the entire repo @@ -50,15 +50,15 @@ However, **5 new HIGH-severity issues** emerged: two dead packages (`packages/co ### H2 — `packages/ui` is a dead shell with zero consumers 🔴 **Files:** -- `/home/calvin/agent-workbench/packages/ui/src/index.ts` (line 1-2): `// Scaffold-only — no runtime implementation yet.` / `export {};` -- `/home/calvin/agent-workbench/packages/ui/package.json`: Only has `typecheck`, no dependencies +- ```REPO_ROOT``/packages/ui/src/index.ts` (line 1-2): `// Scaffold-only — no runtime implementation yet.` / `export {};` +- ```REPO_ROOT``/packages/ui/package.json`: Only has `typecheck`, no dependencies **Evidence:** - Zero `import ... from "@agent-workbench/ui"` occurrences in any `.ts` file across the entire repo (the only hit is in `tests/e2e/boundary-tui-imports.test.ts` line 18 which lists it in an allowed-packages array — not an actual import) - AGENTS.md (line 42): "packages/ui: shared UI primitives only." - `scripts/build-all.sh` does NOT build it - The TUI app (`apps/tui`) does NOT import from `@agent-workbench/ui` — it imports directly from `@agent-workbench/eval`, `@agent-workbench/sdk`, `@agent-workbench/protocol` -- The README at `/home/calvin/agent-workbench/packages/ui/README.md` shows hypothetical usage (`import { formatTimestamp, truncatePath } from "@agent-workbench/ui"`) that doesn't exist +- The README at ```REPO_ROOT``/packages/ui/README.md` shows hypothetical usage (`import { formatTimestamp, truncatePath } from "@agent-workbench/ui"`) that doesn't exist **Impact:** Same as H1 — documentation promises shared UI primitives that don't exist. The TUI app had to implement its own UI rendering without shared abstractions. @@ -68,7 +68,7 @@ However, **5 new HIGH-severity issues** emerged: two dead packages (`packages/co ### H3 — `apps/dashboard` does NOT connect via SDK (contradicts AGENTS.md) 🔴 -**File:** `/home/calvin/agent-workbench/apps/dashboard/package.json` (lines 13-14) +**File:** ```REPO_ROOT``/apps/dashboard/package.json` (lines 13-14) ``` "dependencies": { "solid-js": "^1.9.12" @@ -89,7 +89,7 @@ However, **5 new HIGH-severity issues** emerged: two dead packages (`packages/co ### H4 — Boundary TUI import test disagrees with AGENTS.md (`eval` not allowed) 🔴 -**File:** `/home/calvin/agent-workbench/tests/e2e/boundary-tui-imports.test.ts` (lines 14-19) +**File:** ```REPO_ROOT``/tests/e2e/boundary-tui-imports.test.ts` (lines 14-19) ```typescript const _ALLOWED_PACKAGES = [ "@agent-workbench/protocol", @@ -113,7 +113,7 @@ const _ALLOWED_PACKAGES = [ ### H5 — `docs/02_ARCHITECTURE.md` is stale (Phase 0 header, missing Phase 29/30 details) 🔴 -**File:** `/home/calvin/agent-workbench/docs/dev/02_ARCHITECTURE.md` +**File:** ```REPO_ROOT``/docs/dev/02_ARCHITECTURE.md` **Evidence:** - Line 3: `Status: Phase 0 — Planning Docs` — **Incorrect.** Phases 0–30 are complete. @@ -133,7 +133,7 @@ const _ALLOWED_PACKAGES = [ ### M1 — Dockerfile doesn't copy `apps/mobile-web/` but `build-all.sh` tries to build it 🟡 -**File:** `/home/calvin/agent-workbench/Dockerfile` (lines 4-7) +**File:** ```REPO_ROOT``/Dockerfile` (lines 4-7) ``` COPY packages/ packages/ COPY apps/server/ apps/server/ @@ -141,7 +141,7 @@ COPY apps/cli/ apps/cli/ COPY scripts/ scripts/ ``` -**File:** `/home/calvin/agent-workbench/scripts/build-all.sh` (lines 46-47) +**File:** ```REPO_ROOT``/scripts/build-all.sh` (lines 46-47) ``` echo " [build] apps/mobile-web" (cd "$ROOT/apps/mobile-web" && bun run build 2>&1) || exit 1 @@ -180,7 +180,7 @@ echo " [build] apps/mobile-web" ### M3 — `plugin-sdk` uses `zod "^4.0.0"` while other packages use `"^4.4.3"` 🟡 -**File:** `/home/calvin/agent-workbench/packages/plugin-sdk/package.json` (line 20) +**File:** ```REPO_ROOT``/packages/plugin-sdk/package.json` (line 20) ```json "zod": "^4.0.0" ``` @@ -198,7 +198,7 @@ echo " [build] apps/mobile-web" ### M4 — `scripts/build-all.sh` doesn't build `config` or `ui` (still missing from prior audit) 🟡 -**File:** `/home/calvin/agent-workbench/scripts/build-all.sh` (lines 10-13) +**File:** ```REPO_ROOT``/scripts/build-all.sh` (lines 10-13) ``` # Level 0: no @agent-workbench dependencies for pkg in protocol models storage tokens diff telemetry plugin-sdk auth compliance; do @@ -232,7 +232,7 @@ for pkg in protocol models storage tokens diff telemetry plugin-sdk auth complia ### M6 — `apps/dashboard` has no `@agent-workbench` dependencies despite AGENTS.md claiming SDK connectivity 🟡 -**File:** `/home/calvin/agent-workbench/apps/dashboard/package.json` (lines 13-15) +**File:** ```REPO_ROOT``/apps/dashboard/package.json` (lines 13-15) **Evidence:** - Dashboard's `package.json` only depends on `solid-js` @@ -248,7 +248,7 @@ for pkg in protocol models storage tokens diff telemetry plugin-sdk auth complia ### M7 — `docs/06_SECURITY_MODEL.md` uses future tense for already-shipped capabilities 🟡 -**File:** `/home/calvin/agent-workbench/docs/06_SECURITY_MODEL.md` (lines 5-8) +**File:** ```REPO_ROOT``/docs/06_SECURITY_MODEL.md` (lines 5-8) ``` The system executes in a local developer environment and may eventually read files, edit files, run shell commands, and call model providers. These capabilities are @@ -272,7 +272,7 @@ powerful and must be constrained by default. ### L1 — `docs/27_PROJECT_ROADMAP.md` says "Phase 30 next" but Phase 30 is complete 🟢 -**File:** `/home/calvin/agent-workbench/docs/27_PROJECT_ROADMAP.md` (line 3) +**File:** ```REPO_ROOT``/docs/27_PROJECT_ROADMAP.md` (line 3) ``` Status: Phase 29 complete — Phase 30 (enterprise readiness) next ``` @@ -289,7 +289,7 @@ Status: Phase 29 complete — Phase 30 (enterprise readiness) next ### L2 — Architecture Acceptance Criteria in `docs/02_ARCHITECTURE.md` are entirely unchecked 🟢 -**File:** `/home/calvin/agent-workbench/docs/dev/02_ARCHITECTURE.md` (lines 499-509) +**File:** ```REPO_ROOT``/docs/dev/02_ARCHITECTURE.md` (lines 499-509) ``` [ ] TUI cannot execute tools directly. [ ] TUI cannot write files directly. @@ -310,7 +310,7 @@ Status: Phase 29 complete — Phase 30 (enterprise readiness) next ### L3 — `apps/cli` import depth suggests potential design debt (heavy Node.js `fs` usage vs. plugin-sdk) 🟢 -**File:** `/home/calvin/agent-workbench/apps/cli/src/index.ts` +**File:** ```REPO_ROOT``/apps/cli/src/index.ts` **Evidence:** - CLI imports `PluginManifest` type and `PluginRegistry` from `@agent-workbench/plugin-sdk` @@ -326,7 +326,7 @@ Status: Phase 29 complete — Phase 30 (enterprise readiness) next ### L4 — `packages/telemetry` has empty `exports` section despite being a built package 🟢 -**File:** `/home/calvin/agent-workbench/packages/telemetry/package.json` (lines 7-11) +**File:** ```REPO_ROOT``/packages/telemetry/package.json` (lines 7-11) ```json "exports": { ".": { @@ -355,7 +355,7 @@ Actually wait — I confirmed telemetry exists in Level 0 of build-all.sh and is ### DI Pattern (CoreDependencies) — 🟢 Excellent -**File:** `/home/calvin/agent-workbench/packages/core/src/types.ts` (lines 88-112) +**File:** ```REPO_ROOT``/packages/core/src/types.ts` (lines 88-112) The `CoreDependencies` interface defines a clean dependency injection contract: - 11 dependencies injected: 7 storage repositories, event bus, tool registry, model provider, permission engine/gate, shell runner, and internal services @@ -368,7 +368,7 @@ The `CoreDependencies` interface defines a clean dependency injection contract: ### Protocol Route Contracts — 🟢 Excellent -**File:** `/home/calvin/agent-workbench/packages/protocol/src/routes/` (15 route files) +**File:** ```REPO_ROOT``/packages/protocol/src/routes/` (15 route files) The single-source-of-truth pattern is holding up well: - 15 route contract files under `packages/protocol/src/routes/` diff --git a/docs/dev/AUDIT_REPORT.md b/docs/dev/AUDIT_REPORT.md index 12704cd..50cef73 100644 --- a/docs/dev/AUDIT_REPORT.md +++ b/docs/dev/AUDIT_REPORT.md @@ -24,7 +24,7 @@ This is a **well-maintained, actively developed** TypeScript monorepo with stron ### FINDING 1 — HIGH 🔴: Root-level `typecheck` script missing (breaks lint-staged) -**File:** `/home/calvin/agent-workbench/package.json`, lines 56-62 +**File:** ```REPO_ROOT``/package.json`, lines 56-62 The `lint-staged` config runs `bun run typecheck --noEmit` on `*.{ts,tsx}` files (line 58), but there is no `"typecheck"` script defined at the monorepo root level in `package.json` scripts (line 12-25). Each package/app has `typecheck` in its own `package.json`, but lint-staged runs from the root, so this will fail with `error: missing script "typecheck"`. diff --git a/docs/dev/DOCS_AUDIT_REPORT.md b/docs/dev/DOCS_AUDIT_REPORT.md index 5955942..79f89d2 100644 --- a/docs/dev/DOCS_AUDIT_REPORT.md +++ b/docs/dev/DOCS_AUDIT_REPORT.md @@ -1,7 +1,7 @@ # Docs/ Directory Audit Report **Date:** 2026-07-06 -**Audit scope:** All 33 `.md` files in `/home/calvin/agent-workbench/docs/` (including `compliance/`) +**Audit scope:** All 33 `.md` files in ```REPO_ROOT``/docs/` (including `compliance/`) **Method:** Read first 20+ lines of each file; categorized by content type, stated document type, status, and user-facing value. --- diff --git a/packages/compliance/README.md b/packages/compliance/README.md index 929f2f8..0b9be05 100644 --- a/packages/compliance/README.md +++ b/packages/compliance/README.md @@ -3,6 +3,8 @@ Enterprise compliance features: immutable audit trail, PII scanning, FIPS 140-2 helpers, data retention policies, and air-gapped mode enforcement. +This package is fully implemented and tested (178 tests across 10 files). + ## Exports | Module | Export | Description | diff --git a/packages/config/README.md b/packages/config/README.md index 7b30fef..6fae49a 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -3,21 +3,40 @@ **Status: ⏳ Stub — planned, not yet implemented.** This package is declared in AGENTS.md as responsible for layered config loading -from env, files, and CLI args. No runtime implementation exists yet. +from environment variables, config files, and CLI arguments. -Current behavior: `export {};` (empty barrel). +Current behavior: `export {};` (empty barrel). The package has a `typecheck` +script but no runtime implementation yet. -## Plan +## Planned API -- Load config from environment variables (priority: lowest) -- Load config from YAML/JSON files in `~/.agent-workbench/config/` -- Load config from CLI arguments (priority: highest) +``` +loadConfig(options?: ConfigOptions): AgentWorkbenchConfig +``` + +Layered resolution (highest priority wins): + +1. **CLI arguments** — `--port=8080`, `--provider openai` +2. **Config files** — `~/.agent-workbench/config.yaml` / `config.json` +3. **Environment variables** — `AGENT_WORKBENCH_PORT`, `AGENT_WORKBENCH_PROVIDER` + +### Features + +- Schema validation with Zod (from `@agent-workbench/protocol`) - Secret references: `${{ secrets.MY_SECRET }}` interpolation -- Schema validation with Zod +- Auto-reload on config file change (optional, via watch mode) +- Profile merging: `base.yaml` + `profile..yaml` overlay + +## Consumers (future) + +| Consumer | What it configures | +|----------|-------------------| +| `apps/server` | Server port, TLS, auth, providers | +| `apps/cli` | CLI defaults, plugin paths | +| `packages/core` | Runtime behavior, token budgets | +| `packages/models` | Provider registry defaults | -## Consumers +## Related -Currently: none. When implemented, consumers include: -- `apps/server` — server config -- `apps/cli` — CLI settings -- `packages/core` — runtime configuration +- `@agent-workbench/protocol` — Zod schemas for config validation +- `docs/06_SECURITY_MODEL.md` — Secure config storage guidelines diff --git a/packages/ui/README.md b/packages/ui/README.md index 6619f02..d042b19 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -2,19 +2,38 @@ **Status: ⏳ Stub — planned, not yet implemented.** -This package is declared in AGENTS.md as providing shared UI primitives. -No runtime implementation exists yet. +This package is declared in AGENTS.md as providing shared UI primitives +(formatting, color tokens, path helpers) for use across `apps/tui`, +`apps/mobile-web`, and `apps/dashboard`. -Current behavior: `export {};` (empty barrel). +Current behavior: `export {};` (empty barrel). The package has a `typecheck` +script but no runtime implementation yet. -## Plan +## Planned Exports -- `formatTimestamp` — human-readable relative/absolute timestamps -- `truncatePath` — path shortening for display -- Color token utilities for consistent theming across TUI, mobile-web, and dashboard -- Non-authoritative UI helpers (no business logic, no state) +| Module | Export | Description | +|--------|--------|-------------| +| `formatTimestamp` | `relative()`, `absolute()`, `short()` | Human-readable relative/absolute timestamps | +| `truncatePath` | `truncatePath()` | Path shortening for narrow display panels | +| `colors` | palette tokens | Shared color/theme primitives for consistent theming | + +## Design Principles + +- **No business logic** — pure formatting and display helpers only +- **No state** — all exports are pure functions or constant token maps +- **Framework-agnostic** — usable from SolidJS (TUI, dashboard, mobile-web) and + potentially React/Vue in the future + +## When to Implement + +Priority: Low. The TUI currently handles formatting inline. Extract shared +utilities when a second consumer (mobile-web, dashboard) needs the same +formatting logic — or when the codebase reaches 3+ duplicate format helpers. ## Consumers -Currently: none. When implemented, primary consumer is `apps/tui` (and potentially -`apps/mobile-web` and `apps/dashboard`). +| Consumer | Status | +|----------|--------| +| `apps/tui` | Inline formatting (not yet consuming this package) | +| `apps/mobile-web` | Not yet consuming | +| `apps/dashboard` | Not yet consuming | diff --git a/security-audit-report.md b/security-audit-report.md index 8b5f84f..47a1f6c 100644 --- a/security-audit-report.md +++ b/security-audit-report.md @@ -2,7 +2,7 @@ **Date:** July 7, 2026 **Scope:** Full-stack security audit covering CVE posture, dependency vulnerabilities, secret scanning, code injection, permission model, CI/CD, audit trail, PII scanning, SSO, RBAC, FIPS, SBOM, and supply chain security. -**Repo Path:** `/home/calvin/agent-workbench` +**Repo Path:** ```REPO_ROOT``` **Previous Audit:** July 3, 2026 (Phase 30 landing — 12 exit gates) --- From 0a6bb910404c892001da5c8c503439a97ec48a28 Mon Sep 17 00:00:00 2001 From: MerverliPy Date: Wed, 8 Jul 2026 06:37:54 -0500 Subject: [PATCH 2/2] feat: decouple event routing, optimize scroll perf, add focus traps, and componentize dashboard - Extract monolithic SSE handleEvent from App.tsx into lib/event-dispatcher.ts - Replace synchronous onScroll with IntersectionObserver in ChatView.tsx - Add keyboard focus traps to NavDrawer and PermissionPrompt - Centralize SVG icons into components/icons/index.tsx, fix TabBar role=tablist - Break monolithic dashboard App.tsx into Header, SummaryCards, StatusBreakdown, LatencyHeatmap, CostTrends - Make root shell layout responsive (drop hardcoded 430px max-width) - Fix permission.test.ts assertions to scan new dispatcher file --- apps/dashboard/src/App.tsx | 279 ++---------- apps/dashboard/src/components/CostTrends.tsx | 52 +++ apps/dashboard/src/components/Header.tsx | 63 +++ .../src/components/LatencyHeatmap.tsx | 65 +++ .../src/components/StatusBreakdown.tsx | 35 ++ .../dashboard/src/components/SummaryCards.tsx | 44 ++ apps/dashboard/src/utils/format.ts | 11 + apps/mobile-web/src/App.tsx | 411 +----------------- apps/mobile-web/src/components/ChatView.tsx | 44 +- apps/mobile-web/src/components/NavDrawer.tsx | 87 ++-- .../src/components/PermissionPrompt.tsx | 52 ++- apps/mobile-web/src/components/TabBar.tsx | 82 +--- .../mobile-web/src/components/icons/index.tsx | 117 +++++ apps/mobile-web/src/lib/event-dispatcher.ts | 404 +++++++++++++++++ apps/mobile-web/src/state/permission.test.ts | 22 +- 15 files changed, 970 insertions(+), 798 deletions(-) create mode 100644 apps/dashboard/src/components/CostTrends.tsx create mode 100644 apps/dashboard/src/components/Header.tsx create mode 100644 apps/dashboard/src/components/LatencyHeatmap.tsx create mode 100644 apps/dashboard/src/components/StatusBreakdown.tsx create mode 100644 apps/dashboard/src/components/SummaryCards.tsx create mode 100644 apps/dashboard/src/utils/format.ts create mode 100644 apps/mobile-web/src/components/icons/index.tsx create mode 100644 apps/mobile-web/src/lib/event-dispatcher.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index e26aba0..c6c8445 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,8 +1,14 @@ import type { JSX } from "solid-js"; -import { createResource, createSignal, For, onMount, Show } from "solid-js"; +import { createResource, createSignal, onMount, Show } from "solid-js"; import type { DashboardResponse } from "./client"; import { DashboardClient } from "./client"; +import { Header } from "./components/Header"; +import { SummaryCards } from "./components/SummaryCards"; +import { StatusBreakdown } from "./components/StatusBreakdown"; +import { LatencyHeatmap } from "./components/LatencyHeatmap"; +import { CostTrends } from "./components/CostTrends"; + const DEFAULT_SERVER = "http://localhost:3000"; function getServerUrl(): string { @@ -15,92 +21,36 @@ async function fetchDashboard() { return dashboardClient.fetchDashboard(); } -function formatMs(ms: number): string { - if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`; - if (ms < 1000) return `${ms.toFixed(1)}ms`; - return `${(ms / 1000).toFixed(2)}s`; -} - -function formatCost(cost: number): string { - if (cost === 0) return "$0.00"; - if (cost < 0.01) return `$${cost.toFixed(4)}`; - return `$${cost.toFixed(2)}`; -} - export function App(): JSX.Element { const [serverUrl, setServerUrl] = createSignal(getServerUrl()); const [dashboard, { refetch }] = createResource(fetchDashboard); const [autoRefresh, setAutoRefresh] = createSignal(true); - // Auto-refresh every 10 seconds - const autoRefreshEnabled = autoRefresh(); onMount(() => { - if (!autoRefreshEnabled) return; const interval = setInterval(() => { if (autoRefresh()) refetch(); }, 10000); return () => clearInterval(interval); }); - function handleServerChange(e: Event) { - const input = e.target as HTMLInputElement; - const url = input.value.replace(/\/$/, ""); - setServerUrl(url); - localStorage.setItem("dashboard-server-url", url); - dashboardClient = new DashboardClient(url); + function handleServerChange(url: string) { + const cleanUrl = url.replace(/\/$/, ""); + setServerUrl(cleanUrl); + localStorage.setItem("dashboard-server-url", cleanUrl); + dashboardClient = new DashboardClient(cleanUrl); refetch(); } - const statusColors: Record = { - active: "bg-emerald-500/20 text-emerald-300", - completed: "bg-blue-500/20 text-blue-300", - aborted: "bg-amber-500/20 text-amber-300", - deleted: "bg-red-500/20 text-red-300", - archived: "bg-slate-500/20 text-slate-400", - unknown: "bg-slate-500/20 text-slate-400", - }; - return (
- {/* Header */} -
-
-
-

agent-workbench

-

Observability Dashboard

-
-
-
- - e.key === "Enter" && handleServerChange(e)} - class="bg-slate-700 border border-slate-600 rounded px-3 py-1 text-sm text-slate-200 w-64 focus:outline-none focus:border-blue-500" - /> -
- - -
-
-
+
@@ -123,148 +73,15 @@ export function App(): JSX.Element { {(d) => (
- {/* Summary Cards */} -
- - - - 0 ? "red" : "emerald"} - /> -
- - {/* Session Status Breakdown */} -
-

Sessions by Status

-
- - {([status, count]) => ( -
-
{count}
-
- {status} -
-
- )} -
-
-
- - {/* Latency Heatmap */} -
-

Latency by Operation

- 0} - fallback={ -

- No span data available yet. -

- } - > -
- - - - - - - - - - - - - {([name, stats]) => ( - - - - - - - - )} - - -
OperationCountp50p95p99
- {name} - - {stats.count} - - - - - - -
-
-
-
- - {/* Cost Trends */} -
-

Cost Trends

- 0} - fallback={ -

- No cost data recorded yet. -

- } - > -
- - - - - - - - - - {(entry) => ( - - - - - )} - - -
DateCost (USD)
- {entry.date} - - 1 - ? "text-amber-300" - : entry.cost > 0.1 - ? "text-yellow-300" - : "text-emerald-300" - } - > - {formatCost(entry.cost)} - -
-
-
-
+ + + +
)}
@@ -272,43 +89,3 @@ export function App(): JSX.Element {
); } - -function SummaryCard(props: { - label: string; - value: string | number; - color: string; -}): JSX.Element { - const colorClasses: Record = { - blue: "border-l-blue-500 bg-blue-500/5", - emerald: "border-l-emerald-500 bg-emerald-500/5", - amber: "border-l-amber-500 bg-amber-500/5", - red: "border-l-red-500 bg-red-500/5", - }; - - return ( -
-
- {props.label} -
-
{props.value}
-
- ); -} - -function LatencyBadge(props: { ms: number }): JSX.Element { - const ms = props.ms; - const color = - ms < 10 - ? "text-emerald-400" - : ms < 100 - ? "text-green-400" - : ms < 500 - ? "text-yellow-400" - : ms < 2000 - ? "text-amber-400" - : "text-red-400"; - - return {formatMs(ms)}; -} diff --git a/apps/dashboard/src/components/CostTrends.tsx b/apps/dashboard/src/components/CostTrends.tsx new file mode 100644 index 0000000..7464d06 --- /dev/null +++ b/apps/dashboard/src/components/CostTrends.tsx @@ -0,0 +1,52 @@ +import type { JSX } from "solid-js"; +import { For, Show } from "solid-js"; +import { formatCost } from "../utils/format"; + +interface CostTrendsProps { + daily: Array<{ date: string; cost: number }>; +} + +export function CostTrends(props: CostTrendsProps): JSX.Element { + return ( +
+

Cost Trends

+ 0} + fallback={

No cost data recorded yet.

} + > +
+ + + + + + + + + + {(entry) => ( + + + + + )} + + +
DateCost (USD)
{entry.date} + 1 + ? "text-amber-300" + : entry.cost > 0.1 + ? "text-yellow-300" + : "text-emerald-300" + } + > + {formatCost(entry.cost)} + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/Header.tsx b/apps/dashboard/src/components/Header.tsx new file mode 100644 index 0000000..bfc790f --- /dev/null +++ b/apps/dashboard/src/components/Header.tsx @@ -0,0 +1,63 @@ +import { createSignal } from "solid-js"; +import type { JSX } from "solid-js"; + +interface HeaderProps { + serverUrl: string; + onServerChange: (url: string) => void; + onRefresh: () => void; + autoRefresh: boolean; + onAutoRefreshChange: (enabled: boolean) => void; +} + +export function Header(props: HeaderProps): JSX.Element { + const [localUrl, setLocalUrl] = createSignal(props.serverUrl); + + function handleBlur() { + props.onServerChange(localUrl()); + } + + function handleKeyPress(e: KeyboardEvent) { + if (e.key === "Enter") { + props.onServerChange(localUrl()); + } + } + + return ( +
+
+
+

agent-workbench

+

Observability Dashboard

+
+
+
+ + setLocalUrl(e.currentTarget.value)} + onBlur={handleBlur} + onKeyPress={handleKeyPress} + class="bg-slate-700 border border-slate-600 rounded px-3 py-1 text-sm text-slate-200 w-64 focus:outline-none focus:border-blue-500" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/LatencyHeatmap.tsx b/apps/dashboard/src/components/LatencyHeatmap.tsx new file mode 100644 index 0000000..c4d6a9f --- /dev/null +++ b/apps/dashboard/src/components/LatencyHeatmap.tsx @@ -0,0 +1,65 @@ +import type { JSX } from "solid-js"; +import { For, Show } from "solid-js"; +import { formatMs } from "../utils/format"; + +interface LatencyHeatmapProps { + latencyByOperation: Record< + string, + { count: number; p50: number; p95: number; p99: number } + >; +} + +export function LatencyHeatmap(props: LatencyHeatmapProps): JSX.Element { + return ( +
+

Latency by Operation

+ 0} + fallback={

No span data available yet.

} + > +
+ + + + + + + + + + + + + {([name, stats]) => ( + + + + + + + + )} + + +
OperationCountp50p95p99
{name}{stats.count}
+
+
+
+ ); +} + +function LatencyBadge(props: { ms: number }): JSX.Element { + const ms = props.ms; + const color = + ms < 10 + ? "text-emerald-400" + : ms < 100 + ? "text-green-400" + : ms < 500 + ? "text-yellow-400" + : ms < 2000 + ? "text-amber-400" + : "text-red-400"; + + return {formatMs(ms)}; +} diff --git a/apps/dashboard/src/components/StatusBreakdown.tsx b/apps/dashboard/src/components/StatusBreakdown.tsx new file mode 100644 index 0000000..9dcb3b5 --- /dev/null +++ b/apps/dashboard/src/components/StatusBreakdown.tsx @@ -0,0 +1,35 @@ +import type { JSX } from "solid-js"; +import { For } from "solid-js"; + +interface StatusBreakdownProps { + byStatus: Record; +} + +const statusColors: Record = { + active: "bg-emerald-500/20 text-emerald-300", + completed: "bg-blue-500/20 text-blue-300", + aborted: "bg-amber-500/20 text-amber-300", + deleted: "bg-red-500/20 text-red-300", + archived: "bg-slate-500/20 text-slate-400", + unknown: "bg-slate-500/20 text-slate-400", +}; + +export function StatusBreakdown(props: StatusBreakdownProps): JSX.Element { + return ( +
+

Sessions by Status

+
+ + {([status, count]) => ( +
+
{count}
+
{status}
+
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/SummaryCards.tsx b/apps/dashboard/src/components/SummaryCards.tsx new file mode 100644 index 0000000..4e60729 --- /dev/null +++ b/apps/dashboard/src/components/SummaryCards.tsx @@ -0,0 +1,44 @@ +import type { JSX } from "solid-js"; +import { formatCost } from "../utils/format"; + +interface SummaryCardsProps { + sessions: number; + spans: number; + cost: number; + errors: number; +} + +export function SummaryCards(props: SummaryCardsProps): JSX.Element { + return ( +
+ + + + 0 ? "red" : "emerald"} /> +
+ ); +} + +function SummaryCard(props: { + label: string; + value: string | number; + color: string; +}): JSX.Element { + const colorClasses: Record = { + blue: "border-l-blue-500 bg-blue-500/5", + emerald: "border-l-emerald-500 bg-emerald-500/5", + amber: "border-l-amber-500 bg-amber-500/5", + red: "border-l-red-500 bg-red-500/5", + }; + + return ( +
+
+ {props.label} +
+
{props.value}
+
+ ); +} diff --git a/apps/dashboard/src/utils/format.ts b/apps/dashboard/src/utils/format.ts new file mode 100644 index 0000000..6441d9b --- /dev/null +++ b/apps/dashboard/src/utils/format.ts @@ -0,0 +1,11 @@ +export function formatMs(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`; + if (ms < 1000) return `${ms.toFixed(1)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +export function formatCost(cost: number): string { + if (cost === 0) return "$0.00"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} diff --git a/apps/mobile-web/src/App.tsx b/apps/mobile-web/src/App.tsx index 0c3231c..c8e6ad1 100644 --- a/apps/mobile-web/src/App.tsx +++ b/apps/mobile-web/src/App.tsx @@ -13,32 +13,16 @@ import { PermissionPrompt } from "./components/PermissionPrompt"; import { PanelContainer } from "./components/panels/PanelContainer"; import { TabBar } from "./components/TabBar"; import { TopBar } from "./components/TopBar"; -import { categorizeEvent, getCategoryIcon } from "./lib/events"; +import { handleEvent } from "./lib/event-dispatcher"; import { reconnectClient } from "./lib/sdk"; import { getSettings } from "./lib/settings"; import { - appendActivity, - appendCard, - appendMessage, - appendStreamingDelta, appendSystemNotice, - beginStreaming, - cancelStreaming, - decrementPendingApprovals, fallbackMode, - finalizeStreaming, - incrementPendingApprovals, - pendingApprovalCount, permissionModalOpen, setAvailableAgents, setConnectionError, setConnectionStatus, - setCurrentAgentId, - setPendingPermissionRequest, - setPermissionModalOpen, - setThinkingIndicator, - streamingContent, - updateCardData, } from "./state/app"; export function App(): JSX.Element { @@ -115,394 +99,6 @@ export function App(): JSX.Element { } } - // ── SSE event routing ───────────────────────────────────────────────── - - function handleEvent(event: EventEnvelope): void { - const type = event.type; - const cat = categorizeEvent(type); - const p = event.payload as Record; - - // Log all non-stream events to activity log - if (cat !== "stream" && cat !== "other") { - appendActivity({ - id: event.id, - timestamp: event.timestamp, - category: cat, - icon: getCategoryIcon(cat), - summary: `${type} — ${JSON.stringify(p).slice(0, 100)}`, - }); - } - - // ── Run lifecycle ── - if (type === "run.started") { - setThinkingIndicator(true); - return; - } - if (type === "run.completed") { - setThinkingIndicator(false); - return; - } - - // ── Messages ── - if (type === "message.created" || type === "message.delta") { - const role = (p.role as string | undefined) ?? "assistant"; - const content = (p.content as string | undefined) ?? ""; - if (content) { - appendMessage({ - id: event.id, - role: role as "user" | "assistant" | "system", - content, - createdAt: event.timestamp, - }); - } - return; - } - - // ── Streaming ── - if (type === "model.stream_delta") { - const delta = p.delta as string | undefined; - if (delta) { - if (!streamingContent()) beginStreaming(event.id); - appendStreamingDelta(delta); - } - return; - } - - if (type === "model.stream_complete") { - finalizeStreaming(); - setThinkingIndicator(false); - return; - } - - if (type === "model.stream_error") { - appendSystemNotice(`Stream error: ${(p.message as string) ?? "unknown"}`); - cancelStreaming(); - setThinkingIndicator(false); - return; - } - - // ── Plan events → PlanCard + SummaryCard ── - if (type === "plan.proposed") { - const planPayload = p.plan as Record | undefined; - const steps = - (planPayload?.steps as Array>) ?? []; - const planId = (planPayload?.id as string) ?? event.id; - appendCard("plan", planId, "planId", { - planId, - steps: steps.map((s, i) => ({ - number: (s.number as number) ?? i + 1, - description: (s.description as string) ?? (s.summary as string) ?? "", - status: "pending" as const, - })), - status: "in_progress" as const, - }); - return; - } - - if (type === "plan.step_started") { - const stepIdx = (p.stepIndex as number) ?? (p.step as number) ?? 0; - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("steps" in data) { - const steps = (data as { steps: Array<{ status: string }> }).steps; - if (steps[stepIdx]) steps[stepIdx].status = "in_progress"; - } - }); - return; - } - - if (type === "plan.step_completed") { - const stepIdx = (p.stepIndex as number) ?? (p.step as number) ?? 0; - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("steps" in data) { - const steps = (data as { steps: Array<{ status: string }> }).steps; - if (steps[stepIdx]) steps[stepIdx].status = "completed"; - } - }); - return; - } - - if (type === "plan.step_failed") { - const stepIdx = (p.stepIndex as number) ?? (p.step as number) ?? 0; - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("steps" in data) { - const steps = (data as { steps: Array<{ status: string }> }).steps; - if (steps[stepIdx]) steps[stepIdx].status = "failed"; - } - }); - return; - } - - if (type === "plan.approved") { - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("status" in data) (data as { status: string }).status = "approved"; - }); - return; - } - - if (type === "plan.denied") { - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("status" in data) (data as { status: string }).status = "denied"; - }); - return; - } - - if (type === "plan.completed") { - const planId = (p.planId as string) ?? ""; - updateCardData(planId, (data) => { - if ("status" in data) (data as { status: string }).status = "completed"; - }); - // Remaining system notice as fallback detail - appendSystemNotice("Plan completed"); - return; - } - - // ── Tool events → ToolActivityCard ── - if (type === "tool.requested") { - const toolCallId = (p.toolCallId as string) ?? event.id; - const toolName = - (p.toolName as string) ?? (p.name as string) ?? "unknown"; - appendCard("tool", toolCallId, "toolCallId", { - toolCallId, - toolName, - status: "pending" as const, - }); - return; - } - - if (type === "tool.started") { - const toolCallId = (p.toolCallId as string) ?? ""; - updateCardData(toolCallId, (data) => { - if ("status" in data) - (data as { status: string }).status = "in_progress"; - }); - return; - } - - if (type === "tool.completed") { - const toolCallId = (p.toolCallId as string) ?? ""; - const result = (p.result as string) ?? (p.summary as string) ?? ""; - updateCardData(toolCallId, (data) => { - const d = data as { status: string; result?: string }; - d.status = "completed"; - if (result) d.result = result; - }); - return; - } - - if (type === "tool.failed") { - const toolCallId = (p.toolCallId as string) ?? ""; - const error = - (p.error as string) ?? (p.message as string) ?? "Tool failed"; - updateCardData(toolCallId, (data) => { - const d = data as { status: string; error?: string }; - d.status = "failed"; - d.error = error; - }); - return; - } - - if (type === "tool.aborted") { - const toolCallId = (p.toolCallId as string) ?? ""; - updateCardData(toolCallId, (data) => { - if ("status" in data) (data as { status: string }).status = "aborted"; - }); - return; - } - - // ── Shell events → TerminalCard ── - if (type === "shell.command_started") { - const sessionId = (p.sessionId as string) ?? (p.id as string) ?? event.id; - const command = - (p.command as string) ?? (p.normalized as string) ?? "unknown"; - appendCard("terminal", sessionId, "sessionId", { - sessionId, - command, - output: "", - status: "in_progress" as const, - }); - return; - } - - if (type === "shell.output_chunk") { - const sessionId = (p.sessionId as string) ?? ""; - const chunk = - (p.chunk as string) ?? (p.data as string) ?? (p.output as string) ?? ""; - if (sessionId && chunk) { - updateCardData(sessionId, (data) => { - const d = data as { output: string }; - d.output += chunk; - }); - } - return; - } - - if (type === "shell.command_completed") { - const sessionId = (p.sessionId as string) ?? ""; - const exitCode = p.exitCode as number | undefined; - updateCardData(sessionId, (data) => { - const d = data as { status: string; exitCode?: number }; - d.status = "completed"; - if (exitCode !== undefined) d.exitCode = exitCode; - }); - return; - } - - if (type === "shell.command_failed") { - const sessionId = (p.sessionId as string) ?? ""; - const error = (p.error as string) ?? "Command failed"; - updateCardData(sessionId, (data) => { - const d = data as { status: string; error?: string }; - d.status = "failed"; - d.error = error; - }); - return; - } - - if (type === "shell.command_aborted") { - const sessionId = (p.sessionId as string) ?? ""; - updateCardData(sessionId, (data) => { - if ("status" in data) (data as { status: string }).status = "aborted"; - }); - return; - } - - if (type === "shell.command_risk_classified") { - const sessionId = (p.sessionId as string) ?? ""; - const riskLevel = (p.riskLevel as string) ?? ""; - if (sessionId && riskLevel) { - updateCardData(sessionId, (data) => { - (data as { riskLevel?: string }).riskLevel = riskLevel; - }); - } - return; - } - - // ── Diff/File events → DiffCard ── - if (type === "diff.preview_created") { - const diffPayload = p.diff as Record | undefined; - const files = - (diffPayload?.files as Array>) ?? - (p.files as Array>) ?? - []; - const diffId = (diffPayload?.id as string) ?? event.id; - appendCard("diff", diffId, null, { - files: files.map((f) => { - const entry: { - path: string; - type: "modified" | "added" | "removed"; - diff?: string; - } = { - path: (f.path as string) ?? (f.file as string) ?? "", - type: ((f.type as string) ?? "modified") as - | "modified" - | "added" - | "removed", - }; - const diff = f.diff as string | undefined; - if (diff) entry.diff = diff; - return entry; - }), - status: "completed" as const, - }); - return; - } - - if (type === "file.change_applied") { - const path = (p.path as string) ?? ""; - // Append to most recent DiffCard if one exists in messages, else standalone notice - appendSystemNotice(`File changed: ${path}`); - return; - } - - if (type === "file.change_failed" || type === "file.revert_failed") { - appendSystemNotice(`File error: ${(p.error as string) ?? "unknown"}`); - return; - } - - // ── Permissions → ApprovalCard ── - if (type === "permission.requested") { - const req = p.permissionRequest as PermissionRequest | undefined; - if (req) { - const total = pendingApprovalCount() + 1; - const seq = total; - setPendingPermissionRequest(req); - setPermissionModalOpen(true); - incrementPendingApprovals(); - appendCard("approval", req.id, "requestId", { - requestId: req.id, - toolName: req.toolName, - riskLevel: (req.riskLevel as "low" | "medium" | "high") ?? "medium", - status: "pending" as const, - sequenceNumber: seq, - totalCount: total, - }); - // Auto-scroll to newest approval card - queueMicrotask(() => { - const canvas = document.querySelector('[role="log"]'); - if (canvas) canvas.scrollTop = canvas.scrollHeight; - }); - } - return; - } - - if (type === "permission.decided") { - const requestId = (p.requestId as string) ?? (p.id as string) ?? ""; - if (requestId) { - decrementPendingApprovals(); - updateCardData(requestId, (data) => { - if ("status" in data) - (data as { status: string }).status = "approved"; - }); - } - return; - } - - if (type === "permission.denied") { - const requestId = (p.requestId as string) ?? (p.id as string) ?? ""; - if (requestId) { - decrementPendingApprovals(); - updateCardData(requestId, (data) => { - if ("status" in data) (data as { status: string }).status = "denied"; - }); - } - return; - } - - if (type === "permission.expired") { - const requestId = (p.requestId as string) ?? (p.id as string) ?? ""; - if (requestId) { - decrementPendingApprovals(); - updateCardData(requestId, (data) => { - if ("status" in data) (data as { status: string }).status = "expired"; - }); - } - return; - } - - // ── Token / Compaction ── - if (type === "token_health.warning") { - appendSystemNotice("Token usage warning — consider compacting"); - return; - } - - if (type === "compaction.suggested") { - appendSystemNotice("Compaction suggested — context usage is high"); - return; - } - - // ── Agent ── - if (type === "agent.selected") { - const agentId = p.agentId as string | undefined; - if (agentId) setCurrentAgentId(agentId); - return; - } - } - // ── Lifecycle ──────────────────────────────────────────────────────────── onMount(() => { @@ -518,10 +114,9 @@ export function App(): JSX.Element { - {/* iOS-style app shell: 430px centered with border chrome */}
diff --git a/apps/mobile-web/src/components/ChatView.tsx b/apps/mobile-web/src/components/ChatView.tsx index 9770c06..00d71e4 100644 --- a/apps/mobile-web/src/components/ChatView.tsx +++ b/apps/mobile-web/src/components/ChatView.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js"; -import { createEffect, createSignal, For, Show } from "solid-js"; +import { createEffect, createSignal, For, Show, onMount, onCleanup } from "solid-js"; import { isStreaming, messages, @@ -18,26 +18,43 @@ const SUGGESTED_PROMPTS = [ export function ChatView(): JSX.Element { let scrollRef: HTMLDivElement | undefined; + let bottomAnchorRef: HTMLDivElement | undefined; + let observer: IntersectionObserver | undefined; const [isNearBottom, setIsNearBottom] = createSignal(true); function scrollToBottom(smooth = false): void { - if (scrollRef) { - scrollRef.scrollTo({ - top: scrollRef.scrollHeight, + if (bottomAnchorRef) { + bottomAnchorRef.scrollIntoView({ behavior: smooth ? "smooth" : "instant", + block: "end", }); } } - function checkNearBottom(): void { - if (scrollRef) { - const threshold = 80; - setIsNearBottom( - scrollRef.scrollHeight - scrollRef.scrollTop - scrollRef.clientHeight < - threshold, - ); + onMount(() => { + observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry) { + // If the bottom anchor is intersecting (or very close), we are near bottom + setIsNearBottom(entry.isIntersecting); + } + }, + { + root: scrollRef ?? null, + rootMargin: "80px 0px 0px 0px", // Trigger when within 80px of the bottom + threshold: 0, + } + ); + + if (bottomAnchorRef) { + observer.observe(bottomAnchorRef); } - } + }); + + onCleanup(() => { + observer?.disconnect(); + }); // Auto-scroll on new messages/streaming — but only if user is near bottom createEffect(() => { @@ -56,7 +73,6 @@ export function ChatView(): JSX.Element {
checkNearBottom()} role="log" aria-live="polite" aria-label="Chat messages" @@ -81,6 +97,8 @@ export function ChatView(): JSX.Element {
)}
+ {/* Intersection anchor for infinite scrolling logic */} + {/* Floating scroll-to-bottom button */} diff --git a/apps/mobile-web/src/components/NavDrawer.tsx b/apps/mobile-web/src/components/NavDrawer.tsx index f759d8b..35e28f3 100644 --- a/apps/mobile-web/src/components/NavDrawer.tsx +++ b/apps/mobile-web/src/components/NavDrawer.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js"; -import { createSignal, For, Show } from "solid-js"; +import { createSignal, createEffect, onCleanup, For, Show } from "solid-js"; import type { PanelId } from "../state/app"; import { activePanel, @@ -7,49 +7,13 @@ import { selectPanel, setDrawerOpen, } from "../state/app"; +import { HelpIcon, SettingsIcon } from "./icons"; interface PanelItem { id: PanelId; label: string; } -/* Lucide-style SVG icons for drawer items */ -function SettingsIcon(): JSX.Element { - return ( - - - - - ); -} -function HelpIcon(): JSX.Element { - return ( - - - - - - ); -} - const PANELS: PanelItem[] = [ { id: "settings", label: "Settings" }, { id: "help", label: "Help" }, @@ -59,8 +23,52 @@ const SWIPE_THRESHOLD = 80; export function NavDrawer(): JSX.Element { let drawerRef: HTMLDivElement | undefined; + let closeBtnRef: HTMLButtonElement | undefined; const [touchStartX, setTouchStartX] = createSignal(null); + // ── Focus Trap Logic ──────────────────────────────────────────────── + createEffect(() => { + if (drawerOpen() && drawerRef) { + // Small delay to allow the drawer to render before focusing + requestAnimationFrame(() => { + closeBtnRef?.focus(); + }); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setDrawerOpen(false); + return; + } + + if (e.key === "Tab") { + const focusableElements = drawerRef?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as NodeListOf; + + if (!focusableElements || focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + lastElement?.focus(); + e.preventDefault(); + } + } else { + if (document.activeElement === lastElement) { + firstElement?.focus(); + e.preventDefault(); + } + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)); + } + }); + // ── Swipe-to-close on the drawer panel ──────────────────────────── function handleDrawerTouchStart(e: TouchEvent): void { if (e.touches.length === 1) { @@ -151,7 +159,8 @@ export function NavDrawer(): JSX.Element { agent-workbench