diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index c049167..9b09d6e 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1434,6 +1434,11 @@ dependencies = [ name = "gravity-desktop" version = "0.13.0" dependencies = [ + "block2", + "objc2", + "objc2-avf-audio", + "objc2-foundation", + "objc2-speech", "semver", "serde", "serde_json", @@ -1444,6 +1449,7 @@ dependencies = [ "tauri-plugin-updater", "toml 0.8.2", "ureq", + "windows", ] [[package]] @@ -2316,6 +2322,32 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-audio-toolbox", + "objc2-core-audio-types", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -2327,6 +2359,28 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -2381,6 +2435,20 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -2393,6 +2461,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2456,6 +2537,19 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-speech" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9eb609f9d2a25e0f8b76954f3acaa58348ce2a91285fe867643b2224ac4d263" +dependencies = [ + "block2", + "objc2", + "objc2-avf-audio", + "objc2-core-media", + "objc2-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a9255e8..15d77ec 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -25,3 +25,15 @@ serde_json = "1" semver = "1" ureq = { version = "2", features = ["json"] } toml = "0.8" + +# On-device dictation for the chat composer (Apple's Speech framework). +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +block2 = "0.6" +objc2-foundation = "0.3" +objc2-speech = "0.3" +objc2-avf-audio = "0.3" + +# On-device dictation on Windows (SAPI's in-process recognizer). +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Media_Speech", "Win32_System_Com"] } diff --git a/apps/desktop/src-tauri/Entitlements.plist b/apps/desktop/src-tauri/Entitlements.plist new file mode 100644 index 0000000..82d6e63 --- /dev/null +++ b/apps/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist new file mode 100644 index 0000000..c7a8646 --- /dev/null +++ b/apps/desktop/src-tauri/Info.plist @@ -0,0 +1,11 @@ + + + + + + NSMicrophoneUsageDescription + Gravity listens while you dictate a message to a bot in the chat. + NSSpeechRecognitionUsageDescription + Gravity turns your dictation into text on this Mac, for messages to your bots. + + diff --git a/apps/desktop/src-tauri/src/dictation.rs b/apps/desktop/src-tauri/src/dictation.rs new file mode 100644 index 0000000..b2289ca --- /dev/null +++ b/apps/desktop/src-tauri/src/dictation.rs @@ -0,0 +1,82 @@ +//! On-device dictation for the chat composer. The operating system's own +//! speech recognizer turns the microphone into text, so audio never goes to +//! Gravity or a third party: Apple's Speech framework on macOS, set to +//! recognize on the device whenever the Mac supports it, and SAPI's +//! in-process recognizer on Windows, which always runs on the PC. +//! +//! The composer starts and stops a session; partial and final transcripts +//! arrive as `dictation` events. + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod sapi; + +/// The event the composer listens to. +const EVENT: &str = "dictation"; + +#[derive(Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum DictationEvent { + /// The transcript so far; it may still change. + Partial { text: String }, + /// The settled transcript; the session has ended. + Final { text: String }, + /// The session ended without a transcript, or failed. + Ended { + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, +} + +pub(crate) fn emit(app: &AppHandle, event: DictationEvent) { + let _ = app.emit(EVENT, event); +} + +/// Whether this platform can dictate. +#[tauri::command] +pub fn dictation_available() -> bool { + #[cfg(windows)] + { + sapi::available() + } + #[cfg(not(windows))] + { + cfg!(target_os = "macos") + } +} + +/// Starts listening, asking for speech and microphone permission the first +/// time. Async so the permission prompt never waits on the main thread. +#[tauri::command] +pub async fn start_dictation(app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + tauri::async_runtime::spawn_blocking(move || macos::start(app)) + .await + .map_err(|e| e.to_string())? + } + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || sapi::start(app)) + .await + .map_err(|e| e.to_string())? + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = app; + Err("Dictation is not available on this platform yet.".to_string()) + } +} + +/// Stops listening; the final transcript follows as an event. +#[tauri::command] +pub fn stop_dictation() { + #[cfg(target_os = "macos")] + macos::stop(); + #[cfg(windows)] + sapi::stop(); +} diff --git a/apps/desktop/src-tauri/src/dictation/macos.rs b/apps/desktop/src-tauri/src/dictation/macos.rs new file mode 100644 index 0000000..daa09b5 --- /dev/null +++ b/apps/desktop/src-tauri/src/dictation/macos.rs @@ -0,0 +1,153 @@ +//! Dictation on macOS: `AVAudioEngine` feeds the microphone to an +//! `SFSpeechAudioBufferRecognitionRequest`, recognized on the device. + +use std::ptr::NonNull; +use std::sync::mpsc; +use std::sync::Mutex; +use std::time::Duration; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2_avf_audio::{AVAudioEngine, AVAudioPCMBuffer, AVAudioTime}; +use objc2_foundation::NSError; +use objc2_speech::{ + SFSpeechAudioBufferRecognitionRequest, SFSpeechRecognitionResult, SFSpeechRecognitionTask, + SFSpeechRecognizer, SFSpeechRecognizerAuthorizationStatus, +}; +use tauri::AppHandle; + +use super::{emit, DictationEvent}; + +/// Samples per microphone buffer handed to the recognizer. +const TAP_FRAMES: u32 = 1024; + +/// A running session's objects. They are only touched under `SESSION`'s +/// lock, and AVAudioEngine and the Speech objects accept calls from any +/// thread, so moving them between threads is sound. +struct Session { + engine: Retained, + request: Retained, + task: Retained, + _recognizer: Retained, +} + +// SAFETY: see `Session`; the objects are used from one thread at a time. +unsafe impl Send for Session {} + +static SESSION: Mutex> = Mutex::new(None); +/// A stopped session still finishing its transcript; kept alive until the +/// next one starts, so its final result is not cut off. +static FINISHING: Mutex> = Mutex::new(None); + +fn lock(slot: &'static Mutex>) -> std::sync::MutexGuard<'static, Option> { + slot.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The speech permission, asking the first time. +fn authorize() -> Result<(), String> { + let status = unsafe { SFSpeechRecognizer::authorizationStatus() }; + let status = if status == SFSpeechRecognizerAuthorizationStatus::NotDetermined { + let (tx, rx) = mpsc::channel(); + let handler = RcBlock::new(move |answer: SFSpeechRecognizerAuthorizationStatus| { + let _ = tx.send(answer); + }); + unsafe { SFSpeechRecognizer::requestAuthorization(&handler) }; + rx.recv_timeout(Duration::from_secs(120)) + .map_err(|_| "No answer to the speech recognition permission prompt.".to_string())? + } else { + status + }; + if status == SFSpeechRecognizerAuthorizationStatus::Authorized { + Ok(()) + } else { + Err("Speech recognition is off for Gravity. Turn it on in System Settings › Privacy & Security › Speech Recognition.".to_string()) + } +} + +pub(super) fn start(app: AppHandle) -> Result<(), String> { + stop(); + authorize()?; + let recognizer = unsafe { SFSpeechRecognizer::new() }; + if !unsafe { recognizer.isAvailable() } { + return Err("Speech recognition is not available right now.".to_string()); + } + let request = unsafe { SFSpeechAudioBufferRecognitionRequest::new() }; + unsafe { + request.setShouldReportPartialResults(true); + request.setAddsPunctuation(true); + // Keep the audio on this Mac whenever it can recognize locally. + if recognizer.supportsOnDeviceRecognition() { + request.setRequiresOnDeviceRecognition(true); + } + } + + let engine = unsafe { AVAudioEngine::new() }; + let input = unsafe { engine.inputNode() }; + let format = unsafe { input.outputFormatForBus(0) }; + let feed = request.clone(); + let tap = RcBlock::new( + move |buffer: NonNull, _: NonNull| { + unsafe { feed.appendAudioPCMBuffer(buffer.as_ref()) }; + }, + ); + unsafe { + input.installTapOnBus_bufferSize_format_block( + 0, + TAP_FRAMES, + Some(&format), + RcBlock::as_ptr(&tap), + ); + engine.prepare(); + } + if let Err(error) = unsafe { engine.startAndReturnError() } { + unsafe { input.removeTapOnBus(0) }; + return Err(format!( + "The microphone could not start ({}). Check System Settings › Privacy & Security › Microphone.", + error.localizedDescription() + )); + } + + let results = app.clone(); + let handler = RcBlock::new( + move |result: *mut SFSpeechRecognitionResult, error: *mut NSError| { + if let Some(result) = unsafe { result.as_ref() } { + let text = unsafe { result.bestTranscription().formattedString() }.to_string(); + if unsafe { result.isFinal() } { + emit(&results, DictationEvent::Final { text }); + return; + } + emit(&results, DictationEvent::Partial { text }); + } + if let Some(error) = unsafe { error.as_ref() } { + emit( + &results, + DictationEvent::Ended { + error: Some(error.localizedDescription().to_string()), + }, + ); + } + }, + ); + let task = unsafe { recognizer.recognitionTaskWithRequest_resultHandler(&request, &handler) }; + *lock(&SESSION) = Some(Session { + engine, + request, + task, + _recognizer: recognizer, + }); + Ok(()) +} + +/// Stops the microphone and lets the recognizer settle the transcript. +pub(super) fn stop() { + let Some(session) = lock(&SESSION).take() else { + return; + }; + unsafe { + session.engine.stop(); + session.engine.inputNode().removeTapOnBus(0); + session.request.endAudio(); + session.task.finish(); + } + *lock(&FINISHING) = Some(session); +} diff --git a/apps/desktop/src-tauri/src/dictation/sapi.rs b/apps/desktop/src-tauri/src/dictation/sapi.rs new file mode 100644 index 0000000..0dd8013 --- /dev/null +++ b/apps/desktop/src-tauri/src/dictation/sapi.rs @@ -0,0 +1,320 @@ +//! Dictation on Windows: the SAPI in-process recognizer with its dictation +//! grammar, fed by the default microphone. SAPI's desktop recognizers run +//! entirely on this PC, unlike the WinRT recognizer, whose dictation needs +//! Windows' online speech service. +//! +//! Each session owns a thread that holds the COM objects, so nothing SAPI +//! hands out crosses threads. Phrases SAPI settles are kept; the phrase being +//! spoken shows as a hypothesis after them. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tauri::AppHandle; +use windows::core::{IUnknown, Interface, PWSTR}; +use windows::Win32::Media::Speech::{ + ISpObjectToken, ISpObjectTokenCategory, ISpRecoContext, ISpRecoGrammar, ISpRecoResult, + ISpRecognizer, SpInprocRecognizer, SpMMAudioIn, SpObjectToken, SpObjectTokenCategory, + SPCAT_RECOGNIZERS, SPEI_END_SR_STREAM, SPEI_FALSE_RECOGNITION, SPEI_HYPOTHESIS, + SPEI_RECOGNITION, SPEI_RESERVED1, SPEI_RESERVED2, SPET_LPARAM_IS_OBJECT, + SPET_LPARAM_IS_POINTER, SPET_LPARAM_IS_STRING, SPET_LPARAM_IS_TOKEN, SPEVENT, SPEVENTENUM, + SPLO_STATIC, SPRST_ACTIVE, SPRST_INACTIVE, SPRS_ACTIVE, +}; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, CLSCTX_ALL, + COINIT_MULTITHREADED, +}; + +use super::{emit, DictationEvent}; + +/// How often the session thread looks for a stop request between events. +const POLL: Duration = Duration::from_millis(100); +/// After the microphone stops, how long to wait for the last phrase to +/// settle, and the most to wait in all. The composer stops listening to +/// events three seconds after it asks to stop. +const SETTLE_QUIET: Duration = Duration::from_millis(600); +const SETTLE_MAX: Duration = Duration::from_millis(1500); +/// `SP_GETWHOLEPHRASE` from sapi.h: the start and count that cover a phrase. +const WHOLE_PHRASE: u32 = u32::MAX; + +/// Asks the running session's thread to stop. +static STOP: Mutex>> = Mutex::new(None); + +fn stop_slot() -> std::sync::MutexGuard<'static, Option>> { + STOP.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Whether a SAPI recognizer is installed (Windows ships one per supported +/// display language). +pub(super) fn available() -> bool { + std::thread::spawn(|| { + with_com(|| unsafe { + default_recognizer()?; + Ok(()) + }) + .is_ok() + }) + .join() + .unwrap_or(false) +} + +pub(super) fn start(app: AppHandle) -> Result<(), String> { + stop(); + let stopping = Arc::new(AtomicBool::new(false)); + let (ready, started) = mpsc::channel(); + let flag = stopping.clone(); + std::thread::Builder::new() + .name("dictation".into()) + .spawn(move || { + let _ = with_com(|| { + let session = match unsafe { Session::open() } { + Ok(session) => session, + Err(error) => { + let _ = ready.send(Err(error)); + return Ok(()); + } + }; + let _ = ready.send(Ok(())); + session.run(&app, &flag); + Ok(()) + }); + }) + .map_err(|e| e.to_string())?; + started + .recv() + .map_err(|_| "Dictation stopped before it started.".to_string())??; + *stop_slot() = Some(stopping); + Ok(()) +} + +/// Stops the microphone; the session settles its transcript on its thread. +pub(super) fn stop() { + if let Some(stopping) = stop_slot().take() { + stopping.store(true, Ordering::SeqCst); + } +} + +/// Runs `work` with COM initialized on this thread. +fn with_com(work: impl FnOnce() -> Result) -> Result { + unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) } + .ok() + .map_err(|e| e.message())?; + let result = work(); + unsafe { CoUninitialize() }; + result +} + +/// The installed recognizer the user picked in Speech settings. +unsafe fn default_recognizer() -> Result { + let category: ISpObjectTokenCategory = + CoCreateInstance(&SpObjectTokenCategory, None, CLSCTX_ALL).map_err(|e| e.message())?; + category + .SetId(SPCAT_RECOGNIZERS, false) + .map_err(|e| e.message())?; + let id = category + .GetDefaultTokenId() + .map_err(|_| "No speech recognizer is installed for this language.".to_string())?; + let token: Result = + CoCreateInstance(&SpObjectToken, None, CLSCTX_ALL).map_err(|e| e.message()); + let token = token.and_then(|token| { + token + .SetId(None, windows::core::PCWSTR(id.0), false) + .map_err(|e| e.message())?; + Ok(token) + }); + CoTaskMemFree(Some(id.0 as _)); + token +} + +/// The bit `SetInterest` takes for one event; sapi.h's `SPFEI`. +fn interest(event: SPEVENTENUM) -> u64 { + (1u64 << event.0) | (1u64 << SPEI_RESERVED1.0) | (1u64 << SPEI_RESERVED2.0) +} + +struct Session { + recognizer: ISpRecognizer, + context: ISpRecoContext, + _grammar: ISpRecoGrammar, +} + +/// What has been said so far. +#[derive(Default)] +struct Transcript { + settled: Vec, + speaking: Option, +} + +impl Transcript { + fn text(&self) -> String { + let mut parts: Vec<&str> = self.settled.iter().map(String::as_str).collect(); + if let Some(speaking) = &self.speaking { + parts.push(speaking); + } + parts.join(" ") + } +} + +impl Session { + unsafe fn open() -> Result { + let recognizer: ISpRecognizer = + CoCreateInstance(&SpInprocRecognizer, None, CLSCTX_ALL).map_err(|e| e.message())?; + recognizer + .SetRecognizer(&default_recognizer()?) + .map_err(|e| e.message())?; + let microphone: IUnknown = + CoCreateInstance(&SpMMAudioIn, None, CLSCTX_ALL).map_err(|e| e.message())?; + recognizer + .SetInput(µphone, true) + .map_err(|_| microphone_off())?; + let context = recognizer.CreateRecoContext().map_err(|e| e.message())?; + context.SetNotifyWin32Event().map_err(|e| e.message())?; + let events = [ + SPEI_RECOGNITION, + SPEI_HYPOTHESIS, + SPEI_FALSE_RECOGNITION, + SPEI_END_SR_STREAM, + ] + .into_iter() + .fold(0, |mask, event| mask | interest(event)); + context + .SetInterest(events, events) + .map_err(|e| e.message())?; + let grammar = context.CreateGrammar(0).map_err(|e| e.message())?; + grammar + .LoadDictation(None, SPLO_STATIC) + .map_err(|e| e.message())?; + grammar + .SetDictationState(SPRS_ACTIVE) + .map_err(|e| e.message())?; + recognizer + .SetRecoState(SPRST_ACTIVE) + .map_err(|_| microphone_off())?; + Ok(Self { + recognizer, + context, + _grammar: grammar, + }) + } + + /// Streams the transcript until asked to stop, then settles it. + fn run(self, app: &AppHandle, stopping: &AtomicBool) { + let mut transcript = Transcript::default(); + while !stopping.load(Ordering::SeqCst) { + unsafe { + let _ = self.context.WaitForNotifyEvent(POLL.as_millis() as u32); + } + match self.drain(&mut transcript) { + Ok(true) => emit( + app, + DictationEvent::Partial { + text: transcript.text(), + }, + ), + Ok(false) => {} + Err(error) => { + emit(app, DictationEvent::Ended { error: Some(error) }); + return; + } + } + } + + // The microphone stops; the phrase in progress may still settle. + unsafe { + let _ = self.recognizer.SetRecoState(SPRST_INACTIVE); + } + let began = Instant::now(); + let mut quiet_since = Instant::now(); + while began.elapsed() < SETTLE_MAX && quiet_since.elapsed() < SETTLE_QUIET { + unsafe { + let _ = self.context.WaitForNotifyEvent(POLL.as_millis() as u32); + } + if let Ok(true) = self.drain(&mut transcript) { + quiet_since = Instant::now(); + } + } + emit( + app, + DictationEvent::Final { + text: transcript.text(), + }, + ); + } + + /// Takes the queued events into `transcript`; true when it changed. + fn drain(&self, transcript: &mut Transcript) -> Result { + let mut changed = false; + loop { + let mut events = [SPEVENT::default(); 16]; + let mut fetched = 0u32; + unsafe { + self.context + .GetEvents(events.len() as u32, events.as_mut_ptr(), &mut fetched) + .map_err(|e| e.message())?; + } + if fetched == 0 { + return Ok(changed); + } + for event in &events[..fetched as usize] { + let id = SPEVENTENUM(event._bitfield & 0xffff); + if id == SPEI_END_SR_STREAM { + // lParam carries the audio stream's HRESULT. + let status = windows::core::HRESULT(event.lParam.0 as i32); + if status.is_err() { + return Err(microphone_off()); + } + continue; + } + let text = unsafe { take_result_text(event) }; + match id { + SPEI_RECOGNITION => { + transcript.speaking = None; + if let Some(text) = text.filter(|t| !t.trim().is_empty()) { + transcript.settled.push(text.trim().to_string()); + } + changed = true; + } + SPEI_HYPOTHESIS => { + transcript.speaking = text.map(|t| t.trim().to_string()); + changed = true; + } + SPEI_FALSE_RECOGNITION => { + changed |= transcript.speaking.take().is_some(); + } + _ => {} + } + } + } + } +} + +/// Reads a recognition event's phrase and releases what the event holds +/// (sapi.h's `SpClearEvent`). +unsafe fn take_result_text(event: &SPEVENT) -> Option { + let param = event.lParam.0 as *mut core::ffi::c_void; + if param.is_null() { + return None; + } + let kind = (event._bitfield >> 16) & 0xffff; + if kind == SPET_LPARAM_IS_OBJECT.0 || kind == SPET_LPARAM_IS_TOKEN.0 { + let object = IUnknown::from_raw(param); + let result: ISpRecoResult = object.cast().ok()?; + let mut text = PWSTR::null(); + result + .GetText(WHOLE_PHRASE, WHOLE_PHRASE, true, &mut text, None) + .ok()?; + let phrase = text.to_string().ok(); + CoTaskMemFree(Some(text.0 as _)); + return phrase; + } + if kind == SPET_LPARAM_IS_POINTER.0 || kind == SPET_LPARAM_IS_STRING.0 { + CoTaskMemFree(Some(param as _)); + } + None +} + +fn microphone_off() -> String { + "The microphone could not start. Check Settings › Privacy & security › Microphone, \ + including \"Let desktop apps access your microphone\"." + .to_string() +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e1a6ccf..3a2b10d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod daemon; +mod dictation; mod shortcut; mod updater; @@ -186,6 +187,9 @@ pub fn run() { daemon::restart_local_daemon, daemon::local_daemon_port, daemon::daemon_log_tail, + dictation::dictation_available, + dictation::start_dictation, + dictation::stop_dictation, updater::check_for_update, updater::install_update, updater::relaunch_app diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 782e3d1..4e71875 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -46,6 +46,9 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "macOS": { + "entitlements": "Entitlements.plist" + } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 33fc88a..446b942 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -6,7 +6,7 @@ import type { FirstRunSetup } from "./app/useFirstRunSetup"; import { useDaemonState } from "./app/useDaemonState"; import type { DaemonState } from "./app/useDaemonState"; import { useDesktopShell } from "./app/useDesktopShell"; -import { usePendingDecisions } from "./app/usePendingDecisions"; +import { usePermissionInbox } from "./app/usePermissionInbox"; import { useOverlays } from "./app/useOverlays"; import type { OverlaysApi } from "./app/useOverlays"; import { usePaletteActions } from "./app/usePaletteActions"; @@ -121,7 +121,15 @@ export default function App(): ReactElement { [select], ); - const pending = usePendingDecisions(client, daemon.connected); + const inbox = usePermissionInbox({ + client, + connected: daemon.connected, + bots, + selection: daemon.selection, + select, + addToast, + }); + const pending = inbox.counts; const paletteActions = usePaletteActions({ bots, @@ -157,6 +165,7 @@ export default function App(): ReactElement { onDeleteBot={actions.deleteBot} onDeleteProject={actions.deleteProject} onOpenSettings={overlays.openSettings} + showConversations={client.capabilities.includes("agent_conversations")} />
{overlays.paletteOpen ? ( diff --git a/apps/desktop/src/analytics.tsx b/apps/desktop/src/analytics.tsx index 1f61cc5..98bc3f8 100644 --- a/apps/desktop/src/analytics.tsx +++ b/apps/desktop/src/analytics.tsx @@ -35,6 +35,9 @@ interface AnalyticsEvents { readonly project_lead_set: { readonly cleared: boolean; }; + readonly project_repo_set: { + readonly cleared: boolean; + }; readonly project_deleted: Record; readonly project_renamed: Record; readonly routine_created: { @@ -77,6 +80,7 @@ export type ErrorOperation = | "project_delete" | "project_lead_set" | "project_rename" + | "project_repo_set" | "routine_create" | "routine_list" | "routine_run" diff --git a/apps/desktop/src/app/selection.ts b/apps/desktop/src/app/selection.ts index 3acfcee..9eb23e5 100644 --- a/apps/desktop/src/app/selection.ts +++ b/apps/desktop/src/app/selection.ts @@ -3,6 +3,8 @@ export type Selection = | { readonly kind: "none" } | { readonly kind: "bot"; readonly botId: string } | { readonly kind: "project"; readonly projectId: string } + /** What the project's bots have said to each other. */ + | { readonly kind: "conversations"; readonly projectId: string } /** * The Control center. `decisionId` opens straight onto one record, which is * what a notification or a palette entry hands over. diff --git a/apps/desktop/src/app/useDaemonActions.ts b/apps/desktop/src/app/useDaemonActions.ts index 30edf70..d9114a9 100644 --- a/apps/desktop/src/app/useDaemonActions.ts +++ b/apps/desktop/src/app/useDaemonActions.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { capture, captureException } from "../analytics"; import type { DaemonApi } from "../protocol/api"; -import type { Bot } from "../protocol/entities"; +import type { Bot, ProjectRepo } from "../protocol/entities"; import { errText } from "../util"; import type { Selection } from "./selection"; import type { AddToast } from "./useToasts"; @@ -11,6 +11,7 @@ export interface DaemonActions { readonly createProject: (name: string) => Promise; readonly renameProject: (projectId: string, name: string) => Promise; readonly setProjectLead: (projectId: string, botId: string | null) => Promise; + readonly setProjectRepo: (projectId: string, repo: ProjectRepo | null) => Promise; readonly deleteProject: (projectId: string) => Promise; readonly createBot: (projectId: string) => Promise; /** Creates a project and its first bot, so a fresh install lands on a bot. */ @@ -81,6 +82,30 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { [addToast, client, refreshAll], ); + const setProjectRepo = useCallback( + async (projectId: string, repo: ProjectRepo | null): Promise => { + try { + await client.request( + repo === null + ? { type: "set_project_repo", project_id: projectId, url: null } + : { + type: "set_project_repo", + project_id: projectId, + url: repo.url, + branch: repo.branch, + }, + "project", + ); + capture("project_repo_set", { cleared: repo === null }); + await refreshAll(); + } catch (error) { + captureException(error, "project_repo_set"); + addToast("error", "Failed to set the repository", errText(error)); + } + }, + [addToast, client, refreshAll], + ); + /** * Deleting a project archives every bot inside it, so anything the main pane * was showing is gone by the time this returns; the selection is cleared @@ -146,6 +171,7 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { createProject, renameProject, setProjectLead, + setProjectRepo, deleteProject, createBot, createProjectWithBot, @@ -159,6 +185,7 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { deleteProject, renameProject, setProjectLead, + setProjectRepo, ], ); } diff --git a/apps/desktop/src/app/usePermissionInbox.test.tsx b/apps/desktop/src/app/usePermissionInbox.test.tsx new file mode 100644 index 0000000..faa80a8 --- /dev/null +++ b/apps/desktop/src/app/usePermissionInbox.test.tsx @@ -0,0 +1,103 @@ +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import PermissionCards from "../components/permissions/PermissionCards"; +import type { PermissionRequest } from "../protocol/chat"; +import { pendingCounts } from "../test/decisionFixtures"; +import { FakeDaemon } from "../test/fakeDaemon"; +import * as fx from "../test/fixtures"; +import type { Selection } from "./selection"; +import { usePermissionInbox } from "./usePermissionInbox"; +import type { AddToast } from "./useToasts"; + +function request(over: Partial = {}): PermissionRequest { + return { + id: "r1", + bot_id: "b1", + tool: "Bash", + summary: "Bash: rm -rf build", + input: "{}", + created_at: "2025-01-15T10:00:00Z", + expires_at: "2025-01-15T10:10:00Z", + ...over, + }; +} + +function daemon(pending: readonly PermissionRequest[]): FakeDaemon { + return new FakeDaemon() + .onRequest("list_permissions", (body) => { + expect(body).toEqual({ type: "list_permissions" }); + return { type: "permissions", req_id: "1", permissions: pending }; + }) + .onRequest("count_pending_decisions", () => ({ + type: "pending_decisions", + req_id: "2", + counts: pendingCounts({ total: 2, urgent: 0 }), + })); +} + +const bots = [fx.bot(), fx.bot({ id: "b2", name: "bob" })]; + +function inbox(client: FakeDaemon, selection: Selection) { + const addToast = vi.fn(); + const select = vi.fn<(next: Selection) => void>(); + const hook = renderHook(() => + usePermissionInbox({ client, connected: true, bots, selection, select, addToast }), + ); + return { hook, addToast, select }; +} + +describe("usePermissionInbox", () => { + it("counts every bot's waiting prompts as urgent", async () => { + const { hook } = inbox(daemon([request(), request({ id: "r2", bot_id: "b2" })]), { + kind: "control", + }); + await waitFor(() => { + expect(hook.result.current.counts.total).toBe(4); + }); + expect(hook.result.current.counts.urgent).toBe(2); + }); + + it("raises a toast for a prompt from a bot not on screen", async () => { + const client = daemon([]); + const { addToast, select } = inbox(client, { kind: "bot", botId: "b1" }); + act(() => { + client.emit("permission_request", { type: "permission_request", request: request() }); + }); + expect(addToast).not.toHaveBeenCalled(); + act(() => { + client.emit("permission_request", { + type: "permission_request", + request: request({ id: "r2", bot_id: "b2" }), + }); + }); + expect(addToast).toHaveBeenCalledWith( + "warn", + "bob needs your permission", + "Bash: rm -rf build", + expect.objectContaining({ action: expect.objectContaining({ label: "Answer" }) }), + ); + const options = addToast.mock.calls[0]?.[3]; + act(() => { + options?.action?.run(); + }); + expect(select).toHaveBeenCalledWith({ kind: "control" }); + }); +}); + +describe("PermissionCards from many bots", () => { + it("names each card's bot and opens it", async () => { + const onOpenBot = vi.fn<(botId: string) => void>(); + render( + Promise.resolve() }} + canAnswer + botName={(id) => bots.find((bot) => bot.id === id)?.name} + onOpenBot={onOpenBot} + />, + ); + expect(screen.getByText("bob wants to run")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Open bob" })); + expect(onOpenBot).toHaveBeenCalledWith("b2"); + }); +}); diff --git a/apps/desktop/src/app/usePermissionInbox.ts b/apps/desktop/src/app/usePermissionInbox.ts new file mode 100644 index 0000000..11f5809 --- /dev/null +++ b/apps/desktop/src/app/usePermissionInbox.ts @@ -0,0 +1,69 @@ +import { useEffect, useMemo } from "react"; +import { usePermissions } from "../components/permissions/usePermissions"; +import type { Permissions } from "../components/permissions/usePermissions"; +import type { DaemonApi } from "../protocol/api"; +import type { PendingCounts } from "../protocol/decisions"; +import type { Bot } from "../protocol/entities"; +import type { Selection } from "./selection"; +import { usePendingDecisions } from "./usePendingDecisions"; +import type { AddToast } from "./useToasts"; + +interface InboxDeps { + readonly client: DaemonApi; + readonly connected: boolean; + readonly bots: readonly Bot[]; + readonly selection: Selection; + readonly select: (next: Selection) => void; + readonly addToast: AddToast; +} + +interface PermissionInbox { + readonly permissions: Permissions; + /** Decisions plus waiting permission prompts, which count as urgent: a bot is blocked on each. */ + readonly counts: PendingCounts; +} + +/** + * Everything waiting on the owner — decisions and every bot's permission + * prompts — for the Control Center and the badges. + * + * A prompt waits on the owner wherever they are looking, so one from a bot + * not on screen raises a toast that opens the Control Center. + */ +export function usePermissionInbox({ + client, + connected, + bots, + selection, + select, + addToast, +}: InboxDeps): PermissionInbox { + const decisions = usePendingDecisions(client, connected); + const permissions = usePermissions(client, null, connected); + const waiting = permissions.pending.length; + + const watching = selection.kind === "bot" ? selection.botId : null; + useEffect( + () => + client.on("permission_request", ({ request }) => { + if (request.bot_id === watching) { + return; + } + const name = bots.find((bot) => bot.id === request.bot_id)?.name ?? "A bot"; + addToast("warn", `${name} needs your permission`, request.summary, { + action: { label: "Answer", run: () => select({ kind: "control" }) }, + }); + }), + [client, bots, watching, select, addToast], + ); + + const counts = useMemo( + () => ({ + ...decisions, + total: decisions.total + waiting, + urgent: decisions.urgent + waiting, + }), + [decisions, waiting], + ); + return { permissions, counts }; +} diff --git a/apps/desktop/src/components/BotView.browser.test.tsx b/apps/desktop/src/components/BotView.browser.test.tsx new file mode 100644 index 0000000..138c4fc --- /dev/null +++ b/apps/desktop/src/components/BotView.browser.test.tsx @@ -0,0 +1,72 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { agentsDaemon, browserTabs } from "../test/agentFixtures"; +import * as fx from "../test/fixtures"; +import { actionToastSpy, botSpy, routinesSpy, stubLocalStorage } from "../test/spies"; +import BotView from "./BotView"; + +vi.mock("./TerminalPane", () => ({ + default: (): React.ReactElement =>
, +})); + +function renderView(): ReturnType { + const client = agentsDaemon() + .onRequest("list_routines", () => ({ type: "routines", req_id: "1", routines: [] })) + .onRequest("list_chat", () => ({ + type: "chat", + req_id: "2", + bot_id: "b1", + has_more: false, + turns: [], + })); + render( + , + ); + return client; +} + +describe("BotView with its own browser", () => { + beforeEach(() => { + stubLocalStorage(); + }); + + it("streams the bot's browser from the moment it is selected", async () => { + const client = renderView(); + await waitFor(() => { + expect(client.requests.some((r) => r.body.type === "watch_browser")).toBe(true); + }); + // Still on the chat: the stream is already live, and the tab says so. + const browserTab = screen.getByRole("button", { name: /^Browser/ }); + expect(screen.queryByRole("img", { name: "live" })).not.toBeInTheDocument(); + act(() => { + client.emit("browser_tabs", browserTabs); + client.emit("browser_frame", { + type: "browser_frame", + bot_id: "b1", + tab_id: "tab-1", + data: "SlBFRw==", + width: 800, + height: 600, + }); + }); + expect(screen.getByRole("img", { name: "live" })).toBeInTheDocument(); + await userEvent.click(browserTab); + expect(screen.getByRole("img", { name: "What alice's browser shows" })).toBeInTheDocument(); + }); + + it("shows the bot's memory beside its info", async () => { + renderView(); + await userEvent.click(screen.getByRole("button", { name: "Memory" })); + expect(await screen.findByRole("heading", { name: "Facts" })).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/components/BotView.chat.test.tsx b/apps/desktop/src/components/BotView.chat.test.tsx new file mode 100644 index 0000000..4e1d6be --- /dev/null +++ b/apps/desktop/src/components/BotView.chat.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { turn } from "../test/chatFixtures"; +import { FakeDaemon } from "../test/fakeDaemon"; +import * as fx from "../test/fixtures"; +import { actionToastSpy, botSpy, routinesSpy, stubLocalStorage } from "../test/spies"; +import BotView from "./BotView"; + +vi.mock("./TerminalPane", () => ({ + default: (): React.ReactElement =>
, +})); + +function chatDaemon(): FakeDaemon { + const client = new FakeDaemon() + .onRequest("list_routines", () => ({ type: "routines", req_id: "1", routines: [] })) + .onRequest("list_chat", () => ({ + type: "chat", + req_id: "2", + bot_id: "b1", + has_more: false, + turns: [ + turn({ + items: [ + { + type: "completed", + id: "c1", + task_id: "t1", + result: "Ported.", + artifacts: [{ path: "/p/artifacts/report.md", name: "report.md" }], + }, + ], + }), + ], + })) + .onRequest("read_file", () => ({ + type: "file", + req_id: "3", + file: { name: "report.md", mime: "text/markdown", text: "Report body", truncated: false }, + })) + .onRequest("list_conversations", () => ({ + type: "conversations", + req_id: "4", + conversations: [], + })); + client.capabilities = ["terminal_attach", "chat"]; + return client; +} + +function renderView(client: FakeDaemon, bot = fx.bot()): void { + render( + , + ); +} + +describe("BotView with chat", () => { + beforeEach(() => { + stubLocalStorage(); + }); + + it("opens on the chat and keeps the terminal a tab away", async () => { + renderView(chatDaemon()); + expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("tab-active"); + expect(await screen.findByText("Ported.")).toBeInTheDocument(); + expect(screen.getByTestId("terminal")).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "2", metaKey: true }); + expect(screen.getByRole("button", { name: "Terminal" })).toHaveClass("tab-active"); + + fireEvent.keyDown(window, { key: "l", metaKey: true }); + expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("tab-active"); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus(); + }); + }); + + it("opens a result's file in the Files panel", async () => { + renderView(chatDaemon()); + await userEvent.click(await screen.findByRole("button", { name: "report.md" })); + expect(screen.getByRole("button", { name: "Files" })).toHaveClass("tab-active"); + expect(await screen.findByText("Report body")).toBeInTheDocument(); + }); + + it("gives a linked bot only its chat", () => { + renderView(chatDaemon(), fx.bot({ peer: { id: "x", name: "win-pc", online: true } })); + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Terminal" })).not.toBeInTheDocument(); + expect(screen.queryByTestId("terminal")).not.toBeInTheDocument(); + }); + + it("falls back to the terminal on a daemon without chat", () => { + const client = chatDaemon(); + client.capabilities = ["terminal_attach"]; + renderView(client); + expect(screen.queryByRole("button", { name: "Chat" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Terminal" })).toHaveClass("tab-active"); + }); +}); + +describe("BotView for a linked bot on a daemon with peer chat", () => { + beforeEach(() => { + stubLocalStorage(); + }); + + it("reads its real chat from its machine", async () => { + const client = chatDaemon(); + client.capabilities = ["chat", "peer_chat"]; + renderView(client, fx.bot({ peer: { id: "x", name: "win-pc", online: false } })); + expect(await screen.findByText("Ported.")).toBeInTheDocument(); + expect( + screen.getByText(/runs on win-pc; its chat is read from there\. It is offline/), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/components/BotView.test.tsx b/apps/desktop/src/components/BotView.test.tsx index ebe3924..36a5313 100644 --- a/apps/desktop/src/components/BotView.test.tsx +++ b/apps/desktop/src/components/BotView.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FakeDaemon } from "../test/fakeDaemon"; @@ -74,7 +74,10 @@ describe("BotView", () => { renderView(); expect(screen.getByTestId("terminal")).toBeInTheDocument(); expect(screen.getByRole("complementary", { name: "Bot info" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Info" })).not.toBeInTheDocument(); + // Info is the side panel's tab, never one of the bot's own tabs. + const botTabs = screen.getByRole("button", { name: "Terminal" }).closest("nav"); + expect(botTabs).not.toBeNull(); + expect(within(botTabs ?? document.body).queryByRole("button", { name: "Info" })).toBeNull(); await user.click(screen.getByRole("button", { name: "Routines" })); expect(screen.getByText("Routines", { selector: ".panel-title" })).toBeInTheDocument(); diff --git a/apps/desktop/src/components/BotView.tsx b/apps/desktop/src/components/BotView.tsx index 5e68345..05fdf28 100644 --- a/apps/desktop/src/components/BotView.tsx +++ b/apps/desktop/src/components/BotView.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent, ReactElement } from "react"; -import { isStopped } from "../app/bots"; import type { AddToast } from "../app/useToasts"; import type { DaemonApi } from "../protocol/api"; import type { Bot, Routine } from "../protocol/entities"; @@ -11,11 +10,14 @@ import { saveBotInfoPanel, } from "../settings"; import BotHeader from "./bot/BotHeader"; -import BotTabs from "./bot/BotTabs"; +import BotPanes from "./bot/BotPanes"; +import BotSessionActions from "./bot/BotSessionActions"; +import BotSidePanel from "./bot/BotSidePanel"; +import type { SideTab } from "./bot/BotSidePanel"; +import BotTabs, { botTabs } from "./bot/BotTabs"; import type { BotTab } from "./bot/BotTabs"; -import InfoPanel from "./InfoPanel"; -import RoutinesPanel from "./RoutinesPanel"; -import TerminalPane from "./TerminalPane"; +import { useBotKeys } from "./bot/useBotKeys"; +import { useBrowserWatch } from "./browser/useBrowserWatch"; interface BotViewProps { readonly client: DaemonApi; @@ -26,6 +28,7 @@ interface BotViewProps { readonly onBotUpdated: (bot: Bot) => void; readonly onRoutinesChanged: (botId: string, routines: readonly Routine[]) => void; readonly onToast: AddToast; + readonly onOpenDecision?: (decisionId: string) => void; } interface DragState { @@ -42,14 +45,32 @@ function clamp(value: number, minimum: number, maximum: number): number { export default function BotView(props: BotViewProps): ReactElement { const { client, bot, bots, connected, canControl, onToast } = props; - const [tab, setTab] = useState("terminal"); + const tabs = botTabs( + { + chat: client.capabilities.includes("chat"), + browser: client.capabilities.includes("bot_browser"), + peerTerminal: client.capabilities.includes("peer_terminal"), + peerBrowser: client.capabilities.includes("peer_browser"), + }, + bot.peer != null, + ); + const [tab, setTab] = useState(tabs[0] ?? "terminal"); + const [side, setSide] = useState("info"); + const [openFile, setOpenFile] = useState(null); const [infoPanel, setInfoPanel] = useState(loadBotInfoPanel); const [maxInfoPanelWidth, setMaxInfoPanelWidth] = useState(FALLBACK_MAX_INFO_PANEL_WIDTH); const layoutRef = useRef(null); const dragRef = useRef(null); - // The terminal belongs to the user: any `control` connection may type while - // the bot runs. Bus deliveries arrive via the bot's inbox socket, never here. - const canWrite = canControl && !isStopped(bot); + useBotKeys(tabs, setTab); + // The bot's browser streams from the moment the bot is selected, whatever + // tab is open, so it is live when the Browser tab is. + const browser = useBrowserWatch(client, bot.id, tabs.includes("browser"), connected); + + const showFile = (path: string): void => { + setSide("files"); + setOpenFile(path); + setInfoPanel((current) => ({ ...current, collapsed: false })); + }; useEffect(() => { saveBotInfoPanel(infoPanel); @@ -141,6 +162,15 @@ export default function BotView(props: BotViewProps): ReactElement { + } infoPanelCollapsed={infoPanel.collapsed} onToggleInfoPanel={() => { setInfoPanel((current) => ({ ...current, collapsed: !current.collapsed })); @@ -148,26 +178,26 @@ export default function BotView(props: BotViewProps): ReactElement { />
- -
- {/* Keep the terminal mounted across tab switches to preserve the buffer. */} -
- -
- {tab === "routines" ? ( -
- -
- ) : null} -
+ +
{infoPanel.collapsed ? null : (
- diff --git a/apps/desktop/src/components/CopyButton.tsx b/apps/desktop/src/components/CopyButton.tsx new file mode 100644 index 0000000..17f064a --- /dev/null +++ b/apps/desktop/src/components/CopyButton.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; + +interface CopyButtonProps { + readonly text: string; + /** What is copied, for screen readers: "Copy command". */ + readonly label?: string; + readonly className?: string; +} + +/** Copies `text` to the clipboard, saying "Copied" for a moment. */ +export default function CopyButton({ + text, + label = "Copy", + className = "copy-button", +}: CopyButtonProps): ReactElement { + const [copied, setCopied] = useState(false); + const copy = async (): Promise => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => { + setCopied(false); + }, 1500); + } catch { + setCopied(false); + } + }; + return ( + + ); +} diff --git a/apps/desktop/src/components/InfoPanel.tsx b/apps/desktop/src/components/InfoPanel.tsx index 0900427..aea56ca 100644 --- a/apps/desktop/src/components/InfoPanel.tsx +++ b/apps/desktop/src/components/InfoPanel.tsx @@ -8,6 +8,7 @@ import { errText } from "../util"; import BotAvatar from "./BotAvatar"; import { BOT_ICONS } from "./botIcons"; import BotHistory from "./bot/BotHistory"; +import BotChromeSwitch from "./bot/BotChromeSwitch"; import BotRuntimePicker from "./bot/BotRuntimePicker"; interface InfoPanelProps { @@ -153,7 +154,11 @@ export default function InfoPanel({ {bot.created_by_bot_id != null && bot.created_by_bot_id.length > 0 ? ( <>
Origin
-
Created by another bot
+
+ {bot.temporary === true + ? "Temporary worker spawned by another bot; removed when its task closes" + : "Created by another bot"} +
) : null} @@ -254,6 +259,15 @@ export default function InfoPanel({ onToast={onToast} /> + + Promise; readonly onRenameProject: (projectId: string, name: string) => Promise; readonly onSetProjectLead: (projectId: string, botId: string | null) => Promise; + readonly onSetProjectRepo: (projectId: string, repo: ProjectRepo | null) => Promise; readonly onDeleteProject: (projectId: string) => Promise; + /** Every bot's permission prompts, answered in the Control Center. */ + readonly permissions?: Permissions; + readonly onOpenBot?: (botId: string) => void; } interface EmptyStateProps { @@ -78,6 +85,8 @@ export default function MainPane(props: MainPaneProps): ReactElement { canControl={canControl} decisionId={selection.decisionId} onToast={addToast} + permissions={props.permissions} + onOpenBot={props.onOpenBot} /> ); } @@ -89,17 +98,34 @@ export default function MainPane(props: MainPaneProps): ReactElement { ) : ( item.project_id === project.id)} connected={connected} canControl={canControl} onRename={props.onRenameProject} onSetLead={props.onSetProjectLead} + onSetRepo={props.onSetProjectRepo} onDelete={props.onDeleteProject} /> ); } + if (selection.kind === "conversations") { + const project = daemon.projects.find((item) => item.id === selection.projectId); + return project === undefined ? ( + + ) : ( + item.project_id === project.id)} + connected={connected} + /> + ); + } + if (selection.kind === "bot") { const bot = bots.find((item) => item.id === selection.botId); return bot === undefined ? ( @@ -115,6 +141,9 @@ export default function MainPane(props: MainPaneProps): ReactElement { onBotUpdated={daemon.applyBotUpdate} onRoutinesChanged={daemon.updateBotRoutines} onToast={addToast} + onOpenDecision={(decisionId) => { + daemon.select({ kind: "control", decisionId }); + }} /> ); } diff --git a/apps/desktop/src/components/ProjectRepoForm.test.tsx b/apps/desktop/src/components/ProjectRepoForm.test.tsx new file mode 100644 index 0000000..b935505 --- /dev/null +++ b/apps/desktop/src/components/ProjectRepoForm.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { ProjectRepo } from "../protocol/entities"; +import ProjectRepoForm from "./ProjectRepoForm"; + +function renderForm(repo: ProjectRepo | null = null, disabled = false) { + const onSave = vi.fn<(repo: ProjectRepo | null) => Promise>(() => Promise.resolve()); + render(); + return { onSave }; +} + +describe("ProjectRepoForm", () => { + it("saves a new repository, defaulting the branch to main", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm(); + const save = screen.getByRole("button", { name: "Save repository" }); + expect(save).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Remove" })).toBeNull(); + + await user.type(screen.getByLabelText("Clone URL"), " git@host:me/book.git "); + await user.clear(screen.getByLabelText("Branch")); + await user.click(save); + + expect(onSave).toHaveBeenCalledWith({ url: "git@host:me/book.git", branch: "main" }); + }); + + it("saves only a change, and removes a repository", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm({ url: "git@host:me/book.git", branch: "main" }); + const save = screen.getByRole("button", { name: "Save repository" }); + expect(save).toBeDisabled(); + + await user.clear(screen.getByLabelText("Branch")); + await user.type(screen.getByLabelText("Branch"), "drafts"); + await user.click(save); + expect(onSave).toHaveBeenLastCalledWith({ url: "git@host:me/book.git", branch: "drafts" }); + + await user.click(screen.getByRole("button", { name: "Remove" })); + expect(onSave).toHaveBeenLastCalledWith(null); + }); + + it("is read-only without control", () => { + renderForm({ url: "u", branch: "main" }, true); + expect(screen.getByLabelText("Clone URL")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Remove" })).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/components/ProjectRepoForm.tsx b/apps/desktop/src/components/ProjectRepoForm.tsx new file mode 100644 index 0000000..a899fe3 --- /dev/null +++ b/apps/desktop/src/components/ProjectRepoForm.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; +import type { ProjectRepo } from "../protocol/entities"; + +interface ProjectRepoFormProps { + readonly repo: ProjectRepo | null; + readonly disabled: boolean; + /** `null` clears the repository. */ + readonly onSave: (repo: ProjectRepo | null) => Promise; +} + +/** + * The project's shared git repository. Workers start from a fresh checkout of + * its branch and push their work back, which is how they share files — + * including workers running on a linked machine. + */ +export default function ProjectRepoForm({ + repo, + disabled, + onSave, +}: ProjectRepoFormProps): ReactElement { + const [url, setUrl] = useState(repo?.url ?? ""); + const [branch, setBranch] = useState(repo?.branch ?? "main"); + const [saving, setSaving] = useState(false); + + const next = { url: url.trim(), branch: branch.trim() || "main" }; + const dirty = next.url.length > 0 && (next.url !== repo?.url || next.branch !== repo.branch); + + const run = async (value: ProjectRepo | null): Promise => { + setSaving(true); + try { + await onSave(value); + } finally { + setSaving(false); + } + }; + + return ( +
{ + event.preventDefault(); + if (dirty && !disabled && !saving) { + void run(next); + } + }} + > +

Shared repository

+
+ + { + setUrl(event.target.value); + }} + /> +
+
+ + { + setBranch(event.target.value); + }} + /> + + Each worker a bot spawns clones this branch when it starts and pushes its work back before + it reports. Whatever a worker leaves unpushed is saved to a branch of its own. Each + machine uses its own git credentials. + +
+
+ {repo !== null ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/desktop/src/components/ProjectView.test.tsx b/apps/desktop/src/components/ProjectView.test.tsx index 69a8553..afb85a4 100644 --- a/apps/desktop/src/components/ProjectView.test.tsx +++ b/apps/desktop/src/components/ProjectView.test.tsx @@ -1,6 +1,8 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import type { ProjectRepo } from "../protocol/entities"; +import { FakeDaemon } from "../test/fakeDaemon"; import * as fx from "../test/fixtures"; import ProjectView from "./ProjectView"; @@ -12,14 +14,19 @@ function renderView(over: Partial[0]> = {}) { const onSetLead = vi.fn<(projectId: string, botId: string | null) => Promise>(() => Promise.resolve(), ); + const onSetRepo = vi.fn<(projectId: string, repo: ProjectRepo | null) => Promise>(() => + Promise.resolve(), + ); render( , diff --git a/apps/desktop/src/components/ProjectView.tsx b/apps/desktop/src/components/ProjectView.tsx index 9cc144c..e3fba94 100644 --- a/apps/desktop/src/components/ProjectView.tsx +++ b/apps/desktop/src/components/ProjectView.tsx @@ -1,15 +1,20 @@ import { useState } from "react"; import type { ReactElement } from "react"; -import type { Bot, Project } from "../protocol/entities"; +import type { DaemonApi } from "../protocol/api"; +import type { Bot, Project, ProjectRepo } from "../protocol/entities"; import ConfirmDialog from "./overlay/ConfirmDialog"; +import ProjectRepoForm from "./ProjectRepoForm"; +import WorkersPanel from "./WorkersPanel"; interface ProjectViewProps { + readonly client: DaemonApi; readonly project: Project; readonly bots: readonly Bot[]; readonly connected: boolean; readonly canControl: boolean; readonly onRename: (projectId: string, name: string) => Promise; readonly onSetLead: (projectId: string, botId: string | null) => Promise; + readonly onSetRepo: (projectId: string, repo: ProjectRepo | null) => Promise; readonly onDelete: (projectId: string) => Promise; } @@ -24,7 +29,8 @@ export function deletionBody(project: Project, botCount: number): string { /** Project settings: the name, what it holds, and deletion. */ export default function ProjectView(props: ProjectViewProps): ReactElement { - const { project, bots, connected, canControl, onRename, onSetLead, onDelete } = props; + const { client, project, bots, connected, canControl } = props; + const { onRename, onSetLead, onSetRepo, onDelete } = props; const [name, setName] = useState(project.name); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); @@ -123,6 +129,21 @@ export default function ProjectView(props: ProjectViewProps): ReactElement {
+ {client.capabilities.includes("workers") ? ( + + ) : null} + + onSetRepo(project.id, repo)} + /> + {canControl ? (

Danger zone

diff --git a/apps/desktop/src/components/Sidebar.test.tsx b/apps/desktop/src/components/Sidebar.test.tsx index e849432..4cd11f8 100644 --- a/apps/desktop/src/components/Sidebar.test.tsx +++ b/apps/desktop/src/components/Sidebar.test.tsx @@ -282,6 +282,8 @@ describe("Sidebar", () => { it("shows no control center count when nothing waits", () => { renderSidebar(); expect(document.querySelector(".control-row-count")).toBeNull(); - expect(screen.getByTitle("Decisions waiting on you")).toBeInTheDocument(); + expect( + screen.getByTitle("Decisions and permission prompts waiting on you"), + ).toBeInTheDocument(); }); }); diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 220e30a..9b914cd 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -111,6 +111,7 @@ export default function Sidebar(props: SidebarProps): ReactElement { onDeleteBot={props.onDeleteBot} onDeleteProject={props.onDeleteProject} onTogglePin={onTogglePin} + showConversations={props.showConversations} /> ))} {projects.length === 0 ? ( diff --git a/apps/desktop/src/components/WorkersPanel.test.tsx b/apps/desktop/src/components/WorkersPanel.test.tsx new file mode 100644 index 0000000..08a9071 --- /dev/null +++ b/apps/desktop/src/components/WorkersPanel.test.tsx @@ -0,0 +1,100 @@ +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { WorkerView } from "../protocol/workers"; +import { FakeDaemon } from "../test/fakeDaemon"; +import WorkersPanel, { workerSections } from "./WorkersPanel"; + +function worker(over: Partial = {}): WorkerView { + return { + id: "w1", + project_id: "p1", + name: "ch-1", + state: "running", + machine: "here", + parent_bot_id: "b1", + parent_name: "book", + brief: "Write chapter one.", + created_at: "2025-01-15T09:00:00Z", + started_at: "2025-01-15T09:00:05Z", + ...over, + }; +} + +const sample: readonly WorkerView[] = [ + worker(), + worker({ id: "w2", name: "ch-2", machine: "win-pc" }), + worker({ id: "w3", name: "ch-3", state: "queued", queue_position: 1, started_at: null }), + worker({ id: "w4", name: "ch-0", state: "done", finished_at: "2025-01-15T08:00:00Z" }), +]; + +function daemon(workers: readonly WorkerView[] = sample): FakeDaemon { + return new FakeDaemon().onRequest("list_workers", () => ({ + type: "workers", + req_id: "1", + project_id: "p1", + workers, + running_here: 1, + max_workers_here: 4, + })); +} + +describe("WorkersPanel", () => { + it("shows what runs, what is queued and what finished", async () => { + render(); + const running = await screen.findByRole("region", { name: "Running" }); + expect(within(running).getByText("ch-1")).toBeInTheDocument(); + expect(within(running).getByText("running on win-pc")).toBeInTheDocument(); + const queued = screen.getByRole("region", { name: "Queued" }); + expect(within(queued).getByText("#1 in queue")).toBeInTheDocument(); + expect(within(queued).getByText("Write chapter one.")).toBeInTheDocument(); + const finished = screen.getByRole("region", { name: "Finished" }); + expect(within(finished).getByText("done")).toBeInTheDocument(); + expect(within(finished).queryByRole("button", { name: /Cancel/ })).toBeNull(); + expect(screen.getByText(/1 of 4 slots in use here/)).toBeInTheDocument(); + }); + + it("cancels a spawn as the owner", async () => { + const client = daemon().onRequest("cancel_worker", () => ({ + type: "worker", + req_id: "2", + worker: worker({ id: "w3", name: "ch-3", state: "cancelled" }), + })); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Cancel ch-3" })); + expect(client.requests.map((r) => r.body)).toContainEqual({ + type: "cancel_worker", + worker_id: "w3", + }); + }); + + it("refetches when its project's queue changes", async () => { + const client = daemon([]); + render(); + expect(await screen.findByText(/No workers yet/)).toBeInTheDocument(); + client.onRequest("list_workers", () => ({ + type: "workers", + req_id: "3", + project_id: "p1", + workers: sample, + running_here: 1, + max_workers_here: 4, + })); + act(() => { + client.emit("workers_updated", { type: "workers_updated", project_id: "other" }); + }); + expect(screen.queryByText("ch-1")).toBeNull(); + act(() => { + client.emit("workers_updated", { type: "workers_updated", project_id: "p1" }); + }); + expect(await screen.findByText("ch-1")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Cancel ch-1" })).toBeDisabled(); + }); + }); + + it("splits by state", () => { + const { running, queued, finished } = workerSections(sample); + expect([running.length, queued.length, finished.length]).toEqual([2, 1, 1]); + }); +}); diff --git a/apps/desktop/src/components/WorkersPanel.tsx b/apps/desktop/src/components/WorkersPanel.tsx new file mode 100644 index 0000000..662dd0f --- /dev/null +++ b/apps/desktop/src/components/WorkersPanel.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ReactElement } from "react"; +import { useLoadOnConnect } from "../hooks/useLoadOnConnect"; +import type { DaemonApi } from "../protocol/api"; +import type { WorkerView } from "../protocol/workers"; +import { errText, fmtShortTime } from "../util"; + +interface WorkersPanelProps { + readonly client: DaemonApi; + readonly projectId: string; + readonly connected: boolean; + readonly canControl: boolean; +} + +interface Listing { + readonly workers: readonly WorkerView[]; + readonly runningHere: number; + readonly maxHere: number; +} + +/** Splits a listing into what runs, what waits, and what finished. */ +export function workerSections(workers: readonly WorkerView[]): { + readonly running: readonly WorkerView[]; + readonly queued: readonly WorkerView[]; + readonly finished: readonly WorkerView[]; +} { + return { + running: workers.filter((w) => w.state === "running"), + queued: workers.filter((w) => w.state === "queued"), + finished: workers.filter((w) => w.state !== "running" && w.state !== "queued"), + }; +} + +/** + * The project's temporary workers: the ones running, the queue waiting for a + * slot, and those that recently finished. The owner can cancel any spawn that + * has not finished. + */ +export default function WorkersPanel({ + client, + projectId, + connected, + canControl, +}: WorkersPanelProps): ReactElement { + const [listing, setListing] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + try { + const reply = await client.request( + { type: "list_workers", project_id: projectId }, + "workers", + ); + setListing({ + workers: reply.workers, + runningHere: reply.running_here, + maxHere: reply.max_workers_here, + }); + setError(null); + } catch (failure) { + setError(errText(failure)); + } + }, [client, projectId]); + + useLoadOnConnect(connected, load); + + useEffect( + () => + client.on("workers_updated", (push) => { + if (push.project_id === projectId) { + void load(); + } + }), + [client, load, projectId], + ); + + const cancel = async (worker: WorkerView): Promise => { + try { + await client.request({ type: "cancel_worker", worker_id: worker.id }, "worker"); + await load(); + } catch (failure) { + setError(errText(failure)); + } + }; + + const { running, queued, finished } = workerSections(listing?.workers ?? []); + const row = (worker: WorkerView): ReactElement => ( + void cancel(worker)} + /> + ); + return ( +
+

+ Workers + {listing === null ? null : ( + + {" "} + {listing.runningHere} of {listing.maxHere} slots in use here + + )} +

+ {error === null ? null :
{error}
} + {listing !== null && listing.workers.length === 0 ? ( +

+ No workers yet. Bots spawn temporary workers for pieces of a larger job; they queue here + when every slot is busy. +

+ ) : null} + + + +
+ ); +} + +function WorkerSection({ + title, + workers, + row, +}: { + readonly title: string; + readonly workers: readonly WorkerView[]; + readonly row: (worker: WorkerView) => ReactElement; +}): ReactElement | null { + if (workers.length === 0) { + return null; + } + return ( +
+

+ {title} {workers.length} +

+
    {workers.map(row)}
+
+ ); +} + +/** Where a worker stands, in a few words. */ +function standing(worker: WorkerView): string { + if (worker.state === "queued") { + return worker.queue_position === undefined ? "queued" : `#${worker.queue_position} in queue`; + } + if (worker.state === "running") { + return worker.machine === "here" || worker.machine == null + ? "running" + : `running on ${worker.machine}`; + } + return worker.state; +} + +function WorkerRow({ + worker, + canCancel, + onCancel, +}: { + readonly worker: WorkerView; + readonly canCancel: boolean; + readonly onCancel: () => void; +}): ReactElement { + const active = worker.state === "queued" || worker.state === "running"; + const when = worker.finished_at ?? worker.started_at ?? worker.created_at; + return ( +
  • +
    + {worker.name} + {standing(worker)} + {worker.parent_name == null ? null : ( + for {worker.parent_name} + )} + {fmtShortTime(when)} + {active ? ( + + ) : null} +
    +
    + {worker.brief} +
    + {worker.note === undefined || worker.note.length === 0 ? null : ( +
    {worker.note}
    + )} +
  • + ); +} diff --git a/apps/desktop/src/components/bot/BotChromeSwitch.test.tsx b/apps/desktop/src/components/bot/BotChromeSwitch.test.tsx new file mode 100644 index 0000000..622ee73 --- /dev/null +++ b/apps/desktop/src/components/bot/BotChromeSwitch.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { Bot, NotifyLevel } from "../../protocol/entities"; +import { agentsDaemon } from "../../test/agentFixtures"; +import * as fx from "../../test/fixtures"; +import BotChromeSwitch from "./BotChromeSwitch"; + +describe("BotChromeSwitch", () => { + it("lets the owner allow a bot their Chrome, which restarts it", async () => { + const client = agentsDaemon().onRequest("set_bot_user_chrome", () => ({ + type: "bot", + req_id: "1", + bot: fx.bot({ user_chrome: true }), + })); + const onBotUpdated = vi.fn<(bot: Bot) => void>(); + const onToast = vi.fn<(level: NotifyLevel, title: string, body: string) => void>(); + render( + , + ); + const toggle = screen.getByRole("checkbox", { name: /your Chrome/ }); + expect(toggle).not.toBeChecked(); + await userEvent.click(toggle); + expect(client.requests.at(-1)?.body).toEqual({ + type: "set_bot_user_chrome", + bot_id: "b1", + enabled: true, + }); + expect(onBotUpdated).toHaveBeenCalledWith(expect.objectContaining({ user_chrome: true })); + expect(onToast).toHaveBeenCalledWith("info", "Chrome allowed", expect.stringContaining("with")); + }); + + it("reports a refusal", async () => { + const client = agentsDaemon().onRequest("set_bot_user_chrome", () => { + throw new Error("bot not found"); + }); + const onToast = vi.fn<(level: NotifyLevel, title: string, body: string) => void>(); + render( + void>()} + onToast={onToast} + />, + ); + await userEvent.click(screen.getByRole("checkbox", { name: /your Chrome/ })); + expect(onToast).toHaveBeenCalledWith( + "error", + "Could not change Chrome access", + "bot not found", + ); + }); + + it("is absent for a linked bot and on daemons without bot browsers", () => { + const old = agentsDaemon(); + old.capabilities = ["chat"]; + const { container } = render( + <> + void>()} + onToast={vi.fn<(level: NotifyLevel, title: string, body: string) => void>()} + /> + void>()} + onToast={vi.fn<(level: NotifyLevel, title: string, body: string) => void>()} + /> + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/desktop/src/components/bot/BotChromeSwitch.tsx b/apps/desktop/src/components/bot/BotChromeSwitch.tsx new file mode 100644 index 0000000..3b4f984 --- /dev/null +++ b/apps/desktop/src/components/bot/BotChromeSwitch.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; +import type { DaemonApi } from "../../protocol/api"; +import type { Bot, NotifyLevel } from "../../protocol/entities"; +import { errText } from "../../util"; + +interface BotChromeSwitchProps { + readonly client: DaemonApi; + readonly bot: Bot; + readonly connected: boolean; + readonly canControl: boolean; + readonly onBotUpdated: (bot: Bot) => void; + readonly onToast: (level: NotifyLevel, title: string, body: string) => void; +} + +/** + * Whether the bot may also drive the owner's own Chrome. Off by default: + * every bot has a browser of its own, and this is for the task that needs + * the owner's logged-in sessions. Changing it restarts the bot's session. + */ +export default function BotChromeSwitch({ + client, + bot, + connected, + canControl, + onBotUpdated, + onToast, +}: BotChromeSwitchProps): ReactElement | null { + const [saving, setSaving] = useState(false); + if (!client.capabilities.includes("bot_browser") || bot.peer != null) { + return null; + } + const enabled = bot.user_chrome === true; + const save = async (next: boolean): Promise => { + setSaving(true); + try { + const reply = await client.request( + { type: "set_bot_user_chrome", bot_id: bot.id, enabled: next }, + "bot", + ); + onBotUpdated(reply.bot); + onToast( + "info", + next ? "Chrome allowed" : "Chrome off", + `${reply.bot.name} is restarting ${next ? "with" : "without"} access to your Chrome.`, + ); + } catch (error) { + onToast("error", "Could not change Chrome access", errText(error)); + } finally { + setSaving(false); + } + }; + return ( +
    +
    + +
    + {`${bot.name} always has a browser of its own. Allow this only for tasks that need your logged-in sessions; its tabs then open in your Chrome. Restarts the bot.`} +
    +
    + +
    + ); +} diff --git a/apps/desktop/src/components/bot/BotHeader.tsx b/apps/desktop/src/components/bot/BotHeader.tsx index 3365a3b..1fc9479 100644 --- a/apps/desktop/src/components/bot/BotHeader.tsx +++ b/apps/desktop/src/components/bot/BotHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import type { Bot, BotState } from "../../protocol/entities"; import BotAvatar from "../BotAvatar"; import { InfoPanelIcon } from "./icons"; @@ -31,6 +31,8 @@ interface BotHeaderProps { readonly canControl: boolean; readonly infoPanelCollapsed: boolean; readonly onToggleInfoPanel: () => void; + /** Session actions shown before the info toggle. */ + readonly actions?: ReactNode; } export default function BotHeader(props: BotHeaderProps): ReactElement { @@ -51,10 +53,12 @@ export default function BotHeader(props: BotHeaderProps): ReactElement { )}
    - {/* Bots are always-on, so there is no lifecycle control here. The + {/* Bots are always-on, so there is no start or stop here; Restart + and Clear chat (in `actions`) bring a running bot back. The terminal is the user's whenever they hold `control`; only read-only connections need a badge. */} {canControl ? null : Read-only} + {props.actions} + + {open === null || confirming === null ? null : ( + { + const action = confirming; + setConfirming(null); + void run(action); + }} + onCancel={() => { + setConfirming(null); + }} + /> + )} + + ); +} diff --git a/apps/desktop/src/components/bot/BotSidePanel.tsx b/apps/desktop/src/components/bot/BotSidePanel.tsx new file mode 100644 index 0000000..931cb65 --- /dev/null +++ b/apps/desktop/src/components/bot/BotSidePanel.tsx @@ -0,0 +1,88 @@ +import type { ReactElement } from "react"; +import type { AddToast } from "../../app/useToasts"; +import type { DaemonApi } from "../../protocol/api"; +import type { Bot } from "../../protocol/entities"; +import CommandsPanel from "../commands/CommandsPanel"; +import FilesPanel from "../files/FilesPanel"; +import InfoPanel from "../InfoPanel"; +import MemoryPanel from "../memory/MemoryPanel"; +import TasksPanel from "../tasks/TasksPanel"; + +export type SideTab = "info" | "tasks" | "files" | "commands" | "memory"; + +const SIDE_TABS: readonly SideTab[] = ["info", "tasks", "files", "commands", "memory"]; + +const SIDE_LABEL: Readonly> = { + info: "Info", + tasks: "Tasks", + files: "Files", + commands: "Commands", + memory: "Memory", +}; + +/** The side tabs this daemon serves. */ +function sideTabs(capabilities: readonly string[]): readonly SideTab[] { + return SIDE_TABS.filter((tab) => tab !== "commands" || capabilities.includes("bot_commands")); +} + +interface BotSidePanelProps { + readonly client: DaemonApi; + readonly bot: Bot; + readonly connected: boolean; + readonly canControl: boolean; + readonly side: SideTab; + readonly onSide: (side: SideTab) => void; + readonly openFile: string | null; + readonly onOpenFile: (path: string | null) => void; + readonly onBotUpdated: (bot: Bot) => void; + readonly onToast: AddToast; +} + +/** The right-hand panel: the bot's info, or the project's files. */ +export default function BotSidePanel(props: BotSidePanelProps): ReactElement { + const { client, bot, connected, side, onSide } = props; + return ( + <> + + {side === "info" ? ( + + ) : null} + {side === "tasks" ? : null} + {side === "commands" ? ( + + ) : null} + {side === "memory" ? ( + + ) : null} + {side === "files" ? ( + + ) : null} + + ); +} diff --git a/apps/desktop/src/components/bot/BotTabs.stories.tsx b/apps/desktop/src/components/bot/BotTabs.stories.tsx index 90a8b98..0a25caf 100644 --- a/apps/desktop/src/components/bot/BotTabs.stories.tsx +++ b/apps/desktop/src/components/bot/BotTabs.stories.tsx @@ -5,5 +5,5 @@ import BotTabs from "./BotTabs"; export const Interactive: Story = () => { const [active, setActive] = useState("terminal"); - return ; + return ; }; diff --git a/apps/desktop/src/components/bot/BotTabs.test.ts b/apps/desktop/src/components/bot/BotTabs.test.ts new file mode 100644 index 0000000..25d46db --- /dev/null +++ b/apps/desktop/src/components/bot/BotTabs.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { botTabs } from "./BotTabs"; + +describe("botTabs", () => { + it("offers the browser where the daemon serves one", () => { + expect(botTabs({ chat: true }, false)).toEqual(["chat", "terminal", "routines"]); + expect(botTabs({ chat: true, browser: true }, false)).toEqual([ + "chat", + "terminal", + "browser", + "routines", + ]); + expect(botTabs({ chat: false }, false)).toEqual(["terminal", "routines"]); + }); + + it("gives a linked bot its terminal when the daemon relays it", () => { + expect(botTabs({ chat: true, browser: true }, true)).toEqual(["chat"]); + expect(botTabs({ chat: true, browser: true, peerTerminal: true }, true)).toEqual([ + "chat", + "terminal", + ]); + expect(botTabs({ chat: true, peerTerminal: true, peerBrowser: true }, true)).toEqual([ + "chat", + "terminal", + "browser", + ]); + }); +}); diff --git a/apps/desktop/src/components/bot/BotTabs.tsx b/apps/desktop/src/components/bot/BotTabs.tsx index 3fbe431..8bb81ab 100644 --- a/apps/desktop/src/components/bot/BotTabs.tsx +++ b/apps/desktop/src/components/bot/BotTabs.tsx @@ -1,31 +1,79 @@ import type { ReactElement } from "react"; -const BOT_TABS = ["terminal", "routines"] as const; -export type BotTab = (typeof BOT_TABS)[number]; +export type BotTab = "chat" | "terminal" | "browser" | "routines"; const TAB_LABEL: Readonly> = { + chat: "Chat", terminal: "Terminal", + browser: "Browser", routines: "Routines", }; +/** What the daemon serves, which decides the tabs a bot offers. */ +export interface TabSupport { + readonly chat: boolean; + /** Each bot's own browser, watched live. */ + readonly browser?: boolean; + /** A linked bot's terminal, relayed from its machine. */ + readonly peerTerminal?: boolean; + /** A linked bot's browser, relayed from its machine. */ + readonly peerBrowser?: boolean; +} + +/** + * The tabs a bot offers. A linked bot has its chat, and its terminal and + * browser when the daemon relays them from its machine. + */ +export function botTabs(support: TabSupport, linked: boolean): readonly BotTab[] { + const { chat, browser = false, peerTerminal = false, peerBrowser = false } = support; + if (linked) { + const tabs: BotTab[] = ["chat"]; + if (peerTerminal) { + tabs.push("terminal"); + } + if (peerBrowser) { + tabs.push("browser"); + } + return tabs; + } + const tabs: BotTab[] = chat ? ["chat", "terminal"] : ["terminal"]; + if (browser) { + tabs.push("browser"); + } + tabs.push("routines"); + return tabs; +} + interface BotTabsProps { + readonly tabs: readonly BotTab[]; readonly active: BotTab; readonly onSelect: (tab: BotTab) => void; + /** The bot's browser is open: its tab shows a live dot. */ + readonly browserLive?: boolean; } -export default function BotTabs({ active, onSelect }: BotTabsProps): ReactElement { +export default function BotTabs({ + tabs, + active, + onSelect, + browserLive = false, +}: BotTabsProps): ReactElement { return ( diff --git a/apps/desktop/src/components/bot/useBotKeys.ts b/apps/desktop/src/components/bot/useBotKeys.ts new file mode 100644 index 0000000..41760ec --- /dev/null +++ b/apps/desktop/src/components/bot/useBotKeys.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import type { BotTab } from "./BotTabs"; + +/** + * ⌘1…⌘4 switch the bot's tabs and ⌘L jumps to the chat composer. Registered + * in the capture phase so a focused terminal cannot swallow them. + */ +export function useBotKeys(tabs: readonly BotTab[], onSelect: (tab: BotTab) => void): void { + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) { + return; + } + const index = Number.parseInt(event.key, 10); + const tab = Number.isNaN(index) ? undefined : tabs[index - 1]; + if (tab !== undefined) { + event.preventDefault(); + onSelect(tab); + return; + } + if (event.key.toLowerCase() === "l" && tabs.includes("chat")) { + event.preventDefault(); + onSelect("chat"); + window.requestAnimationFrame(() => { + document.querySelector("[data-chat-composer]")?.focus(); + }); + } + }; + window.addEventListener("keydown", onKey, true); + return () => { + window.removeEventListener("keydown", onKey, true); + }; + }, [tabs, onSelect]); +} diff --git a/apps/desktop/src/components/browser/BrowserActivityList.tsx b/apps/desktop/src/components/browser/BrowserActivityList.tsx new file mode 100644 index 0000000..7a96c52 --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserActivityList.tsx @@ -0,0 +1,89 @@ +import type { ReactElement } from "react"; +import type { BrowserAction } from "../../protocol/agents"; +import { fmtTimestamp } from "../../util"; +import { triggerView } from "../chat/chatModel"; + +/** Consecutive actions of one turn, under what started it. */ +interface TurnGroup { + readonly turnId: string; + readonly at: string; + readonly why: string; + readonly ask: string; + readonly actions: readonly BrowserAction[]; +} + +/** Groups newest-first actions by turn, keeping that order. */ +export function groupByTurn(actions: readonly BrowserAction[]): readonly TurnGroup[] { + const groups: TurnGroup[] = []; + for (const action of actions) { + const last = groups.at(-1); + if (last !== undefined && last.turnId === action.turn_id) { + groups[groups.length - 1] = { ...last, actions: [...last.actions, action] }; + continue; + } + const view = triggerView(action.trigger); + groups.push({ + turnId: action.turn_id, + at: action.at, + why: view.label, + ask: view.text, + actions: [action], + }); + } + return groups; +} + +/** + * Every browser action the bot took, under the request that led to it: the + * answer to "why is it browsing, and for whom". + */ +export default function BrowserActivityList({ + activity, + error, +}: { + readonly activity: readonly BrowserAction[]; + readonly error: string | null; +}): ReactElement { + const groups = groupByTurn(activity); + return ( + + ); +} diff --git a/apps/desktop/src/components/browser/BrowserPane.stories.tsx b/apps/desktop/src/components/browser/BrowserPane.stories.tsx new file mode 100644 index 0000000..2511b30 --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserPane.stories.tsx @@ -0,0 +1,40 @@ +import type { Story } from "@ladle/react"; +import frame from "../../test/browserFrame.jpg?inline"; +import { browserActivity, browserTabs } from "../../test/agentFixtures"; +import BrowserActivityList from "./BrowserActivityList"; +import { BrowserScreen, TabStrip } from "./BrowserPane"; + +const FRAME = { + type: "browser_frame", + bot_id: "b1", + tab_id: "tab-1", + data: frame.slice(frame.indexOf(",") + 1), + width: 640, + height: 400, +} as const; + +export const Live: Story = () => ( +
    +
    +
    + lead uses a browser of its own; your Chrome is off limits. +
    + undefined} /> + +
    + +
    +); + +export const Closed: Story = () => ( +
    +
    + +
    + +
    +); diff --git a/apps/desktop/src/components/browser/BrowserPane.test.tsx b/apps/desktop/src/components/browser/BrowserPane.test.tsx new file mode 100644 index 0000000..9044d3f --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserPane.test.tsx @@ -0,0 +1,129 @@ +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { agentsDaemon, browserActivity, browserTabs } from "../../test/agentFixtures"; +import * as fx from "../../test/fixtures"; +import type { ReactElement } from "react"; +import type { DaemonApi } from "../../protocol/api"; +import type { Bot } from "../../protocol/entities"; +import { groupByTurn } from "./BrowserActivityList"; +import BrowserPane from "./BrowserPane"; +import { useBrowserWatch } from "./useBrowserWatch"; + +/** Holds the watch as the bot view does, for as long as `active`. */ +function Pane({ + client, + bot, + active, + connected, +}: { + readonly client: DaemonApi; + readonly bot: Bot; + readonly active: boolean; + readonly connected: boolean; +}): ReactElement { + const watch = useBrowserWatch(client, bot.id, active, connected); + return ; +} + +const FRAME = { + type: "browser_frame", + bot_id: "b1", + tab_id: "tab-1", + data: "SlBFRw==", + width: 800, + height: 600, +} as const; + +describe("BrowserPane", () => { + it("watches while held, and stops when let go", async () => { + const client = agentsDaemon(); + const view = render(); + await screen.findByText("Opened example.com"); + expect(client.requests.some((r) => r.body.type === "watch_browser")).toBe(false); + view.rerender(); + expect(client.requests.at(-1)?.body).toEqual({ type: "watch_browser", bot_id: "b1" }); + view.rerender(); + expect(client.requests.at(-1)?.body).toEqual({ type: "unwatch_browser" }); + }); + + it("shows the tab on show, live, and lets the owner pick another", async () => { + const client = agentsDaemon(); + render(); + expect(await screen.findByText("Looking for the browser…")).toBeInTheDocument(); + act(() => { + client.emit("browser_tabs", browserTabs); + client.emit("browser_frame", FRAME); + }); + expect( + screen.getByText("https://example.com/pricing", { selector: ".browser-url" }), + ).toBeInTheDocument(); + const shot = screen.getByRole("img", { name: "What alice's browser shows" }); + expect(shot).toHaveAttribute("src", "data:image/jpeg;base64,SlBFRw=="); + const tabs = screen.getByRole("tablist", { name: "Browser tabs" }); + expect(within(tabs).getByRole("tab", { name: "Pricing — Example" })).toHaveAttribute( + "aria-selected", + "true", + ); + + await userEvent.click(within(tabs).getByRole("tab", { name: "Docs" })); + expect(client.requests.at(-1)?.body).toEqual({ + type: "watch_browser", + bot_id: "b1", + tab_id: "tab-2", + }); + await userEvent.click(within(tabs).getByRole("button", { name: "Follow bot" })); + expect(client.requests.at(-1)?.body).toEqual({ type: "watch_browser", bot_id: "b1" }); + }); + + it("says when the browser is closed, and ignores other bots' frames", async () => { + const client = agentsDaemon(); + render(); + act(() => { + client.emit("browser_tabs", { ...browserTabs, bot_id: "other" }); + client.emit("browser_tabs", { ...browserTabs, open: false, tabs: [], active: null }); + }); + expect(await screen.findByText(/alice's browser is closed/)).toBeInTheDocument(); + }); + + it("lists each browser action under the request that led to it", async () => { + render(); + const log = await screen.findByRole("complementary", { name: "Browser activity" }); + expect(within(log).getByText("Task from lead")).toBeInTheDocument(); + expect(within(log).getByText("Compare the pricing tiers")).toBeInTheDocument(); + expect(within(log).getByText("your Chrome")).toBeInTheDocument(); + expect(screen.getByText(/your Chrome is off limits/)).toBeInTheDocument(); + }); + + it("says a linked bot's browser streams from its machine", async () => { + render( + , + ); + expect( + await screen.findByText("alice runs on win-pc; its browser streams from there."), + ).toBeInTheDocument(); + }); + + it("says when the bot may also use the owner's Chrome", async () => { + render( + , + ); + expect(await screen.findByText(/may also use your Chrome/)).toBeInTheDocument(); + }); +}); + +describe("groupByTurn", () => { + it("keeps a turn's actions together, newest turn first", () => { + const groups = groupByTurn(browserActivity); + expect(groups.map((g) => [g.turnId, g.actions.length])).toEqual([ + ["turn-2", 1], + ["turn-1", 2], + ]); + expect(groups[0]?.why).toBe("You"); + }); +}); diff --git a/apps/desktop/src/components/browser/BrowserPane.tsx b/apps/desktop/src/components/browser/BrowserPane.tsx new file mode 100644 index 0000000..78233e0 --- /dev/null +++ b/apps/desktop/src/components/browser/BrowserPane.tsx @@ -0,0 +1,132 @@ +import type { ReactElement } from "react"; +import type { BrowserFramePush, BrowserTabsPush } from "../../protocol/agents"; +import type { DaemonApi } from "../../protocol/api"; +import type { Bot } from "../../protocol/entities"; +import BrowserActivityList from "./BrowserActivityList"; +import { useBrowserActivity } from "./useBrowserActivity"; +import type { BrowserWatch } from "./useBrowserWatch"; + +interface BrowserPaneProps { + readonly client: DaemonApi; + readonly bot: Bot; + /** The bot's browser as it streams (see `useBrowserWatch`). */ + readonly watch: BrowserWatch; + readonly connected: boolean; +} + +/** Where the bot's own browser stands, said above the screen. */ +function chromeNote(bot: Bot): string { + if (bot.peer != null) { + return `${bot.name} runs on ${bot.peer.name}; its browser streams from there.`; + } + return bot.user_chrome === true + ? `${bot.name} uses a browser of its own, and may also use your Chrome.` + : `${bot.name} uses a browser of its own; your Chrome is off limits.`; +} + +/** Each of the bot's tabs; picking one shows it, "Follow" goes back to the bot's. */ +export function TabStrip({ + tabs, + onPick, +}: { + readonly tabs: BrowserTabsPush; + readonly onPick: (tabId: string | null) => void; +}): ReactElement { + return ( +
    + {tabs.tabs.map((tab) => ( + + ))} + +
    + ); +} + +/** The tab on show, as its latest screen. */ +export function BrowserScreen({ + tabs, + frame, + name, +}: { + readonly tabs: BrowserTabsPush | null; + readonly frame: BrowserFramePush | null; + readonly name: string; +}): ReactElement { + if (tabs === null) { + return
    Looking for the browser…
    ; + } + if (!tabs.open) { + return ( +
    + {tabs.reason ?? `${name}'s browser is closed. It opens when ${name} first browses.`} +
    + ); + } + const url = tabs.tabs.find((tab) => tab.id === tabs.active)?.url ?? ""; + return ( +
    +
    + {url} +
    + {frame !== null && frame.tab_id === tabs.active ? ( + {`What + ) : ( +
    Waiting for the page…
    + )} +
    + ); +} + +/** + * The bot's own browser, live: its tabs, the page on show, and every + * browser action it took with the turn that led to it. + */ +export default function BrowserPane({ + client, + bot, + watch, + connected, +}: BrowserPaneProps): ReactElement { + const activity = useBrowserActivity(client, bot.id, connected); + return ( +
    +
    +
    {chromeNote(bot)}
    + {watch.tabs !== null && watch.tabs.open ? ( + + ) : null} + {watch.error === null ? null :
    {watch.error}
    } + +
    + +
    + ); +} diff --git a/apps/desktop/src/components/browser/useBrowserActivity.ts b/apps/desktop/src/components/browser/useBrowserActivity.ts new file mode 100644 index 0000000..cd645a0 --- /dev/null +++ b/apps/desktop/src/components/browser/useBrowserActivity.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; +import { useLoadOnConnect } from "../../hooks/useLoadOnConnect"; +import { useOnBotTurns } from "../../hooks/useOnBotTurns"; +import type { BrowserAction } from "../../protocol/agents"; +import type { DaemonApi } from "../../protocol/api"; +import { errText } from "../../util"; + +/** Waits this long after the bot's last turn update before refetching. */ +const REFRESH_DELAY_MS = 600; + +export interface BrowserActivity { + /** Newest first. */ + readonly items: readonly BrowserAction[]; + readonly error: string | null; +} + +/** The bot's browser actions, refetched as its turns change. */ +export function useBrowserActivity( + client: DaemonApi, + botId: string, + connected: boolean, +): BrowserActivity { + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + try { + const reply = await client.request( + { type: "list_browser_activity", bot_id: botId }, + "browser_activity", + ); + setItems(reply.activity); + setError(null); + } catch (failure) { + setError(errText(failure)); + } + }, [client, botId]); + + useLoadOnConnect(connected, load); + + useOnBotTurns(client, botId, load, REFRESH_DELAY_MS); + + return { items, error }; +} diff --git a/apps/desktop/src/components/browser/useBrowserWatch.ts b/apps/desktop/src/components/browser/useBrowserWatch.ts new file mode 100644 index 0000000..c3bc0fa --- /dev/null +++ b/apps/desktop/src/components/browser/useBrowserWatch.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from "react"; +import type { BrowserFramePush, BrowserTabsPush } from "../../protocol/agents"; +import type { DaemonApi } from "../../protocol/api"; +import { errText } from "../../util"; + +export interface BrowserWatch { + /** The bot's tabs, or null until the daemon has said. */ + readonly tabs: BrowserTabsPush | null; + /** The newest screen of the tab on show. */ + readonly frame: BrowserFramePush | null; + readonly error: string | null; + /** Shows another tab, or follows the bot again with null. */ + readonly pick: (tabId: string | null) => void; +} + +/** + * Streams a bot's browser while `active`: the daemon pushes its tabs as they + * change and each new screen of the tab on show. The bot view holds it for as + * long as the bot is selected, so the browser is live whichever tab is open. + * Mount it keyed by bot: a different bot's browser starts from nothing. + */ +export function useBrowserWatch( + client: DaemonApi, + botId: string, + active: boolean, + connected: boolean, +): BrowserWatch { + const [tabs, setTabs] = useState(null); + const [frame, setFrame] = useState(null); + const [error, setError] = useState(null); + const [picked, setPicked] = useState(null); + + useEffect(() => { + if (!active || !connected) { + return undefined; + } + const offTabs = client.on("browser_tabs", (push) => { + if (push.bot_id === botId) { + setTabs(push); + } + }); + const offFrame = client.on("browser_frame", (push) => { + if (push.bot_id === botId) { + setFrame(push); + } + }); + const watch = async (): Promise => { + try { + await client.request( + picked === null + ? { type: "watch_browser", bot_id: botId } + : { type: "watch_browser", bot_id: botId, tab_id: picked }, + "ok", + ); + setError(null); + } catch (failure) { + setError(errText(failure)); + } + }; + void watch(); + return () => { + offTabs(); + offFrame(); + client.request({ type: "unwatch_browser" }, "ok").catch(() => undefined); + }; + }, [client, botId, active, connected, picked]); + + const pick = useCallback((tabId: string | null): void => { + setPicked(tabId); + }, []); + + return { tabs, frame, error, pick }; +} diff --git a/apps/desktop/src/components/chat/ChatComposer.stories.tsx b/apps/desktop/src/components/chat/ChatComposer.stories.tsx new file mode 100644 index 0000000..e744c3e --- /dev/null +++ b/apps/desktop/src/components/chat/ChatComposer.stories.tsx @@ -0,0 +1,20 @@ +import type { Story } from "@ladle/react"; +import ChatComposer from "./ChatComposer"; + +const send = async (): Promise => {}; + +export const Ready: Story = () => ( +
    + +
    +); + +export const ReadOnly: Story = () => ( +
    + +
    +); diff --git a/apps/desktop/src/components/chat/ChatComposer.test.tsx b/apps/desktop/src/components/chat/ChatComposer.test.tsx new file mode 100644 index 0000000..ef9d7bf --- /dev/null +++ b/apps/desktop/src/components/chat/ChatComposer.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import ChatComposer from "./ChatComposer"; +import { withAttachments } from "./useAttachments"; + +describe("ChatComposer", () => { + it("attaches files and sends their paths with the message", async () => { + const onSend = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined); + const onAttach = vi + .fn<(file: File) => Promise>() + .mockResolvedValue("/p/artifacts/uploads/spec.pdf"); + render( + , + ); + + await userEvent.upload( + screen.getByLabelText("Files to attach"), + new File(["%PDF"], "spec.pdf", { type: "application/pdf" }), + ); + expect(await screen.findByText("spec.pdf")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText(/uploading/)).not.toBeInTheDocument(); + }); + await userEvent.type(screen.getByRole("textbox", { name: "Message" }), "read this{Enter}"); + expect(onSend).toHaveBeenCalledWith( + "read this\n\nAttached files:\n- /p/artifacts/uploads/spec.pdf", + ); + expect(screen.queryByText("spec.pdf")).not.toBeInTheDocument(); + }); + + it("shows a failed upload and lets it be removed", async () => { + const onAttach = vi + .fn<(file: File) => Promise>() + .mockRejectedValue(new Error("too big")); + render( + Promise>()} + onAttach={onAttach} + />, + ); + await userEvent.upload(screen.getByLabelText("Files to attach"), new File(["x"], "huge.bin")); + expect(await screen.findByText(/failed/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Remove huge.bin" })); + expect(screen.queryByText(/huge.bin/)).not.toBeInTheDocument(); + }); + + it("says where a slash command runs", async () => { + render( + Promise>()} + slashHint="Runs in the terminal" + />, + ); + await userEvent.type(screen.getByRole("textbox", { name: "Message" }), "/compact"); + expect(screen.getByText("Runs in the terminal")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Attach files" })).not.toBeInTheDocument(); + }); + + it("lists attachments after the text, or alone", () => { + const done = [{ key: 1, name: "a", path: "/a" }]; + expect(withAttachments("", done)).toBe("Attached files:\n- /a"); + expect(withAttachments("hi", [])).toBe("hi"); + }); +}); diff --git a/apps/desktop/src/components/chat/ChatComposer.tsx b/apps/desktop/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000..ad7a10f --- /dev/null +++ b/apps/desktop/src/components/chat/ChatComposer.tsx @@ -0,0 +1,103 @@ +import { useRef, useState } from "react"; +import type { ClipboardEvent, KeyboardEvent, ReactElement } from "react"; +import type { Dictation } from "../../dictation"; +import { AttachButton, AttachmentChips, MicButton } from "./ComposerParts"; +import { useAttachments, withAttachments } from "./useAttachments"; +import { useDictation } from "./useDictation"; + +interface ChatComposerProps { + /** Why the owner cannot write, or null when they can. */ + readonly disabledReason: string | null; + readonly placeholder: string; + readonly onSend: (text: string) => Promise; + /** Uploads an attached file and returns where bots can read it; absent to turn attaching off. */ + readonly onAttach?: (file: File) => Promise; + /** Shown under a draft that starts with `/`, or null when slash commands are not special. */ + readonly slashHint?: string | null; + /** On-device dictation; the mic button shows only where it is available. */ + readonly dictation?: Dictation; +} + +const LISTENING = "Listening… press the stop button when you are done."; + +/** Enter sends, Shift+Enter adds a line. A failed send keeps the draft. */ +export default function ChatComposer(props: ChatComposerProps): ReactElement { + const { disabledReason, onSend, onAttach } = props; + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const field = useRef(null); + const disabled = disabledReason !== null; + const attachments = useAttachments(onAttach); + const voice = useDictation(props.dictation, draft, setDraft); + const empty = draft.trim() === "" && attachments.ready.length === 0; + const blocked = disabled || sending || attachments.uploading || empty; + + const send = async (): Promise => { + if (blocked) { + return; + } + setSending(true); + try { + await onSend(withAttachments(draft.trim(), attachments.ready)); + setDraft(""); + attachments.clear(); + } finally { + setSending(false); + field.current?.focus(); + } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + void send(); + } + }; + + const onPaste = (event: ClipboardEvent): void => { + const files = [...event.clipboardData.files]; + if (files.length > 0 && onAttach !== undefined) { + event.preventDefault(); + attachments.attach(files); + } + }; + + const slash = + props.slashHint != null && draft.trimStart().startsWith("/") ? props.slashHint : null; + const hint = voice.error ?? (voice.listening ? LISTENING : slash); + return ( +
    + + {hint === null ? null :
    {hint}
    } +
    + {onAttach === undefined ? null : ( + + )} + {voice.available ? : null} +