From 4fdfbe58fd447ae377acaf52d663ed445530a576 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:39 -0300 Subject: [PATCH 1/2] feat(screens): drafts carry the bot's cursor script Each draft served at /view gets a small script first, after the doctype so the page keeps its layout mode. Once the page is read it finds where the content ends (the newest text, or the newest element), scrolls so that spot stays in view, and posts the point, the part around it and the view size it measured in to the app. A frame from another origin may read the page before it knows its size, so the script waits for the resize. The file on disk is served untouched. Spec 22.3, 22.5, 22.6 and T3; tested with the real Claude Code. --- crates/botloftd/src/screens/cursor.js | 86 +++++++++++++++++++++++++++ crates/botloftd/src/screens/cursor.rs | 66 ++++++++++++++++++++ crates/botloftd/src/screens/mod.rs | 1 + crates/botloftd/src/screens/view.rs | 7 ++- crates/botloftd/tests/screens.rs | 15 ++++- docs/spec.md | 9 ++- 6 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 crates/botloftd/src/screens/cursor.js create mode 100644 crates/botloftd/src/screens/cursor.rs diff --git a/crates/botloftd/src/screens/cursor.js b/crates/botloftd/src/screens/cursor.js new file mode 100644 index 00000000..c099f64b --- /dev/null +++ b/crates/botloftd/src/screens/cursor.js @@ -0,0 +1,86 @@ +// The bot's cursor on a screen it is writing (spec 22.3). The daemon puts +// this first in every draft it serves. Once the page is read, it finds where +// the content ends right now (the end of the newest text, or the newest +// element), keeps that spot in view and tells the app where it is and which +// part of the page holds it, in the page's pixels, with the size of the view +// they are measured in. It changes nothing else. +(() => { + const SKIP = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "BR", "WBR"]); + const shown = (rect) => rect !== undefined && (rect.width > 0 || rect.height > 0); + + const end = () => { + const body = document.body; + if (!body) { + return null; + } + const walk = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + const nodes = []; + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + nodes.push(node); + } + for (let at = nodes.length - 1; at >= 0; at -= 1) { + const node = nodes[at]; + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element.closest("script, style, template, noscript") || SKIP.has(element.tagName)) { + continue; + } + if (node.nodeType === Node.TEXT_NODE) { + if (!node.data.trim()) { + continue; + } + const range = document.createRange(); + range.selectNodeContents(node); + const lines = range.getClientRects(); + const last = lines[lines.length - 1]; + if (shown(last)) { + return { element, x: last.right, y: last.top + last.height / 2 }; + } + } else { + const box = element.getBoundingClientRect(); + if (shown(box)) { + return { element, x: box.left + box.width / 2, y: box.top + box.height / 2 }; + } + } + } + return null; + }; + + // The part of the page around the spot: the nearest element that is not + // inline text, short of the whole page. + const part = (element) => { + let current = element; + while (current.parentElement && getComputedStyle(current).display === "inline") { + current = current.parentElement; + } + return current === document.body || current === document.documentElement ? null : current; + }; + + const follow = () => { + // A frame from another origin runs in its own process and learns its + // size a moment after the page is read: measure once it has one. + if (innerWidth === 0 || innerHeight === 0) { + addEventListener("resize", follow, { once: true }); + return; + } + let spot = end(); + if (spot && (spot.y > innerHeight * 0.85 || spot.y < 0)) { + scrollTo({ top: scrollY + spot.y - innerHeight * 0.6, behavior: "instant" }); + spot = end(); + } + const around = spot && part(spot.element); + const box = around?.getBoundingClientRect(); + parent.postMessage( + { + botloft: "cursor", + view: { width: innerWidth, height: innerHeight }, + point: spot ? { x: spot.x, y: spot.y } : null, + box: box ? { x: box.left, y: box.top, width: box.width, height: box.height } : null, + }, + "*", + ); + }; + + addEventListener("DOMContentLoaded", follow); + // Images and fonts move things once they arrive. + addEventListener("load", follow); +})(); diff --git a/crates/botloftd/src/screens/cursor.rs b/crates/botloftd/src/screens/cursor.rs new file mode 100644 index 00000000..5105a835 --- /dev/null +++ b/crates/botloftd/src/screens/cursor.rs @@ -0,0 +1,66 @@ +//! The bot's cursor on a screen it writes (spec 22.3): each draft served +//! carries a small script that tells the app where the page ends now. + +const SCRIPT: &str = include_str!("cursor.js"); +const DOCTYPE: &str = " String { + let script = format!(""); + let rest = html.trim_start_matches(['\u{feff}', ' ', '\t', '\r', '\n']); + let start = html.len() - rest.len(); + let head = rest.get(..DOCTYPE.len()); + if head.is_some_and(|head| head.eq_ignore_ascii_case(DOCTYPE)) { + return match rest.find('>') { + Some(close) => { + let at = start + close + 1; + format!("{}{script}{}", &html[..at], &html[at..]) + } + None => html.to_owned(), + }; + } + let begun = rest.len() < DOCTYPE.len() + && !rest.is_empty() + && DOCTYPE.starts_with(&rest.to_ascii_lowercase()); + if begun { + return html.to_owned(); + } + format!("{script}{html}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const TAG: &str = "\n

Bak")); + + let bare = with_cursor("

Bak"); + assert!(bare.starts_with(TAG)); + assert!(bare.ends_with("

Bak")); + + let marked = with_cursor("\u{feff}

é

"); + assert!(marked.starts_with("\u{feff} ").starts_with(TAG)); + assert!(with_cursor("").starts_with(TAG)); + } + + #[test] + fn the_script_never_closes_early() { + assert!(!SCRIPT.contains("Bak"); + // The bot's cursor comes first, then what the bot wrote so far. + assert!(page.body.starts_with(CURSOR), "{}", page.body); + assert!(page.body.ends_with("

Bak"), "{}", page.body); assert!( page.head .contains("content-security-policy: sandbox allow-scripts") @@ -118,7 +123,12 @@ async fn a_screen_is_built_live_and_then_read_from_disk() { let last = draft(&mut app).await; assert!(last["rev"].as_u64() > first["rev"].as_u64()); let page = get(t.addr, last["url"].as_str().expect("url"), None).await; - assert_eq!(page.body, "

Bakery

\n

Fresh \"bread\"

"); + assert!( + page.body + .ends_with("

Bakery

\n

Fresh \"bread\"

"), + "{}", + page.body + ); // Not on disk yet: listed from the draft, being written. let screens = app @@ -151,6 +161,7 @@ async fn a_screen_is_built_live_and_then_read_from_disk() { "{}", page.body ); + assert!(!page.body.contains(CURSOR), "the file is served as it is"); let screens = app .call("screens.list", json!({ "botId": bot["id"] })) diff --git a/docs/spec.md b/docs/spec.md index 85b65544..6516f9c4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -782,6 +782,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | | Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito | +| Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito | | Telas no WebView2 do app | 22.5 | Não visto: a CSP do Tauri com `frame-src http://127.0.0.1:*` e as telas carregando no `iframe` | manual (PR): `pnpm tauri dev`, pedir uma página HTML a um bot e ver a área de design | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR. @@ -1096,7 +1097,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro ## 22. Telas -Status: **T1 e T2 implementados** (22.8). É o N4 da seção 21. +Status: **T1, T2 e T3 implementados** (22.8). É o N4 da seção 21. ### 22.1 O que é @@ -1111,7 +1112,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com - `GET /view///`, no mesmo servidor do daemon (só 127.0.0.1). A `` tem 128 bits aleatórios, é uma por bot, fica só na memória e muda a cada início do daemon; só o app a recebe, em `screens.list`. `` é `w` (pasta de trabalho da crew) ou `b` (pasta do bot). O caminho é resolvido de verdade e precisa ficar dentro da raiz: `..`, links para fora e entradas ocultas dão 404, sem dizer por quê. CSS, imagens, fontes e scripts ao lado do HTML carregam pelo mesmo caminho, como num site. - Toda resposta leva `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals`: a página roda numa origem opaca, sem acesso ao daemon nem aos cookies e ao armazenamento de 127.0.0.1, também se aberta direto. E ainda `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` e, para um pedido com `Origin: null` (a própria tela buscando um arquivo dela), `Access-Control-Allow-Origin: null`. O tipo sai da extensão (8.4). Acima de 20 MiB, 404. -- Enquanto um arquivo tem rascunho, o endereço dele serve o rascunho. +- Enquanto um arquivo tem rascunho, o endereço dele serve o rascunho, com o script do cursor do bot (22.3). O arquivo do disco é servido como está. - A CSP do app ganha `frame-src http://127.0.0.1:*`: as telas abrem num `iframe` do endereço delas, com os próprios scripts e as bibliotecas que carregam da internet, o que não funcionaria num `srcdoc`, que herda a CSP do app. - O log registra só o status, em `debug`. @@ -1121,6 +1122,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com - Do JSON parcial, o daemon tira `file_path` (quando a string dele fecha) e o começo de `content` que já chegou, com os escapes completos desfeitos. Só vira rascunho um `.html` ou `.htm` numa das duas raízes. - Notificação `screen.draft {botId, path, url, rev, done}`: `url` já aponta para aquela versão (`?rev=`), no máximo uma a cada 250 ms e uma quando a escrita fecha. O rascunho fica até o resultado daquele `tool_use` chegar (gravado, negado ou com erro), e então sai uma última com `done: true`, e o endereço volta a servir o disco. Um arquivo que o dono negou some da área de design. O fim do turno (`result`) também fecha os rascunhos que sobraram. - Rascunho não vai para o banco, para o disco nem para o log. +- **Cursor do bot:** cada rascunho servido leva primeiro um script pequeno do daemon (``; + const rest = html.replace(/^[\uFEFF\s]+/, ""); + const start = html.length - rest.length; + if (rest.slice(0, DOCTYPE.length).toLowerCase() === DOCTYPE) { + const close = rest.indexOf(">"); + const at = start + close + 1; + return close < 0 ? html : `${html.slice(0, at)}${script}${html.slice(at)}`; + } + if (rest.length > 0 && rest.length < DOCTYPE.length && DOCTYPE.startsWith(rest.toLowerCase())) { + return html; + } + return `${script}${html}`; +} export class FakeScreens { private readonly screens = new Map(); @@ -42,7 +61,7 @@ export class FakeScreens { this.revs.set(path, rev); this.fake.emit({ name: "screen.draft", - params: { botId, path, url: pageUrl(html), rev, bytes: html.length, done }, + params: { botId, path, url: pageUrl(withCursor(html)), rev, bytes: html.length, done }, }); } diff --git a/app/src/motion.css b/app/src/motion.css index 9d1c944b..4a37a20b 100644 --- a/app/src/motion.css +++ b/app/src/motion.css @@ -191,7 +191,7 @@ /* The bot's browser, live (features/browser): its cursor glides to each point it acts on, a ring spreads where it clicks once the cursor gets there, and a dot pulses while the page is live. */ -.browser-cursor { +.bot-cursor { position: absolute; display: flex; align-items: flex-start; @@ -202,7 +202,7 @@ top 520ms var(--ease-out); } -.browser-cursor > span { +.bot-cursor > span { margin-top: 14px; } @@ -217,7 +217,7 @@ animation: ripple 700ms var(--ease-out) 420ms both; } -.browser-typing { +.bot-typing { letter-spacing: 1px; animation: caret 0.9s steps(2, jump-none) infinite; } diff --git a/app/vite.config.ts b/app/vite.config.ts index ad92734e..3da1f5c3 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,6 +1,8 @@ import process from "node:process"; +import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; +import { searchForWorkspaceRoot } from "vite"; import { defineConfig } from "vitest/config"; // Set by `tauri dev` when targeting a remote device; unused on desktop. @@ -17,6 +19,15 @@ export default defineConfig({ host: host || false, hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, watch: { ignored: ["**/src-tauri/**"] }, + // The fake daemon's drafts use the daemon's own cursor script, so its + // folder is the one served from outside the app (a `?raw` import is + // checked with its query, which a single file path never matches). + fs: { + allow: [ + searchForWorkspaceRoot(process.cwd()), + fileURLToPath(new URL("../crates/botloftd/src/screens", import.meta.url)), + ], + }, }, build: { // WebView2 on Windows is evergreen Chromium.