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/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