diff --git a/README.md b/README.md index d468eca..51ec094 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Everything else is in **Settings**, a panel that grows out of the open island un ## Install -In Boite, open **Settings > Plugins**, add `https://github.com/beboite/bots`, and confirm. Boite downloads the executable for your system from this repository's releases, checks its sha256 against [`boite-plugin.json`](boite-plugin.json), and starts it. +In Boite, open **Settings > Plugins**, add `https://github.com/Skollheim/bots`, and confirm. Boite downloads the executable for your system from this repository's releases, checks its sha256 against [`boite-plugin.json`](boite-plugin.json), and starts it. The tray icon has **Settings** and **Quit**. @@ -159,7 +159,7 @@ A second launch brings the running Bots forward instead of starting another one. Bots is Tauri 2, Svelte 5, Vite and TypeScript, built with [Bun](https://bun.sh) (the version in `package.json`'s `packageManager`) and stable Rust. On Linux, install the WebKit dependencies first: `libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev`. ```sh -git clone --recurse-submodules https://github.com/beboite/bots +git clone --recurse-submodules https://github.com/Skollheim/bots cd bots bun install --frozen-lockfile bun run check # svelte-check, then the scripts' types diff --git a/boite-plugin.json b/boite-plugin.json index e605977..422cd69 100644 --- a/boite-plugin.json +++ b/boite-plugin.json @@ -4,31 +4,31 @@ "name": "B.O.T.S", "version": "0.1.17", "description": "Little robots on your desktop that follow your Boite agents, answer your questions and keep you company while you work.", - "homepage": "https://github.com/beboite/bots", + "homepage": "https://github.com/Skollheim/bots", "executable": "bots", "artifacts": { "win32-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-pc-windows-msvc.exe", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-pc-windows-msvc.exe", "sha256": "00ab737511e9a52fae92cca0454b2b02e6100f5d36af45ae227e8c02cb651f25" }, "win32-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-pc-windows-msvc.exe", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-pc-windows-msvc.exe", "sha256": "f4c79a827e476869e490ce2eed1097419dc335de016a11a46cc3c13445a8907f" }, "darwin-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-apple-darwin", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-apple-darwin", "sha256": "0786aad2e6b87160ec701c14c5a17100dbcb1d4bba8f24dec8e3545fa00aabcd" }, "darwin-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-apple-darwin", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-apple-darwin", "sha256": "4892cb34487df1129ab5e50bade922c5573db8ac6090518a81b56202ab5e6919" }, "linux-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-unknown-linux-gnu", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-unknown-linux-gnu", "sha256": "84303ed17b86d37d18ece1c45f023c52a926339b0cd54de4d87b84028a65485f" }, "linux-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-unknown-linux-gnu", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-unknown-linux-gnu", "sha256": "aec98c7780abcfc1291f0d6985dcd42451d97d57a71fd9009a44ce383568eb7e" } }, diff --git a/scripts/manifest.test.ts b/scripts/manifest.test.ts index 580e2c4..7029398 100644 --- a/scripts/manifest.test.ts +++ b/scripts/manifest.test.ts @@ -40,7 +40,7 @@ test('a release writes its version, URLs and hashes for the executables it built expect(next.version).toBe('1.2.3'); expect(Object.keys(next.artifacts)).toEqual(['win32-x64', 'darwin-arm64']); expect(next.artifacts['win32-x64']).toEqual({ - url: 'https://github.com/beboite/bots/releases/download/v1.2.3/bots-x86_64-pc-windows-msvc.exe', + url: 'https://github.com/Skollheim/bots/releases/download/v1.2.3/bots-x86_64-pc-windows-msvc.exe', sha256: 'a'.repeat(64), }); expect(next.provides).toEqual({ desktopApp: {} }); diff --git a/scripts/targets.ts b/scripts/targets.ts index 3acdcf4..352fca1 100644 --- a/scripts/targets.ts +++ b/scripts/targets.ts @@ -4,7 +4,7 @@ * `release-manifest.ts` writes their URLs and hashes into `boite-plugin.json`. */ -export const REPOSITORY = 'beboite/bots'; +export const REPOSITORY = 'Skollheim/bots'; export interface Target { /** Boite's platform key, `${process.platform}-${process.arch}`. */ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59ae883..6e3858e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" rust-version = "1.88" publish = false license = "MIT" -repository = "https://github.com/beboite/bots" +repository = "https://github.com/Skollheim/bots" [lib] name = "bots_lib" diff --git a/src-tauri/src/companion_window.rs b/src-tauri/src/companion_window.rs index dbb2f27..599e049 100644 --- a/src-tauri/src/companion_window.rs +++ b/src-tauri/src/companion_window.rs @@ -411,6 +411,8 @@ fn watch(app: AppHandle, shared: Shared, mine: u64) { // Read while hidden too: the shortcut can call the companion over a game. senses.front(&window, tick.is_multiple_of(SCREEN_EVERY_TICKS * FRONT_AGAIN_SCREENS)); } + // Hidden too: a drag's span is given up even over a full-screen app. + crate::island::follow_span(&app, &window); if shared.hidden.load(Ordering::Acquire) { continue; } if tick.is_multiple_of(RAISE_EVERY_TICKS) { crate::platform::keep_on_top(&window); diff --git a/src-tauri/src/island.rs b/src-tauri/src/island.rs index f500b58..80ec3e4 100644 --- a/src-tauri/src/island.rs +++ b/src-tauri/src/island.rs @@ -15,35 +15,45 @@ //! //! While the user drags the island to another place, the window spans the //! whole work area and takes every click (`island_span`), so the page can draw -//! the places it may go to and carry the island over them. +//! the places it may go to and carry the island over them. The pointer taken +//! to another screen takes the span there (`follow_span`), so the island can +//! land on that screen's places. A span the page never ends, its button let +//! go long ago, is given up: the window goes back to the island's place. -use crate::companion_window::{only_companion, origin, pick_monitor, work_area, Area, Edge, Layout}; +use crate::companion_window::{monitor_name, only_companion, origin, pick_monitor, work_area, Area, Edge, Layout}; use serde::Serialize; use std::sync::{Mutex, PoisonError}; -use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, Runtime, Webview, WebviewWindow}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager, Monitor, PhysicalPosition, PhysicalSize, Runtime, Webview, WebviewWindow}; /// How close to the island's edge of a work area a robot dropped on the /// desktop must be, in logical pixels from its centre, to go back into it. pub const DOCK_REACH: f64 = 72.0; +/// How long a span may outlast the button: the island's landing takes well under it. +const SPAN_GIVE_UP: Duration = Duration::from_secs(3); + /// The island the page asked for: the screen, its place, and the window's logical size. #[derive(Clone, Debug, PartialEq)] struct Wanted { monitor: Option, place: Layout, width: f64, height: f64 } -/// What the island asked for last, where that put it, and whether the window -/// spans the work area for a drag. +/// The window spans a screen's work area for a drag: which screen, and since +/// when no button is down. +struct Span { monitor: String, released: Option } + +/// What the island asked for last, where that put it, and the span of a drag. #[derive(Default)] pub struct IslandState { wanted: Mutex>, placed: Mutex>, - spanning: Mutex, + span: Mutex>, } /// A window's place, `(x, y, width, height)` in physical pixels. type Frame = (i32, i32, u32, u32); /// Where the island went, for the page: the screen's name and its scale. -#[derive(Serialize, Debug, PartialEq)] +#[derive(Serialize, Clone, Debug, PartialEq)] pub struct Placed { monitor: String, scale: f64 } /// The island's window on a work area: `size` logical pixels at `scale`, @@ -78,6 +88,21 @@ pub fn docks(area: Area, scale: f64, centre: (i32, i32), place: Layout) -> bool along && near } +/// Which of `screens` (whole screens, physical pixels, as the system lays +/// them out: left of or above the primary one at negative coordinates, each +/// at its own scale) holds `point`, in physical pixels; the nearest one for a +/// point in a gap between them. None without screens. +pub fn screen_at(screens: &[Area], point: (i32, i32)) -> Option { + let away = |&(left, top, width, height): &Area| { + let gap = |at: i64, start: i64, length: i64| if at < start { start - at } else if at >= start + length { at - (start + length) + 1 } else { 0 }; + let (x, y) = (gap(point.0.into(), left.into(), width.into()), gap(point.1.into(), top.into(), height.into())); + x * x + y * y + }; + screens.iter().enumerate().min_by_key(|(_, screen)| away(screen)).map(|(index, _)| index) +} + +fn whole(monitor: &Monitor) -> Area { (monitor.position().x, monitor.position().y, monitor.size().width, monitor.size().height) } + fn frame_for(window: &WebviewWindow, wanted: &Wanted) -> tauri::Result> { let Some(monitor) = pick_monitor(window, wanted.monitor.as_deref())? else { return Ok(None) }; let scale = monitor.scale_factor(); @@ -94,7 +119,7 @@ fn set_frame(window: &WebviewWindow, frame: Frame) -> tauri::Resu /// The drag over: the window takes clicks only over the page's areas again. fn end_span(app: &AppHandle) { - *app.state::().spanning.lock().unwrap_or_else(PoisonError::into_inner) = false; + *app.state::().span.lock().unwrap_or_else(PoisonError::into_inner) = None; crate::companion_window::take_every_click(app, false); } @@ -111,7 +136,7 @@ pub fn forget(app: &AppHandle) { /// while the island is being dragged. pub fn follow(app: &AppHandle, window: &WebviewWindow) { let state = app.state::(); - if *state.spanning.lock().unwrap_or_else(PoisonError::into_inner) { return; } + if state.span.lock().unwrap_or_else(PoisonError::into_inner).is_some() { return; } let Some(wanted) = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return }; let Ok(Some((frame, _, _))) = frame_for(window, &wanted) else { return }; let mut placed = state.placed.lock().unwrap_or_else(PoisonError::into_inner); @@ -142,32 +167,93 @@ pub async fn island_place(app: AppHandle, webview: Webview, monitor: Option Result<(), String> { +pub async fn island_span(app: AppHandle, webview: Webview, span: bool) -> Result, String> { only_companion(&webview)?; let window = app.get_webview_window(crate::companion_window::LABEL).ok_or("the companion window is closed")?; let state = app.state::(); - let Some(wanted) = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return Ok(()) }; + let Some(wanted) = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return Ok(None) }; if !span { end_span(&app); - let Some((frame, _, _)) = frame_for(&window, &wanted).map_err(|error| error.to_string())? else { return Ok(()) }; - set_frame(&window, frame).map_err(|error| error.to_string())?; - *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = Some(frame); - return Ok(()); + put_back(&window, &state, &wanted).map_err(|error| error.to_string())?; + return Ok(None); } - let Some(monitor) = pick_monitor(&window, wanted.monitor.as_deref()).map_err(|error| error.to_string())? else { return Ok(()) }; + let pointed = monitor_under_pointer(&window).map_err(|error| error.to_string())?; + let monitor = match pointed { + Some(monitor) => monitor, + None => match pick_monitor(&window, wanted.monitor.as_deref()).map_err(|error| error.to_string())? { Some(monitor) => monitor, None => return Ok(None) }, + }; // Every click first, so the press carrying the island is not let through as the window grows. crate::companion_window::take_every_click(&app, true); - *state.spanning.lock().unwrap_or_else(PoisonError::into_inner) = true; - let area = work_area(&monitor); - set_frame(&window, area).map_err(|error| error.to_string())?; + let mut current = state.span.lock().unwrap_or_else(PoisonError::into_inner); + span_over(&window, &state, &monitor).map_err(|error| error.to_string())?; + *current = Some(Span { monitor: monitor_name(&monitor), released: None }); + Ok(Some(Placed { monitor: monitor_name(&monitor), scale: monitor.scale_factor() })) +} + +/// The window back at the island's place. +fn put_back(window: &WebviewWindow, state: &IslandState, wanted: &Wanted) -> tauri::Result<()> { + let Some((frame, _, _)) = frame_for(window, wanted)? else { return Ok(()) }; + set_frame(window, frame)?; + *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = Some(frame); + Ok(()) +} + +/// The window over `monitor`'s work area. +fn span_over(window: &WebviewWindow, state: &IslandState, monitor: &Monitor) -> tauri::Result<()> { + let area = work_area(monitor); + set_frame(window, area)?; *state.placed.lock().unwrap_or_else(PoisonError::into_inner) = Some(area); Ok(()) } +/// The screen under the pointer, or the nearest one. +fn monitor_under_pointer(window: &WebviewWindow) -> tauri::Result> { + let cursor = window.cursor_position()?; + let mut monitors = window.available_monitors()?; + let screens: Vec = monitors.iter().map(whole).collect(); + Ok(screen_at(&screens, (cursor.x.round() as i32, cursor.y.round() as i32)).map(|index| monitors.swap_remove(index))) +} + +/// Called by the pointer watch while the island is carried: the pointer on +/// another screen takes the span there, and tells the page the screen +/// (`companion://span`). A span whose button has been up for `SPAN_GIVE_UP` +/// lost its drag (the page missed the release): the window goes back to the +/// island's place and stops taking every click (`companion://span-lost`). +pub fn follow_span(app: &AppHandle, window: &WebviewWindow) { + let state = app.state::(); + // Held while the window moves: `island_place` waits, so the place after it is not undone. + let mut current = state.span.lock().unwrap_or_else(PoisonError::into_inner); + let Some(span) = current.as_mut() else { return }; + if !crate::platform::mouse_down() { + let released = *span.released.get_or_insert_with(Instant::now); + if released.elapsed() < SPAN_GIVE_UP { return; } + *current = None; + drop(current); + end_span(app); + let wanted = state.wanted.lock().unwrap_or_else(PoisonError::into_inner).clone(); + if let Some(wanted) = wanted { + if let Err(error) = put_back(window, &state, &wanted) { eprintln!("[bots] island span: {error}"); } + } + let _ = window.emit("companion://span-lost", ()); + return; + } + span.released = None; + let Ok(Some(monitor)) = monitor_under_pointer(window) else { return }; + let name = monitor_name(&monitor); + if name == span.monitor { return; } + if let Err(error) = span_over(window, &state, &monitor) { + eprintln!("[bots] island span: {error}"); + return; + } + span.monitor = name.clone(); + let _ = window.emit("companion://span", Placed { monitor: name, scale: monitor.scale_factor() }); +} + #[cfg(test)] mod tests { use super::*; @@ -257,4 +343,48 @@ mod tests { // A taskbar on the right: the right half ends before it. assert!(!docks((0, 0, 1858, 1080), 1.0, (1880, 20), at(Edge::Top, Anchor::Right)), "over the taskbar, off the work area"); } + + #[test] + fn the_screen_under_the_pointer_side_by_side() { + // A 1440p screen at 125 % left of a 1080p primary one: the left one at negative x. + let screens = [(0, 0, 1920, 1080), (-2560, 0, 2560, 1440)]; + assert_eq!(screen_at(&screens, (960, 540)), Some(0)); + assert_eq!(screen_at(&screens, (0, 0)), Some(0), "the primary's top left corner is its own"); + assert_eq!(screen_at(&screens, (-1, 0)), Some(1), "one pixel left is the neighbour's"); + assert_eq!(screen_at(&screens, (-2560, 1439)), Some(1)); + assert_eq!(screen_at(&screens, (-1280, 1300)), Some(1), "below the primary's foot, still on the taller screen"); + assert_eq!(screen_at(&screens, (1919, 1079)), Some(0)); + assert_eq!(screen_at(&[], (0, 0)), None); + } + + #[test] + fn the_screen_under_the_pointer_stacked_and_scaled() { + // A 4K screen at 150 % above a 1080p primary at 100 %, a 1366 px laptop at 125 % right of the primary. + let screens = [(0, 0, 1920, 1080), (-960, -2160, 3840, 2160), (1920, 200, 1366, 768)]; + assert_eq!(screen_at(&screens, (100, -1)), Some(1), "just above the primary"); + assert_eq!(screen_at(&screens, (-900, -2160)), Some(1)); + assert_eq!(screen_at(&screens, (100, 1079)), Some(0)); + assert_eq!(screen_at(&screens, (1920, 500)), Some(2), "the laptop's left edge"); + assert_eq!(screen_at(&screens, (3285, 967)), Some(2), "the laptop's last pixel"); + } + + #[test] + fn a_pointer_in_a_gap_takes_the_nearest_screen() { + // The laptop lower than the primary: a gap above it, right of the primary. + let screens = [(0, 0, 1920, 1080), (1920, 200, 1366, 768)]; + assert_eq!(screen_at(&screens, (1925, 50)), Some(0), "just right of the primary, far above the laptop"); + assert_eq!(screen_at(&screens, (2600, 100)), Some(1), "just above the laptop, far right of the primary"); + assert_eq!(screen_at(&screens, (1921, 190)), Some(0), "2 px right of the primary, 10 px above the laptop"); + assert_eq!(screen_at(&screens, (1921, 199)), Some(1), "2 px right of the primary, 1 px above the laptop"); + assert_eq!(screen_at(&screens, (-50, 500)), Some(0), "left of everything"); + } + + #[test] + fn the_pointer_over_a_taskbar_is_on_its_screen() { + // The screens are whole, the taskbar included: over a 62 px taskbar on the left of the second screen is that screen. + let screens = [(0, 0, 1920, 1080), (1920, 0, 2560, 1440)]; + assert_eq!(screen_at(&screens, (1930, 700)), Some(1)); + // A taskbar at the bottom of the primary, the pointer over it. + assert_eq!(screen_at(&screens, (900, 1060)), Some(0)); + } } diff --git a/src/App.svelte b/src/App.svelte index 221f599..88cb042 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,7 +10,8 @@ dragging it back near the island, or "Back to the island", returns it. The shell places the window for either (`island_place`, `companion_place`). Dragging the pill moves the island to another of its six places - (`lib/island/move.svelte.ts`): the window spans the screen meanwhile. + (`lib/island/move.svelte.ts`), on its screen or another: the window spans + the screen under the pointer meanwhile. The settings are a panel of the island (`island/settings/SettingsPanel.svelte`): asked for from the tray, a gear or "Make an agent", the island opens for @@ -25,7 +26,7 @@ import BotsNotice from './components/BotsNotice.svelte'; import { writeStatus } from './lib/companion/memory'; import { captureZone, type ScreenShot } from './lib/companion/screen'; - import { companionHitRects, configureCompanion, coverScreen, inShell, layoutOf, listenShell, placeCompanion, placeIsland, spanIsland, type CompanionLayout, type HitRect } from './lib/companion/shell'; + import { companionHitRects, configureCompanion, coverScreen, inShell, layoutOf, listenShell, placeCompanion, placeIsland, spanIsland, type CompanionLayout, type HitRect, type IslandPlaced } from './lib/companion/shell'; import { writeCompanionPrefs } from './lib/companion/prefs'; import { Companion } from './lib/companion/companion.svelte'; import { IslandFrame } from './lib/island/frame.svelte'; @@ -71,8 +72,8 @@ const move = new IslandMove({ place: () => companion.prefs.islandPlace, island: () => document.querySelector('[data-testid=island]')?.getBoundingClientRect() ?? null, - span: () => void spanIsland(true).catch(() => {}), - land: (place) => (companion.prefs = writeCompanionPrefs({ islandPlace: place })), + span: async () => (await spanIsland(true).catch(() => null))?.monitor ?? null, + land: (patch) => (companion.prefs = writeCompanionPrefs(patch)), reduced: () => prefersReducedMotion.current }); @@ -227,6 +228,9 @@ // The tray's Settings, and the companion's own request, come back from the shell (`bots_settings`). let gone = false; void listenShell('settings', (section) => requestSettings(section)).then((stop) => (gone ? stop() : off.push(stop))); + // The carried island's span follows the pointer to another screen, or is given up after a release the page missed (`island.rs`). + void listenShell('span', (placed) => move.spanned(placed.monitor)).then((stop) => (gone ? stop() : off.push(stop))); + void listenShell('span-lost', () => move.abort()).then((stop) => (gone ? stop() : off.push(stop))); off.push(() => (gone = true)); // Hidden, nothing can be clicked: the areas wait until the window shows again. const rects = setInterval(() => !dormant && requestRects(), 500); diff --git a/src/components/agents/RobotPicker.svelte b/src/components/agents/RobotPicker.svelte index c98ab76..7262d59 100644 --- a/src/components/agents/RobotPicker.svelte +++ b/src/components/agents/RobotPicker.svelte @@ -1,6 +1,7 @@ + +
+ + + + {#if said}{said}{/if} + +
+ + diff --git a/src/components/island/deck/DeckBar.svelte b/src/components/island/deck/DeckBar.svelte index 2dcb9cf..a46fa5d 100644 --- a/src/components/island/deck/DeckBar.svelte +++ b/src/components/island/deck/DeckBar.svelte @@ -1,6 +1,7 @@
- +
+ ({ id, revision: 1, createdAt: 0, updatedAt: 0, name, domain: '', instructions: '', avatar: '', selection: { providerId: 'claude', accountId: 'a', model: null, effort: null, permissionMode: 'default' }, status: 'active', tools: [], accountIntegration: 'provider' }) as AgentProfile; + +const snapshotOf = (...profiles: AgentProfile[]) => ({ profiles, messages: [], sessions: [], work: [], memories: [], deliveries: [], more: { message: false } }) as unknown as AgentsSnapshot; + +class FakeCompanion { + kept: string | null = null; + pokes: string[] = []; + bounces: string[] = []; + readonly assistant: Assistant; + readonly members: Member[]; + readonly active: Member; + dropped = false; + boings: Record = {}; + dizzy: Record = {}; + notes = { alarms: [] }; + mood = { mood: 'idle', busy: false }; + pointerOn = false; + look = {}; + blocking = 0; + senses = { pointer: null }; + dragging = false; + + constructor(snapshot: AgentsSnapshot, kept: string | null = null) { + this.kept = kept; + this.assistant = new Assistant({ + client: () => null, + snapshot: () => snapshot, + reload: () => {}, + remembered: () => this.kept, + remember: (key: string) => void (this.kept = key), + fallback: () => null, + outgoing: () => [], + keep: async () => [], + sent: () => {}, + giveBack: () => {}, + context: () => '' + } as unknown as ConstructorParameters[0]); + const lead = snapshot.profiles[0]!; + this.active = { id: lead.id, name: lead.name, skin: null, talk: { thinking: false } } as unknown as Member; + this.members = [this.active]; + } + + poke(id: string) { + this.pokes.push(id); + } + bounce(id: string) { + this.bounces.push(id); + } + onpress() {} + onmove() {} + onrelease() {} +} + +let shown: { part: Record; target: HTMLElement } | null = null; + +function show(companion: FakeCompanion): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + const props = { companion: companion as unknown as Companion, lead: companion.members[0]!, cheering: false, reduced: true }; + shown = { part: mount(DeckAgent, { target, props }), target }; + flushSync(); + return target; +} + +afterEach(() => { + if (shown) { + unmount(shown.part); + shown.target.remove(); + shown = null; + } +}); + +const robotOf = (target: HTMLElement) => target.querySelector('[data-testid=island-robot]')!; + +test('a click swaps to the next agent, which the Chat talks to and keeps, then round to the first', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno'))); + const target = show(companion); + const robot = robotOf(target); + expect(robot.dataset.agent).toBe('bots'); + expect(robot.getAttribute('aria-label')).toBe(fill(strings.island.agentSwap.label, { name: 'Bots', next: 'Nuno' })); + expect(robot.title).toBe(fill(strings.island.agentSwap.tip, { next: 'Nuno' })); + + robot.click(); + flushSync(); + expect(companion.assistant.agentId).toBe('nuno'); + expect(companion.kept).toBe('nuno'); + expect(companion.bounces).toEqual(['nuno']); + expect(robotOf(target).dataset.agent).toBe('nuno'); + expect(target.querySelector('[data-testid=island-agent-name]')?.textContent).toContain('Nuno'); + expect(robotOf(target).getAttribute('aria-label')).toBe(fill(strings.island.agentSwap.label, { name: 'Nuno', next: 'Bots' })); + + robotOf(target).click(); + flushSync(); + expect(companion.assistant.agentId).toBe('bots'); + expect(companion.kept).toBe('bots'); + expect(companion.pokes).toEqual([]); +}); + +test('the kept agent shows first at the next launch', () => { + const target = show(new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno')), 'nuno')); + expect(robotOf(target).dataset.agent).toBe('nuno'); +}); + +test('with one agent, a click pokes it as before and nothing swaps', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'))); + const target = show(companion); + const robot = robotOf(target); + expect(robot.getAttribute('aria-label')).toBe(fill(strings.island.robot, { name: 'Bots' })); + robot.click(); + flushSync(); + expect(companion.pokes).toEqual(['bots']); + expect(companion.kept).toBeNull(); + expect(robotOf(target).dataset.agent).toBe('bots'); +}); + +test('a drag that just ended is no click', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno'))); + const target = show(companion); + companion.dropped = true; + robotOf(target).click(); + flushSync(); + expect(companion.assistant.agentId).toBe('bots'); + expect(companion.kept).toBeNull(); +}); diff --git a/src/lib/companion/companion-crew.test.ts b/src/lib/companion/companion-crew.test.ts index ff15dfd..1d1967f 100644 --- a/src/lib/companion/companion-crew.test.ts +++ b/src/lib/companion/companion-crew.test.ts @@ -10,7 +10,7 @@ import { BOX_COLORS, boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robot import { strings } from '../strings'; import type { Brain } from './brain'; import { ROLE_START } from './brain'; -import { AGENT_TOOLS, CLASSIC_SKIN, companionAgent, freshSkin, membersOf, memoriesToForget, memoryToKeep, needsFirstAgent, profileWith, repliesAfter, replyTo, skinOf } from './crew'; +import { AGENT_TOOLS, CLASSIC_ROBOT, CLASSIC_SKIN, companionAgent, freshSkin, isClassic, membersOf, memoriesToForget, memoryToKeep, needsFirstAgent, profileWith, repliesAfter, replyTo, skinOf } from './crew'; import { Crew } from './crew.svelte'; import { addReminder, readMemory, readReminders, remember } from './memory'; import { Notes } from './notes.svelte'; @@ -64,7 +64,7 @@ const snapshotOf = (part: Partial) => ({ profiles: [], messages: // Looks // --------------------------------------------------------------------------- -test('an agent stands as its own robot, of any family, or the box its id draws when its picture is text', () => { +test('an agent stands as its own robot, of any family, B.O.T.S\'s face when it chose none, or the box its id draws when its picture is text', () => { expect(skinOf(profile('bots', { avatar: CLASSIC_SKIN }))).toEqual({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); const box = { family: 'box', shape: 4, color: 7, eyes: 2, top: 5 } as const; expect(skinOf(profile('a', { avatar: encodeRobot(box) }))).toEqual(box); @@ -72,8 +72,14 @@ test('an agent stands as its own robot, of any family, or the box its id draws w const robot = { family, shape: 1, color: 2, eyes: 1, top: 3 }; expect(skinOf(profile('a', { avatar: encodeRobot(robot) }))).toEqual(robot); } - // No avatar: the robot the Agents page draws for its id. - expect(skinOf(profile('seeded'))).toEqual(seededRobot('seeded')); + // No avatar: B.O.T.S's own face, whatever the id, and not the jelly the id draws. + expect(skinOf(profile('seeded'))).toEqual(CLASSIC_ROBOT); + expect(skinOf(profile('other', { avatar: ' ' }))).toEqual(CLASSIC_ROBOT); + expect(skinOf(profile('seeded'))).not.toEqual(seededRobot('seeded')); + expect(isClassic(skinOf(profile('bots')))).toBe(true); + // A face chosen, even the jelly the id would draw, is kept. + expect(skinOf(profile('seeded', { avatar: encodeRobot(seededRobot('seeded')) }))).toEqual(seededRobot('seeded')); + expect(isClassic(skinOf(profile('a', { avatar: encodeRobot(box) })))).toBe(false); expect(skinOf(profile('emoji', { avatar: '🦊' }))).toEqual(seededRobot('emoji', 'box')); }); diff --git a/src/lib/companion/crew.ts b/src/lib/companion/crew.ts index d9b5a71..4321533 100644 --- a/src/lib/companion/crew.ts +++ b/src/lib/companion/crew.ts @@ -5,7 +5,7 @@ * to it. Pure, so it is tested without a core. */ import type { AgentConversationMessage, AgentMemory, AgentProfile, AgentSave, AgentScope, AgentsSnapshot, AgentWork } from '@boite/contracts'; -import { boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, robotOf, seededRobot, type Robot } from '../robots'; +import { boxColorOf, encodeRobot, parseRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, seededRobot, type Robot } from '../robots'; import type { Brain } from './brain'; import { namesFact, normalized } from './memory'; @@ -13,10 +13,20 @@ import { namesFact, normalized } from './memory'; export const FIRST_AGENT_NAME = 'B.O.T.S'; /** The classic companion, as an avatar: the first agent keeps the look the companion always had. */ export const CLASSIC_SKIN = encodeRobot({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); +/** B.O.T.S's own face, the classic box drawn after Boite's icon: what an agent that chose no face wears. */ +export const CLASSIC_ROBOT: Readonly = parseRobot(CLASSIC_SKIN)!; -/** The robot an agent stands as: the one the Agents page draws for it, of any family, or the box its id draws when its picture is text. */ +/** Whether a robot is B.O.T.S's own face. */ +export const isClassic = (robot: Robot | null | undefined): boolean => !!robot && encodeRobot(robot) === CLASSIC_SKIN; + +/** + * The robot an agent stands as: the one it chose, of any family; B.O.T.S's + * face when it chose none (Boite's Agents page draws one from its id); the + * box its id draws when its picture is text. + */ export function skinOf(profile: Pick): Robot { - return robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box'); + if (!profile.avatar.trim()) return { ...CLASSIC_ROBOT }; + return parseRobot(profile.avatar) ?? seededRobot(profile.id, 'box'); } /** A number from 0 to n - 1, the same for a seed on every client. */ diff --git a/src/lib/companion/shell.ts b/src/lib/companion/shell.ts index 2093061..eeec10f 100644 --- a/src/lib/companion/shell.ts +++ b/src/lib/companion/shell.ts @@ -58,12 +58,14 @@ export async function placeIsland(monitor: string | null, place: CompanionLayout } /** - * While the pill is dragged to another place, the window spans the island's - * screen and takes every click (`island_span`); `false` puts it back at the - * island's place, for a drag let go where it began. + * While the pill is dragged to another place, the window spans the screen + * under the pointer, the island's, and takes every click (`island_span`): + * answers that screen, then follows the pointer to the others + * (`companion://span`). `false` puts it back at the island's place, for a + * drag let go where it began. */ -export async function spanIsland(span: boolean): Promise { - await invoke('island_span', { span }); +export async function spanIsland(span: boolean): Promise { + return (await invoke('island_span', { span })) ?? null; } /** Hiding for a full-screen app, and the shortcut; rejects when the shortcut is taken. */ diff --git a/src/lib/fake-client/assistant-demo.ts b/src/lib/fake-client/assistant-demo.ts index 2579482..c3430ba 100644 --- a/src/lib/fake-client/assistant-demo.ts +++ b/src/lib/fake-client/assistant-demo.ts @@ -82,9 +82,10 @@ export function rowsOf(ctx: FakeContext): Map { function seedAgents(ctx: FakeContext, now: number): void { const rows = rowsOf(ctx); const made = at(now, 600); + // Atlas chose no face (it wears B.O.T.S's, or the mascot on the island while no row is made), Ledger a retro robot. const agents = [ profile(ASSISTANT_DEMO_AGENT, 'Atlas', 'Research and notes', 'claude-sonnet-5-5', made), - profile(OTHER_AGENT, 'Ledger', 'Invoices and accounts', null, made + MINUTE) + { ...profile(OTHER_AGENT, 'Ledger', 'Invoices and accounts', null, made + MINUTE), avatar: 'bot:c.1.6.2.1' } ]; for (const agent of agents) rows.set(`profile:${agent.id}`, agent); diff --git a/src/lib/island/move.svelte.test.ts b/src/lib/island/move.svelte.test.ts new file mode 100644 index 0000000..f673d39 --- /dev/null +++ b/src/lib/island/move.svelte.test.ts @@ -0,0 +1,151 @@ +import { tick } from 'svelte'; +import { expect, test } from 'vitest'; +import type { IslandPlace } from '../companion/prefs'; +import { IslandMove, MOVE_FROM } from './move.svelte'; +import type { LandPatch } from './place'; + +/** An island 200 × 32 laid out at `base` in the window, drawn the drag's offset away; the shell answers `screen`. */ +function carried(screen: string | null = 'DISPLAY1') { + const pill = document.createElement('button'); + document.body.append(pill); + const landed: LandPatch[] = []; + const at = { base: { x: 412, y: 0 } }; + let spans = 0; + const box = () => ({ x: at.base.x + move.offset.current.x, y: at.base.y + move.offset.current.y, width: 200, height: 32 }) as DOMRect; + const move: IslandMove = new IslandMove({ + place: (): IslandPlace => 'top-center', + island: box, + span: () => { + spans += 1; + return Promise.resolve(screen); + }, + land: (patch) => landed.push(patch), + reduced: () => true + }); + return { move, pill, landed, at, box, spans: () => spans }; +} + +function pointer(type: string, at: { x: number; y: number }, options: { buttons?: number; target?: EventTarget; id?: number } = {}) { + const event = new MouseEvent(type, { clientX: at.x, clientY: at.y, screenX: at.x, screenY: at.y, button: 0, buttons: options.buttons ?? 1, bubbles: true }); + Object.defineProperty(event, 'pointerId', { value: options.id ?? 1 }); + (options.target ?? window).dispatchEvent(event); + return event as PointerEvent; +} + +function press(move: IslandMove, pill: Element, at: { x: number; y: number }) { + const event = new MouseEvent('pointerdown', { clientX: at.x, clientY: at.y, screenX: at.x, screenY: at.y, button: 0, buttons: 1 }); + Object.defineProperty(event, 'pointerId', { value: 1 }); + Object.defineProperty(event, 'currentTarget', { value: pill }); + move.press(event as PointerEvent); +} + +test('a press that does not move far is a click: nothing spans, nothing lands', async () => { + const { move, pill, landed, spans } = carried(); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 500 + MOVE_FROM - 1, y: 10 }); + pointer('pointerup', { x: 500 + MOVE_FROM - 1, y: 10 }, { buttons: 0 }); + await tick(); + expect([move.moving, move.moved, spans(), landed]).toEqual([false, false, 0, []]); +}); + +test('the island stays held where it was taken, the window grown or moved under it', () => { + const { move, pill, at, box } = carried(); + press(move, pill, { x: 450, y: 10 }); + pointer('pointermove', { x: 470, y: 30 }); + expect(move.moving).toBe(true); + expect(move.offset.current).toEqual({ x: 20, y: 20 }); + // The window spans another screen: the island is laid out elsewhere in it, and the next move puts it back under the pointer. + at.base = { x: 900, y: 0 }; + pointer('pointermove', { x: 300, y: 500 }); + expect({ x: box().x, y: box().y }).toEqual({ x: 300 - 38, y: 500 - 10 }); +}); + +test('let go on the same screen, only the place is saved', async () => { + const { move, pill, landed, spans } = carried(); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 900, y: 700 }); + await tick(); + expect(move.over).toBe('bottom-right'); + pointer('pointerup', { x: 900, y: 700 }, { buttons: 0 }); + await tick(); + expect(spans()).toBe(1); + expect(landed).toEqual([{ islandPlace: 'bottom-right' }]); + expect(move.moved).toBe(true); +}); + +test('let go on another screen, its place there and the screen are saved', async () => { + const { move, pill, landed } = carried('DISPLAY2'); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 520, y: 10 }); + await tick(); + move.spanned('DISPLAY1'); + pointer('pointermove', { x: 40, y: 740 }); + pointer('pointerup', { x: 40, y: 740 }, { buttons: 0 }); + await tick(); + expect(landed).toEqual([{ islandPlace: 'bottom-left', monitor: 'DISPLAY1' }]); +}); + +test('a screen told before the first span answers is kept; not carried, a screen told is not taken', async () => { + const { move, pill, landed } = carried('DISPLAY2'); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 520, y: 10 }); + move.spanned('DISPLAY1'); + await tick(); + pointer('pointerup', { x: 520, y: 10 }, { buttons: 0 }); + await tick(); + expect(landed.at(-1)).toEqual({ islandPlace: 'top-center', monitor: 'DISPLAY1' }); + move.settle(); + move.spanned('DISPLAY3'); + // Let go before the shell answered: the screen stays as it was. + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 520, y: 10 }); + pointer('pointerup', { x: 520, y: 10 }, { buttons: 0 }); + await tick(); + expect(landed.at(-1)).toEqual({ islandPlace: 'top-center' }); +}); + +test('the window losing the focus while carried puts the island back and shrinks the window', async () => { + const { move, pill, landed } = carried(); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 800, y: 600 }); + window.dispatchEvent(new Event('blur')); + expect([move.moving, move.landing, move.over, move.offset.current]).toEqual([false, false, null, { x: 0, y: 0 }]); + // The drag is over: a later move or release does nothing. + pointer('pointermove', { x: 900, y: 700 }); + pointer('pointerup', { x: 900, y: 700 }, { buttons: 0 }); + await tick(); + expect([move.moving, landed]).toEqual([false, []]); +}); + +test('a move with no button down is the release the window missed', async () => { + const { move, pill, landed } = carried(); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 900, y: 700 }); + pointer('pointermove', { x: 900, y: 700 }, { buttons: 0 }); + await tick(); + expect(landed).toEqual([{ islandPlace: 'bottom-right' }]); +}); + +test('the pill losing the pointer ends the drag, another element losing it does not', () => { + const { move, pill } = carried(); + const other = document.createElement('span'); + document.body.append(other); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 600, y: 300 }); + pointer('lostpointercapture', { x: 600, y: 300 }, { target: other }); + expect(move.moving).toBe(true); + pointer('pointercancel', { x: 600, y: 300 }, { id: 2 }); + expect(move.moving).toBe(true); + pointer('lostpointercapture', { x: 600, y: 300 }, { target: pill }); + expect(move.moving).toBe(false); +}); + +test('a span given up while landing lands at once', async () => { + const { move, pill } = carried(); + press(move, pill, { x: 500, y: 10 }); + pointer('pointermove', { x: 900, y: 700 }); + pointer('pointerup', { x: 900, y: 700 }, { buttons: 0 }); + move.abort(); + await tick(); + expect([move.moving, move.landing]).toEqual([false, false]); +}); diff --git a/src/lib/island/move.svelte.ts b/src/lib/island/move.svelte.ts index 44c7d59..3cb1264 100644 --- a/src/lib/island/move.svelte.ts +++ b/src/lib/island/move.svelte.ts @@ -2,17 +2,24 @@ * The island carried to another of its places (`place.ts`). A press on the * pill that moves past `MOVE_FROM` carries it: the window spans the screen * (`spanIsland`), the six places show as targets, and the island follows the - * pointer, drawn `offset` away from its place. Let go, it goes to the place - * nearest, which is saved: the page lays it out there at once and the offset, - * from where it was let go, springs back to nothing. Then the window shrinks - * around it again (`App.svelte`). + * pointer, drawn `offset` away from its place, held where it was taken. Let + * go, it goes to the place nearest, which is saved: the page lays it out + * there at once and the offset, from where it was let go, springs back to + * nothing. Then the window shrinks around it again (`App.svelte`). + * + * The pointer taken to another screen takes the span there (`island.rs`, + * `companion://span`): the targets show on that screen, and the island lands + * on it, the screen saved with the place (`landPatch`). + * + * A drag that ends without its release (the window lost the focus or the + * pointer, or the shell gave the span up) puts the island back where it was. * * A press on the robot's face is not this: it pulls the robot out of the * island (`Companion.onpress`). */ import { tick } from 'svelte'; import type { IslandPlace } from '../companion/prefs'; -import { nearestPlace } from './place'; +import { landPatch, nearestPlace, type LandPatch } from './place'; import { FrameSpring } from './spring.svelte'; /** How far the pointer goes before a press carries the island, CSS pixels: as far as a robot is pulled. */ @@ -22,15 +29,25 @@ export interface MoveHost { place(): IslandPlace; /** The island's box as drawn, carried by the drag's offset. */ island(): DOMRect | null; - /** The window spans the island's screen, for the targets and the drag. */ - span(): void; - /** The island goes to `place`, saved. */ - land(place: IslandPlace): void; + /** The window spans the screen under the pointer, the island's, for the targets and the drag; answers its name. */ + span(): Promise; + /** The island goes to its new place, on another screen when the drag took it there; saved. */ + land(patch: LandPatch): void; reduced(): boolean; } type Point = { x: number; y: number }; +interface Press { + id: number; + /** Where the press was on the screen, for `MOVE_FROM`. */ + screen: Point; + /** Where the island was taken, from its top left, CSS pixels. */ + grab: Point | null; + /** The pill, which holds the pointer while the island is carried. */ + target: Element | null; +} + export class IslandMove { /** The island is carried, or still landing: the window spans the screen. */ moving = $state(false); @@ -42,18 +59,36 @@ export class IslandMove { readonly offset = new FrameSpring({ x: 0, y: 0 }, { stiffness: 0.18, damping: 0.72, precision: 0.25 }); /** A drag just ended: the click it ends with opens nothing. */ moved = false; - #press: (Point & { id: number }) | null = null; + #press: Press | null = null; #movedTimer: ReturnType | undefined; + /** The drag under way, so a screen answered for an older one is not taken. */ + #drag = 0; + /** The screen the drag began on, and the one the window spans now. */ + #from: string | null = null; + #on: string | null = null; constructor(private readonly host: MoveHost) {} /** A press on the pill: a drag if it moves far enough, a click otherwise. */ press(event: PointerEvent): void { if (event.button !== 0 || this.moving) return; - this.#press = { x: event.screenX, y: event.screenY, id: event.pointerId }; + const box = this.host.island(); + this.#press = { + id: event.pointerId, + screen: { x: event.screenX, y: event.screenY }, + grab: box ? { x: event.clientX - box.x, y: event.clientY - box.y } : null, + target: event.currentTarget instanceof Element ? event.currentTarget : null + }; window.addEventListener('pointermove', this.#move); window.addEventListener('pointerup', this.#release); - window.addEventListener('pointercancel', this.#release); + window.addEventListener('pointercancel', this.#lost); + window.addEventListener('lostpointercapture', this.#lost); + window.addEventListener('blur', this.abort); + } + + /** The window spans another screen, under the pointer (`companion://span`). */ + spanned(monitor: string): void { + if (this.moving && !this.landing) this.#on = monitor; } /** The island landed, its offset back to nothing: the window shrinks around it. */ @@ -63,6 +98,21 @@ export class IslandMove { this.moving = false; } + /** + * The drag ends without its release: carried, the island goes back where it + * was; landing, it lands at once. Either way the window shrinks around it. + */ + abort = (): void => { + this.#stopListening(); + this.#press = null; + if (!this.moving) return; + this.#endClick(); + this.over = null; + this.offset.set({ x: 0, y: 0 }, { instant: true }); + this.landing = false; + this.moving = false; + }; + dispose(): void { this.#stopListening(); this.offset.stop(); @@ -76,43 +126,90 @@ export class IslandMove { return nearestPlace(centre, { width: window.innerWidth, height: window.innerHeight }, { width: box.width, height: box.height }); } + #start(press: Press): void { + this.moving = true; + this.moved = true; + const drag = ++this.#drag; + this.#from = this.#on = null; + void this.host.span().then( + (monitor) => { + if (drag !== this.#drag) return; + this.#from = monitor; + this.#on ??= monitor; + }, + () => {} + ); + // The pill keeps the pointer, wherever it goes: losing it ends the drag. + try { + press.target?.setPointerCapture(press.id); + } catch { + // A pointer already gone: its release or cancel ends the drag. + } + } + #move = (event: PointerEvent): void => { const press = this.#press; if (!press || event.pointerId !== press.id) return; - const by = { x: event.screenX - press.x, y: event.screenY - press.y }; + // The button went up where the window did not hear it. + if (event.buttons === 0) return void this.#release(event); if (!this.moving) { - if (Math.hypot(by.x, by.y) < MOVE_FROM) return; - this.moving = true; - this.moved = true; - this.host.span(); + if (Math.hypot(event.screenX - press.screen.x, event.screenY - press.screen.y) < MOVE_FROM) return; + this.#start(press); } - this.offset.set(by, { instant: true }); + this.offset.set(this.#offsetAt(press, event), { instant: true }); this.over = this.#nearest(); }; + /** + * The offset that keeps the island where it was taken under the pointer. + * Measured in the window as it is now: the window grown to span a screen, or + * moved to another one, lays the island out elsewhere, and the next move + * follows. + */ + #offsetAt(press: Press, event: PointerEvent): Point { + const box = this.host.island(); + if (!press.grab || !box) return { x: event.screenX - press.screen.x, y: event.screenY - press.screen.y }; + const shown = this.offset.current; + return { x: event.clientX - press.grab.x - (box.x - shown.x), y: event.clientY - press.grab.y - (box.y - shown.y) }; + } + #release = async (event: PointerEvent): Promise => { if (this.#press && event.pointerId !== this.#press.id) return; this.#stopListening(); this.#press = null; if (!this.moving) return; - clearTimeout(this.#movedTimer); - this.#movedTimer = setTimeout(() => (this.moved = false), 300); + this.#endClick(); const place = this.#nearest() ?? this.host.place(); this.over = null; this.landing = true; // Where it was let go, then laid out at its new place, the same offset on: the gap between them springs away. const before = this.host.island(); const shown = this.offset.current; - this.host.land(place); + this.host.land(landPatch(place, this.#from, this.#on)); await tick(); const after = this.host.island(); if (before && after) this.offset.set({ x: before.x + shown.x - after.x, y: before.y + shown.y - after.y }, { instant: true }); this.offset.set({ x: 0, y: 0 }, { instant: this.host.reduced() }); }; + #lost = (event: PointerEvent): void => { + const press = this.#press; + if (!press || event.pointerId !== press.id) return; + // Taking the pointer for the pill takes it from what held it before (a touch's first target): that is no loss. + if (event.type === 'lostpointercapture' && event.target !== press.target) return; + this.abort(); + }; + + #endClick(): void { + clearTimeout(this.#movedTimer); + this.#movedTimer = setTimeout(() => (this.moved = false), 300); + } + #stopListening(): void { window.removeEventListener('pointermove', this.#move); window.removeEventListener('pointerup', this.#release); - window.removeEventListener('pointercancel', this.#release); + window.removeEventListener('pointercancel', this.#lost); + window.removeEventListener('lostpointercapture', this.#lost); + window.removeEventListener('blur', this.abort); } } diff --git a/src/lib/island/place.test.ts b/src/lib/island/place.test.ts index 59ba2f9..9c1019b 100644 --- a/src/lib/island/place.test.ts +++ b/src/lib/island/place.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import { DEFAULT_COMPANION_PREFS, ISLAND_PLACES, parseCompanionPrefs } from '../companion/prefs'; -import { nearestPlace, parsePlace, PLACE_MARGIN, placeLayout, placeParts, placeRect } from './place'; +import { landPatch, nearestPlace, parsePlace, PLACE_MARGIN, placeLayout, placeParts, placeRect } from './place'; const screen = { width: 1600, height: 900 }; const pill = { width: 200, height: 32 }; @@ -41,3 +41,22 @@ test('a dragged island lands on the nearest place', () => { // Off the screen, it still lands on one. expect(nearestPlace({ x: -400, y: 2000 }, screen, pill)).toBe('bottom-left'); }); + +test('carried to another screen, the island lands on that screen\'s places', () => { + // The window spans the other screen's work area: a 1366 × 728 laptop, a 2560 × 1392 screen. + const laptop = { width: 1366, height: 728 }; + expect(nearestPlace({ x: 1250, y: 700 }, laptop, pill)).toBe('bottom-right'); + expect(nearestPlace({ x: 683, y: 20 }, laptop, pill)).toBe('top-center'); + const wide = { width: 2560, height: 1392 }; + expect(nearestPlace({ x: 500, y: 300 }, wide, pill)).toBe('top-left'); + expect(nearestPlace({ x: 1800, y: 1300 }, wide, pill)).toBe('bottom-center'); +}); + +test('a drag saves its place, and its screen only when it ended on another one', () => { + expect(landPatch('bottom-right', 'DISPLAY1', 'DISPLAY1')).toEqual({ islandPlace: 'bottom-right' }); + expect(landPatch('top-left', 'DISPLAY1', 'DISPLAY2')).toEqual({ islandPlace: 'top-left', monitor: 'DISPLAY2' }); + // The shell said nothing (the demo, or a release before it answered): the screen stays as it was, the primary one included. + expect(landPatch('top-center', null, null)).toEqual({ islandPlace: 'top-center' }); + expect(landPatch('top-center', 'DISPLAY1', null)).toEqual({ islandPlace: 'top-center' }); + expect(landPatch('bottom-left', null, 'DISPLAY2')).toEqual({ islandPlace: 'bottom-left', monitor: 'DISPLAY2' }); +}); diff --git a/src/lib/island/place.ts b/src/lib/island/place.ts index a8a7e8c..2f30642 100644 --- a/src/lib/island/place.ts +++ b/src/lib/island/place.ts @@ -6,9 +6,9 @@ * * While the pill is dragged, the window spans the screen and the page draws * the six places as targets (`placeRect`); the island goes to the one nearest - * where it was let go (`nearestPlace`). + * where it was let go (`nearestPlace`), on the screen it was let go on (`landPatch`). */ -import { ISLAND_PLACES, type IslandEdge, type IslandPlace, type IslandSide } from '../companion/prefs'; +import { ISLAND_PLACES, type CompanionPrefs, type IslandEdge, type IslandPlace, type IslandSide } from '../companion/prefs'; import type { CompanionLayout } from '../companion/shell'; /** The island's distance from the screen's side at the left and right places, CSS pixels. */ @@ -58,3 +58,15 @@ export function nearestPlace(centre: { x: number; y: number }, screen: Size, siz } return best; } + +/** What a dragged island saves where it lands: its place, and the screen when it is not the one it began on. */ +export type LandPatch = Pick & Partial>; + +/** + * The patch for an island let go at `place` on the screen named `to`, its drag + * begun on `from` (null when the shell did not say). On the same screen the + * screen setting stays as it was, the primary one included. + */ +export function landPatch(place: IslandPlace, from: string | null, to: string | null): LandPatch { + return to !== null && to !== from ? { islandPlace: place, monitor: to } : { islandPlace: place }; +} diff --git a/src/lib/island/swap.test.ts b/src/lib/island/swap.test.ts new file mode 100644 index 0000000..3021f97 --- /dev/null +++ b/src/lib/island/swap.test.ts @@ -0,0 +1,65 @@ +/* + * The deck's top-left character: the next agent a click turns to, and the + * look each one wears on the island. + */ +import { expect, test } from 'vitest'; +import type { AgentProfile } from '@boite/contracts'; +import { CLASSIC_ROBOT } from '../companion/crew'; +import { encodeRobot, type Robot } from '../robots'; +import { islandMember, islandSkin, nextAgent } from './swap'; + +const keys = (...names: string[]) => names.map((key) => ({ key })); + +test('a click turns to the next agent, round to the first after the last', () => { + const agents = keys('bots', 'nuno', 'core-b/scout'); + expect(nextAgent(agents, 'bots')?.key).toBe('nuno'); + expect(nextAgent(agents, 'nuno')?.key).toBe('core-b/scout'); + expect(nextAgent(agents, 'core-b/scout')?.key).toBe('bots'); + // Every agent is reached once before the first comes back. + const seen: string[] = []; + let at: string | null = 'bots'; + for (let step = 0; step < agents.length; step++) seen.push((at = nextAgent(agents, at)!.key)); + expect(seen).toEqual(['nuno', 'core-b/scout', 'bots']); +}); + +test('two agents swap back and forth; with one or none there is nothing to turn to', () => { + const pair = keys('bots', 'nuno'); + expect(nextAgent(pair, 'bots')?.key).toBe('nuno'); + expect(nextAgent(pair, 'nuno')?.key).toBe('bots'); + expect(nextAgent(keys('bots'), 'bots')).toBeNull(); + expect(nextAgent([], null)).toBeNull(); +}); + +test('an agent no longer listed turns to the one after the first', () => { + expect(nextAgent(keys('bots', 'nuno', 'atlas'), 'gone')?.key).toBe('nuno'); + expect(nextAgent(keys('bots', 'nuno'), null)?.key).toBe('nuno'); +}); + +const profile = (id: string, avatar = ''): AgentProfile => + ({ id, revision: 1, createdAt: 0, updatedAt: 0, name: id, domain: '', instructions: '', avatar, selection: { providerId: 'claude', accountId: 'a', model: null, effort: null, permissionMode: 'default' }, status: 'active', tools: [], accountIntegration: 'provider' }) as AgentProfile; +const local = (id: string, avatar = '') => ({ key: id, coreId: null, profile: profile(id, avatar) }); + +const mascot: Robot = { family: 'jelly', shape: 1, color: 2, eyes: 3, top: 0 }; +const retro: Robot = { family: 'retro', shape: 1, color: 6, eyes: 2, top: 1 }; + +test('on the island, an agent of the row wears its look there (the lead the mascot), another its own face', () => { + const members = [{ id: 'bots', skin: mascot }, { id: 'nuno', skin: retro }]; + expect(islandSkin(local('bots'), members)).toEqual(mascot); + expect(islandSkin(local('nuno', encodeRobot(retro)), members)).toEqual(retro); + // Outside the row: its own face, B.O.T.S's when it chose none. + expect(islandSkin(local('atlas'), members)).toEqual(CLASSIC_ROBOT); + expect(islandSkin(local('ledger', encodeRobot(retro)), members)).toEqual(retro); + // Another machine's agent of the same id is not this machine's lead. + expect(islandSkin({ key: 'core-b/bots', coreId: 'core-b', profile: profile('bots') }, members)).toEqual(CLASSIC_ROBOT); +}); + +test('with no row made, the agent the Chat lands on unasked is the island’s lone character', () => { + const lone = [{ id: '', skin: mascot }]; + expect(islandMember(local('atlas'), lone, 'atlas')).toBe(lone[0]); + expect(islandSkin(local('atlas'), lone, 'atlas')).toEqual(mascot); + // Any other agent wears its own face. + expect(islandMember(local('ledger', encodeRobot(retro)), lone, 'atlas')).toBeUndefined(); + expect(islandSkin(local('ledger', encodeRobot(retro)), lone, 'atlas')).toEqual(retro); + // No mascot chosen: B.O.T.S's own face, as the pill shows. + expect(islandSkin(local('atlas'), [{ id: '', skin: null }], 'atlas')).toEqual(CLASSIC_ROBOT); +}); diff --git a/src/lib/island/swap.ts b/src/lib/island/swap.ts new file mode 100644 index 0000000..4f93a38 --- /dev/null +++ b/src/lib/island/swap.ts @@ -0,0 +1,39 @@ +/* + * The deck's top-left character: the agent the Chat talks to + * (`assistant.svelte.ts`), which a click turns to the next of the user's + * agents (`deck/DeckAgent.svelte`). The Chat keeps the one chosen. Pure, so + * it is tested without a core. + */ +import { skinOf } from '../companion/crew'; +import type { PlacedAgent } from '../group/agents'; +import type { Robot } from '../robots'; + +type Standing = readonly (M & { id: string })[]; + +/** The agent after `current`, back to the first after the last; null when there is no other to turn to. */ +export function nextAgent(agents: readonly T[], current: string | null): T | null { + if (agents.length < 2) return null; + const at = Math.max(0, agents.findIndex((agent) => agent.key === current)); + return agents[(at + 1) % agents.length]!; +} + +/** + * The companion's row member an agent is on the island: the one of its id on + * this machine. With no row made yet, the island's lone character (id '') is + * the agent the Chat lands on unasked, `first`. + */ +export function islandMember(agent: Pick, members: Standing, first: string | null = null): (M & { id: string }) | undefined { + if (agent.coreId !== null) return undefined; + const lone = members.length === 1 && members[0]!.id === '' ? members[0] : undefined; + if (lone) return agent.key === first ? lone : undefined; + return members.find((one) => one.id === agent.profile.id); +} + +/** + * What an agent wears on the island: its look in the companion's row when it + * stands there (the lead wears the mascot chosen in Settings), else its own + * face, B.O.T.S's when it chose none. + */ +export function islandSkin(agent: Pick, members: Standing<{ skin: Robot | null }>, first: string | null = null): Robot { + return islandMember(agent, members, first)?.skin ?? skinOf(agent.profile); +} diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index f3a4834..b919e27 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -103,6 +103,7 @@ export const strings = { }, talking: '{name} is answering…', robot: '{name}. Click to poke it, drag it out to set it loose on the desktop.', + agentSwap: { label: '{name}, the agent the Chat talks to. Click to switch to {next}, drag it out to set it loose on the desktop.', tip: 'Switch to {next}' }, chat: { empty: 'Ask your bots anything: they answer here.', to: 'Talk to', @@ -819,6 +820,7 @@ export const strings = { family: 'Style', shape: 'Shape', color: 'Color', eyes: 'Eyes', top: 'Accessory', shuffle: 'Shuffle', customize: 'Customize', done: 'Done', option: '{part} {n}', families: { jelly: 'Jelly', bubble: 'Bubble', capsule: 'Capsule', retro: 'Retro', box: 'Box' }, box: { shape: 'Lid', color: 'Body', eyes: 'Eyes', top: 'Accessory' }, + bots: 'B.O.T.S', botsFace: 'B.O.T.S’s own face, the classic box', } }, diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 5a0992f..60ddaf2 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -88,6 +88,7 @@ export const fr: Messages = { }, talking: '{name} répond…', robot: '{name}. Cliquez pour le taquiner, faites-le glisser hors de l’île pour le poser sur le bureau.', + agentSwap: { label: '{name}, l’agent à qui parle le Chat. Cliquez pour passer à {next}, faites-le glisser hors de l’île pour le poser sur le bureau.', tip: 'Passer à {next}' }, chat: { empty: 'Demandez ce que vous voulez à vos bots : ils répondent ici.', to: 'Parler à', @@ -793,6 +794,7 @@ export const fr: Messages = { family: 'Style', shape: 'Forme', color: 'Couleur', eyes: 'Yeux', top: 'Accessoire', shuffle: 'Mélanger', customize: 'Personnaliser', done: 'Terminé', option: '{part} {n}', families: { jelly: 'Gelée', bubble: 'Bulle', capsule: 'Capsule', retro: 'Rétro', box: 'Boîte' }, box: { shape: 'Couvercle', color: 'Corps', eyes: 'Yeux', top: 'Accessoire' }, + bots: 'B.O.T.S', botsFace: 'Le visage de B.O.T.S, la boîte classique', } },