From b60fdc5fd4130467cda8893bc163647250753011 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:03:07 -0300 Subject: [PATCH 1/5] feat(core): export the avatar palette and field limits to TypeScript The app's forms need the daemon's length limits and color palette; the generated file now carries them so the UI never restates them. --- app/src/lib/protocol.gen.ts | 11 +++++++++++ crates/botloft-core/src/protocol/export.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f41f3148..7b441bd8 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -3,6 +3,17 @@ export const PROTOCOL_VERSION = 1; +/** Avatar colors the daemon assigns in order (spec 15.3). */ +export const AVATAR_PALETTE = ["#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0"] as const; + +/** Longest values the daemon accepts, in characters. */ +export const FIELD_LIMITS = { + name: 64, + role: 200, + instructions: 32000, + message: 100000, +} as const; + /** * Identifies a crew. */ diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index a37be4e6..30ce3625 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -8,7 +8,9 @@ use std::path::Path; use ts_rs::{Config, TS}; use super::*; +use crate::avatar::PALETTE; use crate::ids::{BotId, CrewId, DeliveryId, MessageId, TaskId}; +use crate::validate; const HEADER: &str = "\ // Generated by `cargo test -p botloft-core export_bindings`. Do not edit by hand. @@ -50,6 +52,26 @@ fn export_bindings() { out.text, "\nexport const PROTOCOL_VERSION = {PROTOCOL_VERSION};\n" ); + out.text + .push_str("/** Avatar colors the daemon assigns in order (spec 15.3). */\n"); + let palette: Vec = PALETTE.iter().map(|color| format!("\"{color}\"")).collect(); + let _ = writeln!( + out.text, + "export const AVATAR_PALETTE = [{}] as const;\n", + palette.join(", ") + ); + out.text + .push_str("/** Longest values the daemon accepts, in characters. */\n"); + out.text.push_str("export const FIELD_LIMITS = {\n"); + for (name, max) in [ + ("name", validate::NAME_MAX_CHARS), + ("role", validate::ROLE_MAX_CHARS), + ("instructions", validate::INSTRUCTIONS_MAX_CHARS), + ("message", validate::MESSAGE_MAX_CHARS), + ] { + let _ = writeln!(out.text, " {name}: {max},"); + } + out.text.push_str("} as const;\n\n"); out.decl::(); out.decl::(); From 97d0af91dbb8e75a7565be89d2c2720c71b2fcae Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:03:07 -0300 Subject: [PATCH 2/5] feat(app): find, check and start the daemon from the Tauri shell Commands for what the page cannot do (spec 15.2): daemon_status checks /health on the port from \config.toml, daemon_start launches the botloftd.exe next to the app detached so it outlives the window, read_owner_token reads the owner token, and open_path opens folders only. The window has no decorations (the app draws its own title bar), and the CSP allows ws://127.0.0.1 on any port so a dev daemon with its own port is reachable. --- Cargo.lock | 4 + app/src-tauri/Cargo.toml | 4 + app/src-tauri/capabilities/default.json | 8 +- app/src-tauri/src/daemon.rs | 251 ++++++++++++++++++++++++ app/src-tauri/src/lib.rs | 58 +++++- app/src-tauri/tauri.conf.json | 9 +- 6 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 app/src-tauri/src/daemon.rs 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/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", From bdcc3815e734fee797a6a200beb4130826dd464e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:03:25 -0300 Subject: [PATCH 3/5] feat(app): JSON-RPC client, BotloftApi and the in-memory fake daemon rpc.ts speaks spec 11 over WebSocket: session.hello first, requests queued until it is accepted, answers matched by id, typed RpcError codes, and reconnection with backoff (0.5 s doubling to 5 s). Requests already sent when the socket drops fail instead of being replayed, since they may have run. A refused hello (token or protocol) stops for good. BotloftApi is the contract components use; client.ts implements it over rpc.ts and FakeBotloft in memory, typed from the generated RpcMethods so every method is covered. Host is the same idea for the Tauri commands and the window. --- app/src/lib/api.ts | 25 +++ app/src/lib/client.ts | 25 +++ app/src/lib/fake.ts | 287 +++++++++++++++++++++++++++++++ app/src/lib/fakeConversation.ts | 102 +++++++++++ app/src/lib/fakeHost.ts | 47 +++++ app/src/lib/fakeRules.ts | 33 ++++ app/src/lib/host.ts | 29 ++++ app/src/lib/rpc.test.ts | 205 ++++++++++++++++++++++ app/src/lib/rpc.ts | 292 ++++++++++++++++++++++++++++++++ app/src/lib/tauriHost.ts | 22 +++ 10 files changed, 1067 insertions(+) create mode 100644 app/src/lib/api.ts create mode 100644 app/src/lib/client.ts create mode 100644 app/src/lib/fake.ts create mode 100644 app/src/lib/fakeConversation.ts create mode 100644 app/src/lib/fakeHost.ts create mode 100644 app/src/lib/fakeRules.ts create mode 100644 app/src/lib/host.ts create mode 100644 app/src/lib/rpc.test.ts create mode 100644 app/src/lib/rpc.ts create mode 100644 app/src/lib/tauriHost.ts diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 00000000..361828c4 --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,25 @@ +// The contract the UI uses to talk to the daemon (spec 15.1). Components +// depend on this interface only: `client.ts` implements it over the +// WebSocket and `fake.ts` in memory for tests. + +import type { ConnectionState, Method, ParamsArg, Result, ServerEvent } from "./rpc"; + +export type { ConnectionState, Method, Params, Result, ServerEvent } from "./rpc"; +export { ClientErrorCode, RpcError } from "./rpc"; + +export interface BotloftApi { + /** Calls a daemon method; rejects with `RpcError`. */ + call(method: M, ...params: ParamsArg): Promise>; + /** Server notifications, in order. Returns the unsubscribe function. */ + subscribe(listener: (event: ServerEvent) => void): () => void; + connection(): ConnectionState; + onConnection(listener: (state: ConnectionState) => void): () => void; +} + +/** A readable sentence for a failed call. */ +export function errorText(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/app/src/lib/client.ts b/app/src/lib/client.ts new file mode 100644 index 00000000..b8136468 --- /dev/null +++ b/app/src/lib/client.ts @@ -0,0 +1,25 @@ +// `BotloftApi` over the daemon's WebSocket. + +import type { BotloftApi } from "./api"; +import { RpcConnection, type RpcOptions } from "./rpc"; + +export interface Client extends BotloftApi { + close(): void; +} + +export function connect(options: RpcOptions): Client { + const connection = new RpcConnection(options); + connection.start(); + return { + call: (method, ...params) => connection.request(method, ...params), + subscribe: (listener) => connection.onNotification(listener), + connection: () => connection.state, + onConnection: (listener) => connection.onState(listener), + close: () => connection.close(), + }; +} + +/** Where the app reaches a daemon listening on `port`. */ +export function rpcUrl(port: number): string { + return `ws://127.0.0.1:${port}/rpc`; +} diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts new file mode 100644 index 00000000..e04b25f1 --- /dev/null +++ b/app/src/lib/fake.ts @@ -0,0 +1,287 @@ +// FakeBotloft: an in-memory daemon for component tests (spec 15.1). It +// follows the daemon's rules closely enough for the UI: validation, +// handles, archiving, and the notifications each change sends. + +import type { BotloftApi } from "./api"; +import { conversationHandlers } from "./fakeConversation"; +import { checkName, conflict, invalid, notFound, slugify } from "./fakeRules"; +import { + AVATAR_PALETTE, + type Bot, + type BotId, + type BotState, + type Crew, + type CrewId, + type Delivery, + type Message, + type SystemStatus, + type Task, +} from "./protocol.gen"; +import type { + ConnectionState, + Method, + Params, + ParamsArg, + Result, + RpcError, + ServerEvent, +} from "./rpc"; + +export type Handlers = { [M in Method]: (params: Params) => Result }; + +export class FakeBotloft implements BotloftApi { + readonly crews = new Map(); + readonly bots = new Map(); + readonly messages: Message[] = []; + readonly deliveries = new Map(); + readonly tasks = new Map(); + system: SystemStatus = { + daemonVersion: "0.1.0", + protocol: 1, + uptimeMs: 1000, + claudeVersion: "2.1.284", + runtimeError: null, + deliveries: { pending: 0, dead: 0 }, + }; + /** Every call, in order. */ + readonly calls: { method: Method; params: unknown }[] = []; + /** Bytes written to each bot's terminal. */ + readonly terminalInput: { botId: BotId; data: string }[] = []; + now = 1_760_000_000_000; + closed = false; + private counter = 0; + private state: ConnectionState = { kind: "open", daemonVersion: "0.1.0" }; + private readonly listeners = new Set<(event: ServerEvent) => void>(); + private readonly connectionListeners = new Set<(state: ConnectionState) => void>(); + private readonly failures = new Map(); + + call(method: M, ...params: ParamsArg): Promise> { + this.calls.push({ method, params: params[0] }); + const failure = this.failures.get(method); + if (failure) { + this.failures.delete(method); + return Promise.reject(failure); + } + try { + const handler = this.handlers[method] as (params: unknown) => Result; + return Promise.resolve(structuredClone(handler(params[0]))); + } catch (error) { + return Promise.reject(error); + } + } + + subscribe(listener: (event: ServerEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + connection(): ConnectionState { + return this.state; + } + + onConnection(listener: (state: ConnectionState) => void): () => void { + this.connectionListeners.add(listener); + return () => this.connectionListeners.delete(listener); + } + + /** Satisfies `Client`; records that the app let go of the connection. */ + close(): void { + this.closed = true; + } + + // Test helpers. + + /** The next call to `method` fails with `error`. */ + failNext(method: Method, error: RpcError): void { + this.failures.set(method, error); + } + + /** Sends a notification, unless disconnected: then it is lost, as on a socket. */ + emit(event: ServerEvent): void { + if (this.state.kind !== "open") { + return; + } + for (const listener of this.listeners) { + listener(structuredClone(event)); + } + } + + setConnection(state: ConnectionState): void { + this.state = state; + for (const listener of this.connectionListeners) { + listener(state); + } + } + + addCrew(name: string): Crew { + return this.handlers["crews.create"]({ name }); + } + + addBot(crewId: CrewId, name: string, role = ""): Bot { + return this.handlers["bots.create"]({ crewId, name, role, instructions: "" }); + } + + setBotState(botId: BotId, state: BotState, generation?: number): void { + const bot = this.bot(botId); + bot.state = state; + bot.generation = generation ?? bot.generation ?? 1; + this.emit({ name: "bot.state", params: { botId, state, generation: bot.generation } }); + } + + /** A new id with `prefix`, like the daemon's. */ + id(prefix: string): string { + this.counter += 1; + return `${prefix}_${String(this.counter).padStart(4, "0")}`; + } + + private crew(crewId: CrewId, active = true): Crew { + const crew = this.crews.get(crewId); + if (!crew) { + throw notFound(`crew ${crewId}`); + } + if (active && crew.archivedAt !== null) { + throw conflict(`crew ${crewId} is archived`); + } + return crew; + } + + /** The bot; archived ones only when `active` is false. */ + bot(botId: BotId, active = true): Bot { + const bot = this.bots.get(botId); + if (!bot) { + throw notFound(`bot ${botId}`); + } + if (active && bot.archivedAt !== null) { + throw conflict(`bot ${botId} is archived`); + } + return bot; + } + + private activeBots(crewId?: CrewId): Bot[] { + return [...this.bots.values()].filter( + (bot) => bot.archivedAt === null && (crewId === undefined || bot.crewId === crewId), + ); + } + + private changedCrew(crew: Crew): Crew { + this.emit({ name: "crew.changed", params: crew }); + return crew; + } + + private changedBot(bot: Bot): Bot { + this.emit({ name: "bot.changed", params: bot }); + return bot; + } + + private handle(name: string, crewId: CrewId, except?: BotId): string { + const handle = slugify(name, "bot"); + const taken = this.activeBots(crewId).some((bot) => bot.handle === handle && bot.id !== except); + if (taken) { + throw invalid(`@${handle} is already used in this crew`); + } + return handle; + } + + private readonly handlers: Handlers = { + "session.hello": () => ({ daemonVersion: this.system.daemonVersion, protocol: 1 }), + "system.status": () => this.system, + "crews.list": () => [...this.crews.values()].filter((crew) => crew.archivedAt === null), + "crews.create": ({ name }) => { + const checked = checkName(name); + const crew: Crew = { + id: this.id("crw"), + name: checked, + slug: slugify(checked, "crew"), + paused: false, + createdAt: this.now, + archivedAt: null, + }; + this.crews.set(crew.id, crew); + return this.changedCrew(crew); + }, + "crews.rename": ({ crewId, name }) => { + const crew = this.crew(crewId); + crew.name = checkName(name); + return this.changedCrew(crew); + }, + "crews.setPaused": ({ crewId, paused }) => { + const crew = this.crew(crewId); + crew.paused = paused; + return this.changedCrew(crew); + }, + "crews.archive": ({ crewId }) => { + const crew = this.crew(crewId, false); + if (crew.archivedAt === null) { + crew.archivedAt = this.now; + for (const bot of this.activeBots(crewId)) { + bot.archivedAt = this.now; + bot.state = "archived"; + this.changedBot(bot); + } + this.changedCrew(crew); + } + return crew; + }, + "bots.list": ({ crewId }) => { + if (crewId !== undefined) { + this.crew(crewId); + } + return this.activeBots(crewId); + }, + "bots.create": ({ crewId, name, role, instructions, color }) => { + const crew = this.crew(crewId); + const checked = checkName(name); + const handle = this.handle(checked, crewId); + const index = [...this.bots.values()].filter((bot) => bot.crewId === crewId).length; + const bot: Bot = { + id: this.id("bot"), + crewId, + name: checked, + handle, + slug: handle, + role: role.trim(), + instructions, + color: color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59", + paused: false, + state: crew.paused ? "offline" : "launching", + generation: crew.paused ? null : 1, + workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`, + createdAt: this.now, + archivedAt: null, + }; + this.bots.set(bot.id, bot); + return this.changedBot(bot); + }, + "bots.update": ({ botId, name, role, instructions, color }) => { + const bot = this.bot(botId); + if (name !== undefined) { + bot.name = checkName(name); + bot.handle = this.handle(bot.name, bot.crewId, botId); + } + bot.role = role?.trim() ?? bot.role; + bot.instructions = instructions ?? bot.instructions; + bot.color = color ?? bot.color; + return this.changedBot(bot); + }, + "bots.setPaused": ({ botId, paused }) => { + const bot = this.bot(botId); + bot.paused = paused; + return this.changedBot(bot); + }, + "bots.restart": ({ botId }) => { + const bot = this.bot(botId); + this.setBotState(botId, "launching", (bot.generation ?? 0) + 1); + return bot; + }, + "bots.archive": ({ botId }) => { + const bot = this.bot(botId, false); + if (bot.archivedAt === null) { + bot.archivedAt = this.now; + bot.state = "archived"; + this.changedBot(bot); + } + return bot; + }, + ...conversationHandlers(this), + }; +} diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts new file mode 100644 index 00000000..7107d311 --- /dev/null +++ b/app/src/lib/fakeConversation.ts @@ -0,0 +1,102 @@ +// The fake daemon's terminal, messages, deliveries and tasks. + +import type { FakeBotloft, Handlers } from "./fake"; +import { conflict, invalid, notFound } from "./fakeRules"; +import type { Delivery, Message } from "./protocol.gen"; + +type Conversation = Pick< + Handlers, + | "terminal.attach" + | "terminal.detach" + | "terminal.write" + | "terminal.resize" + | "messages.send" + | "messages.list" + | "deliveries.list" + | "deliveries.retry" + | "tasks.list" +>; + +export function conversationHandlers(fake: FakeBotloft): Conversation { + return { + "terminal.attach": ({ botId }) => { + const bot = fake.bot(botId, false); + return { generation: bot.generation ?? 1, offset: 0, reset: true }; + }, + "terminal.detach": () => null, + "terminal.write": ({ botId, data }) => { + fake.terminalInput.push({ botId, data }); + return null; + }, + "terminal.resize": () => null, + "messages.send": ({ botId, body }) => { + const bot = fake.bot(botId); + if (!body.trim()) { + throw invalid("body must not be empty"); + } + const message: Message = { + id: fake.id("msg"), + crewId: bot.crewId, + fromKind: "owner", + fromBotId: null, + toBotId: botId, + kind: "note", + body: body.trim(), + taskId: null, + createdAt: fake.now, + }; + fake.messages.push(message); + const delivery: Delivery = { + id: fake.id("dlv"), + messageId: message.id, + botId, + state: "pending", + attempts: 0, + nextAttemptAt: fake.now, + lastError: null, + updatedAt: fake.now, + }; + fake.deliveries.set(delivery.id, delivery); + fake.emit({ name: "message.created", params: message }); + fake.emit({ name: "delivery.changed", params: delivery }); + return message; + }, + "messages.list": ({ crewId, botId, before, limit }) => { + const matches = fake.messages.filter( + (message) => + (crewId === undefined || message.crewId === crewId) && + (botId === undefined || message.toBotId === botId || message.fromBotId === botId) && + (before === undefined || message.id < before), + ); + return matches.reverse().slice(0, limit ?? 50); + }, + "deliveries.list": ({ state, botId }) => + [...fake.deliveries.values()] + .filter( + (delivery) => + (state === undefined || delivery.state === state) && + (botId === undefined || delivery.botId === botId), + ) + .sort((a, b) => b.updatedAt - a.updatedAt), + "deliveries.retry": ({ deliveryId }) => { + const delivery = fake.deliveries.get(deliveryId); + if (!delivery) { + throw notFound(`delivery ${deliveryId}`); + } + if (delivery.state !== "dead") { + throw conflict("only a dead delivery can be retried"); + } + Object.assign(delivery, { state: "pending", attempts: 0, lastError: null }); + fake.emit({ name: "delivery.changed", params: delivery }); + return delivery; + }, + "tasks.list": ({ crewId, status }) => + [...fake.tasks.values()] + .filter( + (task) => + (crewId === undefined || task.crewId === crewId) && + (status === undefined || task.status === status), + ) + .reverse(), + }; +} diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts new file mode 100644 index 00000000..d54e7f22 --- /dev/null +++ b/app/src/lib/fakeHost.ts @@ -0,0 +1,47 @@ +// A `Host` for tests: a daemon that is running unless told otherwise. + +import type { DaemonStatus, Host } from "./host"; + +export class FakeHost implements Host { + status: DaemonStatus = { state: "running", port: 45710, version: "0.1.0", protocol: 1 }; + /** What `startDaemon` leaves behind. */ + afterStart: DaemonStatus = { state: "running", port: 45710, version: "0.1.0", protocol: 1 }; + token: string | Error = "a".repeat(64); + readonly opened: string[] = []; + starts = 0; + maximized = false; + closed = false; + + daemonStatus(): Promise { + return Promise.resolve(this.status); + } + + startDaemon(): Promise { + this.starts += 1; + this.status = this.afterStart; + return Promise.resolve(this.status); + } + + readOwnerToken(): Promise { + return this.token instanceof Error ? Promise.reject(this.token) : Promise.resolve(this.token); + } + + openPath(path: string): Promise { + this.opened.push(path); + return Promise.resolve(); + } + + window = { + minimize: () => Promise.resolve(), + toggleMaximize: () => { + this.maximized = !this.maximized; + return Promise.resolve(); + }, + close: () => { + this.closed = true; + return Promise.resolve(); + }, + isMaximized: () => Promise.resolve(this.maximized), + onResized: () => Promise.resolve(() => {}), + }; +} diff --git a/app/src/lib/fakeRules.ts b/app/src/lib/fakeRules.ts new file mode 100644 index 00000000..42bf082a --- /dev/null +++ b/app/src/lib/fakeRules.ts @@ -0,0 +1,33 @@ +// Rules the fake daemon shares between its handlers: errors, slugs and +// names, close enough to the daemon's for the UI. + +import { FIELD_LIMITS, RpcErrorCode } from "./protocol.gen"; +import { RpcError } from "./rpc"; + +export const invalid = (message: string) => new RpcError(RpcErrorCode.validation, message); +export const notFound = (what: string) => new RpcError(RpcErrorCode.notFound, `${what} not found`); +export const conflict = (message: string) => new RpcError(RpcErrorCode.conflict, message); + +/** ASCII, lowercase, hyphens; close enough to the daemon's slugs. */ +export function slugify(name: string, fallback: string): string { + const slug = name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + return slug || fallback; +} + +/** A crew or bot name, trimmed and checked like the daemon does. */ +export function checkName(value: string): string { + const name = value.trim(); + if (!name) { + throw invalid("name must not be empty"); + } + if (name.length > FIELD_LIMITS.name) { + throw invalid(`name must be at most ${FIELD_LIMITS.name} characters`); + } + return name; +} diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts new file mode 100644 index 00000000..4197e319 --- /dev/null +++ b/app/src/lib/host.ts @@ -0,0 +1,29 @@ +// What the app needs from the native shell (spec 15.2): the daemon's +// status, starting it, the owner token, Explorer and the window. The Tauri +// implementation lives in `tauriHost.ts`; tests pass a fake. + +export type DaemonStatus = + | { state: "running"; port: number; version: string; protocol: number } + /** Nothing listens on the port. `home` is the daemon's data folder. */ + | { state: "stopped"; port: number; home: string } + /** Something that is not botloftd holds the port. */ + | { state: "foreign"; port: number }; + +export interface AppWindow { + minimize(): Promise; + toggleMaximize(): Promise; + close(): Promise; + isMaximized(): Promise; + /** Called when the window is resized; returns the unsubscribe function. */ + onResized(listener: () => void): Promise<() => void>; +} + +export interface Host { + daemonStatus(): Promise; + /** Starts the daemon if needed and waits for it to answer. */ + startDaemon(): Promise; + readOwnerToken(): Promise; + /** Opens a folder in Explorer. */ + openPath(path: string): Promise; + window: AppWindow; +} diff --git a/app/src/lib/rpc.test.ts b/app/src/lib/rpc.test.ts new file mode 100644 index 00000000..1f3cc512 --- /dev/null +++ b/app/src/lib/rpc.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { RpcErrorCode } from "./protocol.gen"; +import { ClientErrorCode, RpcConnection, RpcError, type SocketLike } from "./rpc"; + +class FakeSocket implements SocketLike { + onopen: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + readonly sent: { id?: number; method?: string; params?: unknown }[] = []; + closed = false; + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + close(): void { + this.closed = true; + } + + open(): void { + this.onopen?.({}); + } + + reply(id: number | undefined, result: unknown): void { + this.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", id, result }) }); + } + + fail(id: number | undefined, code: number, message: string): void { + this.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) }); + } + + notify(method: string, params: unknown): void { + this.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", method, params }) }); + } + + drop(): void { + this.onclose?.({}); + } + + last(): { id?: number; method?: string; params?: unknown } { + const frame = this.sent.at(-1); + if (!frame) { + throw new Error("nothing sent"); + } + return frame; + } +} + +function setup(options: { requestTimeoutMs?: number } = {}) { + const sockets: FakeSocket[] = []; + const connection = new RpcConnection({ + url: "ws://127.0.0.1:45710/rpc", + token: "t".repeat(64), + client: { name: "test", version: "1" }, + socket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + ...options, + }); + const socket = () => { + const current = sockets.at(-1); + if (!current) { + throw new Error("no socket"); + } + return current; + }; + /** Opens the socket and accepts the hello. */ + const accept = () => { + socket().open(); + socket().reply(socket().last().id, { daemonVersion: "0.1.0", protocol: 1 }); + }; + return { connection, sockets, socket, accept }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("RpcConnection", () => { + test("says hello first and holds requests until the daemon accepts it", async () => { + const { connection, socket } = setup(); + connection.start(); + const crews = connection.request("crews.list"); + expect(connection.state.kind).toBe("connecting"); + socket().open(); + expect(socket().sent).toEqual([ + { + jsonrpc: "2.0", + id: 1, + method: "session.hello", + params: { token: "t".repeat(64), client: { name: "test", version: "1" }, protocol: 1 }, + }, + ]); + socket().reply(1, { daemonVersion: "0.1.0", protocol: 1 }); + expect(connection.state).toEqual({ kind: "open", daemonVersion: "0.1.0" }); + // Methods without params send no params field. + expect(socket().last()).toEqual({ jsonrpc: "2.0", id: 2, method: "crews.list" }); + socket().reply(2, []); + await expect(crews).resolves.toEqual([]); + }); + + test("answers carry their result or a typed error", async () => { + const { connection, socket, accept } = setup(); + connection.start(); + accept(); + const created = connection.request("crews.create", { name: "" }); + expect(socket().last()).toMatchObject({ method: "crews.create", params: { name: "" } }); + socket().fail(socket().last().id, RpcErrorCode.validation, "name must not be empty"); + const error = await created.catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(RpcError); + expect(error).toMatchObject({ + code: RpcErrorCode.validation, + message: "name must not be empty", + }); + }); + + test("delivers notifications to every listener", () => { + const { connection, socket, accept } = setup(); + const seen: string[] = []; + connection.onNotification((event) => seen.push(event.name)); + connection.start(); + accept(); + socket().notify("bot.state", { botId: "bot_1", state: "idle", generation: 3 }); + expect(seen).toEqual(["bot.state"]); + }); + + test("a refused hello fails for good", async () => { + const { connection, sockets, socket } = setup(); + connection.start(); + const pending = connection.request("crews.list"); + socket().open(); + socket().fail(1, RpcErrorCode.notAuthenticated, "invalid token"); + expect(connection.state).toEqual({ kind: "failed", reason: "invalid token" }); + await expect(pending).rejects.toMatchObject({ code: RpcErrorCode.notAuthenticated }); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(1); + await expect(connection.request("crews.list")).rejects.toMatchObject({ + code: ClientErrorCode.closed, + }); + }); + + test("reconnects with backoff after losing the daemon", async () => { + const { connection, sockets, socket, accept } = setup(); + const states: string[] = []; + connection.onState((state) => states.push(state.kind)); + connection.start(); + accept(); + const inflight = connection.request("crews.list"); + socket().drop(); + await expect(inflight).rejects.toMatchObject({ code: ClientErrorCode.connectionLost }); + expect(connection.state.kind).toBe("waiting"); + + const queued = connection.request("bots.list", {}); + vi.advanceTimersByTime(499); + expect(sockets).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(sockets).toHaveLength(2); + // The next loss waits twice as long. + socket().drop(); + vi.advanceTimersByTime(999); + expect(sockets).toHaveLength(2); + vi.advanceTimersByTime(1); + accept(); + expect(socket().last()).toMatchObject({ method: "bots.list", params: {} }); + socket().reply(socket().last().id, []); + await expect(queued).resolves.toEqual([]); + expect(states).toEqual([ + "connecting", + "open", + "waiting", + "connecting", + "waiting", + "connecting", + "open", + ]); + }); + + test("a request the daemon never answers times out", async () => { + const { connection, accept } = setup({ requestTimeoutMs: 1000 }); + connection.start(); + accept(); + const slow = connection.request("system.status"); + vi.advanceTimersByTime(1000); + await expect(slow).rejects.toMatchObject({ code: ClientErrorCode.timeout }); + }); + + test("close stops everything", async () => { + const { connection, sockets, socket } = setup(); + connection.start(); + const queued = connection.request("crews.list"); + connection.close(); + await expect(queued).rejects.toMatchObject({ code: ClientErrorCode.closed }); + expect(socket().closed).toBe(true); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(1); + expect(connection.state.kind).toBe("closed"); + }); +}); diff --git a/app/src/lib/rpc.ts b/app/src/lib/rpc.ts new file mode 100644 index 00000000..5f9b7043 --- /dev/null +++ b/app/src/lib/rpc.ts @@ -0,0 +1,292 @@ +// JSON-RPC 2.0 over WebSocket to botloftd (spec 11): `session.hello` +// first, requests queued while disconnected, and reconnection with backoff. + +import { + type ClientInfo, + type HelloResult, + PROTOCOL_VERSION, + RpcErrorCode, + type RpcMethods, + type RpcNotifications, +} from "./protocol.gen"; + +export type Method = keyof RpcMethods; +export type Params = RpcMethods[M]["params"]; +export type Result = RpcMethods[M]["result"]; +/** No argument for methods without params. */ +export type ParamsArg = Params extends undefined ? [] : [Params]; +export type NotificationName = keyof RpcNotifications; +export type ServerEvent = { + [N in NotificationName]: { name: N; params: RpcNotifications[N] }; +}[NotificationName]; + +/** Codes for failures that happen on this side of the socket. */ +export const ClientErrorCode = { + connectionLost: -1, + timeout: -2, + closed: -3, +} as const; + +export class RpcError extends Error { + readonly code: number; + + constructor(code: number, message: string) { + super(message); + this.name = "RpcError"; + this.code = code; + } +} + +export type ConnectionState = + | { kind: "connecting" } + | { kind: "open"; daemonVersion: string } + /** Lost the daemon; trying again at `retryAt` (ms). */ + | { kind: "waiting"; retryAt: number } + /** The daemon refused this client (token or protocol); no retries. */ + | { kind: "failed"; reason: string } + | { kind: "closed" }; + +/** The part of `WebSocket` the connection uses. */ +export interface SocketLike { + onopen: ((event: unknown) => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: ((event: unknown) => void) | null; + onerror: ((event: unknown) => void) | null; + send(data: string): void; + close(): void; +} + +export interface RpcOptions { + url: string; + token: string; + client: ClientInfo; + socket?: (url: string) => SocketLike; + retryInitialMs?: number; + retryMaxMs?: number; + requestTimeoutMs?: number; +} + +interface Pending { + method: string; + params: unknown; + resolve: (value: unknown) => void; + reject: (error: RpcError) => void; + timer: ReturnType | undefined; +} + +export class RpcConnection { + private readonly options: Required; + private socket: SocketLike | null = null; + private nextId = 1; + private retryMs: number; + private retryTimer: ReturnType | undefined; + /** Waiting for the connection to open. */ + private readonly queue: Pending[] = []; + /** Sent, waiting for the answer. */ + private readonly inflight = new Map(); + private readonly notificationListeners = new Set<(event: ServerEvent) => void>(); + private readonly stateListeners = new Set<(state: ConnectionState) => void>(); + state: ConnectionState = { kind: "closed" }; + + constructor(options: RpcOptions) { + this.options = { + socket: (url) => new WebSocket(url) as unknown as SocketLike, + retryInitialMs: 500, + retryMaxMs: 5000, + requestTimeoutMs: 30_000, + ...options, + }; + this.retryMs = this.options.retryInitialMs; + } + + start(): void { + if (this.state.kind === "closed" || this.state.kind === "failed") { + this.open(); + } + } + + /** Stops for good: pending requests fail and nothing reconnects. */ + close(): void { + clearTimeout(this.retryTimer); + this.setState({ kind: "closed" }); + this.dropSocket(); + this.failAll(new RpcError(ClientErrorCode.closed, "the connection was closed")); + } + + request(method: M, ...params: ParamsArg): Promise> { + return new Promise>((resolve, reject) => { + if (this.state.kind === "closed" || this.state.kind === "failed") { + reject(new RpcError(ClientErrorCode.closed, "not connected to the daemon")); + return; + } + const pending: Pending = { + method, + params: params[0], + resolve: resolve as (value: unknown) => void, + reject, + timer: undefined, + }; + if (this.state.kind === "open") { + this.send(pending); + } else { + this.queue.push(pending); + } + }); + } + + onNotification(listener: (event: ServerEvent) => void): () => void { + this.notificationListeners.add(listener); + return () => this.notificationListeners.delete(listener); + } + + onState(listener: (state: ConnectionState) => void): () => void { + this.stateListeners.add(listener); + return () => this.stateListeners.delete(listener); + } + + private open(): void { + this.setState({ kind: "connecting" }); + const socket = this.options.socket(this.options.url); + this.socket = socket; + socket.onopen = () => this.hello(socket); + socket.onmessage = (event) => { + if (typeof event.data === "string") { + this.receive(event.data); + } + }; + socket.onclose = () => this.lost(socket); + socket.onerror = () => this.lost(socket); + } + + private hello(socket: SocketLike): void { + const id = this.nextId++; + const done = (result: HelloResult) => { + this.retryMs = this.options.retryInitialMs; + this.setState({ kind: "open", daemonVersion: result.daemonVersion }); + for (const pending of this.queue.splice(0)) { + this.send(pending); + } + }; + const refused = (error: RpcError) => { + // A wrong token or protocol will not fix itself by retrying. + const fatal = + error.code === RpcErrorCode.notAuthenticated || error.code === RpcErrorCode.validation; + if (fatal) { + this.setState({ kind: "failed", reason: error.message }); + this.dropSocket(); + this.failAll(error); + } + }; + this.inflight.set(id, { + method: "session.hello", + params: undefined, + resolve: (value) => done(value as HelloResult), + reject: refused, + timer: undefined, + }); + const params = { + token: this.options.token, + client: this.options.client, + protocol: PROTOCOL_VERSION, + }; + socket.send(JSON.stringify({ jsonrpc: "2.0", id, method: "session.hello", params })); + } + + private send(pending: Pending): void { + const id = this.nextId++; + pending.timer = setTimeout(() => { + this.inflight.delete(id); + pending.reject(new RpcError(ClientErrorCode.timeout, `${pending.method} timed out`)); + }, this.options.requestTimeoutMs); + this.inflight.set(id, pending); + const frame: Record = { jsonrpc: "2.0", id, method: pending.method }; + if (pending.params !== undefined) { + frame.params = pending.params; + } + this.socket?.send(JSON.stringify(frame)); + } + + private receive(text: string): void { + let frame: { + id?: unknown; + method?: unknown; + params?: unknown; + result?: unknown; + error?: { code?: unknown; message?: unknown }; + }; + try { + frame = JSON.parse(text); + } catch { + return; + } + if (typeof frame.id === "number") { + const pending = this.inflight.get(frame.id); + if (!pending) { + return; + } + this.inflight.delete(frame.id); + clearTimeout(pending.timer); + if (frame.error) { + const code = typeof frame.error.code === "number" ? frame.error.code : 0; + const message = typeof frame.error.message === "string" ? frame.error.message : "error"; + pending.reject(new RpcError(code, message)); + } else { + pending.resolve(frame.result ?? null); + } + } else if (typeof frame.method === "string") { + const event = { name: frame.method, params: frame.params } as ServerEvent; + for (const listener of this.notificationListeners) { + listener(event); + } + } + } + + private lost(socket: SocketLike): void { + if (socket !== this.socket) { + return; + } + this.dropSocket(); + // Requests already sent may or may not have run; the caller decides. + for (const [id, pending] of this.inflight) { + this.inflight.delete(id); + clearTimeout(pending.timer); + pending.reject( + new RpcError(ClientErrorCode.connectionLost, "lost the connection to the daemon"), + ); + } + if (this.state.kind === "failed" || this.state.kind === "closed") { + return; + } + const delay = this.retryMs; + this.retryMs = Math.min(this.retryMs * 2, this.options.retryMaxMs); + this.setState({ kind: "waiting", retryAt: Date.now() + delay }); + this.retryTimer = setTimeout(() => this.open(), delay); + } + + private dropSocket(): void { + const socket = this.socket; + this.socket = null; + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + socket.close(); + } + } + + private failAll(error: RpcError): void { + for (const pending of [...this.queue.splice(0), ...this.inflight.values()]) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.inflight.clear(); + } + + private setState(state: ConnectionState): void { + this.state = state; + for (const listener of this.stateListeners) { + listener(state); + } + } +} diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts new file mode 100644 index 00000000..d2434cf8 --- /dev/null +++ b/app/src/lib/tauriHost.ts @@ -0,0 +1,22 @@ +// `Host` over the Tauri commands in `src-tauri/src/lib.rs`. + +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { DaemonStatus, Host } from "./host"; + +export function tauriHost(): Host { + const window = getCurrentWindow(); + return { + daemonStatus: () => invoke("daemon_status"), + startDaemon: () => invoke("daemon_start"), + readOwnerToken: () => invoke("read_owner_token"), + openPath: (path) => invoke("open_path", { path }), + window: { + minimize: () => window.minimize(), + toggleMaximize: () => window.toggleMaximize(), + close: () => window.close(), + isMaximized: () => window.isMaximized(), + onResized: (listener) => window.onResized(() => listener()), + }, + }; +} From e644f13b291bc17210f2ec23ed7cfa8c2b521496 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:03:25 -0300 Subject: [PATCH 4/5] feat(app): onboarding, title bar, crews and bots (M4) - Onboarding: finds the daemon, offers to start it, reads the owner token and connects; a taken port, another protocol version or a refused hello each get their own message. The first run lists what is ready (daemon, Claude Code) and explains the one-time folder trust prompt per bot. - Store: crews, bots and system.status load on every (re)connection, then follow crew.changed, bot.changed and bot.state; archived records leave. - Screens: crews and their bots in the sidebar, a crew overview with bot cards, and a bot view with pause, restart (fresh or not), edit, open folder and archive. State reads as color, icon and label; paused bots read as paused. Daemon validation errors stay in the form. - Own title bar with Windows controls, light and dark themes following Windows, IBM Plex type, bot avatars drawn from the mascot. - `pnpm dev` in a plain browser runs the UI against FakeBotloft, for UI work without Tauri or the daemon. The preview is not in release builds. --- app/package.json | 6 +- app/pnpm-lock.yaml | 54 +++++++ app/src/App.test.tsx | 130 ++++++++++++++- app/src/App.tsx | 39 ++++- app/src/dev/preview.ts | 48 ++++++ app/src/features/bots/BotAvatar.tsx | 23 +++ app/src/features/bots/BotDialog.tsx | 137 ++++++++++++++++ app/src/features/bots/BotStateBadge.tsx | 108 +++++++++++++ app/src/features/bots/BotView.tsx | 155 ++++++++++++++++++ app/src/features/crews/CrewDialog.tsx | 74 +++++++++ app/src/features/crews/CrewView.tsx | 141 +++++++++++++++++ app/src/features/crews/Sidebar.tsx | 83 ++++++++++ app/src/features/onboarding/Onboarding.tsx | 112 +++++++++++++ app/src/features/onboarding/Welcome.tsx | 88 +++++++++++ app/src/features/onboarding/link.test.ts | 87 +++++++++++ app/src/features/onboarding/link.ts | 138 ++++++++++++++++ app/src/index.css | 110 +++++++++++++ app/src/main.tsx | 30 +++- app/src/shell/TitleBar.tsx | 113 +++++++++++++ app/src/shell/Workspace.tsx | 121 ++++++++++++++ app/src/shell/theme.ts | 73 +++++++++ app/src/store/app.test.ts | 73 +++++++++ app/src/store/app.ts | 174 +++++++++++++++++++++ app/src/store/context.tsx | 45 ++++++ app/src/ui/Button.tsx | 45 ++++++ app/src/ui/Callout.tsx | 37 +++++ app/src/ui/Confirm.tsx | 45 ++++++ app/src/ui/Dialog.tsx | 51 ++++++ app/src/ui/Field.tsx | 74 +++++++++ app/src/ui/Menu.tsx | 79 ++++++++++ app/src/ui/toast.tsx | 64 ++++++++ 31 files changed, 2540 insertions(+), 17 deletions(-) create mode 100644 app/src/dev/preview.ts create mode 100644 app/src/features/bots/BotAvatar.tsx create mode 100644 app/src/features/bots/BotDialog.tsx create mode 100644 app/src/features/bots/BotStateBadge.tsx create mode 100644 app/src/features/bots/BotView.tsx create mode 100644 app/src/features/crews/CrewDialog.tsx create mode 100644 app/src/features/crews/CrewView.tsx create mode 100644 app/src/features/crews/Sidebar.tsx create mode 100644 app/src/features/onboarding/Onboarding.tsx create mode 100644 app/src/features/onboarding/Welcome.tsx create mode 100644 app/src/features/onboarding/link.test.ts create mode 100644 app/src/features/onboarding/link.ts create mode 100644 app/src/shell/TitleBar.tsx create mode 100644 app/src/shell/Workspace.tsx create mode 100644 app/src/shell/theme.ts create mode 100644 app/src/store/app.test.ts create mode 100644 app/src/store/app.ts create mode 100644 app/src/store/context.tsx create mode 100644 app/src/ui/Button.tsx create mode 100644 app/src/ui/Callout.tsx create mode 100644 app/src/ui/Confirm.tsx create mode 100644 app/src/ui/Dialog.tsx create mode 100644 app/src/ui/Field.tsx create mode 100644 app/src/ui/Menu.tsx create mode 100644 app/src/ui/toast.tsx diff --git a/app/package.json b/app/package.json index 2a19e4dd..7eea0ca1 100644 --- a/app/package.json +++ b/app/package.json @@ -16,9 +16,13 @@ "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", + "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..c52d5c1a 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -166,15 +166,27 @@ 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 + 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 +359,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==} @@ -1087,6 +1105,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 +1351,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 +1461,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 @@ -1924,6 +1969,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 +2170,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/App.test.tsx b/app/src/App.test.tsx index 5e593ce8..75225745 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,8 +1,128 @@ -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 } 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(); +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(); + }); }); 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..6c874012 --- /dev/null +++ b/app/src/dev/preview.ts @@ -0,0 +1,48 @@ +// 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. + +import type { Connect } from "../features/onboarding/link"; +import type { Client } from "../lib/client"; +import { FakeBotloft } from "../lib/fake"; +import { FakeHost } from "../lib/fakeHost"; +import type { Host } from "../lib/host"; + +export function previewProps(search: string): { host: Host; connect: Connect } { + const params = new URLSearchParams(search); + 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 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); + 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"); +} 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" + /> +
+