diff --git a/03_implementation/docs/handoffs/GUI_VISUAL_E2E_COMPLETION_LEDGER_2026-05-10.md b/03_implementation/docs/handoffs/GUI_VISUAL_E2E_COMPLETION_LEDGER_2026-05-10.md index 485b4852..5a4a366a 100644 --- a/03_implementation/docs/handoffs/GUI_VISUAL_E2E_COMPLETION_LEDGER_2026-05-10.md +++ b/03_implementation/docs/handoffs/GUI_VISUAL_E2E_COMPLETION_LEDGER_2026-05-10.md @@ -34,7 +34,7 @@ | 11 | Shell/Design Tokens | PR opened (W15-A11) — top 5 W14-A4 deltas applied: primary cyan->blue (`#3b80f4`, legacy fallback preserved), sidebar default 260->220 px, surface `#0f1626`->`#01101a`, surface2 `#141d33`->`#001420`, card radius 8->6 px. `npm run build` + `tsc --noEmit` + 4/4 Playwright smoke @ 1536/1672/1920 PASS. | | 12 | Dashboard Modes | _pending_ | | 13 | Source OS + 60 Apps | _pending_ | -| 14 | Primary Tabs A (Autopilot/Design/Gen3D/Jobs) | _pending_ | +| 14 | Primary Tabs A (Autopilot/Design/Gen3D/Jobs) | claude/w15-a14-tabs-a — Autopilot: +PlannerQueue/+AgentActivityFeed/+FreezeThawControls components wired to live `/api/workflows/active`, `/api/agents`, freeze/thaw endpoints (blocked-honest when not implemented). Design/Gen3D/Jobs already FULLY_WIRED per W15-A3 audit; verified no fake fallback + tsc/build PASS. 4 screenshots in `proof/screenshots/w15-a14/`. | | 15 | Primary Tabs B (Printers/Observe/Agents/Learning) | _pending_ | | 16 | Primary Tabs C (Artifacts/Approvals/Plugins/Roadmap) | _pending_ | | 17 | Settings + Themes | _pending_ | @@ -59,3 +59,4 @@ - 2026-05-10: Wave 15 launched; Phase 0 complete. - 2026-05-10: W15-CLEANUP (PR 3 of 8 sequence per W15-A6 §6) — delete 9 unrouted UNACCEPTABLE_FAKE tabs + 11 orphan mock data files + 1 stale 14-tab `app/routes.tsx` manifest. Branch `claude/w15-cleanup-fake-tabs` from `origin/develop@8dccb7b`. Pre-deletion grep audit: 0 of 21 files had any production import (all consumer references were inside the deleted-tab set). Verification: `tsc --noEmit` PASS, `npm run build` PASS, `vitest run` 118 passed / 4 skipped / 0 failed. Diff: 21 deletions only, 2148 net lines removed. Cites W15-A4 §3, §6 + W15-A3 (stale routes.tsx). Sources: ESLint `no-unused-modules` + TypeScript dead-code-elimination patterns. - 2026-05-10: Agent 11 (Shell/Design Tokens) opened PR `claude/w15-a11-shell-tokens`. Cites W14-A4 audit. Brand decision: primary shifted cyan->blue with `--h3d-color-primary-cyan-legacy` (`#22d3ee`) preserved for one-step revert; Tailwind `accent.cyan` alias kept verbatim so 88+ existing component refs are not visually moved. Self-audit: build PASS, tsc PASS, 4 smoke specs PASS @ 1536x1024 + 1672x941 + 1920x1080, theme toggle still works, no boot console errors. +- 2026-05-10: Agent 14 (Primary Tabs A) landed on `claude/w15-a14-tabs-a` from `origin/develop`. Added `components/autopilot/{PlannerQueue,AgentActivityFeed,FreezeThawControls}.tsx` wired into `tabs/Autopilot.tsx`. No fake data: planner queue reads `getActiveWorkflows()`, agent activity reads `getAgents()`, freeze/thaw posts to `/api/autopilot/freeze`+`/thaw` and renders explicit "blocked" when 404/405 (current backend, owned by A20). tsc/build PASS. Screenshots saved to `03_implementation/proof/screenshots/w15-a14/{autopilot,design,gen3d,jobs,autopilot-1536x1400,autopilot-freeze-thaw-detail}.png`. Locks: claude-w15-a14-tabs-a. Rebased onto `origin/develop@c2d4a13a` 2026-05-10 (W15-FIX-210-v2; cascade past PR #214/#209/#218/#215/#216/#217). diff --git a/03_implementation/proof/screenshots/w15-a14/autopilot-1536x1400.png b/03_implementation/proof/screenshots/w15-a14/autopilot-1536x1400.png new file mode 100644 index 00000000..a3fdc904 Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/autopilot-1536x1400.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/autopilot-freeze-thaw-detail.png b/03_implementation/proof/screenshots/w15-a14/autopilot-freeze-thaw-detail.png new file mode 100644 index 00000000..36db0ff6 Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/autopilot-freeze-thaw-detail.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/autopilot-fullpage.png b/03_implementation/proof/screenshots/w15-a14/autopilot-fullpage.png new file mode 100644 index 00000000..419f5c6f Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/autopilot-fullpage.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/autopilot.png b/03_implementation/proof/screenshots/w15-a14/autopilot.png new file mode 100644 index 00000000..419f5c6f Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/autopilot.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/design.png b/03_implementation/proof/screenshots/w15-a14/design.png new file mode 100644 index 00000000..06cf1be1 Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/design.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/gen3d.png b/03_implementation/proof/screenshots/w15-a14/gen3d.png new file mode 100644 index 00000000..75f17415 Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/gen3d.png differ diff --git a/03_implementation/proof/screenshots/w15-a14/jobs.png b/03_implementation/proof/screenshots/w15-a14/jobs.png new file mode 100644 index 00000000..7d0988da Binary files /dev/null and b/03_implementation/proof/screenshots/w15-a14/jobs.png differ diff --git a/03_implementation/ui/src/components/autopilot/AgentActivityFeed.tsx b/03_implementation/ui/src/components/autopilot/AgentActivityFeed.tsx new file mode 100644 index 00000000..9b80ecad --- /dev/null +++ b/03_implementation/ui/src/components/autopilot/AgentActivityFeed.tsx @@ -0,0 +1,134 @@ +/** + * AgentActivityFeed — Wave 15 A14. + * + * Display widget for live agent activity. Reads `Agent[]` from + * `adapters.getAgents()` (which hits `/api/agents`) and surfaces a + * compact, honest view of which agents are active right now. + * + * Strict rules: + * - No fake fallback data. + * - No write actions — read-only feed. + */ +import { useEffect, useState } from "react"; +import { adapters } from "../../api/adapters"; +import type { Agent, AgentStatus } from "../../types/agent"; + +type LoadState = "loading" | "ready" | "error"; + +export interface AgentActivityFeedProps { + agents?: Agent[]; +} + +export function AgentActivityFeed({ agents: propAgents }: AgentActivityFeedProps = {}) { + const [agents, setAgents] = useState(propAgents ?? []); + const [state, setState] = useState(propAgents ? "ready" : "loading"); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (propAgents) { + setAgents(propAgents); + setState("ready"); + return; + } + let mounted = true; + const load = () => { + adapters + .getAgents() + .then((next) => { + if (!mounted) { + return; + } + setAgents(next); + setState("ready"); + setErrorMsg(null); + }) + .catch((error) => { + if (!mounted) { + return; + } + setState("error"); + setErrorMsg(error instanceof Error ? error.message : "agents API unreachable"); + }); + }; + load(); + const timer = window.setInterval(load, 20_000); + return () => { + mounted = false; + window.clearInterval(timer); + }; + }, [propAgents]); + + // Sort: active first, then paused, then idle, then error. + const sorted = [...agents].sort((a, b) => statusWeight(a.status) - statusWeight(b.status)); + const activeCount = agents.filter((a) => a.status === "active").length; + + return ( +
+
+
+

AGENT ACTIVITY

+

Live agent roster from /api/agents.

+
+ + {state === "loading" ? "loading…" : `${activeCount}/${agents.length} active`} + +
+
+ {state === "error" && ( +
+ Blocked: agents endpoint unreachable. {errorMsg ?? ""} +
+ )} + {state === "ready" && agents.length === 0 && ( +
+ No agents returned by the live agents API. +
+ )} + {sorted.map((agent) => ( +
+ +
+
{agent.role}
+
+ {agent.task_count} task{agent.task_count === 1 ? "" : "s"} ·{" "} + {agent.model_provider || "—"} +
+
+ + {agent.last_activity_utc + ? new Date(agent.last_activity_utc).toLocaleTimeString() + : "—"} + +
+ ))} +
+
+ ); +} + +function statusWeight(status: AgentStatus): number { + if (status === "active") return 0; + if (status === "paused") return 1; + if (status === "idle") return 2; + return 3; // error +} + +function statusDot(status: AgentStatus): string { + if (status === "active") return "bg-accent-green"; + if (status === "paused") return "bg-amber-400"; + if (status === "error") return "bg-red-500"; + return "bg-muted"; +} diff --git a/03_implementation/ui/src/components/autopilot/FreezeThawControls.tsx b/03_implementation/ui/src/components/autopilot/FreezeThawControls.tsx new file mode 100644 index 00000000..6e2570f7 --- /dev/null +++ b/03_implementation/ui/src/components/autopilot/FreezeThawControls.tsx @@ -0,0 +1,145 @@ +/** + * FreezeThawControls — Wave 15 A14. + * + * Freeze / thaw the autopilot loop. The current Hermes3D backend exposes no + * `/api/autopilot/freeze` or `/api/autopilot/thaw` endpoint (verified + * 2026-05-10 against `src/hermes3d/api/routes/autopilot.py` — only + * /readiness, /next-gate, /write-plan, /write-report, /guardrails exist). + * + * Strict rules: + * - No fake "frozen" state that lies to the operator. We probe the + * endpoint and surface the real outcome. + * - When the endpoint is missing we render an explicit "blocked" notice + * so the operator knows the control is not wired yet (per W15 A20). + * - When the endpoint exists and accepts the call we record a proof event. + */ +import { useState } from "react"; +import { Snowflake, Sun } from "lucide-react"; +import { adapters } from "../../api/adapters"; + +type HermesImportMeta = ImportMeta & { + env: { + VITE_HERMES3D_BRIDGE_PORT?: string; + }; +}; + +const DEFAULT_BRIDGE_PORT = "8765"; +const LIVE_BRIDGE_PORT = (import.meta as HermesImportMeta).env.VITE_HERMES3D_BRIDGE_PORT ?? DEFAULT_BRIDGE_PORT; +const LIVE_BASE_URL = `http://127.0.0.1:${LIVE_BRIDGE_PORT}`; + +type Mode = "frozen" | "thawed" | "unknown"; + +export function FreezeThawControls() { + const [mode, setMode] = useState("unknown"); + const [message, setMessage] = useState(null); + const [pending, setPending] = useState(false); + + const trigger = async (path: "freeze" | "thaw") => { + setPending(true); + let accepted = false; + let summary = "no response"; + try { + const response = await fetch(`${LIVE_BASE_URL}/api/autopilot/${path}`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ source: "autopilot.freeze_thaw_controls" }), + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + if (response.status === 404 || response.status === 405) { + summary = `Blocked: /api/autopilot/${path} is not implemented on this backend (W15 A20 owns the endpoint).`; + } else if (!response.ok) { + summary = `Blocked: backend rejected ${path} (${response.status} ${response.statusText}).`; + } else { + accepted = true; + summary = payloadSummary(payload, path); + setMode(path === "freeze" ? "frozen" : "thawed"); + } + } catch (error) { + summary = `Blocked: backend unreachable at ${LIVE_BASE_URL} (${describeError(error)}).`; + } finally { + setPending(false); + } + setMessage(summary); + await adapters + .emitProofEvent(`autopilot.${path}.requested`, { accepted, summary }) + .catch(() => {/* best effort */}); + }; + + return ( +
+
+
+

FREEZE / THAW

+

+ Stop or resume the autopilot loop. Calls /api/autopilot/freeze + /thaw. +

+
+ + {mode} + +
+
+ + +
+ {message && ( +
+ {message} +
+ )} +
+ ); +} + +function modeTone(mode: Mode): string { + if (mode === "frozen") return "bg-cyan-900/40 text-cyan-200"; + if (mode === "thawed") return "bg-green-900/40 text-green-200"; + return "bg-surface2 text-muted"; +} + +function payloadSummary(payload: unknown, fallback: string): string { + if (typeof payload === "object" && payload !== null && !Array.isArray(payload)) { + const record = payload as Record; + if (typeof record.message === "string") return record.message; + if (typeof record.status === "string") return record.status; + if (typeof record.proof_event_id === "string") return `proof ${record.proof_event_id}`; + } + return `${fallback} accepted`; +} + +function describeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return "unknown"; +} diff --git a/03_implementation/ui/src/components/autopilot/PlannerQueue.tsx b/03_implementation/ui/src/components/autopilot/PlannerQueue.tsx new file mode 100644 index 00000000..e541e84e --- /dev/null +++ b/03_implementation/ui/src/components/autopilot/PlannerQueue.tsx @@ -0,0 +1,125 @@ +/** + * PlannerQueue — Wave 15 A14. + * + * Display widget for the autopilot planner queue. Reads `Workflow[]` from + * `adapters.getActiveWorkflows()` (which hits `/api/workflows/active`) and + * surfaces a compact, honest view of in-flight planner work. + * + * Strict rules: + * - No fake fallback data. If the API returns `[]` we render an empty + * state. If the API throws we surface an "unreachable" notice. + * - No write actions here — this component is read-only. + */ +import { useEffect, useState } from "react"; +import { adapters } from "../../api/adapters"; +import type { Workflow } from "../../types/workflow"; + +type LoadState = "loading" | "ready" | "error"; + +export interface PlannerQueueProps { + /** Optional caller-supplied workflows (e.g. for tests). When omitted the component fetches itself. */ + workflows?: Workflow[]; +} + +export function PlannerQueue({ workflows: propWorkflows }: PlannerQueueProps = {}) { + const [workflows, setWorkflows] = useState(propWorkflows ?? []); + const [state, setState] = useState(propWorkflows ? "ready" : "loading"); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (propWorkflows) { + setWorkflows(propWorkflows); + setState("ready"); + return; + } + let mounted = true; + const load = () => { + setState((prev) => (prev === "error" ? "loading" : prev)); + adapters + .getActiveWorkflows() + .then((next) => { + if (!mounted) { + return; + } + setWorkflows(next); + setState("ready"); + setErrorMsg(null); + }) + .catch((error) => { + if (!mounted) { + return; + } + setState("error"); + setErrorMsg(error instanceof Error ? error.message : "workflows API unreachable"); + }); + }; + load(); + const timer = window.setInterval(load, 15_000); + return () => { + mounted = false; + window.clearInterval(timer); + }; + }, [propWorkflows]); + + return ( +
+
+
+

PLANNER QUEUE

+

Active workflows reported by the live backend.

+
+ + {state === "loading" ? "loading…" : `${workflows.length} active`} + +
+
+ {state === "error" && ( +
+ Blocked: planner queue endpoint unreachable. {errorMsg ?? ""} +
+ )} + {state === "ready" && workflows.length === 0 && ( +
+ No active workflows returned by the live workflows API. +
+ )} + {workflows.map((workflow) => ( +
+
+
{workflow.name}
+
+ stage {workflow.active_stage + 1}/{workflow.stages.length} · started{" "} + {workflow.started_utc ? new Date(workflow.started_utc).toLocaleTimeString() : "—"} +
+
+ + {workflow.status} + +
+ ))} +
+
+ ); +} + +function workflowTone(status: Workflow["status"]): string { + if (status === "active") { + return "bg-cyan-900/40 text-cyan-200"; + } + if (status === "completed") { + return "bg-green-900/40 text-green-200"; + } + if (status === "failed") { + return "bg-red-900/40 text-red-200"; + } + return "bg-surface2 text-muted"; +} diff --git a/03_implementation/ui/src/tabs/Autopilot.tsx b/03_implementation/ui/src/tabs/Autopilot.tsx index a93f5ee0..a133fe85 100644 --- a/03_implementation/ui/src/tabs/Autopilot.tsx +++ b/03_implementation/ui/src/tabs/Autopilot.tsx @@ -2,6 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import { adapters } from "../api/adapters"; import { TABS } from "../app/routes"; import { useStore } from "../app/store"; +import { AgentActivityFeed } from "../components/autopilot/AgentActivityFeed"; +import { FreezeThawControls } from "../components/autopilot/FreezeThawControls"; +import { PlannerQueue } from "../components/autopilot/PlannerQueue"; import type { AutopilotCheck, GuardrailPolicy } from "../types/autopilot"; type HermesImportMeta = ImportMeta & { @@ -76,7 +79,8 @@ export function AutopilotTab() { }; return ( -
+
+

READINESS CHECKS

@@ -134,6 +138,12 @@ export function AutopilotTab() { ))}
+
+
+ + + +
); } diff --git a/03_implementation/ui/tests/screenshots/w15-a14-capture.mjs b/03_implementation/ui/tests/screenshots/w15-a14-capture.mjs new file mode 100644 index 00000000..4c4e2151 --- /dev/null +++ b/03_implementation/ui/tests/screenshots/w15-a14-capture.mjs @@ -0,0 +1,66 @@ +// Wave 15 A14 — Primary Tabs A screenshot harness. +// +// Captures 4 PNGs (Autopilot, Design, Gen3D, Jobs) at 1536x1024 against +// the local vite preview at http://127.0.0.1:4173. Saves to +// proof/screenshots/w15-a14/.png in the repo root. +// +// Run manually: +// node tests/screenshots/w15-a14-capture.mjs +// +// This script is referenced from the PR body; it is NOT wired into CI. +import { chromium } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, "../../../.."); +const OUT_DIR = path.join(REPO_ROOT, "03_implementation/proof/screenshots/w15-a14"); +fs.mkdirSync(OUT_DIR, { recursive: true }); + +const TABS = [ + { id: "autopilot", hash: "#autopilot" }, + { id: "design", hash: "#design" }, + { id: "gen3d", hash: "#gen3d" }, + { id: "jobs", hash: "#jobs" }, +]; + +const BASE = process.env.W15_PREVIEW_URL || "http://127.0.0.1:4173"; + +const browser = await chromium.launch(); +try { + const context = await browser.newContext({ viewport: { width: 1536, height: 1024 } }); + const page = await context.newPage(); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error(`[console.error] ${msg.text()}`); + } + }); + for (const tab of TABS) { + const target = `${BASE}/${tab.hash}`; + console.log(`-> ${target}`); + await page.goto(target, { waitUntil: "networkidle", timeout: 30_000 }); + // Allow react state + intervals to settle. + await page.waitForTimeout(1500); + const outPath = path.join(OUT_DIR, `${tab.id}.png`); + await page.screenshot({ path: outPath, fullPage: false }); + console.log(` wrote ${outPath}`); + if (tab.id === "autopilot") { + const fullOut = path.join(OUT_DIR, `${tab.id}-fullpage.png`); + await page.screenshot({ path: fullOut, fullPage: true }); + console.log(` wrote ${fullOut}`); + // Also capture a taller viewport so freeze/thaw fits in one frame. + const tallContext = await browser.newContext({ viewport: { width: 1536, height: 1400 } }); + const tallPage = await tallContext.newPage(); + await tallPage.goto(target, { waitUntil: "networkidle", timeout: 30_000 }); + await tallPage.waitForTimeout(1500); + const tallOut = path.join(OUT_DIR, `${tab.id}-1536x1400.png`); + await tallPage.screenshot({ path: tallOut, fullPage: false }); + console.log(` wrote ${tallOut}`); + await tallContext.close(); + } + } + await context.close(); +} finally { + await browser.close(); +} diff --git a/03_implementation/ui/tests/screenshots/w15-a14-verify-freeze-thaw.mjs b/03_implementation/ui/tests/screenshots/w15-a14-verify-freeze-thaw.mjs new file mode 100644 index 00000000..b54e85f1 --- /dev/null +++ b/03_implementation/ui/tests/screenshots/w15-a14-verify-freeze-thaw.mjs @@ -0,0 +1,27 @@ +// Wave 15 A14 — confirm freeze/thaw element is present in autopilot DOM. +import { chromium } from "@playwright/test"; +const BASE = process.env.W15_PREVIEW_URL || "http://127.0.0.1:4173"; +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } }); + page.on("console", (msg) => { if (msg.type() === "error") console.error(`[err] ${msg.text()}`); }); + await page.goto(`${BASE}/#autopilot`, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(1500); + const present = { + planner: await page.locator('[data-testid="autopilot-planner-queue"]').count(), + agents: await page.locator('[data-testid="autopilot-agent-activity"]').count(), + freezeThaw: await page.locator('[data-testid="autopilot-freeze-thaw"]').count(), + freezeBtn: await page.locator('[data-testid="autopilot-freeze-button"]').count(), + thawBtn: await page.locator('[data-testid="autopilot-thaw-button"]').count(), + }; + console.log("DOM presence:", JSON.stringify(present, null, 2)); + // Scroll the freeze/thaw into view and capture. + const el = page.locator('[data-testid="autopilot-freeze-thaw"]').first(); + await el.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {}); + await page.screenshot({ path: "G:/Github/Hermes3D/03_implementation/proof/screenshots/w15-a14/autopilot-freeze-thaw-detail.png", clip: await el.boundingBox().then((b) => ({ x: Math.max(b.x - 12, 0), y: Math.max(b.y - 12, 0), width: Math.min(b.width + 24, 1536), height: Math.min(b.height + 24, 1024) })) }).catch(async (e) => { + console.error("bbox screenshot failed:", e.message); + await page.screenshot({ path: "G:/Github/Hermes3D/03_implementation/proof/screenshots/w15-a14/autopilot-after-scroll.png" }); + }); +} finally { + await browser.close(); +}