From dee59d997d39e46221914bcdab3cdfa829f4f300 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:50:56 -0300 Subject: [PATCH 1/6] feat(web): Serve realtime runs canvas in the browser Add a localhost-only codedeck web command that serves a self-contained canvas page: session nodes grouped by run with the orchestrator at the center, live status, and per-session event streams translated into motion (particles, pulses, activity feed). Includes port parsing, SSE framing, and route helpers with unit tests. --- src/cli/commands/web.ts | 249 ++++++++++++++++++ src/cli/index.ts | 3 + src/web/canvas-page.ts | 538 ++++++++++++++++++++++++++++++++++++++ tests/web-command.test.ts | 212 +++++++++++++++ 4 files changed, 1002 insertions(+) create mode 100644 src/cli/commands/web.ts create mode 100644 src/web/canvas-page.ts create mode 100644 tests/web-command.test.ts diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts new file mode 100644 index 0000000..6cf075d --- /dev/null +++ b/src/cli/commands/web.ts @@ -0,0 +1,249 @@ +import http from "node:http"; +import { spawn } from "node:child_process"; +import { InvalidArgumentError, type Command } from "commander"; +import { IpcClient } from "../../daemon/ipc.js"; +import { CANVAS_PAGE } from "../../web/canvas-page.js"; + +export const DEFAULT_WEB_PORT = 3100; + +export function parseWebPort(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_WEB_PORT; + if (!/^\d+$/.test(raw)) { + throw new InvalidArgumentError("--port must be a positive integer"); + } + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new InvalidArgumentError("--port must be a positive integer"); + } + return port; +} + +export type SessionSubpathAction = "stream" | "logs" | "get"; + +export interface SessionSubpath { + action: SessionSubpathAction; + id: string; +} + +/** + * Pure route split for /api/sessions/:id[/stream|/logs]. More specific routes + * must be tried before this one so "stream" never lands in :id handling. + */ +export function parseSessionSubpath(parts: string[]): SessionSubpath | null { + if (parts.length < 3 || parts[0] !== "api" || parts[1] !== "sessions" || !parts[2]) { + return null; + } + const id = decodeURIComponent(parts[2]); + if (parts.length === 3) return { action: "get", id }; + if (parts.length === 4 && parts[3] === "stream") return { action: "stream", id }; + if (parts.length === 4 && parts[3] === "logs") return { action: "logs", id }; + return null; +} + +export function sseData(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export function sseNamed(name: string, data: unknown): string { + return `event: ${name}\n${sseData(data)}`; +} + +export function sseComment(text = "conectado"): string { + return `: ${text}\n\n`; +} + +export function openBrowser(url: string): boolean { + const opener = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + const child = spawn(opener, args, { detached: true, stdio: "ignore" }); + child.unref(); + return true; + } catch { + return false; + } +} + +export interface WebBridge { + request(method: string, params: unknown): Promise; + subscribe( + sessionId: string, + onEvent: (event: { type?: string } & Record) => void, + onDone?: () => void, + onError?: (error: Error) => void, + ): () => void; +} + +function sendJson(res: http.ServerResponse, code: number, value: unknown): void { + res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(value)); +} + +export function createWebHandler(bridge: WebBridge): http.RequestListener { + return (req, res) => { + void (async () => { + try { + const url = new URL(req.url || "/", "http://x"); + const parts = url.pathname.split("/").filter(Boolean); + + if (req.method === "GET" && url.pathname === "/") { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(CANVAS_PAGE); + return; + } + if (req.method === "GET" && url.pathname === "/api/health") { + sendJson(res, 200, { ok: true }); + return; + } + if (req.method === "GET" && url.pathname === "/api/status") { + sendJson(res, 200, await bridge.request("daemon.status", {})); + return; + } + if (req.method === "GET" && url.pathname === "/api/sessions") { + const all = url.searchParams.get("all") === "1"; + sendJson(res, 200, await bridge.request("session.list", { all })); + return; + } + if (req.method === "GET" && url.pathname === "/api/usage") { + const period = url.searchParams.get("period") || "today"; + sendJson(res, 200, await bridge.request("usage.query", { period })); + return; + } + + const sub = req.method === "GET" ? parseSessionSubpath(parts) : null; + if (sub?.action === "stream") { + // Pre-flight before the 200: otherwise the browser holds an + // EventSource open on pings with no visible error when the daemon + // is down or the session does not exist. + try { + await bridge.request("session.get", { id: sub.id }); + } catch { + sendJson(res, 404, { error: "session not found or daemon unavailable" }); + return; + } + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(sseComment()); + const keepalive = setInterval(() => { + try { + res.write(sseComment("ping")); + } catch { + // Client is gone; req close below owns the cleanup. + } + }, 25000); + const off = bridge.subscribe( + sub.id, + (event) => { + try { + res.write(sseData(event)); + } catch { + // Client is gone; req close below owns the cleanup. + } + }, + () => { + try { + res.write(sseNamed("done", {})); + } catch { + // Client is gone; req close below owns the cleanup. + } + }, + () => { + try { + res.write(sseNamed("error", { error: "daemon disconnected" })); + } catch { + // Client is gone; req close below owns the cleanup. + } + }, + ); + req.on("close", () => { + clearInterval(keepalive); + off(); + }); + return; + } + if (sub?.action === "logs") { + sendJson(res, 200, await bridge.request("session.logs", { id: sub.id })); + return; + } + if (sub?.action === "get") { + sendJson(res, 200, await bridge.request("session.get", { id: sub.id })); + return; + } + sendJson(res, 404, { error: "not found" }); + } catch (error) { + // The pre-flight and stream branches answer above, so reaching here + // means no bytes were written yet and a JSON error is still safe. + try { + sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }); + } catch { + try { + res.end(); + } catch { + // Last resort: the socket is already unusable. + } + } + } + })(); + }; +} + +export interface WebCommandOptions { + port?: string; + open?: boolean; +} + +export function registerWebCommand(program: Command): void { + program + .command("web") + .description("Serve the realtime runs canvas in the browser (localhost only, no auth)") + .option("--port ", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) + .option("--open", "open the canvas in the default browser") + .action(async (opts: WebCommandOptions) => { + let port: number; + try { + port = parseWebPort(opts.port); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + throw error; + } + + const client = new IpcClient(); + try { + await client.ensureDaemonStarted(); + } catch { + // The bridge answers with JSON errors when the daemon is down, so a + // cold start failing here must not take the page down with it. + } + + const server = http.createServer(createWebHandler(client)); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }).catch((error: unknown) => { + console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + + const url = `http://127.0.0.1:${port}/`; + console.log(`CodeDeck web on ${url}`); + if (opts.open && !openBrowser(url)) { + console.log(`Could not open a browser, visit ${url} manually.`); + } + + const shutdown = () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1000).unref?.(); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 7de7726..63bbd2d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,7 @@ import { registerModelsCommand } from "./commands/models.js"; import { registerOpenCommand } from "./commands/open.js"; import { registerSetupCommand } from "./commands/setup.js"; import { registerUsageCommand } from "./commands/usage.js"; +import { registerWebCommand } from "./commands/web.js"; import { getCliInvocation, getCliName } from "./cli-name.js"; function getVersion(): string { @@ -69,6 +70,7 @@ Recommended flow: $ ${cli} run "task" --bg --json # starts in background $ ${cli} wait # waits without ps/show loop $ ${cli} logs --follow # inspect progress + $ ${cli} web --open # realtime runs canvas in the browser Run '${cli} --help' for command-specific options. Docs: https://github.com/4ndreello/run-agent @@ -89,6 +91,7 @@ registerModelsCommand(program); registerOpenCommand(program); registerSetupCommand(program); registerUsageCommand(program); +registerWebCommand(program); // Make `codedeck help` behave like `codedeck --help` program.command("help", { hidden: true }).action(() => program.outputHelp()); diff --git a/src/web/canvas-page.ts b/src/web/canvas-page.ts new file mode 100644 index 0000000..9dd59bb --- /dev/null +++ b/src/web/canvas-page.ts @@ -0,0 +1,538 @@ +/** Realtime runs canvas served by `codedeck web`. + * + * Single self-contained page (no external requests): the script polls + * `api/sessions` and opens one `EventSource` per active session. + * Promoted from spikes/web-top.mjs; keep the spike behavior in sync + * only by re-running the Playwright check, never by importing it. + */ +export const CANVAS_PAGE: string = ` + + + + +CodeDeck canvas realtime + + + + + +
+

Canvas das runs

conectando...

+
+ + +
+
TrabalhandoEsperando vocêEm pausaConcluída
+

AGORA MESMO

    +
    arraste para organizar · scroll dá zoom
    + + +`; diff --git a/tests/web-command.test.ts b/tests/web-command.test.ts new file mode 100644 index 0000000..621c317 --- /dev/null +++ b/tests/web-command.test.ts @@ -0,0 +1,212 @@ +import http from "node:http"; +import { Command } from "commander"; +import { describe, expect, it, afterEach, vi } from "vitest"; +import { CANVAS_PAGE } from "../src/web/canvas-page.js"; +import { + createWebHandler, + parseSessionSubpath, + parseWebPort, + registerWebCommand, + sseComment, + sseData, + sseNamed, + type WebBridge, +} from "../src/cli/commands/web.js"; + +function bridge(overrides: Partial = {}): WebBridge & { calls: string[] } { + const calls: string[] = []; + const base: WebBridge & { calls: string[] } = { + calls, + request: async (method: string) => { + calls.push(method); + if (method === "session.list") return { sessions: [] }; + if (method === "session.get") return { session: { id: "a1" } }; + if (method === "session.logs") return { events: [] }; + if (method === "daemon.status") return { ok: true }; + if (method === "usage.query") return { totals: {} }; + throw new Error(`unexpected method ${method}`); + }, + subscribe: (_id, _onEvent, _onDone) => () => {}, + ...overrides, + }; + return base; +} + +let servers: http.Server[] = []; +afterEach(async () => { + await Promise.all( + servers.map((s) => new Promise((resolve) => s.close(() => resolve()))), + ); + servers = []; +}); + +async function listen(handler: http.RequestListener): Promise { + const server = http.createServer(handler); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no address"); + return `http://127.0.0.1:${address.port}`; +} + +describe("parseWebPort", () => { + it("defaults to 3100", () => { + expect(parseWebPort(undefined)).toBe(3100); + }); + + it("accepts a valid port", () => { + expect(parseWebPort("8080")).toBe(8080); + }); + + it("rejects non-numeric, zero, and out-of-range ports", () => { + for (const raw of ["abc", "0", "-1", "65536", "3.5", ""]) { + expect(() => parseWebPort(raw)).toThrow("--port must be a positive integer"); + } + }); +}); + +describe("parseSessionSubpath", () => { + it("matches get, stream, and logs", () => { + expect(parseSessionSubpath(["api", "sessions", "a1"])).toEqual({ action: "get", id: "a1" }); + expect(parseSessionSubpath(["api", "sessions", "a1", "stream"])).toEqual({ + action: "stream", + id: "a1", + }); + expect(parseSessionSubpath(["api", "sessions", "a1", "logs"])).toEqual({ + action: "logs", + id: "a1", + }); + }); + + it("decodes the session id", () => { + expect(parseSessionSubpath(["api", "sessions", "a%201", "logs"])).toEqual({ + action: "logs", + id: "a 1", + }); + }); + + it("rejects anything else", () => { + expect(parseSessionSubpath(["api", "sessions"])).toBeNull(); + expect(parseSessionSubpath(["api", "sessions", "", "stream"])).toBeNull(); + expect(parseSessionSubpath(["api", "sessions", "a1", "send"])).toBeNull(); + expect(parseSessionSubpath(["api", "sessions", "a1", "stream", "x"])).toBeNull(); + expect(parseSessionSubpath(["api", "other", "a1"])).toBeNull(); + }); +}); + +describe("sse framing", () => { + it("frames data, named events, and comments", () => { + expect(sseData({ a: 1 })).toBe('data: {"a":1}\n\n'); + expect(sseNamed("done", {})).toBe("event: done\ndata: {}\n\n"); + expect(sseComment()).toBe(": conectado\n\n"); + }); +}); + +describe("web handler", () => { + it("serves the canvas page at /", async () => { + const base = await listen(createWebHandler(bridge())); + const res = await fetch(`${base}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("Canvas das runs"); + }); + + it("answers health and unknown routes", async () => { + const base = await listen(createWebHandler(bridge())); + const health = await fetch(`${base}/api/health`); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true }); + + const missing = await fetch(`${base}/nope`); + expect(missing.status).toBe(404); + }); + + it("proxies session list and get", async () => { + const base = await listen(createWebHandler(bridge())); + const list = await fetch(`${base}/api/sessions`); + expect(list.status).toBe(200); + expect(await list.json()).toEqual({ sessions: [] }); + + const get = await fetch(`${base}/api/sessions/a1`); + expect(get.status).toBe(200); + }); + + it("returns 404 on the stream pre-flight when the session is unknown", async () => { + const b = bridge(); + const inner = b.request; + b.request = async (method: string, params: unknown) => { + b.calls.push(method); + if (method === "session.get") throw new Error("gone"); + return inner(method, params); + }; + const base = await listen(createWebHandler(b)); + const res = await fetch(`${base}/api/sessions/zz/stream`); + expect(res.status).toBe(404); + expect(b.calls).toContain("session.get"); + expect(b.calls).not.toContain("session.subscribe"); + }); + + it("streams one event then done, and unsubscribes on disconnect", async () => { + let offCalled = false; + const b = bridge({ + subscribe: (id, onEvent, onDone) => { + expect(id).toBe("a1"); + const timer = setTimeout(() => { + onEvent({ type: "text.delta", delta: "hi" }); + onDone?.(); + }, 10); + return () => { + offCalled = true; + clearTimeout(timer); + }; + }, + }); + const base = await listen(createWebHandler(b)); + const res = await fetch(`${base}/api/sessions/a1/stream`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (let i = 0; i < 4; i++) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + if (text.includes("event: done")) break; + } + await reader.cancel(); + expect(text).toContain(": conectado"); + expect(text).toContain('"text.delta"'); + expect(text).toContain("event: done"); + // Give the server close handler a tick to run the unsubscribe. + await vi.waitFor(() => expect(offCalled).toBe(true)); + }); +}); + +describe("canvas page", () => { + it("wires the realtime endpoints", () => { + expect(CANVAS_PAGE).toContain("api/sessions"); + expect(CANVAS_PAGE).toContain("new EventSource"); + expect(CANVAS_PAGE).toContain("text.delta"); + }); + + it("makes no external requests", () => { + const externals = CANVAS_PAGE.match(/https?:\/\/[^"'\s>]+/g) ?? []; + expect(externals).toEqual([]); + }); + + it("keeps the template literal intact (no backticks or interpolation)", () => { + expect(CANVAS_PAGE).not.toContain("`"); + expect(CANVAS_PAGE).not.toContain("${"); + }); +}); + +describe("registerWebCommand", () => { + it("registers the web command", () => { + const program = new Command(); + registerWebCommand(program); + expect(program.commands.map((c) => c.name())).toContain("web"); + }); +}); From 5ea995a98df66b3ddf50999da27826bce1ad2529 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:53:14 -0300 Subject: [PATCH 2/6] fix(web): Use loopback base URL for request parsing A non-loopback dummy base trips the S5332 cleartext-protocol rule even though only the path and query are read. 127.0.0.1 keeps the parse identical and stays inside the rule's loopback exception. --- src/cli/commands/web.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index 6cf075d..7f0f6f4 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -88,7 +88,10 @@ export function createWebHandler(bridge: WebBridge): http.RequestListener { return (req, res) => { void (async () => { try { - const url = new URL(req.url || "/", "http://x"); + // Loopback base: only the path and query of the incoming request are + // read, nothing is ever fetched. (A non-loopback dummy base trips + // the S5332 cleartext-protocol rule.) + const url = new URL(req.url || "/", "http://127.0.0.1"); const parts = url.pathname.split("/").filter(Boolean); if (req.method === "GET" && url.pathname === "/") { From 7bc1c433510fe3c3d2c0b4ab8e3ad4573754bb29 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:57:06 -0300 Subject: [PATCH 3/6] fix(web): Repair canvas controls and add hide-completed toggle The motion button toggled the class on the inner SVG instead of the button, so its pressed state never followed the click. Recenter forced zoom 1, which lands in the void between runs on a spread fleet; it now refits the most active run like the initial framing. Also adds a hide toggle for finished sessions with a hidden count in the summary. --- src/web/canvas-page.ts | 74 ++++++++++++++++++++++++++------------- tests/web-command.test.ts | 8 +++++ 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/web/canvas-page.ts b/src/web/canvas-page.ts index 9dd59bb..ba150d3 100644 --- a/src/web/canvas-page.ts +++ b/src/web/canvas-page.ts @@ -72,7 +72,8 @@ export const CANVAS_PAGE: string = `

    Canvas das runs

    conectando...

    - + +
    TrabalhandoEsperando vocêEm pausaConcluída

    AGORA MESMO

      @@ -238,10 +239,11 @@ function feed(agent, text) { for (var i = 0; i < items.length; i++) items[i].classList.toggle("fresh", i === 0); } function paintSummary() { - var working = 0, waiting = 0; + var working = 0, waiting = 0, hidden = 0; nodes.forEach(function (n) { if (n.status === "working" || n.status === "starting") working++; if (n.status === "needs_input") waiting++; + if (hideDone && isDone(n.status)) hidden++; }); var el = document.getElementById("summary"); el.innerHTML = ""; @@ -249,6 +251,7 @@ function paintSummary() { var b1 = document.createElement("b"); b1.textContent = working + " trabalhando"; el.appendChild(b1); el.appendChild(document.createTextNode(" · ")); var b2 = document.createElement("b"); b2.textContent = waiting + " esperando você"; el.appendChild(b2); + if (hidden > 0) el.appendChild(document.createTextNode(" · " + hidden + " ocultas")); } /* Reconcilia o poll com os nos: preserva posicao arrastada, atualiza status. */ @@ -325,24 +328,31 @@ function reconcile(sessions) { window.__fitted = true; // Enquadra o run mais ativo, nao a frota inteira: 100 nos fixos nunca // cabem legiveis numa viewport. O resto fica ao redor para o pan. - var activeByRun = {}; - nodes.forEach(function (n) { - if (isActive(n.status)) activeByRun[n.runId] = (activeByRun[n.runId] || 0) + 1; - }); - var bestRun = null, bestCount = 0; - for (var rk in activeByRun) { - if (activeByRun[rk] > bestCount) { bestCount = activeByRun[rk]; bestRun = rk; } - } - var focus = bestRun ? nodes.filter(function (n) { return n.runId === bestRun; }) : nodes; - var xs = focus.map(function (n) { return n.x; }); - var ys = focus.map(function (n) { return n.y; }); - var bw = Math.max.apply(null, xs) - Math.min.apply(null, xs) + 700; - var bh = Math.max.apply(null, ys) - Math.min.apply(null, ys) + 500; - cam.x = (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2; - cam.y = (Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2; - cam.zoom = Math.min(1, Math.max(0.4, Math.min(window.innerWidth / bw, window.innerHeight / bh))); + fitCamera(bestRunFocus()); } } +function bestRunFocus() { + var activeByRun = {}; + nodes.forEach(function (n) { + if (isActive(n.status)) activeByRun[n.runId] = (activeByRun[n.runId] || 0) + 1; + }); + var bestRun = null, bestCount = 0; + for (var rk in activeByRun) { + if (activeByRun[rk] > bestCount) { bestCount = activeByRun[rk]; bestRun = rk; } + } + return bestRun ? nodes.filter(function (n) { return n.runId === bestRun; }) : nodes; +} +function fitCamera(list) { + if (!list.length) return; + var xs = list.map(function (n) { return n.x; }); + var ys = list.map(function (n) { return n.y; }); + var bw = Math.max.apply(null, xs) - Math.min.apply(null, xs) + 700; + var bh = Math.max.apply(null, ys) - Math.min.apply(null, ys) + 500; + cam.x = (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2; + cam.y = (Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2; + cam.zoom = Math.min(1, Math.max(0.4, Math.min(window.innerWidth / bw, window.innerHeight / bh))); +} +function isDone(s) { return s === "completed" || s === "failed" || s === "stopped"; } function isActive(s) { return s === "working" || s === "starting" || s === "needs_input"; } function ensureStreams() { var open = 0; @@ -424,6 +434,7 @@ function frame() { drawGrid(); nodes.forEach(function (n) { if (n.isOrch || n.runId === "solo" || n.runId.indexOf("solo:") === 0) return; + if (hideDone && isDone(n.status)) return; var o = null; for (var i = 0; i < nodes.length; i++) { if (nodes[i].runId === n.runId && nodes[i].isOrch) { o = nodes[i]; break; } @@ -440,6 +451,10 @@ function frame() { }); for (var i = particles.length - 1; i >= 0; i--) { var p = particles[i]; + if (hideDone && (isDone(p.from.status) || isDone(p.to.status))) { + particles.splice(i, 1); + continue; + } p.t += motion ? p.speed : 0; if (p.t > 1.1) { particles.splice(i, 1); continue; } if (p.t < 0) continue; @@ -469,6 +484,9 @@ function frame() { ctx.fill(); } nodes.forEach(function (n) { + var hidden = hideDone && isDone(n.status); + n.el.style.display = hidden ? "none" : ""; + if (hidden) return; var p = w2s(n.x, n.y); var w = n.isOrch ? 228 : 196; // O no escala junto com o zoom: sem isso o DOM fica gigante no mundo @@ -512,15 +530,21 @@ window.addEventListener("wheel", function (ev) { }, { passive: false }); document.getElementById("btnMotion").onclick = function (ev) { motion = !motion; - ev.target.classList.toggle("on", motion); + // currentTarget, nunca target: o clique cai no SVG interno e a classe + // precisa alternar no botao para o estado visual acompanhar. + ev.currentTarget.classList.toggle("on", motion); }; document.getElementById("btnReset").onclick = function () { - if (!nodes.length) return; - var xs = nodes.map(function (n) { return n.x; }); - var ys = nodes.map(function (n) { return n.y; }); - cam.x = (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2; - cam.y = (Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2; - cam.zoom = 1; + // Volta para a acao (mesmo enquadramento da abertura), nunca um zoom + // cego: com a frota espalhada, zoom 1 ou fit-geral cai no vazio entre runs. + fitCamera(bestRunFocus()); +}; +var hideDone = false; +document.getElementById("btnHide").onclick = function (ev) { + hideDone = !hideDone; + ev.currentTarget.classList.toggle("on", hideDone); + ev.currentTarget.title = hideDone ? "Mostrar concluídas" : "Ocultar concluídas"; + paintSummary(); }; function poll() { diff --git a/tests/web-command.test.ts b/tests/web-command.test.ts index 621c317..af13456 100644 --- a/tests/web-command.test.ts +++ b/tests/web-command.test.ts @@ -192,6 +192,14 @@ describe("canvas page", () => { expect(CANVAS_PAGE).toContain("text.delta"); }); + it("has the canvas controls", () => { + expect(CANVAS_PAGE).toContain("btnMotion"); + expect(CANVAS_PAGE).toContain("btnReset"); + expect(CANVAS_PAGE).toContain("btnHide"); + expect(CANVAS_PAGE).toContain("fitCamera"); + expect(CANVAS_PAGE).toContain("currentTarget"); + }); + it("makes no external requests", () => { const externals = CANVAS_PAGE.match(/https?:\/\/[^"'\s>]+/g) ?? []; expect(externals).toEqual([]); From 5deb13676f4aad57d2754a0279ba47881d665e6e Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:16:38 -0300 Subject: [PATCH 4/6] feat(web): Agent detail drawer, URL filters, edge markers, tighter layout Clicking a node opens a drawer with model, repo, pid, event counts and a humanized history tail fetched from session.get. Hide/motion state lives in the query string so F5 keeps the filters. Offscreen active sessions get clickable edge pills that jump the camera to them. Ring spacing tightened (verified numerically) and tool activity shows a snippet of the real input instead of a generic label. --- src/web/canvas-page.ts | 281 ++++++++++++++++++++++++++++++++++++-- tests/web-command.test.ts | 5 + 2 files changed, 273 insertions(+), 13 deletions(-) diff --git a/src/web/canvas-page.ts b/src/web/canvas-page.ts index ba150d3..8355286 100644 --- a/src/web/canvas-page.ts +++ b/src/web/canvas-page.ts @@ -18,6 +18,9 @@ export const CANVAS_PAGE: string = ` #nodes { position: fixed; inset: 0; overflow: hidden; pointer-events: none; } .hud { position: fixed; z-index: 10; } #title, #feed, #legend, #hint { background: transparent; border: none; text-shadow: 0 1px 10px rgba(0,0,0,.8); } + /* Sem reacao a mouse: sao texto flutuante, e os cliques precisam varar + ate o canvas (pills de borda, pan e nos embaixo deles). */ + #title, #feed, #legend, #hint { pointer-events: none; } #title { top: 16px; left: 16px; } #title h1 { font-size: 15px; margin: 0 0 2px; } #title h1 .live { display: inline-block; width: 8px; height: 8px; border-radius: 99px; background: #30d158; margin-right: 8px; animation: breathe 2s ease-in-out infinite; } @@ -47,6 +50,25 @@ export const CANVAS_PAGE: string = ` #feed li.fresh { color: #f5f5f7; } @keyframes feedIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; } } #hint { left: 50%; transform: translateX(-50%); bottom: 16px; font-size: 12px; color: #98989f; white-space: nowrap; } + #detail { position: fixed; top: 0; right: 0; bottom: 0; width: min(360px, 92vw); z-index: 20; + background: rgba(10, 12, 16, 0.92); border-left: 1px solid rgba(255,255,255,0.1); + backdrop-filter: blur(14px); padding: 18px; overflow-y: auto; display: none; } + #detail.open { display: block; } + #detail h2 { font-size: 15px; margin: 0 0 2px; display: flex; align-items: center; gap: 8px; } + #detail h2 .logo { width: 22px; height: 22px; display: inline-flex; } + #detail h2 .logo svg { width: 22px; height: 22px; fill: #f5f5f7; } + #detail .dsub { font-size: 12.5px; color: #98989f; margin-bottom: 12px; } + #detail dl { margin: 0 0 12px; display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; font-size: 12.5px; } + #detail dt { color: #98989f; } + #detail dd { margin: 0; word-break: break-word; } + #detail h3 { font-size: 11px; letter-spacing: 0.06em; color: #98989f; margin: 14px 0 8px; } + #detail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 12.5px; color: #c7c7cc; } + #detail ul li b { color: #f5f5f7; font-weight: 600; } + #detail .ts { color: #636366; font-size: 11.5px; } + #detailClose { position: absolute; top: 12px; right: 12px; width: 30px; height: 30px; border-radius: 99px; + border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #98989f; + cursor: pointer; font-size: 14px; line-height: 1; font-family: inherit; } + #detailClose:hover { color: #f5f5f7; border-color: rgba(255,255,255,0.25); } .node { position: absolute; width: 196px; background: #13161c; border: 1px solid rgba(255,255,255,.09); border-radius: 14px; padding: 11px 13px; pointer-events: auto; cursor: grab; user-select: none; box-shadow: 0 8px 28px rgba(0,0,0,.45); } @@ -77,7 +99,15 @@ export const CANVAS_PAGE: string = `
      TrabalhandoEsperando vocêEm pausaConcluída

      AGORA MESMO

        -
        arraste para organizar · scroll dá zoom
        +
        arraste para organizar · scroll dá zoom · clique num agente para o detalhe
        +