From 69fb65d16e85fd7793f4cbe20ed5c2e5c7293d6c Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:26:58 -0300 Subject: [PATCH 1/2] feat(web): Canvas chat, message send, and hide-completed default Detail drawer becomes a conversation view: the user's prompt (turn.started.prompt, attached by the daemon on the initial turn and on every send) renders as a user bubble, assistant message events as full answer bubbles, and tool/permission/file events as compact lines. The transcript reads GET /api/sessions/:id/logs (up to 1000 events) instead of the 10-event session.get slice, and autoscrolls to the latest entry. Sending is wired through a new POST /api/sessions/:id/send, proxying to the existing daemon session.send method (resume turn). The body is capped at 64 KiB (413), validated (400), and daemon error codes map to HTTP statuses: SESSION_NOT_FOUND 404, SESSION_BUSY 409, CAPABILITY_NOT_SUPPORTED 400. The send box locks itself while the session is working/starting and for origin=open interactive heads, with the reason spelled out next to the input. Hiding completed sessions is now the default: a clean URL is the filtered state and ?hide=0 opts back in (?hide=1 keeps working). --- src/cli/commands/web.ts | 76 +++++++++++++-- src/web/canvas-page.ts | 191 ++++++++++++++++++++++++++++---------- tests/web-command.test.ts | 104 ++++++++++++++++++++- 3 files changed, 311 insertions(+), 60 deletions(-) diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index 7f0f6f4..2df7ff8 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -18,7 +18,7 @@ export function parseWebPort(raw: string | undefined): number { return port; } -export type SessionSubpathAction = "stream" | "logs" | "get"; +export type SessionSubpathAction = "stream" | "logs" | "get" | "send"; export interface SessionSubpath { action: SessionSubpathAction; @@ -26,8 +26,10 @@ export interface SessionSubpath { } /** - * 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. + * Pure route split for /api/sessions/:id[/stream|/logs|/get|/send]. More + * specific routes must be tried before this one so "stream" never lands in + * :id handling. "send" is the only POST route; the method check lives at the + * callsite, so a GET to /send parses here and then falls through to 404. */ export function parseSessionSubpath(parts: string[]): SessionSubpath | null { if (parts.length < 3 || parts[0] !== "api" || parts[1] !== "sessions" || !parts[2]) { @@ -37,6 +39,7 @@ export function parseSessionSubpath(parts: string[]): SessionSubpath | null { 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 }; + if (parts.length === 4 && parts[3] === "send") return { action: "send", id }; return null; } @@ -52,6 +55,27 @@ export function sseComment(text = "conectado"): string { return `: ${text}\n\n`; } +// POST /api/sessions/:id/send body cap. A turn prompt larger than this is +// almost always a mistake; the drain (no req.destroy) keeps the socket clean. +export const MAX_SEND_BODY_BYTES = 64 * 1024; + +/** Validates the parsed /send body: returns the trimmed message or null. */ +export function parseSendBody(body: unknown): string | null { + if (typeof body !== "object" || body === null || !("message" in body)) return null; + const message: unknown = body.message; + if (typeof message !== "string") return null; + const trimmed = message.trim(); + return trimmed === "" ? null : trimmed; +} + +/** Daemon error code → HTTP status for the send proxy. */ +function sendErrorStatus(code: string | undefined): number { + if (code === "SESSION_NOT_FOUND") return 404; + if (code === "SESSION_BUSY") return 409; + if (code === "CAPABILITY_NOT_SUPPORTED") return 400; + return 502; +} + export function openBrowser(url: string): boolean { const opener = process.platform === "darwin" @@ -118,8 +142,46 @@ export function createWebHandler(bridge: WebBridge): http.RequestListener { return; } - const sub = req.method === "GET" ? parseSessionSubpath(parts) : null; - if (sub?.action === "stream") { + const sub = parseSessionSubpath(parts); + if (req.method === "POST" && sub?.action === "send") { + // Body cap: stop buffering past the limit but keep draining so the + // socket closes cleanly; a loopback client has nothing to gain. + const raw = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size <= MAX_SEND_BODY_BYTES) chunks.push(chunk); + }); + req.on("end", () => resolve(size > MAX_SEND_BODY_BYTES ? null : Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); + if (raw === null) { + sendJson(res, 413, { error: "message body too large" }); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + sendJson(res, 400, { error: "invalid JSON body" }); + return; + } + const message = parseSendBody(parsed); + if (message === null) { + sendJson(res, 400, { error: "message required" }); + return; + } + try { + const result = await bridge.request("session.send", { id: sub.id, message }); + sendJson(res, 200, result); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : undefined; + sendJson(res, sendErrorStatus(code), { error: error instanceof Error ? error.message : String(error) }); + } + return; + } + if (req.method === "GET" && 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. @@ -172,11 +234,11 @@ export function createWebHandler(bridge: WebBridge): http.RequestListener { }); return; } - if (sub?.action === "logs") { + if (req.method === "GET" && sub?.action === "logs") { sendJson(res, 200, await bridge.request("session.logs", { id: sub.id })); return; } - if (sub?.action === "get") { + if (req.method === "GET" && sub?.action === "get") { sendJson(res, 200, await bridge.request("session.get", { id: sub.id })); return; } diff --git a/src/web/canvas-page.ts b/src/web/canvas-page.ts index 24e63bd..8371d9b 100644 --- a/src/web/canvas-page.ts +++ b/src/web/canvas-page.ts @@ -69,6 +69,20 @@ export const CANVAS_PAGE: string = ` 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); } + #dChat { border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 10px; max-height: 34vh; overflow-y: auto; display: flex; flex-direction: column; gap: 9px; } + #dChat .msg { font-size: 12.5px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; padding: 7px 9px; border-radius: 9px; } + #dChat .msg .who { display: block; font-size: 10.5px; letter-spacing: 0.04em; color: #98989f; margin-bottom: 2px; } + #dChat .msg.user { background: rgba(10,132,255,0.16); } + #dChat .msg.assistant { background: rgba(255,255,255,0.05); } + #dChat .evt { font-size: 11.5px; color: #636366; } + #dChat .empty { color: #636366; font-size: 12px; } + #dSend { display: flex; gap: 8px; margin-top: 10px; } + #dSendText { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; color: #f5f5f7; padding: 8px 10px; font-size: 12.5px; font-family: inherit; } + #dSendText:focus { outline: none; border-color: rgba(255,255,255,0.3); } + #dSendText:disabled { opacity: 0.4; } + #dSendBtn { border: 1px solid rgba(255,255,255,0.14); background: rgba(10,132,255,0.25); color: #f5f5f7; border-radius: 10px; padding: 8px 12px; cursor: pointer; font-family: inherit; font-size: 12.5px; } + #dSendBtn:disabled { opacity: 0.4; cursor: default; } + #dSendMsg { font-size: 11.5px; color: #98989f; margin: 6px 0 0; min-height: 15px; } .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); } @@ -105,8 +119,13 @@ export const CANVAS_PAGE: string = `

-

HISTÓRICO

- +

CONVERSA

+
+
+ + +
+