From 98bc20b19fbe55fefd6a672495d362fcb5461227 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:55:43 -0300 Subject: [PATCH] fix(desktop): the outline froze on the bot's first click; the panel opens as a tool starts The cursor's follow() held its lock and then called moving(), which took the same lock again: the outline's thread waited on itself forever at the bot's first action with a point. The outline stopped following its window, and Windows turned the frozen window into a gray "not responding" ghost over the owner's screen. moving() is now worked out under the lock already held, and the daemon turns off window ghosting so a busy outline can never cover the screen. The desktop panel also opens as soon as a desktop tool starts in the chat, not only after the first reading or action came back. --- app/src/features/bots/useFollowBot.ts | 31 ++++++++++---- app/src/features/desktop/panel.test.tsx | 10 +++++ .../src/platform/desktop/windows/outline.rs | 15 ++++--- .../desktop/windows/outline_cursor.rs | 41 ++++++++++++++++--- .../platform/desktop/windows/outline_tests.rs | 14 ++++++- docs/spec.md | 4 +- 6 files changed, 91 insertions(+), 24 deletions(-) diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts index e6c74bc3..69239341 100644 --- a/app/src/features/bots/useFollowBot.ts +++ b/app/src/features/bots/useFollowBot.ts @@ -2,8 +2,9 @@ // so the owner sees it happen: its browser starting or a request for a // hand opens the browser (spec 21.8), and a screen it starts writing opens // the design area (spec 22.5), and the bot starting to use the owner's -// desktop after a pause opens the desktop panel (spec 24.9), whatever panel -// was open. Each opens once per +// desktop after a pause (a desktop tool starting in the chat, or the daemon +// saying it used a window) opens the desktop panel (spec 24.9), whatever +// panel was open. Each opens once per // start; closing it leaves the button's dot. When the browser goes to rest // (spec 21.2) its panel closes, and it opens again once the bot wakes it. // The browser in the owner's hands is never taken away. The owner can turn this off in Settings @@ -15,6 +16,7 @@ import { useEffect, useRef } from "react"; import type { Bot } from "../../lib/protocol.gen"; import { prefs } from "../../shell/prefs"; import { useApi, useApp } from "../../store/context"; +import { isDesktopTool } from "../desktop/showDesktop"; export type Followed = "browser" | "screens" | "desktop"; @@ -87,23 +89,34 @@ export function useFollowBot({ useEffect(() => { let last: number | null = null; - return api.subscribe((event) => { - if (event.name !== "desktop.changed" || event.params.botId !== bot.id) { - return; - } - const { at, window, stopped } = event.params; - if (at === null || !window || stopped || at === last) { + // A use of the desktop: a desktop tool starting in the chat, or the + // daemon saying the bot read or acted in a window. + const used = (at: number) => { + if (at === last) { return; } const was = last; last = at; - if (was !== null && at - was < DESKTOP_PAUSE) { + if (was !== null && Math.abs(at - was) < DESKTOP_PAUSE) { return; } const { side, held, open } = now.current; if (side !== "desktop" && !held && prefs.followBot.get()) { open("desktop"); } + }; + return api.subscribe((event) => { + if (event.name === "chat.item" && event.params.item.botId === bot.id) { + const { body, createdAt } = event.params.item; + if (body.kind === "tool" && body.status === "running" && isDesktopTool(body.name)) { + used(createdAt); + } + } else if (event.name === "desktop.changed" && event.params.botId === bot.id) { + const { at, window, stopped } = event.params; + if (at !== null && window && !stopped) { + used(at); + } + } }); }, [api, bot.id]); } diff --git a/app/src/features/desktop/panel.test.tsx b/app/src/features/desktop/panel.test.tsx index e0ea3805..7f5b7c56 100644 --- a/app/src/features/desktop/panel.test.tsx +++ b/app/src/features/desktop/panel.test.tsx @@ -124,4 +124,14 @@ describe("the desktop panel", () => { within(options).getByRole("switch", { name: "While you are away: Notepad" }), ).toBeDefined(); }); + + test("opens as soon as a desktop tool starts in the chat", async () => { + const { fake, scout } = await openScout(); + expect(screen.queryByRole("complementary", { name: "Scout's desktop" })).toBeNull(); + act(() => { + fake.now += 3 * 60_000; + fake.chat.tool(scout.id, "mcp__botloft__desktop_windows", { status: "running" }); + }); + expect(await within(panel()).findByText("Scout has not used your desktop yet")).toBeDefined(); + }); }); diff --git a/crates/botloftd/src/platform/desktop/windows/outline.rs b/crates/botloftd/src/platform/desktop/windows/outline.rs index ff89984a..fcc4766d 100644 --- a/crates/botloftd/src/platform/desktop/windows/outline.rs +++ b/crates/botloftd/src/platform/desktop/windows/outline.rs @@ -19,12 +19,12 @@ use windows::Win32::UI::HiDpi::{ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, GetDpiForWindow, SetThreadDpiAwarenessContext, }; use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DispatchMessageW, GW_HWNDPREV, GetClientRect, GetMessageW, - GetWindow, HWND_TOP, IsIconic, IsWindow, IsWindowVisible, KillTimer, LWA_COLORKEY, MSG, - PostMessageW, RegisterClassW, SW_HIDE, SW_SHOWNOACTIVATE, SWP_NOACTIVATE, - SetLayeredWindowAttributes, SetTimer, SetWindowDisplayAffinity, SetWindowPos, ShowWindow, - TranslateMessage, WDA_EXCLUDEFROMCAPTURE, WM_APP, WM_PAINT, WM_TIMER, WNDCLASSW, WS_EX_LAYERED, - WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_POPUP, + CreateWindowExW, DefWindowProcW, DisableProcessWindowsGhosting, DispatchMessageW, GW_HWNDPREV, + GetClientRect, GetMessageW, GetWindow, HWND_TOP, IsIconic, IsWindow, IsWindowVisible, + KillTimer, LWA_COLORKEY, MSG, PostMessageW, RegisterClassW, SW_HIDE, SW_SHOWNOACTIVATE, + SWP_NOACTIVATE, SetLayeredWindowAttributes, SetTimer, SetWindowDisplayAffinity, SetWindowPos, + ShowWindow, TranslateMessage, WDA_EXCLUDEFROMCAPTURE, WM_APP, WM_PAINT, WM_TIMER, WNDCLASSW, + WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_POPUP, }; use windows::core::{PCWSTR, w}; @@ -80,6 +80,9 @@ fn run(made: &Sender>) { // SAFETY: a window class, a window and its message loop, on this thread. unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + // A busy outline must never turn into Windows' gray "not + // responding" ghost over the owner's screen. + DisableProcessWindowsGhosting(); let Ok(module) = GetModuleHandleW(None) else { let _ = made.send(None); return; diff --git a/crates/botloftd/src/platform/desktop/windows/outline_cursor.rs b/crates/botloftd/src/platform/desktop/windows/outline_cursor.rs index e01bfa6f..63467c4e 100644 --- a/crates/botloftd/src/platform/desktop/windows/outline_cursor.rs +++ b/crates/botloftd/src/platform/desktop/windows/outline_cursor.rs @@ -77,16 +77,23 @@ pub(super) fn follow(cursor: Option<&ScreenCursor>) -> bool { click: cursor.click, }); } - moving(now) + // With the lock held: taking it again here would wait forever. + still(motion.as_ref(), now) +} + +fn still(motion: Option<&Motion>, now: Instant) -> bool { + motion.is_some_and(|motion| now.duration_since(motion.started) < GLIDE + RIPPLE) } /// Whether the cursor still glides or its ring still grows. pub(super) fn moving(now: Instant) -> bool { - MOTION - .lock() - .unwrap_or_else(PoisonError::into_inner) - .as_ref() - .is_some_and(|motion| now.duration_since(motion.started) < GLIDE + RIPPLE) + still( + MOTION + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref(), + now, + ) } fn colorref((red, green, blue): (u8, u8, u8)) -> COLORREF { @@ -198,6 +205,28 @@ pub(super) unsafe fn paint( mod tests { use super::*; + #[test] + fn a_new_action_sets_the_cursor_off_without_waiting_on_itself() { + let cursor = ScreenCursor { + x: 0.5, + y: 0.5, + click: true, + typing: false, + name: "Scout".to_owned(), + at: 1, + }; + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let moving = follow(Some(&cursor)); + let _ = done.send(moving); + }); + let moving = finished + .recv_timeout(Duration::from_secs(2)) + .expect("follow came back"); + assert!(moving, "it glides"); + assert!(!follow(None), "and goes"); + } + #[test] fn the_cursor_glides_and_lands_where_the_bot_acted() { let start = ((0.0, 0.0), (1.0, 0.5)); diff --git a/crates/botloftd/src/platform/desktop/windows/outline_tests.rs b/crates/botloftd/src/platform/desktop/windows/outline_tests.rs index b3eaa473..c8d320c0 100644 --- a/crates/botloftd/src/platform/desktop/windows/outline_tests.rs +++ b/crates/botloftd/src/platform/desktop/windows/outline_tests.rs @@ -11,6 +11,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ }; use windows::core::w; +use super::super::ScreenCursor; use super::test_window::TestWindow; use super::{capture, handle, outline}; @@ -63,6 +64,17 @@ fn the_outline_goes_around_the_window_and_follows_it() { let above = unsafe { GetWindow(app, GW_HWNDPREV) }.ok(); assert_eq!(above, outline_window(), "right above the app"); + // The bot clicks: its cursor is drawn, and the outline goes on. + let cursor = ScreenCursor { + x: 0.5, + y: 0.5, + click: true, + typing: false, + name: "Scout".to_owned(), + at: 1, + }; + outline::show(window.id, (0x5E, 0xC8, 0xFF), Some(cursor.clone())); + // The window moves; the outline follows on the next show. // SAFETY: moves the test's own window. let _ = unsafe { @@ -77,7 +89,7 @@ fn the_outline_goes_around_the_window_and_follows_it() { ) }; let moved = capture::frame(window.id).expect("frame"); - outline::show(window.id, (0x5E, 0xC8, 0xFF), None); + outline::show(window.id, (0x5E, 0xC8, 0xFF), Some(cursor)); soon("moved along", || around(moved, place())); // SAFETY: minimizes and restores the test's own window. diff --git a/docs/spec.md b/docs/spec.md index 4c28d336..96e42609 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1767,8 +1767,8 @@ No servidor `botloft` (11), como as do navegador. ### 24.9 App -- **Painel "Desktop"** ao lado do chat, o quarto lugar do dock do computador do bot (navegador, terminal, arquivos e desktop, 15.1), com a mesma largura: a foto ao vivo da janela que o bot leu ou em que agiu por último, sobre a mesa na cor do bot, o nome do app e o título da janela, e a linha do que ele acabou de fazer, no idioma do dono ("Clicou em "Save"", "Escreveu em "Nome"", "Escolheu "Large"", "Leu a janela"). No cabeçalho, "Ao vivo" com a foto chegando e **Parar**; parado, o selo "Parado" e um aviso com **Deixar continuar**. Sem janela ainda, o painel explica que ela aparece ali quando o bot ler ou usar um app liberado. Embaixo, a dica do atalho Ctrl+Alt+Esc. As linhas das tools `desktop_*` no chat têm o botão de tela que abre o painel, como as do navegador abrem o dele. Sobre a foto, o cursor do bot desliza até o ponto de cada ação e um anel marca o clique, como no navegador (21.8). O painel abre sozinho quando o bot começa a usar o desktop, isto é, no primeiro `desktop.changed` com janela depois de 2 minutos sem nenhum, sobre o painel que estiver aberto, menos com o navegador nas mãos do dono, e só se o dono não desligou "seguir o bot" (15.1); fechado no meio, as ações seguintes da mesma vez não o abrem de novo. -- **Contorno na tela:** enquanto um bot lê ou age numa janela, ela ganha um contorno na cor do bot, na tela do dono (3 px a 96 DPI, em volta da moldura do DWM). É uma janela própria do daemon, num thread com fila própria: em camadas com uma cor que vira transparente, que deixa os cliques passarem, não pega o foco e não entra na lista de janelas (`WS_EX_TOOLWINDOW`). Não fica sobre tudo: vai logo acima da janela do app na ordem das janelas (atrás da que estava acima dela), então uma janela do dono na frente do app cobre o contorno também. Ela segue a janela a cada 250 ms (posição e ordem) e some com a janela minimizada, escondida ou fechada. Aparece no `desktop.changed` de uma leitura, foto ou ação, e sai 3 s depois de o turno do bot acabar, com um minuto sem uso do desktop no mesmo turno, ou quando o dono o para. Um contorno por vez: o da janela usada por último. Dentro dele vai o **cursor do bot** na tela do dono, sem mexer no mouse dele: uma seta na cor do bot, contornada de branco, com o nome do bot numa pílula ao lado (e "•••" enquanto ele escreve). Ele desliza em 350 ms até o ponto de cada ação (`x` e `y` do `DesktopAction`), e um anel na cor do bot cresce onde ele clicou. Depois de só ler, fica onde estava; noutra janela, começa sem cursor até a primeira ação. Desenhado na janela do contorno (GDI, sem suavização, por causa da cor transparente), com um timer de 16 ms só enquanto se move (`platform/desktop/windows/outline_cursor.rs`). Quem decide é uma tarefa do daemon que escuta `desktop.changed` e `bot.state` (`desktop/outline.rs`); a janela fica em `platform/desktop/windows/outline.rs`. +- **Painel "Desktop"** ao lado do chat, o quarto lugar do dock do computador do bot (navegador, terminal, arquivos e desktop, 15.1), com a mesma largura: a foto ao vivo da janela que o bot leu ou em que agiu por último, sobre a mesa na cor do bot, o nome do app e o título da janela, e a linha do que ele acabou de fazer, no idioma do dono ("Clicou em "Save"", "Escreveu em "Nome"", "Escolheu "Large"", "Leu a janela"). No cabeçalho, "Ao vivo" com a foto chegando e **Parar**; parado, o selo "Parado" e um aviso com **Deixar continuar**. Sem janela ainda, o painel explica que ela aparece ali quando o bot ler ou usar um app liberado. Embaixo, a dica do atalho Ctrl+Alt+Esc. As linhas das tools `desktop_*` no chat têm o botão de tela que abre o painel, como as do navegador abrem o dele. Sobre a foto, o cursor do bot desliza até o ponto de cada ação e um anel marca o clique, como no navegador (21.8). O painel abre sozinho quando o bot começa a usar o desktop, isto é, quando uma tool `desktop_*` começa no chat (`chat.item` em `running`, antes do resultado) ou no primeiro `desktop.changed` com janela, depois de 2 minutos sem nenhum dos dois, sobre o painel que estiver aberto, menos com o navegador nas mãos do dono, e só se o dono não desligou "seguir o bot" (15.1); fechado no meio, as ações seguintes da mesma vez não o abrem de novo. +- **Contorno na tela:** enquanto um bot lê ou age numa janela, ela ganha um contorno na cor do bot, na tela do dono (3 px a 96 DPI, em volta da moldura do DWM). É uma janela própria do daemon, num thread com fila própria: em camadas com uma cor que vira transparente, que deixa os cliques passarem, não pega o foco e não entra na lista de janelas (`WS_EX_TOOLWINDOW`). Não fica sobre tudo: vai logo acima da janela do app na ordem das janelas (atrás da que estava acima dela), então uma janela do dono na frente do app cobre o contorno também. Ela segue a janela a cada 250 ms (posição e ordem) e some com a janela minimizada, escondida ou fechada. Aparece no `desktop.changed` de uma leitura, foto ou ação, e sai 3 s depois de o turno do bot acabar, com um minuto sem uso do desktop no mesmo turno, ou quando o dono o para. Um contorno por vez: o da janela usada por último. Dentro dele vai o **cursor do bot** na tela do dono, sem mexer no mouse dele: uma seta na cor do bot, contornada de branco, com o nome do bot numa pílula ao lado (e "•••" enquanto ele escreve). Ele desliza em 350 ms até o ponto de cada ação (`x` e `y` do `DesktopAction`), e um anel na cor do bot cresce onde ele clicou. Depois de só ler, fica onde estava; noutra janela, começa sem cursor até a primeira ação. Desenhado na janela do contorno (GDI, sem suavização, por causa da cor transparente), com um timer de 16 ms só enquanto se move (`platform/desktop/windows/outline_cursor.rs`). O daemon desliga o "fantasma" do Windows (`DisableProcessWindowsGhosting`): uma janela que para de responder vira um retângulo cinza opaco de "não está respondendo" por cima da tela, e o contorno nunca pode tapar a tela do dono assim (visto no 0.9.1 de dev, com uma trava que esperava por ela mesma no cursor). Quem decide é uma tarefa do daemon que escuta `desktop.changed` e `bot.state` (`desktop/outline.rs`); a janela fica em `platform/desktop/windows/outline.rs`. - **O que o daemon guarda** (`DesktopState`, em memória, por bot): a janela (`id`, título, app), a última ação (`DesktopAction`: clicar, escrever, escolher ou rolar, o nome do controle, a opção escolhida e o ponto, `x` e `y` em frações da moldura da janela a partir do canto de cima à esquerda: o centro do controle pela acessibilidade, ou o ponto da foto num clique de `desktop_click_at`, sem ponto nas teclas ou com o controle fora da janela; nada depois de só ler), que o app diz no idioma do dono, quando foi, e se o dono o parou. Cada leitura, foto ou ação o atualiza e sai a notificação `desktop.changed` para todos os apps. - **Foto ao vivo:** `desktop.watch {botId}` devolve o estado e a foto mais nova (`DesktopView`) e, enquanto alguma conexão assiste aquele bot, o daemon fotografa a janela dele quatro vezes por segundo (`PrintWindow`, como `desktop_screenshot`) e manda `desktop.frame` só quando a foto mudou, só para quem assiste, como os quadros do navegador (21.7): um app lento recebe a mais nova. Quando a última conexão para (`desktop.unwatch`, outro `desktop.watch` ou a conexão caindo), as fotos param. - **Parar:** `desktop.stop {botId}` marca o bot como parado e `desktop.resume {botId}` o deixa continuar; os dois devolvem o `DesktopState`. Parado, toda tool `desktop_*` dele recusa, de novo depois de esperar o dono ou a vez no desktop, com o aviso de que o dono o parou e que ele deve contar no chat o que fazia. Uma ação já em andamento termina (ela dura no máximo 5 s, 24.5). Parado fica até o dono deixar continuar. Isso vive na memória: um daemon que reinicia começa com ninguém parado.