diff --git a/CLAUDE.md b/CLAUDE.md index dbf93c8a..c5eab9cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ cargo build -p botloftd cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check -cargo run -p botloftd -- serve --config .dev\config.toml # daemon isolado de dev +cargo run -p botloftd -- serve # com BOTLOFT_HOME de dev (abaixo) # Tipos TS gerados a partir do Rust cargo test -p botloft-core export_bindings @@ -38,11 +38,12 @@ cargo test -p botloft-core export_bindings # App cd app pnpm install -pnpm tauri dev +pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME e o inicia (target\debug\botloftd.exe) +pnpm dev # só a UI num navegador comum, com o FakeBotloft (src/dev/preview.ts) pnpm check # tsc --noEmit + biome check + vitest run ``` -Em dev, use `BOTLOFT_HOME=.dev\home` para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. +Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. ## Convenções diff --git a/Cargo.lock b/Cargo.lock index 25c0e45f..98ed8371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,8 +266,12 @@ dependencies = [ name = "botloft-app" version = "0.1.0" dependencies = [ + "dirs", + "serde", + "serde_json", "tauri", "tauri-build", + "toml 1.1.6+spec-1.1.0", ] [[package]] diff --git a/app/package.json b/app/package.json index 2a19e4dd..06cbe98d 100644 --- a/app/package.json +++ b/app/package.json @@ -16,9 +16,16 @@ "check": "pnpm typecheck && pnpm lint && pnpm test" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2.12.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.48.0", "react": "^19.3.0", - "react-dom": "^19.3.0" + "react-dom": "^19.3.0", + "zustand": "^5.0.15" }, "devDependencies": { "@biomejs/biome": "2.5.14", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 8509b778..6da6a843 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -166,15 +166,36 @@ importers: .: dependencies: + '@fontsource/ibm-plex-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/ibm-plex-sans': + specifier: ^5.3.0 + version: 5.3.0 '@tauri-apps/api': specifier: ^2.12.0 version: 2.12.0 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-webgl': + specifier: ^0.19.0 + version: 0.19.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 + lucide-react: + specifier: ^1.48.0 + version: 1.48.0(react@19.3.0) react: specifier: ^19.3.0 version: 19.3.0 react-dom: specifier: ^19.3.0 version: 19.3.0(react@19.3.0) + zustand: + specifier: ^5.0.15 + version: 5.0.15(@types/react@19.3.0)(react@19.3.0) devDependencies: '@biomejs/biome': specifier: 2.5.14 @@ -347,6 +368,12 @@ packages: '@noble/hashes': optional: true + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@fontsource/ibm-plex-sans@5.3.0': + resolution: {integrity: sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -830,6 +857,15 @@ packages: '@vitest/spy@5.0.2': resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-webgl@0.19.0': + resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1087,6 +1123,11 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1328,6 +1369,24 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@asamuzakjp/css-color@7.1.2': @@ -1420,6 +1479,10 @@ snapshots: '@exodus/bytes@1.16.0': {} + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@fontsource/ibm-plex-sans@5.3.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1725,6 +1788,12 @@ snapshots: '@vitest/spy@5.0.2': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-webgl@0.19.0': {} + + '@xterm/xterm@6.0.0': {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -1924,6 +1993,10 @@ snapshots: lru-cache@11.5.3: {} + lucide-react@1.48.0(react@19.3.0): + dependencies: + react: 19.3.0 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -2121,3 +2194,8 @@ snapshots: xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + zustand@5.0.15(@types/react@19.3.0)(react@19.3.0): + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.0 diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index caf8f968..dfeee243 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -17,7 +17,11 @@ crate-type = ["rlib"] tauri-build = { version = "2", features = [] } [dependencies] +dirs = "7.0.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" tauri = { version = "2", features = [] } +toml = "1.1.6" [lints] workspace = true diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c2ca36e6..e87b6114 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -3,5 +3,11 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close" + ] } diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs new file mode 100644 index 00000000..faa6ba42 --- /dev/null +++ b/app/src-tauri/src/daemon.rs @@ -0,0 +1,251 @@ +//! Finding, checking and starting the local daemon (spec 15.2). +//! +//! The app resolves the daemon's data folder the way the daemon does +//! (spec 5): `BOTLOFT_HOME`, else `%LOCALAPPDATA%\Botloft`. The port comes +//! from `config.toml` in that folder, so a dev daemon with its own +//! `BOTLOFT_HOME` is found without extra setup. + +use std::io::{Read as _, Write as _}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +const HOME_ENV: &str = "BOTLOFT_HOME"; +const DEFAULT_PORT: u16 = 45710; +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +const READ_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a freshly started daemon may take to answer `/health`. +const START_TIMEOUT: Duration = Duration::from_secs(15); + +/// Where the daemon lives. +#[derive(Debug, Clone)] +pub struct Endpoint { + pub home: PathBuf, + pub port: u16, +} + +/// What answers on the daemon's port. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum DaemonStatus { + #[serde(rename_all = "camelCase")] + Running { + port: u16, + version: String, + protocol: u32, + }, + /// Nothing listens on the port. + #[serde(rename_all = "camelCase")] + Stopped { port: u16, home: String }, + /// Something that is not a Botloft daemon holds the port. + #[serde(rename_all = "camelCase")] + Foreign { port: u16 }, +} + +#[derive(Deserialize)] +struct Health { + status: String, + version: String, + protocol: u32, +} + +#[derive(Deserialize)] +struct Config { + port: Option, +} + +pub fn endpoint() -> Result { + let home = match std::env::var_os(HOME_ENV).filter(|value| !value.is_empty()) { + Some(home) => std::path::absolute(home).map_err(|err| err.to_string())?, + None => dirs::data_local_dir() + .map(|dir| dir.join("Botloft")) + .ok_or("cannot find the local application data folder")?, + }; + let port = read_port(&home.join("config.toml"))?; + Ok(Endpoint { home, port }) +} + +fn read_port(config: &Path) -> Result { + let text = match std::fs::read_to_string(config) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(DEFAULT_PORT), + Err(err) => return Err(format!("cannot read {}: {err}", config.display())), + }; + let parsed: Config = + toml::from_str(&text).map_err(|err| format!("invalid {}: {err}", config.display()))?; + Ok(parsed.port.unwrap_or(DEFAULT_PORT)) +} + +pub fn status(endpoint: &Endpoint) -> DaemonStatus { + match health(endpoint.port) { + Probe::Healthy(health) => DaemonStatus::Running { + port: endpoint.port, + version: health.version, + protocol: health.protocol, + }, + Probe::Closed => DaemonStatus::Stopped { + port: endpoint.port, + home: endpoint.home.display().to_string(), + }, + Probe::Foreign => DaemonStatus::Foreign { + port: endpoint.port, + }, + } +} + +enum Probe { + Healthy(Health), + Closed, + Foreign, +} + +fn health(port: u16) -> Probe { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let Ok(mut stream) = TcpStream::connect_timeout(&address, CONNECT_TIMEOUT) else { + return Probe::Closed; + }; + let _ = stream.set_read_timeout(Some(READ_TIMEOUT)); + let _ = stream.set_write_timeout(Some(READ_TIMEOUT)); + let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + let mut response = Vec::new(); + if stream.write_all(request.as_bytes()).is_err() || stream.read_to_end(&mut response).is_err() { + return Probe::Foreign; + } + let response = String::from_utf8_lossy(&response); + let Some((head, body)) = response.split_once("\r\n\r\n") else { + return Probe::Foreign; + }; + if !head.starts_with("HTTP/1.1 200") { + return Probe::Foreign; + } + match serde_json::from_str::(body) { + Ok(health) if health.status == "ok" => Probe::Healthy(health), + _ => Probe::Foreign, + } +} + +/// Starts `botloftd.exe serve` from the app's folder, detached so it keeps +/// running after the app closes (spec 1), and waits for it to answer. +pub fn start(endpoint: &Endpoint) -> Result { + if let running @ DaemonStatus::Running { .. } = status(endpoint) { + return Ok(running); + } + let binary = daemon_binary()?; + spawn_detached(&binary).map_err(|err| format!("cannot start {}: {err}", binary.display()))?; + let started = Instant::now(); + loop { + let current = status(endpoint); + if matches!(current, DaemonStatus::Running { .. }) || started.elapsed() > START_TIMEOUT { + return Ok(current); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// `botloftd.exe` next to the app: the installer puts it there (M5), and +/// in dev both land in `target\debug`. +fn daemon_binary() -> Result { + let app = std::env::current_exe().map_err(|err| err.to_string())?; + let binary = app.with_file_name(if cfg!(windows) { + "botloftd.exe" + } else { + "botloftd" + }); + if binary.is_file() { + Ok(binary) + } else { + Err(format!( + "{} was not found. Build it with `cargo build -p botloftd`.", + binary.display() + )) + } +} + +fn spawn_detached(binary: &Path) -> std::io::Result<()> { + let mut command = Command::new(binary); + command + .arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP; + // Leave the app's job, if it runs in one that would kill children + // on close. Jobs that forbid breaking away refuse the flag. + command.creation_flags(flags | CREATE_BREAKAWAY_FROM_JOB); + if command.spawn().is_ok() { + return Ok(()); + } + command.creation_flags(flags); + } + command.spawn().map(drop) +} + +/// The owner token the daemon wrote on its first start (spec 13). +pub fn owner_token(endpoint: &Endpoint) -> Result { + let path = endpoint.home.join("secrets").join("owner.token"); + let text = std::fs::read_to_string(&path).map_err(|err| match err.kind() { + std::io::ErrorKind::NotFound => format!( + "{} does not exist yet; the daemon creates it on its first start", + path.display() + ), + _ => format!("cannot read {}: {err}", path.display()), + })?; + let token = text.trim(); + if token.len() == 64 && token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(token.to_owned()) + } else { + Err(format!("{} is not a valid token", path.display())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_defaults_and_reads_the_config() { + let dir = std::env::temp_dir().join(format!("botloft-app-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let config = dir.join("config.toml"); + assert_eq!(read_port(&config), Ok(DEFAULT_PORT)); + std::fs::write(&config, "log_level = \"debug\"\n").expect("write"); + assert_eq!(read_port(&config), Ok(DEFAULT_PORT)); + std::fs::write(&config, "port = 45799\n[courier]\nmax_attempts = 3\n").expect("write"); + assert_eq!(read_port(&config), Ok(45799)); + std::fs::write(&config, "port = \"x\"").expect("write"); + assert!(read_port(&config).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_closed_port_reads_as_stopped() { + // Bind and drop to find a port nothing listens on. + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + assert!(matches!(health(port), Probe::Closed)); + } + + #[test] + fn a_port_that_is_not_botloft_reads_as_foreign() { + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("accept"); + let mut buffer = [0u8; 512]; + let _ = socket.read(&mut buffer); + let _ = socket.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + }); + assert!(matches!(health(port), Probe::Foreign)); + server.join().expect("server"); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 387880cf..3797bffd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,9 +1,65 @@ //! Tauri shell for the Botloft app. The UI talks to `botloftd` over -//! WebSocket; native commands (spec 15.2) are added here. +//! WebSocket; these commands cover what a web page cannot do (spec 15.2). + +mod daemon; + +use std::path::Path; + +use daemon::DaemonStatus; + +/// Runs blocking work off the main thread. +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(work) + .await + .map_err(|err| err.to_string())? +} + +/// Whether the daemon answers `/health`, and on which port. +#[tauri::command] +async fn daemon_status() -> Result { + blocking(|| daemon::endpoint().map(|endpoint| daemon::status(&endpoint))).await +} + +/// Starts the daemon next to the app when it is not running (M4; M5 +/// installs it as a scheduled task instead). +#[tauri::command] +async fn daemon_start() -> Result { + blocking(|| daemon::start(&daemon::endpoint()?)).await +} + +#[tauri::command] +async fn read_owner_token() -> Result { + blocking(|| daemon::owner_token(&daemon::endpoint()?)).await +} + +/// Opens a folder in Explorer. Files are refused: Explorer would run them. +#[tauri::command] +async fn open_path(path: String) -> Result<(), String> { + blocking(move || { + let folder = Path::new(&path); + if !folder.is_absolute() || !folder.is_dir() { + return Err(format!("{path} is not a folder")); + } + std::process::Command::new("explorer.exe") + .arg(folder) + .spawn() + .map(drop) + .map_err(|err| format!("cannot open Explorer: {err}")) + }) + .await +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + daemon_status, + daemon_start, + read_owner_token, + open_path + ]) .run(tauri::generate_context!()) .expect("error while running the Botloft app"); } diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 21471453..a9a5512c 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -16,16 +16,19 @@ "width": 1280, "height": 800, "minWidth": 900, - "minHeight": 560 + "minHeight": 560, + "decorations": false } ], "security": { - "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:45710 http://127.0.0.1:45710; style-src 'self' 'unsafe-inline'; img-src 'self' data:" + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" } }, "bundle": { "active": true, - "targets": ["nsis"], + "targets": [ + "nsis" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 5e593ce8..d989b281 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,8 +1,180 @@ -import { render, screen } from "@testing-library/react"; -import { expect, test } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { App } from "./App"; +import type { Client } from "./lib/client"; +import { FakeBotloft } from "./lib/fake"; +import { FakeHost } from "./lib/fakeHost"; -test("renders the app title", () => { - render(); - expect(screen.getByRole("heading", { name: "Botloft" })).toBeDefined(); +// xterm.js needs a real browser; the terminal has its own tests. +vi.mock("./features/terminal/TerminalView", () => ({ + TerminalView: ({ botId }: { botId: string }) =>
, +})); + +afterEach(cleanup); + +function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { + render( fake as Client} />); + return { fake, host }; +} + +function type(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} + +/** The crews sidebar. */ +const sidebar = () => screen.getByRole("navigation", { name: "Crews" }); + +async function newBot(name: string) { + const [headerButton] = await screen.findAllByRole("button", { name: "New bot" }); + fireEvent.click(headerButton as HTMLElement); + type("Name", name); + type("Role", `${name} role`); + fireEvent.click(screen.getByRole("button", { name: "Create bot" })); +} + +describe("app", () => { + test("a first run shows what is ready and opens the first crew", async () => { + renderApp(); + expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); + expect(screen.getByText("Version 2.1.284")).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Create your first crew" })); + type("Name", "Ops"); + fireEvent.click(screen.getByRole("button", { name: "Create crew" })); + expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(within(sidebar()).getByRole("button", { name: /Ops/ })).toBeDefined(); + }); + + test("a new bot opens with its handle and follows its state", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await newBot("Scout"); + expect(await screen.findByRole("heading", { level: 1, name: "Scout" })).toBeDefined(); + expect(screen.getByText("@scout")).toBeDefined(); + const view = screen.getByRole("region", { name: "Scout" }); + expect(within(view).getByText("Starting")).toBeDefined(); + const bot = [...fake.bots.values()][0]; + act(() => fake.setBotState(bot?.id ?? "", "needs_approval")); + // The badge, and a callout that says what to do. + expect(within(view).getAllByText("Needs approval")).toHaveLength(2); + expect(within(view).getByRole("alert").textContent).toContain("permission prompt"); + }); + + test("the daemon's validation errors stay in the form", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + fake.addBot(crew.id, "Scout"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + await newBot("Scout"); + expect((await screen.findByRole("alert")).textContent).toBe( + "@scout is already used in this crew", + ); + expect(screen.getByRole("dialog", { name: "New bot" })).toBeDefined(); + }); + + test("the owner starts the daemon from the app", async () => { + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; + renderApp(new FakeBotloft(), host); + fireEvent.click(await screen.findByRole("button", { name: "Start the daemon" })); + expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); + expect(host.starts).toBe(1); + }); + + test("archiving a bot asks first", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + fake.addBot(crew.id, "Scout"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Archive bot" })); + const dialog = screen.getByRole("dialog", { name: "Archive Scout?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" })); + expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(); + expect(fake.calls.at(-1)?.method).toBe("bots.archive"); + }); + + test("pausing a crew shows it everywhere", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + fake.setBotState(scout.id, "offline"); + renderApp(fake); + fireEvent.click(await screen.findByRole("button", { name: "Pause crew" })); + expect(await screen.findByRole("button", { name: "Resume crew" })).toBeDefined(); + expect(within(sidebar()).getAllByText("Paused")).toHaveLength(2); + }); + + test("a missing Claude Code says why bots cannot start", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + fake.system = { + ...fake.system, + claudeVersion: null, + runtimeError: "claude.exe was not found on PATH.", + }; + renderApp(fake); + const banner = await screen.findByText("Bots cannot start"); + expect(banner.closest("[role=alert]")?.textContent).toContain("claude.exe was not found"); + }); + + test("a lost connection shows in the title bar", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + act(() => fake.setConnection({ kind: "waiting", retryAt: 0 })); + expect(screen.getByText("Reconnecting…")).toBeDefined(); + }); + test("a bot's view shows its terminal, and details in a second tab", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout", "Finds sources"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); + expect(screen.getByTestId(`terminal-${scout.id}`)).toBeDefined(); + fireEvent.click(screen.getByRole("tab", { name: "Details" })); + expect(screen.getByText(scout.workspace)).toBeDefined(); + // The terminal stays mounted behind the other tab. + expect(screen.getByTestId(`terminal-${scout.id}`)).toBeDefined(); + }); + + test("a start that takes long points at the trust prompt", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); + vi.useFakeTimers(); + try { + act(() => fake.setBotState(scout.id, "launching", 2)); + expect(screen.queryByText("Still starting")).toBeNull(); + act(() => vi.advanceTimersByTime(4000)); + expect(screen.getByText("Still starting")).toBeDefined(); + act(() => fake.setBotState(scout.id, "idle")); + expect(screen.queryByText("Still starting")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + test("a bot that never started says how to start it", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + await fake.call("crews.setPaused", { crewId: crew.id, paused: true }); + const scout = fake.addBot(crew.id, "Scout"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); + expect(screen.getByText(/has not started since the daemon did/).textContent).toContain( + "Resume it to start it.", + ); + expect(screen.queryByTestId(`terminal-${scout.id}`)).toBeNull(); + }); }); diff --git a/app/src/App.tsx b/app/src/App.tsx index 2e317365..cd9f0390 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,10 +1,35 @@ -export function App() { +import { useEffect, useState } from "react"; +import { useStore } from "zustand"; +import { type Connect, createLink } from "./features/onboarding/link"; +import { Onboarding } from "./features/onboarding/Onboarding"; +import type { Host } from "./lib/host"; +import { TitleBar } from "./shell/TitleBar"; +import { useThemeRoot } from "./shell/theme"; +import { Workspace } from "./shell/Workspace"; +import { DaemonProvider, HostProvider } from "./store/context"; +import { Toaster } from "./ui/toast"; + +export function App({ host, connect }: { host: Host; connect: Connect }) { + useThemeRoot(); + const [link] = useState(() => createLink(host, connect)); + const current = useStore(link, (state) => state.current); + useEffect(() => { + link.getState().check(); + }, [link]); + return ( -
-
-

Botloft

-

Always-on Claude Code crews.

-
-
+ + {current.step === "connected" ? ( + + + + ) : ( +
+ + +
+ )} + +
); } diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts new file mode 100644 index 00000000..3b8670f4 --- /dev/null +++ b/app/src/dev/preview.ts @@ -0,0 +1,102 @@ +// Dev only: `pnpm dev` opened in a plain browser runs the app against the +// in-memory fake daemon, for UI work without Tauri or botloftd. +// `?empty` starts with no crews, `?stopped` with the daemon stopped. +// `?live=` talks to a real dev daemon instead; its owner token is read +// from sessionStorage "botloft.devToken", set by hand, never from the URL. + +import type { Connect } from "../features/onboarding/link"; +import { type Client, connect, rpcUrl } from "../lib/client"; +import { FakeBotloft } from "../lib/fake"; +import { FakeHost } from "../lib/fakeHost"; +import type { Host } from "../lib/host"; +import { PROTOCOL_VERSION } from "../lib/protocol.gen"; + +export function previewProps(search: string): { host: Host; connect: Connect } { + const params = new URLSearchParams(search); + const live = Number(params.get("live")); + if (live > 0) { + return liveProps(live); + } + const fake = new FakeBotloft(); + const host = new FakeHost(); + if (params.has("stopped")) { + host.status = { + state: "stopped", + port: 45710, + home: "C:\\Users\\owner\\AppData\\Local\\Botloft", + }; + } + if (!params.has("empty")) { + seed(fake); + } + return { host, connect: () => fake as Client }; +} + +function liveProps(port: number): { host: Host; connect: Connect } { + const host = new FakeHost(); + host.status = { state: "running", port, version: "dev", protocol: PROTOCOL_VERSION }; + host.token = + sessionStorage.getItem("botloft.devToken") ?? + new Error('Set sessionStorage "botloft.devToken" to the dev daemon\'s owner token.'); + const client = { name: "botloft-preview", version: "dev" }; + return { + host, + connect: (daemonPort, token) => connect({ url: rpcUrl(daemonPort), token, client }), + }; +} + +function seed(fake: FakeBotloft): void { + const research = fake.addCrew("Research"); + const scout = fake.addBot(research.id, "Scout", "Finds sources and summarizes them"); + const writer = fake.addBot(research.id, "Writer", "Turns notes into a weekly report"); + const reviewer = fake.addBot( + research.id, + "Revisão", + "Checks facts and tone before anything ships", + ); + const analyst = fake.addBot(research.id, "Analyst", "Crunches the numbers behind each claim"); + fake.setBotState(scout.id, "idle", 3); + fake.setBotState(writer.id, "busy", 2); + fake.setBotState(reviewer.id, "needs_approval", 5); + fake.setBotState(analyst.id, "rate_limited", 1); + fake.terminals.output(scout.id, SCOUT_SCREEN); + fake.terminals.output(reviewer.id, PROMPT_SCREEN); + const ops = fake.addCrew("Ops"); + const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays"); + const watcher = fake.addBot(ops.id, "Watcher", "Keeps an eye on the error log"); + void fake.call("crews.setPaused", { crewId: ops.id, paused: true }); + fake.setBotState(deploy.id, "offline"); + fake.setBotState(watcher.id, "offline"); +} + +const ESC = String.fromCharCode(27); +const NEWLINE = String.fromCharCode(13, 10); +const dim = (text: string) => `${ESC}[2m${text}${ESC}[0m`; +const accent = (text: string) => `${ESC}[38;5;209m${text}${ESC}[0m`; +const bold = (text: string) => `${ESC}[1m${text}${ESC}[0m`; + +const SCOUT_SCREEN = [ + `${bold("scout")} ${dim("· research")}`, + "", + `${accent(">")} Summarize the three newest papers in shared/inbox`, + "", + `${accent("●")} Reading shared/inbox/2026-09-27-retrieval.pdf`, + `${accent("●")} Reading shared/inbox/2026-09-26-agents.pdf`, + `${accent("●")} Wrote shared/summaries/week-39.md ${dim("(42 lines)")}`, + "", + "Done. The summary is in shared/summaries/week-39.md.", + "", + `${accent(">")} `, +].join(NEWLINE); + +const PROMPT_SCREEN = [ + `${bold("revisao")} ${dim("· research")}`, + "", + `${accent("●")} Checking the claims in shared/summaries/week-39.md`, + "", + `${ESC}[33mAllow this command?${ESC}[0m npm run check-links`, + " 1. Yes", + " 2. Yes, and don't ask again for this command", + " 3. No", + "", +].join(NEWLINE); diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx new file mode 100644 index 00000000..5e09be56 --- /dev/null +++ b/app/src/features/bots/BotAvatar.tsx @@ -0,0 +1,23 @@ +// The Botloft mascot in the bot's own color (spec 15.3). The color only +// tells bots apart; state is shown elsewhere with an icon and a label. + +const BODY = + "M468 58C600 105 730 180 800 290C830 340 845 400 870 440C885 465 915 475 930 455C948 430 945 400 938 378C1060 440 1170 600 1210 800C1240 950 1235 1120 1190 1254L70 1254L60 1254C10 1130 0 950 60 800C100 700 150 650 180 590C210 520 220 440 260 385C290 340 330 312 372 292C350 330 340 370 348 390C355 410 380 412 395 398C440 355 500 300 510 220C518 150 495 100 468 58Z"; + +export function BotAvatar({ color, size = 28 }: { color: string; size?: number }) { + return ( + + + + + + + + ); +} diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx new file mode 100644 index 00000000..249e5dac --- /dev/null +++ b/app/src/features/bots/BotDialog.tsx @@ -0,0 +1,137 @@ +import { Check } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { errorText } from "../../lib/api"; +import { AVATAR_PALETTE, type Bot, type CrewId, FIELD_LIMITS } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import { TextArea, TextField } from "../../ui/Field"; +import { BotAvatar } from "./BotAvatar"; + +type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot: Bot }); + +/** Creates a bot in `crewId`, or edits `bot`. */ +export function BotDialog(props: Props) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const selectBot = useApp((state) => state.selectBot); + const editing = props.bot; + const [name, setName] = useState(editing?.name ?? ""); + const [role, setRole] = useState(editing?.role ?? ""); + const [instructions, setInstructions] = useState(editing?.instructions ?? ""); + const [color, setColor] = useState(editing?.color); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + let bot: Bot; + if (editing) { + bot = await api.call("bots.update", { + botId: editing.id, + ...(name !== editing.name && { name }), + ...(role !== editing.role && { role }), + ...(instructions !== editing.instructions && { instructions }), + ...(color !== undefined && color !== editing.color && { color }), + }); + } else { + bot = await api.call("bots.create", { + crewId: props.crewId, + name, + role, + instructions, + ...(color !== undefined && { color }), + }); + } + putBot(bot); + selectBot(bot.id); + props.onClose(); + } catch (failure) { + setError(errorText(failure)); + setBusy(false); + } + }; + + const formId = "bot-form"; + return ( + + + + + } + > +
+
+ setName(event.target.value)} + placeholder="Reviewer" + hint="Other bots reach it by the handle made from this name." + autoFocus + required + /> + setRole(event.target.value)} + placeholder="Reviews pull requests before they merge" + /> +
+