Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions app/src/features/bots/useFollowBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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]);
}
10 changes: 10 additions & 0 deletions app/src/features/desktop/panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
15 changes: 9 additions & 6 deletions crates/botloftd/src/platform/desktop/windows/outline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -80,6 +80,9 @@ fn run(made: &Sender<Option<isize>>) {
// 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;
Expand Down
41 changes: 35 additions & 6 deletions crates/botloftd/src/platform/desktop/windows/outline_cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down
14 changes: 13 additions & 1 deletion crates/botloftd/src/platform/desktop/windows/outline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading