diff --git a/AGENTS.md b/AGENTS.md index 09fbde0..e5b7a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ cargo test --manifest-path src-tauri/Cargo.toml or an agent in Boite). It never reads Boite's files beyond `core.json`. - Tests and manual runs use a throwaway core on a temporary data folder, never the data of a Boite someone uses. -- Native commands check which webview calls them (`only_bots`, - `only_companion`), and every command is listed in +- Native commands check which webview calls them (`only_companion`: the + companion's window is Bots's only page), and every command is listed in `src-tauri/permissions/bots.toml` and covered by `src-tauri/src/acl.rs`. - Quit exits with code 0; any other exit tells Boite to start Bots again. diff --git a/README.md b/README.md index 1c85f08..d468eca 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The pictures below come from the [demo mode](#demo-mode), on Boite's fake core. ### Every session at a glance -Point at the pill and the island opens as a deck of tabs. **Threads** lists every conversation running in Boite, however many there are, including those running on your other machines. Sub-agents fold under the conversation that started them. A click opens the thread in Boite. +Point at the pill and the island opens as a deck of tabs. **Threads** lists every conversation running in Boite, however many there are, including those running on your other machines, each with its machine's name. Sub-agents fold under the conversation that started them. A click opens the thread in Boite.

The deck open on its threads tab, listing the sessions at work, one of them unfolded on its sub-agents @@ -36,7 +36,7 @@ When an agent asks for a permission or has a question, the pill says so. Open it - **Threads**: the sessions at work and the requests waiting for you. - **Today**: the clock and the week, the next scheduled mission, your to-do list, a focus timer and the day's numbers. -- **Chat**: your conversation with B.O.T.S. +- **Chat**: your conversation with B.O.T.S, or with any agent of your Boite, on this machine or another one. - **Stats**: each subscription's quota windows and when they reset, the threads finished today with their files, time and tokens, and the memory your agents hold. - **Music**: the player, with the words. - **Apps**, **Clipboard**, **Wallpapers** and **System**: your computer, on Windows. @@ -132,10 +132,10 @@ A pomodoro or a timer sits beside it. ### Settings -Everything else is in **Settings**, from the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French. +Everything else is in **Settings**, a panel that grows out of the open island under its bar, from the bar's gear or the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French.

- The B.O.T.S settings: where the robots sit, open on hover, pill at rest, screen, shortcut and the mascot + The B.O.T.S settings under the open island's bar: a rail of sections, and Place with where the robots sit, the screen, the shortcut, open on hover, the pill at rest and the bar's edit mode

## Install @@ -173,7 +173,7 @@ cd src-tauri && cargo test && cargo clippy --all-targets -- -D warnings The demo runs the robots on Boite's in-memory fake core: no Boite needed. -- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or open `?view=settings&fake=1` for the settings). +- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or `&pose=settings§ion=crew` for the settings panel on a section). - In the desktop window: `BOTS_DEMO=1 bun run tauri dev` (`BOTS_DEMO=hud` for the busy version). Development builds only. In the browser, a few more parameters set a scene for captures: `pose=collapsed|peek|open|deck|desktop|away`, `tab=threads|today|chat|stats|music|apps|clipboard|wallpaper|system`, `calm=1` to answer the starting requests, `music=playing&lyrics=1`, `rest=live|quota|music|timer|robot`, `timer=1`, `todo=1`, `mission=ask|list`, `source=engine|recent|folder` for the wallpapers, `bar=custom` and `edit=1` for the bar, and more. [`src/lib/island/pose.ts`](src/lib/island/pose.ts) lists them all. The pictures of this README were taken that way, with the English interface. diff --git a/docs/media/settings.png b/docs/media/settings.png index 156d269..2223e6d 100644 Binary files a/docs/media/settings.png and b/docs/media/settings.png differ diff --git a/scripts/perf-probe.ts b/scripts/perf-probe.ts new file mode 100644 index 0000000..08e6975 --- /dev/null +++ b/scripts/perf-probe.ts @@ -0,0 +1,347 @@ +/* + * Measures what each demo pose costs while it sits still: the main thread's + * time (Performance.getMetrics deltas) and the frame intervals a rAF probe + * sees, in headless Chrome driven over plain CDP, GPU on, never SwiftShader. + * `open-close` opens the island from the pill and closes it, four times. + * + * Measure the production build with the poses in (`perf.vite.config.ts`): + * the dev server's Svelte traces every state write, and that tracing is + * most of what it would measure. + * + * bun x vite build --config scripts/perf.vite.config.ts + * bun x vite preview --config scripts/perf.vite.config.ts --port 5282 + * bun scripts/perf-probe.ts --base http://localhost:5282 [--seconds 10] + * [--only closed,deck-music] [--json out.json] [--profile] [--frames] [--unthrottled] + * + * Each pose runs two phases of `--seconds`: the metrics alone, so a probe + * does not add frames to an idle page, then the rAF probe for the intervals. + * `--profile` adds the functions that took the most main thread time + * during the metrics phase, by self time, a browser call named with the + * page's function that made it. + * `--frames` traces the compositor over the metrics phase instead and + * counts, per second, the frames it drew (`DrawFrame`), the frames it was + * woken for (`BeginFrame`) and the frames the display put out. At the + * default 60 Hz, a page that animates smoothly draws about 60 a second and + * would draw 240 on a 240 Hz screen; a page at rest draws none. + * `--unthrottled` makes Chrome draw as fast as the GPU allows: on a + * machine whose GPU is shared (a game, another guest), it takes that GPU + * from them; keep to the 60 Hz runs there. + * `--unthrottled` lifts Chrome's frame cap, the closest a headless browser + * gets to a high refresh screen: what the page does per frame then shows as + * frames it cannot keep. A headless Linux Chrome says nothing of WebView2's + * own compositor or of a real 144 or 240 Hz display's vsync. + */ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** `motion`: the island opened from the pill and closed again, four times, measured while it moves. */ +type Pose = { name: string; query: string; motion?: boolean }; + +const FAMILIES = ['box', 'jelly', 'bubble', 'capsule', 'retro']; + +/** The poses of `src/lib/island/pose.ts` the measures go through. */ +export const POSES: Pose[] = [ + { name: 'closed', query: 'pose=collapsed&calm=1' }, + { name: 'open-close', query: 'pose=collapsed&calm=1', motion: true }, + { name: 'closed-working', query: 'pose=collapsed' }, + { name: 'peek', query: 'pose=peek&calm=1' }, + ...['threads', 'today', 'chat', 'stats', 'music', 'apps', 'clipboard', 'wallpaper', 'system'].map((tab) => ({ name: `open-${tab}`, query: `pose=open&tab=${tab}&calm=1` })), + { name: 'deck-music', query: 'pose=deck&tab=music&calm=1' }, + { name: 'assistant', query: 'pose=open&tab=chat&scene=running&calm=1' }, + { name: 'desktop-idle', query: 'pose=desktop&calm=1&sessions=none' }, + { name: 'desktop-working', query: 'pose=desktop' }, + // At rest, for `--frames`: each family's companion awake and asleep (the gallery, `MascotGallery.svelte`), and in the app. + ...FAMILIES.flatMap((family) => [ + { name: `rest-${family}`, query: `view=mascots&family=${family}&mood=idle&shapes=1` }, + { name: `asleep-${family}`, query: `view=mascots&family=${family}&mood=asleep&shapes=1` }, + { name: `closed-${family}`, query: `pose=collapsed&calm=1&skin=${family}` }, + { name: `desktop-${family}`, query: `pose=desktop&calm=1&skin=${family}` } + ]) +]; + +const METRICS = ['TaskDuration', 'ScriptDuration', 'LayoutDuration', 'RecalcStyleDuration', 'LayoutCount', 'RecalcStyleCount'] as const; + +function arg(name: string): string | null { + const at = process.argv.indexOf(`--${name}`); + return at < 0 ? null : (process.argv[at + 1] ?? ''); +} + +/** One CDP connection to a page, its replies matched by id. */ +export class Cdp { + private next = 0; + private waiting = new Map void; reject: (error: Error) => void }>(); + private handlers = new Map void)[]>(); + /** Hears an event of the page (`Tracing.dataCollected`). */ + on(method: string, handler: (params: unknown) => void): void { + this.handlers.set(method, [...(this.handlers.get(method) ?? []), handler]); + } + private constructor(private socket: WebSocket) { + socket.addEventListener('message', (event) => { + const data = JSON.parse(String(event.data)) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message: string } }; + if (data.method) for (const handler of this.handlers.get(data.method) ?? []) handler(data.params); + if (data.id === undefined) return; + const pending = this.waiting.get(data.id); + this.waiting.delete(data.id); + if (data.error) pending?.reject(new Error(data.error.message)); + else pending?.resolve(data.result); + }); + } + static open(url: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.addEventListener('open', () => resolve(new Cdp(socket))); + socket.addEventListener('error', () => reject(new Error(`CDP connection to ${url} failed`))); + }); + } + send>(method: string, params: Record = {}): Promise { + const id = ++this.next; + this.socket.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => this.waiting.set(id, { resolve: resolve as (value: unknown) => void, reject })); + } + async evaluate(expression: string): Promise { + const reply = await this.send<{ result: { value: T }; exceptionDetails?: { text: string } }>('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (reply.exceptionDetails) throw new Error(reply.exceptionDetails.text); + return reply.result.value; + } + close(): void { + this.socket.close(); + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function metrics(cdp: Cdp): Promise> { + const { metrics: list } = await cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); + return Object.fromEntries(list.map(({ name, value }) => [name, value])); +} + +/** What runs on the page: its CSS animations and transitions, by animated property. */ +const ANIMATIONS = `(() => { + const running = document.getAnimations().filter((a) => a.playState === 'running'); + const props = {}; + for (const a of running) { + const frames = a.effect && a.effect.getKeyframes ? a.effect.getKeyframes() : []; + const keys = new Set(frames.flatMap((f) => Object.keys(f).filter((k) => !['offset', 'computedOffset', 'easing', 'composite'].includes(k)))); + const name = (a.animationName || a.transitionProperty || 'js') + ':' + [...keys].join('+'); + props[name] = (props[name] || 0) + 1; + } + return { running: running.length, props }; +})()`; + +/** Opens the island with a click on the pill, closes it with Escape, and keeps the frame times while it moves. */ +const MOTION = `(async () => { + const stamps = []; + const record = (ms) => new Promise((resolve) => { + const end = performance.now() + ms; + const tick = (now) => { stamps.push(now); if (now < end) requestAnimationFrame(tick); else resolve(); }; + requestAnimationFrame(tick); + }); + const gaps = []; + for (let round = 0; round < 4; round++) { + for (const move of ['open', 'close']) { + stamps.length = 0; + if (move === 'open') document.querySelector('.island .bar')?.click(); + else dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await record(900); + for (let i = 1; i < stamps.length; i++) gaps.push(stamps[i] - stamps[i - 1]); + } + } + return gaps; +})()`; + +const PROBE = (ms: number) => `new Promise((resolve) => { + const stamps = []; + const end = performance.now() + ${ms}; + const tick = (now) => { stamps.push(now); if (now < end) requestAnimationFrame(tick); else resolve(stamps); }; + requestAnimationFrame(tick); +})`; + +function quantile(sorted: number[], q: number): number { + if (!sorted.length) return 0; + return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!; +} + +export type PoseResult = { + pose: string; + perSecond: Record<(typeof METRICS)[number], number>; + frames: { count: number; p50: number; p95: number; p99: number; dropped: number }; + animations: { running: number; props: Record }; + /** With `--frames`: what the compositor did per second over the phase (`DrawFrame`: frames drawn; `BeginFrame`: woken for a frame; `swaps`: frames the display put out). */ + compositor?: { draws: number; wakes: number; swaps: number }; + /** With `--profile`: the heaviest functions, self time in ms over the phase. */ + hot?: { at: string; ms: number }[]; +}; + +type CallFrame = { functionName: string; url: string; lineNumber: number; columnNumber: number }; +type Profile = { nodes: { id: number; callFrame: CallFrame; children?: number[] }[]; samples: number[]; timeDeltas: number[] }; + +function where(frame: CallFrame): string { + const file = frame.url.replace(/^.*\/(src|node_modules|vendor|assets)\//, '$1/').replace(/\?.*$/, ''); + return `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}:${frame.columnNumber + 1}`; +} + +/** Self time by function, heaviest first; idle and the program's own bookkeeping left out. */ +function hottest(profile: Profile, count = 12): { at: string; ms: number }[] { + const byId = new Map(profile.nodes.map((node) => [node.id, node.callFrame])); + const parent = new Map(); + for (const node of profile.nodes) for (const child of node.children ?? []) parent.set(child, node.id); + const self = new Map(); + profile.samples.forEach((id, index) => { + const frame = byId.get(id); + if (!frame || ['(idle)', '(program)', '(root)'].includes(frame.functionName)) return; + // A browser call (a layout read, a style write) is named with the page's function that made it. + const caller = frame.url ? null : byId.get(parent.get(id) ?? -1); + const at = caller?.url ? `${frame.functionName} < ${where(caller)}` : where(frame); + self.set(at, (self.get(at) ?? 0) + (profile.timeDeltas[index] ?? 0) / 1000); + }); + return [...self].sort((a, b) => b[1] - a[1]).slice(0, count).map(([at, ms]) => ({ at, ms: Math.round(ms * 10) / 10 })); +} + +async function measure(port: number, base: string, pose: Pose, seconds: number, profile = false, frames = false): Promise { + const created = (await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json()) as { id: string; webSocketDebuggerUrl: string }; + const cdp = await Cdp.open(created.webSocketDebuggerUrl); + try { + await cdp.send('Page.enable'); + await cdp.send('Performance.enable', { timeDomain: 'threadTicks' }); + await cdp.send('Page.navigate', { url: `${base}/?fake=1&${pose.query}` }); + await cdp.evaluate(`new Promise((r) => document.readyState === 'complete' ? r() : addEventListener('load', () => r()))`); + // The pose lands, its entrance animations end. + await sleep(4000); + if (profile) { + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 200 }); + await cdp.send('Profiler.start'); + } + const traced: { name: string }[] = []; + let traceDone: Promise = Promise.resolve(); + if (frames) { + cdp.on('Tracing.dataCollected', (params) => traced.push(...(params as { value: { name: string }[] }).value)); + traceDone = new Promise((resolve) => cdp.on('Tracing.tracingComplete', () => resolve())); + await cdp.send('Tracing.start', { traceConfig: { includedCategories: ['disabled-by-default-devtools.timeline.frame', 'viz'] }, transferMode: 'ReportEvents' }); + } + const before = await metrics(cdp); + const moving = pose.motion ? cdp.evaluate(MOTION) : sleep(seconds * 1000).then(() => null); + const motionGaps = await moving; + const after = await metrics(cdp); + let compositor: PoseResult['compositor']; + if (frames) { + await cdp.send('Tracing.end'); + await traceDone; + const count = (name: string) => traced.filter((event) => event.name === name).length / seconds; + compositor = { draws: count('DrawFrame'), wakes: count('BeginFrame'), swaps: count('Display::DrawAndSwap') }; + } + const hot = profile ? hottest((await cdp.send<{ profile: Profile }>('Profiler.stop')).profile) : undefined; + // A moving pose lasts as long as its moves: 4 rounds of two 900 ms moves. + const span = motionGaps ? 7.2 : seconds; + const perSecond = Object.fromEntries( + METRICS.map((key) => { + const delta = (after[key] ?? 0) - (before[key] ?? 0); + // Durations come in seconds: ms of main thread per second of wall time. + return [key, key.endsWith('Duration') ? (delta * 1000) / span : delta / span]; + }) + ) as PoseResult['perSecond']; + const animations = await cdp.evaluate(ANIMATIONS); + const stamps = motionGaps || frames ? [] : await cdp.evaluate(PROBE(seconds * 1000)); + const gaps = (motionGaps ?? stamps.slice(1).map((stamp, index) => stamp - stamps[index]!)).sort((a, b) => a - b); + const p50 = quantile(gaps, 0.5); + return { + pose: pose.name, + perSecond, + frames: { count: gaps.length, p50, p95: quantile(gaps, 0.95), p99: quantile(gaps, 0.99), dropped: gaps.filter((gap) => gap > p50 * 1.5).length }, + animations, + ...(compositor ? { compositor } : {}), + ...(hot ? { hot } : {}) + }; + } finally { + cdp.close(); + await fetch(`http://127.0.0.1:${port}/json/close/${created.id}`).catch(() => undefined); + } +} + +export async function launch(unthrottled: boolean): Promise<{ port: number; stop: () => void }> { + const profile = mkdtempSync(join(tmpdir(), 'bots-perf-')); + const flags = [ + '--headless=new', + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + '--mute-audio', + '--enable-gpu', + '--use-gl=angle', + '--use-angle=gl-egl', + '--disable-software-rasterizer', + '--window-size=1280,900', + '--no-first-run', + ...(unthrottled ? ['--disable-frame-rate-limit', '--disable-gpu-vsync'] : []) + ]; + const chrome = spawn(process.env.CHROME ?? '/usr/bin/google-chrome', flags, { stdio: 'ignore' }); + const stop = () => { + chrome.kill(); + rmSync(profile, { recursive: true, force: true }); + }; + for (let tries = 0; tries < 100; tries++) { + try { + const port = Number(readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]); + if (port) return { port, stop }; + } catch { + /* not written yet */ + } + await sleep(100); + } + stop(); + throw new Error('Chrome did not open its DevTools port'); +} + +/** Refuses a software renderer: its numbers say nothing about a real GPU. */ +async function renderer(port: number): Promise { + const created = (await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json()) as { id: string; webSocketDebuggerUrl: string }; + const cdp = await Cdp.open(created.webSocketDebuggerUrl); + const name = await cdp.evaluate(`(() => { const gl = document.createElement('canvas').getContext('webgl2'); const d = gl && gl.getExtension('WEBGL_debug_renderer_info'); return d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : null; })()`); + cdp.close(); + await fetch(`http://127.0.0.1:${port}/json/close/${created.id}`).catch(() => undefined); + if (!name || /swiftshader|llvmpipe|softpipe/i.test(name)) throw new Error(`Hardware rendering unavailable: ${name}`); + return name; +} + +const fixed = (value: number, digits = 1) => value.toFixed(digits); + +export function table(results: PoseResult[]): string { + const head = '| Pose | Task ms/s | Script ms/s | Layout ms/s | Style ms/s | Layouts/s | Styles/s | Frame p50 | p95 | p99 | Dropped | Running anims |'; + const rows = results.map(({ pose, perSecond: m, frames: f, animations: a }) => + `| ${pose} | ${fixed(m.TaskDuration)} | ${fixed(m.ScriptDuration)} | ${fixed(m.LayoutDuration, 2)} | ${fixed(m.RecalcStyleDuration, 2)} | ${fixed(m.LayoutCount)} | ${fixed(m.RecalcStyleCount)} | ${fixed(f.p50)} | ${fixed(f.p95)} | ${fixed(f.p99)} | ${f.dropped} | ${a.running} |` + ); + return [head, '|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|', ...rows].join('\n'); +} + +/** With `--frames`: the compositor's work per second, pose by pose. */ +export function framesTable(results: PoseResult[]): string { + const head = '| Pose | Frames drawn/s | Compositor wakes/s | Display swaps/s | Task ms/s | Running anims |'; + const rows = results.map(({ pose, compositor: c, perSecond: m, animations: a }) => `| ${pose} | ${fixed(c?.draws ?? 0)} | ${fixed(c?.wakes ?? 0)} | ${fixed(c?.swaps ?? 0)} | ${fixed(m.TaskDuration)} | ${a.running} |`); + return [head, '|---|---:|---:|---:|---:|---:|', ...rows].join('\n'); +} + +if (import.meta.main) { + const base = arg('base') ?? 'http://localhost:5174'; + const seconds = Number(arg('seconds') ?? 10); + const only = arg('only')?.split(','); + const poses = only ? POSES.filter(({ name }) => only.includes(name)) : POSES; + const { port, stop } = await launch(process.argv.includes('--unthrottled')); + try { + console.log(`GPU: ${await renderer(port)}`); + // A first load warms Vite's transforms, kept out of the numbers. + await measure(port, base, poses[0]!, 1); + const results: PoseResult[] = []; + for (const pose of poses) { + const result = await measure(port, base, pose, seconds, process.argv.includes('--profile'), process.argv.includes('--frames')); + results.push(result); + for (const { at, ms } of result.hot ?? []) console.error(` ${ms.toFixed(1)} ms ${at}`); + console.error(`${pose.name}: task ${fixed(result.perSecond.TaskDuration)} ms/s, frames p50 ${fixed(result.frames.p50)} ms, anims ${JSON.stringify(result.animations.props)}`); + } + console.log(process.argv.includes('--frames') ? framesTable(results) : table(results)); + const json = arg('json'); + if (json) writeFileSync(json, JSON.stringify(results, null, 2)); + } finally { + stop(); + } +} diff --git a/scripts/perf.vite.config.ts b/scripts/perf.vite.config.ts new file mode 100644 index 0000000..a321d3f --- /dev/null +++ b/scripts/perf.vite.config.ts @@ -0,0 +1,19 @@ +/* + * The page as it ships, with the demo poses in: `scripts/perf-probe.ts` + * measures this build, since the dev server's Svelte traces every state + * write and its numbers are mostly that tracing. + * + * `bun x vite build --config scripts/perf.vite.config.ts`, then + * `bun x vite preview --config scripts/perf.vite.config.ts --port 5282`. + */ +import { defineConfig, mergeConfig } from 'vite'; +import base from '../vite.config.ts'; + +export default mergeConfig( + base, + defineConfig({ + // The demo gates (`import.meta.env.DEV && ...`) stay open in this production build. + define: { 'import.meta.env.DEV': 'true' }, + build: { outDir: 'node_modules/.bots-perf', emptyOutDir: true } + }) +); diff --git a/src-tauri/capabilities/settings.json b/src-tauri/capabilities/settings.json deleted file mode 100644 index 65c7984..0000000 --- a/src-tauri/capabilities/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "settings", - "description": "The settings window may connect to the core and list the screens. Remote origins receive nothing.", - "webviews": ["settings"], - "permissions": ["allow-settings-ui", "core:event:default"] -} diff --git a/src-tauri/permissions/bots.toml b/src-tauri/permissions/bots.toml index 0293698..9de4658 100644 --- a/src-tauri/permissions/bots.toml +++ b/src-tauri/permissions/bots.toml @@ -1,6 +1,6 @@ [[permission]] identifier = "allow-companion-ui" -description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there." +description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings panel and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there." commands.allow = [ "core_endpoint", "companion_monitors", @@ -42,7 +42,3 @@ commands.allow = [ "computer_engine_control", ] -[[permission]] -identifier = "allow-settings-ui" -description = "Allows the settings window to connect to the core and list the screens the companion can sit on." -commands.allow = ["core_endpoint", "companion_monitors"] diff --git a/src-tauri/src/acl.rs b/src-tauri/src/acl.rs index ed7df57..4e7bb6b 100644 --- a/src-tauri/src/acl.rs +++ b/src-tauri/src/acl.rs @@ -34,14 +34,11 @@ mod tests { } #[test] - fn the_settings_window_connects_and_lists_screens_but_does_not_drive_the_companion() { + fn the_settings_live_in_the_companion_and_no_settings_webview_is_granted_anything() { let mut context: tauri::Context = crate::bots_context(); let authority = context.runtime_authority_mut(); - for command in ["core_endpoint", "companion_monitors", "plugin:event|listen", "plugin:event|unlisten"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_some(), "settings cannot invoke {command}"); - } - for command in ["companion_place", "island_place", "companion_capture", "companion_keep", "companion_configure", "bots_tray_labels", "bots_settings"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "settings can invoke {command}"); + for command in COMPANION { + assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "a settings webview can invoke {command}"); } } } diff --git a/src-tauri/src/companion_window.rs b/src-tauri/src/companion_window.rs index 7be6ab4..86f8d40 100644 --- a/src-tauri/src/companion_window.rs +++ b/src-tauri/src/companion_window.rs @@ -254,7 +254,7 @@ fn place(window: &WebviewWindow, monitor: Option<&str>, anchor: A /// The page's query: `BOTS_DEMO=1` runs a development build on the fake core, /// `BOTS_DEMO=hud` with the HUD demo seeded too. A release build ignores it. -pub fn demo_query() -> &'static str { +fn demo_query() -> &'static str { if !cfg!(debug_assertions) { return ""; } match std::env::var("BOTS_DEMO").as_deref() { Ok("hud") => "fake=1&hud=1", @@ -428,12 +428,18 @@ fn watch(app: AppHandle, shared: Shared, mine: u64) { /// The shortcut, or Bots launched again: the companion comes forward, over a /// full-screen app too, and the page opens its question bar. pub fn summon(app: &AppHandle) { - let Some(window) = app.get_webview_window(LABEL) else { return }; + if let Some(window) = bring_forward(app) { let _ = window.emit("companion://summon", ()); } +} + +/// The companion's window over the others, with the keyboard, back from +/// hiding for a full-screen app: for the shortcut and for Settings. +pub fn bring_forward(app: &AppHandle) -> Option> { + let window = app.get_webview_window(LABEL)?; app.state::().shared.hidden.store(false, Ordering::Release); let _ = window.show(); crate::platform::keep_on_top(&window); let _ = window.set_focus(); - let _ = window.emit("companion://summon", ()); + Some(window) } /// Opens the companion, at start. It stays until Bots quits: closing it is @@ -465,7 +471,7 @@ fn window_of(app: &AppHandle) -> Result { /// The screens connected now, for the setting that picks one. #[tauri::command] pub async fn companion_monitors(app: AppHandle, webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + only_companion(&webview)?; let primary = app.primary_monitor().map_err(|error| error.to_string())?.map(|monitor| monitor_name(&monitor)); Ok(app.available_monitors().map_err(|error| error.to_string())?.into_iter().map(|monitor| { let name = monitor_name(&monitor); diff --git a/src-tauri/src/endpoint.rs b/src-tauri/src/endpoint.rs index 5f5caaa..22f543d 100644 --- a/src-tauri/src/endpoint.rs +++ b/src-tauri/src/endpoint.rs @@ -108,11 +108,11 @@ fn find() -> Option { listening(&endpoint.url).then_some(endpoint) } -/// The core to connect to, or none yet. Only Bots's own pages may ask: the -/// answer carries the owner's token. +/// The core to connect to, or none yet. Only the companion's page may ask: +/// the answer carries the owner's token. #[tauri::command] pub async fn core_endpoint(webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + crate::companion_window::only_companion(&webview)?; tauri::async_runtime::spawn_blocking(find).await.map_err(|error| error.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c4977b..da6eeee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,17 +18,6 @@ mod tray; #[link(name = "resource", kind = "static", modifiers = "-bundle")] unsafe extern "C" {} -use tauri::Webview; - -/// Bots's own pages, the companion and its settings: the only ones that may -/// ask for the core's token or list the screens. -pub(crate) fn only_bots(webview: &Webview) -> Result<(), String> { - match webview.label() { - companion_window::LABEL | tray::SETTINGS_LABEL => Ok(()), - other => Err(format!("the webview {other:?} may not invoke this command")), - } -} - // One expansion: on macOS generate_context also embeds the application plist. fn bots_context() -> tauri::Context { tauri::generate_context!() @@ -102,8 +91,7 @@ pub fn run() { } }; app.run(|_app, event| { - // The settings window closing leaves the companion: Bots stops on Quit - // alone, or when Boite stops it. + // Bots stops on Quit alone, or when Boite stops it. if let tauri::RunEvent::ExitRequested { code: None, api, .. } = event { api.prevent_exit(); } }); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e00b3a3..c06c827 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,22 +1,24 @@ -//! The tray icon, its menu, the settings window and quitting. +//! The tray icon, its menu, opening the settings and quitting. +//! +//! The settings have no window of their own: they are a panel of the island, +//! in the companion's window (`components/island/settings/`). Settings brings +//! that window forward and asks its page for the panel (`companion://settings`). -use std::sync::{Mutex, PoisonError}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, Runtime, Webview, WebviewUrl, + AppHandle, Emitter, Manager, Runtime, Webview, }; -pub const SETTINGS_LABEL: &str = "settings"; pub const TRAY_ID: &str = "bots"; +/// The longest section name the page may ask the panel to open on. +const SECTION_MAX: usize = 24; -/// The tray menu's items and the settings window's title, kept so the page -/// can name them in its language. +/// The tray menu's items, kept so the page can name them in its language. struct Labels { tray: tauri::tray::TrayIcon, settings: MenuItem, quit: MenuItem, - title: Mutex, } /// Quit: Bots stops with code 0, which Boite reads as stopped on purpose, @@ -25,47 +27,43 @@ pub fn quit(app: &AppHandle) { app.exit(0); } -/// Opens the settings window, or brings it forward when it is open. -pub fn open_settings(app: &AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { - let _ = window.unminimize(); - window.show()?; - return window.set_focus(); - } - let title = app.try_state::>() - .map(|labels| labels.title.lock().unwrap_or_else(PoisonError::into_inner).clone()) - .unwrap_or_else(|| "B.O.T.S settings".into()); - let query = crate::companion_window::demo_query(); - let page = if query.is_empty() { "index.html?view=settings".to_string() } else { format!("index.html?view=settings&{query}") }; - let window = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, WebviewUrl::App(page.into())) - .title(title).inner_size(720.0, 760.0).min_inner_size(520.0, 480.0) - .on_navigation(|url| matches!(url.scheme(), "tauri" | "http" | "https") && matches!(url.host_str(), Some("tauri.localhost") | Some("localhost"))) - .build()?; - window.set_focus() +/// A section the page may name: a short lowercase word. The page knows its +/// sections (`lib/island/settings-panel.svelte.ts`) and opens the last one +/// shown on any name it does not. +fn is_section(name: &str) -> bool { + !name.is_empty() && name.len() <= SECTION_MAX && name.bytes().all(|byte| byte.is_ascii_lowercase()) +} + +/// The companion's window comes forward with the keyboard and opens its +/// settings panel, on `section` when one is named. +pub fn open_settings(app: &AppHandle, section: Option<&str>) -> tauri::Result<()> { + let Some(window) = crate::companion_window::bring_forward(app) else { return Ok(()) }; + window.emit("companion://settings", section) } -/// `companion://settings` from the companion: its settings, in their own window. +/// The companion asks for its settings panel, on a section or the last one shown. #[tauri::command] -pub async fn bots_settings(app: AppHandle, webview: Webview) -> Result<(), String> { +pub async fn bots_settings(app: AppHandle, webview: Webview, section: Option) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - open_settings(&app).map_err(|error| error.to_string()) + if let Some(name) = section.as_deref().filter(|name| !is_section(name)) { + return Err(format!("{name:?} is not a settings section")); + } + open_settings(&app, section.as_deref()).map_err(|error| error.to_string()) } /// The tray is native and the sentences live in the page's catalogue: the /// companion sends them at start and on every language change. English until /// then. #[tauri::command] -pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String, title: String) -> Result<(), String> { +pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - if [&tooltip, &settings, &quit, &title].iter().any(|label| label.trim().is_empty()) { - return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}, title {title:?}")); + if [&tooltip, &settings, &quit].iter().any(|label| label.trim().is_empty()) { + return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}")); } let Some(labels) = app.try_state::>() else { return Ok(()) }; labels.tray.set_tooltip(Some(tooltip)).map_err(|error| format!("the tray kept its tooltip: {error}"))?; labels.settings.set_text(settings).map_err(|error| format!("the tray's Settings item kept its label: {error}"))?; labels.quit.set_text(quit).map_err(|error| format!("the tray's Quit item kept its label: {error}"))?; - *labels.title.lock().unwrap_or_else(PoisonError::into_inner) = title.clone(); - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { let _ = window.set_title(&title); } Ok(()) } @@ -85,7 +83,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { }) .on_menu_event(|app, event| match event.id().as_ref() { "settings" => { - if let Err(error) = open_settings(app) { eprintln!("[bots] the settings window did not open: {error}"); } + if let Err(error) = open_settings(app, None) { eprintln!("[bots] the settings did not open: {error}"); } } "quit" => quit(app), _ => {} @@ -94,6 +92,21 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { builder = builder.icon(icon); } let tray = builder.build(app)?; - app.manage(Labels { tray, settings, quit: leave, title: Mutex::new("B.O.T.S settings".into()) }); + app.manage(Labels { tray, settings, quit: leave }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_section_is_a_short_lowercase_word() { + for name in ["place", "crew", "permissions"] { + assert!(is_section(name), "{name}"); + } + for name in ["", "Crew", "crew settings", "../crew", "brain-model", "a".repeat(SECTION_MAX + 1).as_str()] { + assert!(!is_section(name), "{name:?}"); + } + } +} diff --git a/src/App.svelte b/src/App.svelte index a996a01..141a075 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -9,6 +9,11 @@ (`components/desktop/`). Dragging a robot out of the island sets it loose; dragging it back against the top edge, or "Back to the island", returns it. The shell places the window for either (`island_place`, `companion_place`). + + 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 + them, drawn for them even while the robots are loose, and held open while + they show. --> -
+
- {#if island} - + {#if shown} + {:else} - + {/if}
` sets their size (64 by default, the desktop's); + `family=` shows every shape of that family instead; `mood=` + (or `mood=asleep`) keeps that one column and `shapes=` the first rows, for measuring one robot at rest. +--> + + +
+ + {#each MOODS as column, index (index)}{column.caption}{/each} + {#each ROBOTS as robot, row (row)} + {strings.agents.robot.families[robot.family]}{own ? ` ${robot.shape + 1}` : ''} + {#each MOODS as column, index (index)} + + + + {/each} + {/each} +
+ + diff --git a/src/SettingsApp.svelte b/src/SettingsApp.svelte deleted file mode 100644 index 3d2a9e1..0000000 --- a/src/SettingsApp.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - -
- {#if link.waiting} -
{}} />
- {/if} - -
(notice = null)} />
-
- - diff --git a/src/app.test.ts b/src/app.test.ts index 09d5876..07f71a0 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import App from './App.svelte'; -import SettingsApp from './SettingsApp.svelte'; +import { requestSettings, settingsPanel } from './lib/island/settings-panel.svelte'; import { strings } from './lib/strings'; /** The demo loads its fake core on demand: in a cold run its first import alone can take seconds. */ @@ -9,7 +9,7 @@ const DEMO_WAIT = { timeout: 15_000 }; let shown: { app: Record; target: HTMLElement } | null = null; -function show(page: typeof App | typeof SettingsApp, search: string) { +function show(page: typeof App, search: string) { history.replaceState(null, '', `/${search}`); const target = document.body.appendChild(document.createElement('div')); shown = { app: mount(page, { target }), target }; @@ -23,6 +23,7 @@ afterEach(() => { shown.target.remove(); shown = null; } + settingsPanel.close(); window.localStorage.clear(); history.replaceState(null, '', '/'); }); @@ -39,15 +40,38 @@ test('the demo mode runs the companion on the fake core', async () => { expect(target.querySelector('[data-testid="companion"]')).not.toBeNull(); }); -test('the settings window lists the crew, without a link to Boite\'s Advanced page', async () => { - const target = show(SettingsApp, '?view=settings&fake=1'); +test('a request opens the island and its settings on a section; the deck\'s gear puts them away', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('crew'); + flushSync(); + expect(target.querySelector('[data-testid="island-settings-panel"]')).not.toBeNull(); + const gear = target.querySelector('[data-testid="island-settings"]')!; + expect(gear.getAttribute('aria-pressed')).toBe('true'); + expect(target.querySelector('[data-testid="settings-section-crew"]')?.getAttribute('aria-selected')).toBe('true'); await vi.waitFor(() => expect(target.querySelector('[data-testid="companion-crew"] ul, [data-testid="companion-crew"] form')).not.toBeNull(), DEMO_WAIT); - expect(target.querySelector('[data-testid="companion-settings"]')).not.toBeNull(); expect(target.querySelector('[data-testid="companion-open-advanced"]')).toBeNull(); - expect(document.title).toBe(strings.bots.settingsTitle); -}); + target.querySelector('[data-testid="settings-section-place"]')!.click(); + flushSync(); + expect(target.querySelector('[data-testid="companion-robots"]')).not.toBeNull(); + gear.click(); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(gear.getAttribute('aria-pressed')).toBe('false'); +}, 40_000); -test('the settings window waits for Boite too', async () => { - const target = show(SettingsApp, '?view=settings'); - await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.waiting)); -}); +test('Escape closes the settings panel first, before a request waiting under it or the island', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('focus'); + flushSync(); + // The fake core starts with requests waiting: their card sits under the panel. + await vi.waitFor(() => expect(target.querySelector('[data-testid="island-attention"]')).not.toBeNull(), DEMO_WAIT); + const asked = target.querySelector('[data-testid="attention-of"]')?.textContent; + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + expect(target.querySelector('[data-testid="attention-of"]')?.textContent).toBe(asked); +}, 30_000); diff --git a/src/bots.css b/src/bots.css index 09a4eed..5f5f3b5 100644 --- a/src/bots.css +++ b/src/bots.css @@ -3,7 +3,7 @@ /* The soft shadow under a robot, the same on light and dark desktops. */ --bots-shadow: drop-shadow(0 1.5px 2px rgb(0 0 0 / 0.35)); - /* The island and the settings window: pure black, dark cards, hairlines. */ + /* The island and its settings panel: pure black, dark cards, hairlines. */ --island-bg: #000; --island-card: #0e0e12; --island-card-hover: #16161c; @@ -79,6 +79,15 @@ --color-accent-soft: rgb(139 124 246 / 0.16); --color-accent-ink: #fff; --color-surface-2: var(--island-card-hover); + /* The chips, popovers and effort track of Boite's model picker (`components/model-picker/`), at Boite's dark values. */ + --color-surface-3: #1f1f26; + --color-active: rgb(255 255 255 / 0.09); + --control-glaze: linear-gradient(145deg, rgb(255 255 255 / 0.055), rgb(255 255 255 / 0.012) 48%, transparent); + --color-control-hover: var(--color-surface-3); + --color-reasoning: var(--color-accent); + --color-reasoning-on: #fff; + --shadow-e1: 0 1px 2px rgb(0 0 0 / 0.35), inset 0 1px 0 rgb(255 255 255 / 0.05); + --shadow-e2: 0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(255 255 255 / 0.05); --color-success: var(--island-done); --color-live: var(--island-attention); --color-danger: var(--island-danger); @@ -87,6 +96,11 @@ font-family: var(--island-font); } +/* Logos drawn for both themes take their dark colours on the island's black, whatever Boite's theme. */ +:root[data-theme] .island-tokens [data-logo] .themed { + fill: var(--fill-dark); +} + /* A card of the island's views and of Settings, and the small heading over it. */ .island-card { padding: 10px 12px; @@ -173,3 +187,24 @@ --island-key: rgb(255 255 255 / 0.12); --island-attention-ink: #000; } + +/* + * Out of sight (`data-dormant`, `App.svelte`): behind a full-screen game, the + * window hidden, the endless animations (the robots' blinks, the pulses) + * wait where they are instead of drawing frames nobody sees. Svelte's + * transitions are not CSS animations and still end. + */ +:root[data-dormant] *, +:root[data-dormant] *::before, +:root[data-dormant] *::after { + animation-play-state: paused !important; +} + +/* + * A robot out of view (`data-offscreen`, `onscreen` in `lib/companion/beats.ts`): + * scrolled away in a list or clipped, its endless animations wait. + */ +[data-offscreen], +[data-offscreen] * { + animation-play-state: paused !important; +} diff --git a/src/components/CompanionSettings.svelte b/src/components/CompanionSettings.svelte deleted file mode 100644 index df35fec..0000000 --- a/src/components/CompanionSettings.svelte +++ /dev/null @@ -1,412 +0,0 @@ - - - -{#snippet picker(label: string, hint: string | null, items: MenuItem[], current: string, onpick: (id: string) => void, testid: string, onreach?: () => void)} - -
- - {label}{#if hint}{/if} - - - {current} - -
-{/snippet} - -
-
-

{strings.companion.heading}

-
- - {#if !inShell()} -

{copy.desktopOnly}

- {/if} - -
-

{copy.place}

-
- {copy.robots} -
- {#each ROBOTS as robots (robots)} - - {/each} -
-
- {#if prefs.robots === 'island'} - - {@render picker(copy.pillRest, copy.pillRestHint, pillRestItems, copy.pillRests[prefs.pillRest], (id) => save({ pillRest: id as PillRest }), 'companion-pill-rest')} -
- {copy.bar} - -
- {/if} - {@render picker(copy.screen, copy.screenHint, screenItems, prefs.monitor ?? copy.primary, (id) => save({ monitor: id === PRIMARY ? null : id }), 'companion-screen')} - {#if prefs.robots === 'desktop'} - {@render picker(copy.position, copy.positionHint, anchorItems, copy.anchors[prefs.anchor], (id) => save({ anchor: id as CompanionAnchor }), 'companion-position')} - {/if} - {@render picker(copy.hotkey, copy.hotkeyHint, hotkeyItems, prefs.hotkey ?? copy.hotkeyOff, (id) => save({ hotkey: id === OFF ? null : id }), 'companion-hotkey')} - {#if hotkeyTaken}{/if} -
- -
-

{copy.mascot}

- save({ mascot: robot as Mascot })} /> - {#if prefs.mascot} - - {/if} -
- - - -
-

{copy.brain}

- {@render picker(copy.model, copy.modelHint, modelItems, currentModel, pickModel, 'companion-model', readAllModels)} - {#if accounts.length > 1} - {@render picker(copy.account, null, accountItems, account?.label ?? '', (id) => save({ accountId: id }), 'companion-account')} - {/if} - {#if levels.length > 0} - {@render picker(copy.effort, null, effortItems, currentEffort, (id) => save({ effort: id === AUTO ? null : id }), 'companion-effort')} - {/if} - {@render picker(copy.control, copy.controlHint, controlItems, copy.controls[prefs.control], (id) => save({ control: id as CompanionControl }), 'companion-control')} -

{copy.controlsHint[prefs.control]}

- {#if warmMinutes !== null} -

{warmMinutes > 0 ? fill(copy.warmOn, { minutes: String(warmMinutes) }) : copy.warmOff}

- - {/if} -
- -
- - - - {#if prefs.quotas && gauges.length > 0} -
- {#each gauges as gauge (gauge.id)} - - {/each} -
- {/if} - - - - - {@render picker(copy.radar, copy.radarHint, radarItems, radarLabel(prefs.radarMinutes), (id) => save({ radarMinutes: Number(id) }), 'companion-radar-minutes')} -
- -
- -
- -
-

{copy.pomodoro}

- {@render picker(copy.workMinutes, null, workItems, minutesLabel(prefs.workMinutes), (id) => save({ workMinutes: Number(id) }), 'companion-work-minutes')} - {@render picker(copy.breakMinutes, null, breakItems, minutesLabel(prefs.breakMinutes), (id) => save({ breakMinutes: Number(id) }), 'companion-break-minutes')} - -
- - - - -
- - diff --git a/src/components/agents/RobotFace.svelte b/src/components/agents/RobotFace.svelte index 457f38a..af6322c 100644 --- a/src/components/agents/RobotFace.svelte +++ b/src/components/agents/RobotFace.svelte @@ -1,4 +1,21 @@ + +
{#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -196,7 +212,7 @@ {:else if robot.eyes === 3} {:else}{/if} -
+
{#if robot.top >= 3 && robot.top <= 6} @@ -215,7 +231,9 @@ {:else if robot.family === 'capsule'}
- + +
+ {#if robot.top === 1} {:else if robot.top === 2} @@ -228,6 +246,10 @@ {/if} + + {#if robot.top === 1}{/if} +
+ {#if robot.shape === 0} {:else if robot.shape === 1} @@ -236,8 +258,7 @@ - {#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -248,7 +269,7 @@ {/if} -
+
{:else if robot.family === 'box'} @@ -268,8 +289,9 @@ {:else} - - + +
+ {#if robot.top === 0} {:else if robot.top === 1} @@ -278,6 +300,14 @@ {/if} + + {#if robot.top === 0} + + {:else if robot.top === 1} + + {/if} +
+ @@ -286,12 +316,7 @@ {#if head.h - (screen.y - head.y) - screen.h > 8}{/if} - {#if robot.top === 0} - - {:else if robot.top === 1} - - {/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -302,7 +327,7 @@ {/if} -
+
{/if}
@@ -346,17 +371,24 @@ .box-led { fill: var(--robot-led); stroke: var(--box-line); stroke-width: 1.5; } /* The capsule leans round the middle of its face. */ .tilt { transform: rotate(-11deg) translate(4.6875%, 9.375%); transform-origin: 50% 56.25%; } + /* A capsule's top turns round the middle of its head, a retro antenna round its foot. */ + .cap { transform-origin: 52% 50%; } + .antenna { transform-origin: var(--antenna-at); } /* A blink at rest, a glance while working, a hop while it waits on the user; moves in hundredths of the square. */ .eyes { transform-origin: var(--eyes-at); } - [data-state='idle'] .eyes { animation: robot-blink 4.6s infinite; } + /* Kept on their own layer between blinks: made at each blink, they would be painted again each time. */ + [data-state='idle'] .eyes { will-change: transform; } + /* At rest a blink about every 4.6 s, started by the page's one timer (`beats.ts`). */ + .robot:global([data-beat-blink])[data-state='idle'] .eyes { animation: robot-blink 0.368s; } [data-state='working'] .eyes { animation: robot-glance 2.4s ease-in-out infinite; } [data-state='working'] .led { animation: robot-pulse 1.2s ease-in-out infinite; } [data-state='waiting'] .whole { animation: robot-hop 1.8s ease-in-out infinite; } /* A jelly at work squishes on the spot. */ [data-family='jelly'][data-state='working'] .whole { transform-origin: 50% 96.875%; animation: robot-squish 0.9s ease-in-out infinite; } @keyframes robot-squish { 0%, 100% { transform: scale(1, 1); } 40% { transform: scale(1.07, 0.93); } 70% { transform: scale(0.96, 1.04); } } - @keyframes robot-blink { 0%, 92%, 100% { transform: scaleY(1); } 95% { transform: scaleY(0.12); } } + /* The last 8 % of the 4.6 s loop it was: shut at 3/8 of the way. */ + @keyframes robot-blink { 0%, 100% { transform: scaleY(1); } 37.5% { transform: scaleY(0.12); } } @keyframes robot-glance { 0%, 20% { transform: translateX(0); } 30%, 50% { transform: translateX(-3.4375%); } 60%, 80% { transform: translateX(3.4375%); } 90%, 100% { transform: translateX(0); } } @keyframes robot-hop { 0%, 70%, 100% { transform: translateY(0); } 80% { transform: translateY(-4.6875%); } 90% { transform: translateY(0.78125%); } } @keyframes robot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } diff --git a/src/components/agents/RobotPicker.svelte b/src/components/agents/RobotPicker.svelte index ea34859..c98ab76 100644 --- a/src/components/agents/RobotPicker.svelte +++ b/src/components/agents/RobotPicker.svelte @@ -11,7 +11,7 @@ * Shuffle for a new one, and one row per part where every choice is drawn * on the robot itself, so nothing has to be read. A box's lid, body and * eyes are colours, so those rows are swatches. `families` narrows the styles - * offered: the island's mascot is a box or a jelly. + * offered. */ let { robot, onpick, compact = false, families = ROBOT_FAMILIES }: { robot: Robot; onpick: (robot: Robot) => void; compact?: boolean; families?: readonly RobotFamily[] } = $props(); const labels = $derived(strings.agents.robot); diff --git a/src/components/companion/BubbleCharacter.svelte b/src/components/companion/BubbleCharacter.svelte new file mode 100644 index 0000000..ef2c10b --- /dev/null +++ b/src/components/companion/BubbleCharacter.svelte @@ -0,0 +1,125 @@ + + + + + + diff --git a/src/components/companion/CapsuleCharacter.svelte b/src/components/companion/CapsuleCharacter.svelte new file mode 100644 index 0000000..ea63da7 --- /dev/null +++ b/src/components/companion/CapsuleCharacter.svelte @@ -0,0 +1,111 @@ + + + + + + diff --git a/src/components/companion/CharacterMarks.svelte b/src/components/companion/CharacterMarks.svelte new file mode 100644 index 0000000..2dbb4d2 --- /dev/null +++ b/src/components/companion/CharacterMarks.svelte @@ -0,0 +1,37 @@ + + + +{#if mark === 'z'} + +{:else if mark === 'shout'} + +{:else if mark === 'spark'} + +{/if} + + diff --git a/src/components/companion/CompanionAllowRules.svelte b/src/components/companion/CompanionAllowRules.svelte index 570a70f..51e7d88 100644 --- a/src/components/companion/CompanionAllowRules.svelte +++ b/src/components/companion/CompanionAllowRules.svelte @@ -22,14 +22,13 @@ } +
-
-

{copy.title}

- {#if rules.length > 0} + {#if rules.length > 0} +
- {/if} -
-

{copy.lead}

+
+ {/if} {#if rules.length === 0}

{copy.empty}

{:else} @@ -50,9 +49,11 @@ diff --git a/src/components/companion/companion-character.svelte.test.ts b/src/components/companion/companion-character.svelte.test.ts new file mode 100644 index 0000000..e43fd98 --- /dev/null +++ b/src/components/companion/companion-character.svelte.test.ts @@ -0,0 +1,50 @@ +/* + * The companion draws a skin with its family's character: the box for a box + * or no skin, a Jelly, Bubble, Capsule or Retro robot for the others. + */ +import { afterEach, expect, test } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import type { Robot } from '../../lib/robots'; +import CompanionCharacter from './CompanionCharacter.svelte'; + +let shown: { character: Record; target: HTMLElement } | null = null; + +function show(skin: Robot | null, mood: 'idle' | 'working' | 'calling' = 'idle'): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + shown = { character: mount(CompanionCharacter, { target, props: { mood, skin, lively: false } }), target }; + flushSync(); + return target.firstElementChild as HTMLElement; +} + +afterEach(() => { + if (shown) { + unmount(shown.character); + shown.target.remove(); + shown = null; + } +}); + +const robot = (family: Robot['family']): Robot => ({ family, shape: 1, color: 2, eyes: 1, top: 1 }); + +test('no skin and a box skin draw the box', () => { + expect(show(null).classList.contains('char')).toBe(true); + unmount(shown!.character); + shown!.target.remove(); + expect(show(robot('box')).classList.contains('char')).toBe(true); +}); + +for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + test(`a ${family} skin draws the ${family} character, with the mood's face`, () => { + const root = show(robot(family), 'working'); + expect(root.classList.contains(family)).toBe(true); + expect(root.classList.contains('f-working')).toBe(true); + expect(root.querySelector(`[data-family='${family}']`)).not.toBeNull(); + expect(root.getAttribute('role')).toBe('img'); + }); +} + +test('a calling robot shows its mark', () => { + const root = show(robot('retro'), 'calling'); + expect(root.classList.contains('f-calling')).toBe(true); + expect(root.querySelector('.shout')?.textContent).toBe('!'); +}); diff --git a/src/components/desktop/DesktopCompanion.svelte b/src/components/desktop/DesktopCompanion.svelte index 7399ddc..d623a09 100644 --- a/src/components/desktop/DesktopCompanion.svelte +++ b/src/components/desktop/DesktopCompanion.svelte @@ -11,7 +11,7 @@ import { PanelTop } from '@lucide/svelte'; import type { Companion } from '../../lib/companion/companion.svelte'; import { writeCompanionPrefs } from '../../lib/companion/prefs'; - import { inShell, showMain, type CompanionLayout } from '../../lib/companion/shell'; + import { inShell, openSettings, showMain, type CompanionLayout } from '../../lib/companion/shell'; import { strings } from '../../lib/strings'; import CompanionAsk from '../companion/CompanionAsk.svelte'; import CompanionAttachments from '../companion/CompanionAttachments.svelte'; @@ -128,7 +128,7 @@ onask={(screen) => void companion.ask(screen)} onscope={(screenScope) => (companion.prefs = writeCompanionPrefs({ screenScope }))} onstop={() => void companion.active.talk.stop()} - onsettings={() => void showMain(null)} + onsettings={() => void openSettings().catch(() => {})} to={companion.members.length > 1 ? companion.active.name : null} /> diff --git a/src/components/island/Island.svelte b/src/components/island/Island.svelte index 8397a3d..90727a4 100644 --- a/src/components/island/Island.svelte +++ b/src/components/island/Island.svelte @@ -12,6 +12,9 @@ deck's tabs and the island's controls, and the tab chosen under it, the requests waiting and the memory warning laid above it. + Open, the settings grow out of the deck under its bar + (`settings/SettingsPanel.svelte`), over the tab, longer than the deck. + Beside the pill and the peek hang two drops: the timer on the left while one runs, the music on the right (`music/MusicBubble.svelte`). Each is born from the island's edge on a black neck that snaps, and is taken back the @@ -28,7 +31,8 @@ --> + +{machine} + + diff --git a/src/components/island/assistant/AgentMenu.svelte b/src/components/island/assistant/AgentMenu.svelte index 85faa5f..9aff6a8 100644 --- a/src/components/island/assistant/AgentMenu.svelte +++ b/src/components/island/assistant/AgentMenu.svelte @@ -1,7 +1,8 @@ @@ -71,20 +74,22 @@ > {agent.name} + {#if assistant.machine}{/if} {#if open} diff --git a/src/components/island/assistant/AssistantChat.svelte b/src/components/island/assistant/AssistantChat.svelte index 862ee2f..96c95e8 100644 --- a/src/components/island/assistant/AssistantChat.svelte +++ b/src/components/island/assistant/AssistantChat.svelte @@ -29,6 +29,7 @@ const copy = strings.island.assistant; let list = $state(null); + let head = $state(null); let field = $state(null); /** The reader is at the end: what streams in keeps it there. */ let pinned = true; @@ -112,18 +113,18 @@ {#if nobody}

{copy.noAgent}

- +
{:else} -
+
{#if agent} - + head} /> - - {#if open} - - {/if} -
+{#if store} +
+ (pickerOpen = shown)} onpick={(patch) => void assistant.setModel(patch.model, patch)} /> + {#if levels.length > 0} + (effortOpen = shown)} onpick={(id) => void assistant.setEffort(id)} /> + {/if} +
+{/if} diff --git a/src/components/island/deck/DeckBar.svelte b/src/components/island/deck/DeckBar.svelte index f28b668..5e3134f 100644 --- a/src/components/island/deck/DeckBar.svelte +++ b/src/components/island/deck/DeckBar.svelte @@ -3,7 +3,8 @@ count of requests waiting on it), the things the user keeps in the bar (`lib/island/bar.ts`: the deck's tabs, an app or a web link) as a tablist whose focus moves with the arrows, Home and End, and the island's controls - on the right: the quotas, the pin, Settings and the sound. + on the right: the quotas, the pin, Settings (the panel under the bar, + `settings/SettingsPanel.svelte`) and the sound. In edit mode (`frame.editing`, asked from Settings or by a right-click on the bar), each thing wiggles a little: a drag moves it along its row @@ -18,13 +19,13 @@ import { Check, Gauge, Globe, Pin, PinOff, Settings, Volume2, VolumeX, X } from '@lucide/svelte'; import type { Companion, Member } from '../../../lib/companion/companion.svelte'; import { writeCompanionPrefs } from '../../../lib/companion/prefs'; - import { openSettings } from '../../../lib/companion/shell'; import { barTabs, canRemove, LABEL_MAX, linkHost, moveItem, moveTool, removeItem, removeTool, renameItem, shownTab, writeBarEdit, type BarItem, type BarTool } from '../../../lib/island/bar'; import { appIcons as icons, type Bar } from '../../../lib/island/bar.svelte'; import { RowSorter } from '../../../lib/island/bar-sort.svelte'; import { tabKey, type DeckTab } from '../../../lib/island/deck-tabs'; import { gazeOf, moodOf } from '../../../lib/island/face'; import type { IslandFrame } from '../../../lib/island/frame.svelte'; + import { settingsPanel } from '../../../lib/island/settings-panel.svelte'; import { openApp, openLink } from '../../../lib/windows/native'; import { fill, strings } from '../../../lib/strings'; import CompanionCharacter from '../../companion/CompanionCharacter.svelte'; @@ -389,7 +390,7 @@ {copy.deck.bar.done} {:else} - {/if} diff --git a/src/components/island/deck/DeckTile.svelte b/src/components/island/deck/DeckTile.svelte index 67160e4..c44d1bb 100644 --- a/src/components/island/deck/DeckTile.svelte +++ b/src/components/island/deck/DeckTile.svelte @@ -69,10 +69,12 @@ scrollbar-width: thin; scrollbar-color: var(--island-line-strong) transparent; } - /* The island's cards, drawn inside a tile: the tile is their box. */ + /* The island's cards, drawn inside a tile: the tile is their box, and the surface what fades over them. */ .content :global(.island-card) { + --island-card: var(--deck-tile); padding: 0; border: none; background: none; + box-shadow: none; } diff --git a/src/components/island/music/MusicBubble.svelte b/src/components/island/music/MusicBubble.svelte index 05b88eb..6a406ac 100644 --- a/src/components/island/music/MusicBubble.svelte +++ b/src/components/island/music/MusicBubble.svelte @@ -1,7 +1,7 @@ + +
+
+ + {copy.model}{#if copy.modelHint}{/if} + + save(next)} /> +
+ save({ control: id as CompanionControl })} testid="companion-control" /> + + {#if prefs.control === 'auto'}

{copy.controlsHint.auto}

{/if} +
+{#if warmMinutes !== null} +

{warmMinutes > 0 ? fill(copy.warmOn, { minutes: String(warmMinutes) }) : copy.warmOff}

+{/if} + + diff --git a/src/components/island/settings/FocusSection.svelte b/src/components/island/settings/FocusSection.svelte new file mode 100644 index 0000000..7898a39 --- /dev/null +++ b/src/components/island/settings/FocusSection.svelte @@ -0,0 +1,28 @@ + + + +
+ save({ workMinutes: Number(id) })} testid="companion-work-minutes" /> + save({ breakMinutes: Number(id) })} testid="companion-break-minutes" /> + save({ focusOnWork })} testid="companion-focus-on-work" /> +
diff --git a/src/components/island/settings/IslandSection.svelte b/src/components/island/settings/IslandSection.svelte new file mode 100644 index 0000000..1beb002 --- /dev/null +++ b/src/components/island/settings/IslandSection.svelte @@ -0,0 +1,101 @@ + + + +
+ save({ music })} testid="companion-music" /> + save({ lyrics })} testid="companion-lyrics" /> + save({ quotas })} testid="companion-quotas" /> + {#if prefs.quotas && gauges.length > 0} +
+ {#each gauges as gauge (gauge.id)} + + {/each} +
+ {/if} +
+ +
+ save({ closeOutside })} testid="companion-close-outside" /> + save({ hideFullscreen })} testid="companion-hide-fullscreen" /> + save({ sounds })} testid="companion-sounds" /> +
+ +
+ save({ awayRecap })} testid="companion-away-recap" /> + save({ radarMinutes: Number(id) })} testid="companion-radar-minutes" /> +
+ + diff --git a/src/components/island/settings/MascotSection.svelte b/src/components/island/settings/MascotSection.svelte new file mode 100644 index 0000000..57103e8 --- /dev/null +++ b/src/components/island/settings/MascotSection.svelte @@ -0,0 +1,31 @@ + + + +
+ save({ mascot: robot as Mascot })} /> + {#if prefs.mascot} + + {/if} +
+ + diff --git a/src/components/island/settings/PlaceSection.svelte b/src/components/island/settings/PlaceSection.svelte new file mode 100644 index 0000000..0501243 --- /dev/null +++ b/src/components/island/settings/PlaceSection.svelte @@ -0,0 +1,116 @@ + + + +
+
+ {copy.robots} +
+ {#each ROBOTS as robots (robots)} + + {/each} +
+
+ save({ monitor: id === PRIMARY ? null : id })} testid="companion-screen" /> + {#if prefs.robots === 'desktop'} + save({ anchor: id as CompanionAnchor })} testid="companion-position" /> + {/if} + save({ hotkey: id === OFF ? null : id })} testid="companion-hotkey" /> + {#if hotkeyTaken}{/if} +
+ +{#if prefs.robots === 'island'} +
+ save({ openOnHover })} testid="companion-open-on-hover" /> + save({ pillRest: id as PillRest })} testid="companion-pill-rest" /> +
+ {copy.bar} + + +
+
+{/if} + + diff --git a/src/components/island/settings/SettingPick.svelte b/src/components/island/settings/SettingPick.svelte new file mode 100644 index 0000000..9af14be --- /dev/null +++ b/src/components/island/settings/SettingPick.svelte @@ -0,0 +1,41 @@ + + + +
+ + {label}{#if hint}{/if} + + + {current} + +
+ + diff --git a/src/components/island/settings/SettingSwitch.svelte b/src/components/island/settings/SettingSwitch.svelte new file mode 100644 index 0000000..c1dccfa --- /dev/null +++ b/src/components/island/settings/SettingSwitch.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/src/components/island/settings/SettingsPanel.svelte b/src/components/island/settings/SettingsPanel.svelte new file mode 100644 index 0000000..27bb469 --- /dev/null +++ b/src/components/island/settings/SettingsPanel.svelte @@ -0,0 +1,379 @@ + + + + + + + + diff --git a/src/components/island/views/AttentionCard.svelte b/src/components/island/views/AttentionCard.svelte index 0c57276..df90602 100644 --- a/src/components/island/views/AttentionCard.svelte +++ b/src/components/island/views/AttentionCard.svelte @@ -9,6 +9,7 @@ --> + +{#if seats.length > 1} +
+ {#each seats as seat (seat.id)} + + {/each} +
+{/if} + + diff --git a/src/components/model-picker/CompanionModelPicker.svelte b/src/components/model-picker/CompanionModelPicker.svelte new file mode 100644 index 0000000..a7ef736 --- /dev/null +++ b/src/components/model-picker/CompanionModelPicker.svelte @@ -0,0 +1,68 @@ + + + +
+ save(companionPick('auto')) }} onpick={(patch: PickPatch) => save(companionPick(patch))} /> + {#if choice && levels.length > 0} + save({ effort: id })} /> + {/if} + {#if picker.error}{/if} +
+ + diff --git a/src/components/model-picker/EffortSlider.svelte b/src/components/model-picker/EffortSlider.svelte new file mode 100644 index 0000000..bc12bff --- /dev/null +++ b/src/components/model-picker/EffortSlider.svelte @@ -0,0 +1,343 @@ + + + + + +
+ + + {#if popover.shown} + + {/if} +
+ + diff --git a/src/components/model-picker/ModelPicker.svelte b/src/components/model-picker/ModelPicker.svelte new file mode 100644 index 0000000..f137c52 --- /dev/null +++ b/src/components/model-picker/ModelPicker.svelte @@ -0,0 +1,818 @@ + + + + + +
+ {#if !choice && !auto && store.owner && store.opensSettings} + + + {:else} + + {/if} + + {#if popover.shown} + + {/if} + {#if popover.shown && legacy.shown && !favoritesOpen && !autoOpen && !pending && filteredLegacy.length > 0} + + {/if} +
+ + diff --git a/src/components/model-picker/ProviderTiles.svelte b/src/components/model-picker/ProviderTiles.svelte new file mode 100644 index 0000000..3bcd026 --- /dev/null +++ b/src/components/model-picker/ProviderTiles.svelte @@ -0,0 +1,159 @@ + + + +{#if auto} + +{/if} + +{#each tiles as tile (tile.provider.id)} + +{/each} +{#if onmore && listed.length < store.providers.length} + +{/if} + + diff --git a/src/lib/companion/beats.test.ts b/src/lib/companion/beats.test.ts new file mode 100644 index 0000000..6c07023 --- /dev/null +++ b/src/lib/companion/beats.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from 'vitest'; +import { Beats, type Clock } from './beats'; + +/** A clock the test moves by hand, with the timers it holds. */ +function fakeClock() { + let now = 0; + let timers: { at: number; callback: () => void; handle: number }[] = []; + let handles = 0; + const clock: Clock = { + now: () => now, + set: (callback, ms) => { + timers.push({ at: now + ms, callback, handle: ++handles }); + return handles; + }, + clear: (handle) => (timers = timers.filter((timer) => timer.handle !== handle)) + }; + const advance = (ms: number) => { + const end = now + ms; + for (;;) { + const due = timers.filter((timer) => timer.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!due) break; + timers = timers.filter((timer) => timer !== due); + now = due.at; + due.callback(); + } + now = end; + }; + return { clock, advance, pending: () => timers.length }; +} + +/** When the mark was on, over `ms` read every 10 ms. */ +function marks(node: Element, name: string, advance: (ms: number) => void, ms: number): number[] { + const on: number[] = []; + for (let at = 0; at < ms; at += 10) { + advance(10); + if (node.hasAttribute(`data-beat-${name}`)) on.push(at); + } + return on; +} + +test('a gesture shows for its length, about every period, with one timer for every robot', () => { + const { clock, advance, pending } = fakeClock(); + const beats = new Beats(clock, () => 0.5, () => true); + const robots = [document.createElement('div'), document.createElement('div'), document.createElement('div')]; + robots.forEach((robot) => beats.add(robot, { name: 'blink', every: 5000, length: 350 })); + expect(pending()).toBe(1); + const on = marks(robots[0]!, 'blink', advance, 20_000); + // Four blinks in twenty seconds, each 350 ms long. + const starts = on.filter((at, index) => index === 0 || at - on[index - 1]! > 10); + expect(starts).toHaveLength(4); + expect(on.length).toBeGreaterThanOrEqual(4 * 34); + expect(on.length).toBeLessThanOrEqual(4 * 36); + expect(pending()).toBe(1); +}); + +test('the rhythm drifts within its jitter, never in step across robots', () => { + const { clock, advance } = fakeClock(); + let seed = 1; + const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); + const beats = new Beats(clock, random, () => true); + const [a, b] = [document.createElement('div'), document.createElement('div')]; + beats.add(a, { name: 'blink', every: 5000, length: 350 }); + beats.add(b, { name: 'blink', every: 5000, length: 350 }); + const onA = marks(a, 'blink', advance, 60_000); + const starts = onA.filter((at, index) => index === 0 || at - onA[index - 1]! > 10); + const gaps = starts.slice(1).map((at, index) => at - starts[index]!); + for (const gap of gaps) { + expect(gap).toBeGreaterThanOrEqual(4000 - 20); + expect(gap).toBeLessThanOrEqual(6000 + 20); + } +}); + +test('nothing starts while gestures may not, an inactive face, or after the stop', () => { + const { clock, advance, pending } = fakeClock(); + let may = false; + const beats = new Beats(clock, () => 0.5, () => may); + const node = document.createElement('div'); + const handle = beats.add(node, { name: 'blink', every: 1000, length: 100 }); + expect(marks(node, 'blink', advance, 5000)).toHaveLength(0); + may = true; + handle.update({ name: 'blink', every: 1000, length: 100, active: false }); + expect(marks(node, 'blink', advance, 5000)).toHaveLength(0); + handle.update({ name: 'blink', every: 1000, length: 100 }); + expect(marks(node, 'blink', advance, 5000).length).toBeGreaterThan(0); + handle.stop(); + expect(node.hasAttribute('data-beat-blink')).toBe(false); + expect(pending()).toBe(0); +}); diff --git a/src/lib/companion/beats.ts b/src/lib/companion/beats.ts new file mode 100644 index 0000000..deaefe8 --- /dev/null +++ b/src/lib/companion/beats.ts @@ -0,0 +1,167 @@ +/* + * The robots' occasional gestures (a blink, a flicker, a twitch of the + * antenna), started by one timer for every robot on the page. + * + * A blink every five seconds written as an endless CSS animation holds + * still 93 % of its cycle, yet keeps the compositor awake on every frame of + * the display to find nothing changed. Here a gesture is a short animation + * that runs only while it shows: `beat` marks the element with + * `data-beat-` for `length` ms, and the stylesheet plays the gesture + * once on that mark. One timer serves every robot, each at its own jittered + * rhythm around `every`. Nothing starts while the window is out of sight + * (`data-dormant`, `App.svelte`), under reduced motion, or for an element + * off screen (`onscreen`). + */ + +export interface BeatOptions { + /** The gesture, in the mark's name: `data-beat-`. */ + name: string; + /** About how often, in ms. */ + every: number; + /** How long the gesture plays, in ms. */ + length: number; + /** The share of `every` the rhythm drifts by, either way. */ + jitter?: number; + /** Off, the gesture never starts (a face whose eyes are shut). */ + active?: boolean; +} + +interface Entry { + node: Element; + options: BeatOptions; + /** When it next starts, or ends while it plays. */ + at: number; + playing: boolean; +} + +/** The page's clock and timer, a test's in tests. */ +export interface Clock { + now(): number; + set(callback: () => void, ms: number): unknown; + clear(handle: unknown): void; +} + +const pageClock: Clock = { + now: () => performance.now(), + set: (callback, ms) => setTimeout(callback, ms), + clear: (handle) => clearTimeout(handle as ReturnType) +}; + +/** Whether gestures may start: the window in sight and motion allowed. */ +function lively(): boolean { + if (typeof document === 'undefined') return false; + const root = document.documentElement; + if (root.hasAttribute('data-dormant') || root.dataset.motion === 'reduced') return false; + return !(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches); +} + +export class Beats { + private readonly entries = new Set(); + private timer: unknown = null; + private timerAt = Infinity; + + constructor( + private readonly clock: Clock = pageClock, + private readonly random: () => number = Math.random, + private readonly may: (node: Element) => boolean = (node) => lively() && !offscreen.has(node) + ) {} + + /** Starts `node`'s gesture now and then, until the returned stop. */ + add(node: Element, options: BeatOptions): { update(options: BeatOptions): void; stop(): void } { + // A random first time, as each endless animation started when its robot came: robots never blink together. + const entry: Entry = { node, options, at: this.clock.now() + this.random() * options.every, playing: false }; + this.entries.add(entry); + this.arm(); + return { + update: (next) => { + entry.options = next; + if (next.active === false) this.end(entry); + }, + stop: () => { + this.end(entry); + this.entries.delete(entry); + this.arm(); + } + }; + } + + private next(options: BeatOptions): number { + const jitter = options.jitter ?? 0.2; + return options.every * (1 - jitter + 2 * jitter * this.random()); + } + + private end(entry: Entry): void { + if (!entry.playing) return; + entry.playing = false; + entry.node.removeAttribute(`data-beat-${entry.options.name}`); + } + + private tick = (): void => { + this.timer = null; + this.timerAt = Infinity; + const now = this.clock.now(); + for (const entry of this.entries) { + if (entry.at > now) continue; + if (entry.playing) { + this.end(entry); + entry.at = now + Math.max(0, this.next(entry.options) - entry.options.length); + } else if (entry.options.active !== false && this.may(entry.node)) { + entry.playing = true; + entry.node.setAttribute(`data-beat-${entry.options.name}`, ''); + entry.at = now + entry.options.length; + } else entry.at = now + this.next(entry.options); + } + this.arm(); + }; + + /** One timer, set for the soonest start or end. */ + private arm(): void { + let soonest = Infinity; + for (const entry of this.entries) soonest = Math.min(soonest, entry.at); + if (soonest === this.timerAt) return; + if (this.timer !== null) this.clock.clear(this.timer); + this.timer = null; + this.timerAt = soonest; + if (soonest !== Infinity) this.timer = this.clock.set(this.tick, Math.max(0, soonest - this.clock.now())); + } +} + +/** The page's beats: one timer for every robot. */ +export const beats = new Beats(); + +/** A Svelte action: `use:beat={{ name: 'blink', every: 5000, length: 350 }}`. */ +export function beat(node: Element, options: BeatOptions): { update(options: BeatOptions): void; destroy(): void } { + const handle = beats.add(node, options); + return { update: handle.update, destroy: handle.stop }; +} + +/** The elements out of view, by `onscreen`'s one observer. */ +const offscreen = new WeakSet(); +let observer: IntersectionObserver | null = null; + +/** + * A Svelte action: while the element is out of view (scrolled away in a + * list, clipped by the island), it is marked `data-offscreen`, its endless + * animations wait (`bots.css`) and its gestures do not start. + */ +export function onscreen(node: Element): { destroy(): void } { + if (typeof IntersectionObserver === 'undefined') return { destroy() {} }; + observer ??= new IntersectionObserver((seen) => { + for (const { target, isIntersecting } of seen) { + if (isIntersecting) { + offscreen.delete(target); + target.removeAttribute('data-offscreen'); + } else { + offscreen.add(target); + target.setAttribute('data-offscreen', ''); + } + } + }); + observer.observe(node); + return { + destroy() { + observer?.unobserve(node); + offscreen.delete(node); + } + }; +} diff --git a/src/lib/companion/character.test.ts b/src/lib/companion/character.test.ts new file mode 100644 index 0000000..0913087 --- /dev/null +++ b/src/lib/companion/character.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { characterOf, faceState, markOf } from './character'; + +test('each skin is drawn by its family, and no skin by the classic box', () => { + for (const family of ['box', 'jelly', 'bubble', 'capsule', 'retro'] as const) { + expect(characterOf({ family, shape: 0, color: 0, eyes: 0, top: 0 })).toBe(family); + } + expect(characterOf(null)).toBe('box'); + expect(characterOf(undefined)).toBe('box'); +}); + +test('the drawing blinks at rest, glances at work, hops when it calls, keeps still with its eyes shut', () => { + expect(faceState('awake')).toBe('idle'); + expect(faceState('happy')).toBe('idle'); + expect(faceState('working')).toBe('working'); + expect(faceState('calling')).toBe('waiting'); + expect(faceState('alarm')).toBe('waiting'); + for (const face of ['sleep', 'calm', 'rest', 'startled', 'yawn'] as const) expect(faceState(face)).toBe('still'); +}); + +test('a z asleep, a ! when it calls, sparks for joy, nothing otherwise', () => { + expect(markOf('sleep')).toBe('z'); + expect(markOf('calling')).toBe('shout'); + expect(markOf('alarm')).toBe('shout'); + expect(markOf('cheer')).toBe('spark'); + expect(markOf('happy')).toBe('spark'); + expect(markOf('working')).toBeNull(); +}); diff --git a/src/lib/companion/character.ts b/src/lib/companion/character.ts new file mode 100644 index 0000000..4400139 --- /dev/null +++ b/src/lib/companion/character.ts @@ -0,0 +1,56 @@ +/* + * What every animated companion shares, whatever its family: the face + * `CompanionCharacter` works out from the mood and its flags, the props it + * hands a family's character (`JellyCharacter`, `BubbleCharacter`, + * `CapsuleCharacter`, `RetroCharacter`), the state its `RobotFace` drawing + * takes, and which character draws a skin. The box is `CompanionCharacter`'s + * own drawing. + */ +import type { Robot, RobotFamily } from '../robots'; + +/** What the companion shows it feels now. */ +export type Face = 'awake' | 'sleep' | 'yawn' | 'groove' | 'typing' | 'working' | 'calling' | 'happy' | 'worried' | 'alarm' | 'calm' | 'rest' | 'cheer' | 'game' | 'startled'; +/** An idle gesture now and then. */ +export type Act = 'look' | 'blink' | 'peek' | 'stretch' | 'yawn'; +/** The light's colour: the mood's, or a calm one. */ +export type Tone = 'idle' | 'working' | 'calling' | 'happy' | 'worried'; +/** How `RobotFace` moves on its own: a blink, a glance, a hop, or nothing. */ +export type FaceState = 'idle' | 'working' | 'waiting' | 'still'; + +/** What `CompanionCharacter` hands the character of a family other than the box. */ +export interface CharacterProps { + robot: Robot; + face: Face; + tone: Tone; + /** Where the eyes look, each axis from -1 to 1. */ + look: { x: number; y: number }; + /** Poked: a hop. */ + bouncing: boolean; + /** Poked too often: a wobble, eyes spinning. */ + dizzy: boolean; + act: Act | null; + size: number; + label: string; +} + +/** The faces with the eyes shut or nearly: the drawing keeps still, so the character can close them. */ +const SHUT: ReadonlySet = new Set(['sleep', 'calm', 'rest', 'startled', 'yawn']); + +/** The state `RobotFace` takes for a face: it blinks at rest, glances while working, hops while it calls. */ +export function faceState(face: Face): FaceState { + if (SHUT.has(face)) return 'still'; + if (face === 'working') return 'working'; + return face === 'calling' || face === 'alarm' ? 'waiting' : 'idle'; +} + +/** The character that draws a skin: its family's, or the classic box when there is none. */ +export function characterOf(skin: Robot | null | undefined): RobotFamily { + return skin?.family ?? 'box'; +} + +/** The mark beside the character: a z asleep, a ! when it calls, sparks for joy. */ +export function markOf(face: Face): 'z' | 'shout' | 'spark' | null { + if (face === 'sleep') return 'z'; + if (face === 'calling' || face === 'alarm') return 'shout'; + return face === 'happy' || face === 'cheer' ? 'spark' : null; +} diff --git a/src/lib/companion/companion-crew.test.ts b/src/lib/companion/companion-crew.test.ts index e4487ad..ff15dfd 100644 --- a/src/lib/companion/companion-crew.test.ts +++ b/src/lib/companion/companion-crew.test.ts @@ -6,7 +6,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import type { Account, AgentConversationMessage, AgentMemory, AgentProfile, AgentScope, AgentsSnapshot, ProviderSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot } from '../robots'; +import { BOX_COLORS, boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; import { strings } from '../strings'; import type { Brain } from './brain'; import { ROLE_START } from './brain'; @@ -15,7 +15,6 @@ import { Crew } from './crew.svelte'; import { addReminder, readMemory, readReminders, remember } from './memory'; import { Notes } from './notes.svelte'; import { readCompanionPrefs, writeCompanionPrefs } from './prefs'; -import { toBox } from './skin'; afterEach(() => { vi.useRealTimers(); @@ -65,33 +64,49 @@ const snapshotOf = (part: Partial) => ({ profiles: [], messages: // Looks // --------------------------------------------------------------------------- -test('an agent stands as a box: its own box, any other robot made one in its colour, or the box its id draws', () => { +test('an agent stands as its own robot, of any family, 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); - // A jelly robot keeps its colour (one up, past the classic body) and its top as the accessory. - expect(skinOf(profile('a', { avatar: encodeRobot({ family: 'jelly', shape: 1, color: 2, eyes: 1, top: 3 }) }))).toMatchObject({ family: 'box', color: 3, top: 3 }); - // No avatar: the robot the Agents page draws for its id, as a box. - expect(skinOf(profile('seeded'))).toEqual(toBox(seededRobot('seeded'))); + for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + 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')); expect(skinOf(profile('emoji', { avatar: '🦊' }))).toEqual(seededRobot('emoji', 'box')); }); -test('a fresh look takes a body colour nobody wears, never the classic one, and always an accessory', () => { - const worn = (color: number) => ({ family: 'box' as const, shape: 0, color, eyes: 0, top: 0 }); - for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) { - const others = [worn(0), worn(1), worn(2), worn(3)]; +test('a fresh look takes a family and a colour nobody wears, never the classic body, a box always with an accessory', () => { + const box = (color: number): Robot => ({ family: 'box', shape: 0, color, eyes: 0, top: 0 }); + const seeds = Array.from({ length: 40 }, (_, index) => `seed-${index}`); + for (const seed of seeds) { + const others = [box(0), box(1), box(2), box(3)]; const skin = robotOf('x', freshSkin(seed, others))!; - expect(skin.family).toBe('box'); - expect(skin.color).toBeGreaterThan(3); - expect(skin.color).toBeLessThan(BOX_COLORS); + expect(skin.family).not.toBe('box'); + expect(boxColorOf(skin)).toBeGreaterThan(3); + expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); + } + // Over many seeds, every family but the one worn comes up. + const drawn = new Set(seeds.map((seed) => robotOf('x', freshSkin(seed, [box(0)]))!.family)); + expect([...drawn].sort()).toEqual(['bubble', 'capsule', 'jelly', 'retro']); + // Every family worn: any family again, a box with an accessory on its lid. + const crowd: Robot[] = ROBOT_FAMILIES.map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 })); + const boxes = seeds.map((seed) => robotOf('x', freshSkin(seed, crowd))!).filter((skin) => skin.family === 'box'); + expect(boxes.length).toBeGreaterThan(0); + for (const skin of boxes) { + expect(skin.color).toBeGreaterThan(0); expect(skin.top).toBeGreaterThan(0); expect(skin.top).toBeLessThan(ROBOT_PARTS.box.top); - expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); } - const all = Array.from({ length: BOX_COLORS }, (_, color) => worn(color)); - expect(robotOf('x', freshSkin('crowded', all))!.color).toBeGreaterThan(0); - const lastFree = all.filter((robot) => robot.color !== 6); - expect(robotOf('x', freshSkin('one-left', lastFree))!.color).toBe(6); + // Every colour worn: still never the classic body. + const all = [...crowd, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color))]; + for (const seed of seeds) expect(boxColorOf(robotOf('x', freshSkin(seed, all))!)).toBeGreaterThan(0); + // One box colour left: a jelly takes it, as its own colour one below. + const jellies: Robot[] = ['box', 'bubble', 'capsule', 'retro'].map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 }) as Robot); + const lastFree = [...jellies, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color)).filter((robot) => robot.color !== 6)]; + const last = robotOf('x', freshSkin('one-left', lastFree))!; + expect(last).toMatchObject({ family: 'jelly', color: 5 }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/companion/companion.svelte.ts b/src/lib/companion/companion.svelte.ts index ba046d6..6068c8a 100644 --- a/src/lib/companion/companion.svelte.ts +++ b/src/lib/companion/companion.svelte.ts @@ -148,9 +148,10 @@ export class Companion { readonly assistant: Assistant = new Assistant({ client: () => this.client, snapshot: () => this.crew.snapshot, + elsewhere: () => this.crew.elsewhere, reload: () => void this.crew.load(), remembered: () => this.prefs.assistantAgent, - remember: (agentId) => void (this.prefs = writeCompanionPrefs({ assistantAgent: agentId })), + remember: (key) => void (this.prefs = writeCompanionPrefs({ assistantAgent: key })), fallback: () => this.crew.members[0]?.id ?? null, outgoing: () => joinScreen(this.attached.held, this.shot), keep: async (files) => { @@ -202,7 +203,7 @@ export class Companion { readonly senses: Senses; // With no agent yet, a lone character stands, and says so when asked. The - // first one wears the mascot chosen in Settings, else its own box. + // first one wears the mascot chosen in Settings, else its own robot. readonly members = $derived.by(() => this.crew.members.length > 0 ? this.crew.members.map((agent, index) => ({ id: agent.id, name: agent.name, skin: (index === 0 && this.prefs.mascot) || skinOf(agent), talk: this.talkOf(agent.id) })) @@ -730,13 +731,16 @@ export class Companion { let assistantAgent: string | undefined; const initialize = async () => { let client: Client; - // The other machines of the group, as they come and go: their threads on the next read. - const changed = () => this.soon(0); + // The other machines of the group, as they come and go: their threads and agents on the next read. + const changed = () => { + this.soon(0); + this.crew.changed(); + }; if (fake) { const { FakeClient } = await import('../fake-client'); - const { demoGroup } = await import('../fake-client/machines-demo'); + const { demoGroup, DEMO_MACHINES } = await import('../fake-client/machines-demo'); assistantAgent = (await import('../fake-client/assistant-demo')).ASSISTANT_DEMO_AGENT; - const local = new FakeClient({ hudDemo: true, sessionsDemo: true, assistantDemo: true }); + const local = new FakeClient({ hudDemo: true, sessionsDemo: true, assistantDemo: true, coreName: DEMO_MACHINES.local }); client = await demoGroup(local, new URLSearchParams(location.search).get('machines'), changed); this.notice = strings.bots.demo; } else { diff --git a/src/lib/companion/crew.svelte.ts b/src/lib/companion/crew.svelte.ts index b83297b..cb8c4c0 100644 --- a/src/lib/companion/crew.svelte.ts +++ b/src/lib/companion/crew.svelte.ts @@ -6,6 +6,10 @@ * whose directives (`directives.ts`) are carried out once: the facts go to the * agent's own memory, the reminders to this computer, the rest to the page. * + * The other machines' agents are read too, after this machine's and without + * waiting on them (`group/agents.ts`): the island's assistant lists them. The + * companion's row, its roles and its memory stay this machine's. + * * The companion keeps its role in each of its agents' instructions * (`withRole`), with Boite's projects, and the first time it makes an agent of * its own, Bots, which takes over the facts the companion kept on this @@ -13,6 +17,8 @@ */ import type { AgentConversationMessage, AgentProfile, AgentsSnapshot } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; +import { GroupClient } from '../group/group-client'; +import { mergeElsewhere, type MachineAgents } from '../group/agents'; import { fill, strings } from '../strings'; import { COMPANION_DOMAIN, permissionModeOf, pickBrain, ROLE_START, roleBlock, settleModel, withoutRole, withRole } from './brain'; import { CLASSIC_SKIN, companionAgent, FIRST_AGENT_NAME, lastMessageAt, liveMemories, membersOf, memoriesToForget, memoryToKeep, needsFirstAgent, profileWith, repliesAfter, sessionThreadOf } from './crew'; @@ -70,6 +76,8 @@ export async function releaseAgent(client: Client, profile: AgentProfile): Promi export class Crew { snapshot = $state.raw(null); + /** The agents of the group's other connected machines, in roster order. */ + elsewhere = $state.raw([]); /** Why the companion has no agent of its own yet: none could run, or making it failed. */ problem = $state(null); @@ -117,6 +125,8 @@ export class Crew { return; } this.loading = true; + // Asked at the same time and never waited for: a slow member does not hold this machine's list. + if (client instanceof GroupClient) void this.readElsewhere(client); try { const snapshot = await client.call('agents.snapshot', {}); if (this.disposed) return; @@ -137,6 +147,15 @@ export class Crew { if (!this.disposed) this.host.loaded(); } + /** The other machines' agents; a member that does not answer in time keeps its last list while connected. */ + private async readElsewhere(client: GroupClient): Promise { + const answers = await client.elsewhere('agents.snapshot', {}); + if (this.disposed) return; + const read = answers.map(({ core, result }) => ({ coreId: core.coreId, machine: core.name, snapshot: result })); + this.elsewhere = mergeElsewhere(this.elsewhere, read, client.machines().filter((machine) => machine.state === 'ready')); + this.host.loaded(); + } + /** * The first agent tried again now, while none stands: the user asked, or an * account or a provider changed. At startup the core may still be checking diff --git a/src/lib/companion/crew.ts b/src/lib/companion/crew.ts index 2ee78bc..d9b5a71 100644 --- a/src/lib/companion/crew.ts +++ b/src/lib/companion/crew.ts @@ -5,34 +5,46 @@ * to it. Pure, so it is tested without a core. */ import type { AgentConversationMessage, AgentMemory, AgentProfile, AgentSave, AgentScope, AgentsSnapshot, AgentWork } from '@boite/contracts'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; +import { boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, robotOf, seededRobot, type Robot } from '../robots'; import type { Brain } from './brain'; import { namesFact, normalized } from './memory'; -import { toBox, type BoxRobot } from './skin'; /** The name of the agent the companion makes for itself the first time. */ 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 }); -/** The box an agent stands as: its own robot made a box, keeping its colour, or the box its id draws. */ -export function skinOf(profile: Pick): BoxRobot { - return toBox(robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box')); +/** 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. */ +export function skinOf(profile: Pick): Robot { + return robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box'); +} + +/** A number from 0 to n - 1, the same for a seed on every client. */ +function pickFor(seed: string, n: number): number { + let hash = 2166136261; + for (const char of seed) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619); + return (hash >>> 0) % n; } /** - * A look to tell an agent apart from `others`, as an avatar: the box `seed` - * draws, on a body colour none of them wears while one is left (never the - * classic body, which the first agent keeps), with an accessory on its lid. + * A look to tell an agent apart from `others`, as an avatar: a robot of a + * family none of them is while one is left, any of the five otherwise, drawn + * from `seed`. Its colour, seen as a box's (`boxColorOf`), is one none of + * them wears while one is left, and never the classic body, which the first + * agent keeps; a box also gets an accessory on its lid. */ export function freshSkin(seed: string, others: Robot[]): string { - const drawn = seededRobot(seed, 'box'); - const worn = new Set(others.map((robot) => robot.color)); - const colors = Array.from({ length: BOX_COLORS - 1 }, (_, index) => index + 1); - const free = colors.filter((color) => !worn.has(color)); - const pool = free.length > 0 ? free : colors; - const top = drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; - return encodeRobot({ ...drawn, color: pool[drawn.color % pool.length]!, top }); + const taken = new Set(others.map((robot) => robot.family)); + const families = ROBOT_FAMILIES.filter((family) => !taken.has(family)); + const pool = families.length > 0 ? families : ROBOT_FAMILIES; + const family = pool[pickFor(seed, pool.length)]!; + const drawn = seededRobot(seed, family); + const worn = new Set(others.map(boxColorOf)); + const colors = Array.from({ length: robotColors(family) }, (_, color) => color).filter((color) => boxColorOf({ family, color }) !== 0); + const free = colors.filter((color) => !worn.has(boxColorOf({ family, color }))); + const choice = free.length > 0 ? free : colors; + const top = family === 'box' && drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; + return encodeRobot({ ...drawn, color: choice[drawn.color % choice.length]!, top }); } /** The collaboration tools a companion agent gets, as the Agents page gives a new agent. */ diff --git a/src/lib/companion/memory.ts b/src/lib/companion/memory.ts index 1c6507d..942065a 100644 --- a/src/lib/companion/memory.ts +++ b/src/lib/companion/memory.ts @@ -2,7 +2,7 @@ * What the companion keeps between conversations, on this computer only, in * `localStorage` beside its preferences: the facts it learnt about the user, * the reminders it was asked for, and how its shortcut fared, which the - * Settings page reads from the other window. Nothing of it goes to the core + * settings panel reads. Nothing of it goes to the core * except the facts, which ride in front of a conversation's first request. */ diff --git a/src/lib/companion/prefs.ts b/src/lib/companion/prefs.ts index 8f2807c..00a290a 100644 --- a/src/lib/companion/prefs.ts +++ b/src/lib/companion/prefs.ts @@ -10,7 +10,7 @@ * first one leading (`crew.svelte.ts`). The brain fields are what a companion * agent made from here runs on; each agent then keeps its own. */ -import { encodeRobot, parseRobot, type Robot, type RobotFamily } from '../robots'; +import { encodeRobot, parseRobot, ROBOT_FAMILIES, type Robot, type RobotFamily } from '../robots'; /** Along the top of the screen, or `free`: where the user dropped the character. */ export type CompanionAnchor = 'left' | 'center' | 'right' | 'free'; @@ -25,9 +25,9 @@ export type PillRest = 'live' | 'quota' | 'music' | 'timer' | 'robot'; export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const satisfies readonly PillRest[]; /** The character's centre, in fractions of the screen's work area. */ export interface CompanionSpot { x: number; y: number } -/** The families the island's mascot may come from: Boîte, the box, or a Jelly. */ -export const MASCOT_FAMILIES = ['box', 'jelly'] as const satisfies readonly RobotFamily[]; -export type Mascot = Robot & { family: (typeof MASCOT_FAMILIES)[number] }; +/** The families the island's mascot may come from: every robot Boite draws, and the box. */ +export const MASCOT_FAMILIES: readonly RobotFamily[] = ROBOT_FAMILIES; +export type Mascot = Robot; /** The shortcuts Settings offers, in the shell's notation (`platform/desktop.rs`). */ export const COMPANION_HOTKEYS = ['Alt+Shift+Space', 'Ctrl+Alt+Space', 'Ctrl+Shift+Space', 'Ctrl+Alt+B'] as const; @@ -41,7 +41,7 @@ export interface CompanionPrefs { openOnHover: boolean; /** What the pill shows at rest (`IslandBar.svelte`). */ pillRest: PillRest; - /** The island's character, the first agent's on the desktop; null is that agent's own box (Boîte by default). */ + /** The island's character, the first agent's on the desktop; null is that agent's own robot (Boîte, the classic box, by default). */ mascot: Mascot | null; anchor: CompanionAnchor; /** Where the character was dropped, with `anchor: 'free'`. */ @@ -138,11 +138,11 @@ function spotOf(value: unknown): CompanionSpot | null { return left === null || top === null ? null : { x: left, y: top }; } -/** A box or a jelly, each part in its range; anything else is no mascot. */ +/** A robot of a known family, each part in its range; anything else is no mascot. */ function mascotOf(value: unknown): Mascot | null { if (!value || typeof value !== 'object') return null; const { family, shape, color, eyes, top } = value as Record; - if (family !== 'box' && family !== 'jelly') return null; + if (!(MASCOT_FAMILIES as readonly unknown[]).includes(family)) return null; const parts = [shape, color, eyes, top]; if (!parts.every((part) => typeof part === 'number' && Number.isInteger(part) && part >= 0)) return null; const robot = parseRobot(encodeRobot({ family, shape, color, eyes, top } as Robot)); diff --git a/src/lib/companion/shell.ts b/src/lib/companion/shell.ts index 3d8c88a..c8d0652 100644 --- a/src/lib/companion/shell.ts +++ b/src/lib/companion/shell.ts @@ -5,6 +5,7 @@ */ import { reveal } from '../reveal'; import type { CompanionAnchor, CompanionSpot } from './prefs'; +import { requestSettings, type SettingsSection } from '../island/settings-panel.svelte'; export interface CompanionMonitor { name: string; @@ -115,20 +116,27 @@ export function bytesOf(data: string): Uint8Array { return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } -/** Opens Bots's settings window, or brings it forward. */ -export async function openSettings(): Promise { - await invoke('bots_settings'); +/** + * Opens Bots's settings, a panel of the island (`lib/island/settings-panel.svelte.ts`), + * on `section` when one is named. In the shell the window comes forward with + * the keyboard first (`bots_settings`), and answers with `companion://settings`. + */ +export async function openSettings(section?: SettingsSection): Promise { + if (!inShell()) return requestSettings(section); + await invoke('bots_settings', { section: section ?? null }).catch((error: unknown) => { + requestSettings(section); + throw error; + }); } -/** Shows a thread in Boite's window (`ui.reveal`); null opens Bots's settings. A failure is told, not thrown. */ -export async function showMain(threadId: string | null): Promise { - if (threadId === null) return openSettings(); +/** Shows a thread in Boite's window (`ui.reveal`). A failure is told, not thrown. */ +export async function showMain(threadId: string): Promise { await reveal({ kind: 'thread', threadId }); } -/** Shows an agent's conversation in Boite's Agents page (`ui.reveal`). */ -export async function showAgent(agentId: string): Promise { - await reveal({ kind: 'agent', agentId }); +/** Shows an agent's conversation in Boite's Agents page (`ui.reveal`); `coreId` names its machine when it is another of the group. */ +export async function showAgent(agentId: string, coreId: string | null = null): Promise { + await reveal(coreId === null ? { kind: 'agent', agentId } : { kind: 'agent', agentId, coreId }); } /** Opens Boite's app updates, where a newer Boite installs (`ui.reveal`). */ diff --git a/src/lib/companion/skin.ts b/src/lib/companion/skin.ts index 9b58439..f64161a 100644 --- a/src/lib/companion/skin.ts +++ b/src/lib/companion/skin.ts @@ -1,13 +1,14 @@ /* - * The companion's skin: the colours and the accessory a box robot gives the + * The box's skin: the colours and the accessory a box robot gives the * companion box, so several agents standing side by side as companions can be - * told apart. The skin is the agent's robot (`lib/robots.ts`, family `box`), - * kept in its profile's `avatar`, so the Agents page draws the same look. + * told apart. The skin is the agent's robot (`lib/robots.ts`), kept in its + * profile's `avatar`, so the Agents page draws the same look; a robot of + * another family is drawn by its own character (`lib/companion/character.ts`). * * What a caller needs: * - `seededRobot(seed, 'box')` (robots.ts): the box an id draws. - * - `isBox(robot)`: whether a robot is a box; `CompanionCharacter` draws the - * classic companion for anything else, or for no skin at all. + * - `isBox(robot)`: whether a robot is a box, which `CompanionCharacter` + * paints; no skin at all draws the classic companion. * - `toBox(robot)`: any robot as a box that keeps its colour and accessory slot. * - `boxPaint(robot)`: the CSS colours a box wears, for anything drawing it. * diff --git a/src/lib/companion/stepped.test.ts b/src/lib/companion/stepped.test.ts new file mode 100644 index 0000000..6662b99 --- /dev/null +++ b/src/lib/companion/stepped.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vitest'; +import { easeInOut, keyframes, LARGEST_PX, LOOPS, MAX_STEP_PX, poseAt, STEP_HZ, stepsOf, type Loop } from './stepped'; + +/** Where a point of the robot's square (fractions of its size) is drawn at `t`, in screen pixels at `LARGEST_PX`. */ +function placed(loop: Loop, t: number, [x, y]: [number, number]): [number, number] { + const pose = poseAt(loop, t); + const [ox, oy] = loop.origin; + // As CSS applies `rotate() translateY() scale()` round the origin: scale first, then the move, then the turn. + const sx = (x - ox) * pose.scaleX; + const sy = (y - oy) * pose.scaleY + pose.translateY / 100; + const angle = (pose.rotate * Math.PI) / 180; + return [(ox + sx * Math.cos(angle) - sy * Math.sin(angle)) * LARGEST_PX, (oy + sx * Math.sin(angle) + sy * Math.cos(angle)) * LARGEST_PX]; +} + +test('no step of a stepped loop moves any corner of the robot by a quarter pixel at its largest', () => { + const corners: [number, number][] = [[0, 0], [1, 0], [0, 1], [1, 1]]; + for (const loop of LOOPS) { + const steps = stepsOf(loop); + let most = 0; + for (let step = 0; step < steps; step++) { + for (const corner of corners) { + const [ax, ay] = placed(loop, step / steps, corner); + const [bx, by] = placed(loop, (step + 1) / steps, corner); + most = Math.max(most, Math.hypot(bx - ax, by - ay)); + } + } + expect(most, loop.name).toBeLessThan(MAX_STEP_PX); + } +}); + +test('a loop keeps its rhythm: STEP_HZ samples a second, through the poses it had smooth', () => { + for (const loop of LOOPS) { + expect(stepsOf(loop)).toBe((loop.ms / 1000) * STEP_HZ); + for (const key of loop.keys) { + const pose = poseAt(loop, key.at === 1 ? 0 : key.at); + expect(pose.scaleX).toBeCloseTo(key.pose.scaleX ?? 1, 6); + expect(pose.scaleY).toBeCloseTo(key.pose.scaleY ?? 1, 6); + expect(pose.rotate).toBeCloseTo(key.pose.rotate ?? 0, 6); + } + } +}); + +test('the curve between two poses is CSS ease-in-out', () => { + expect(easeInOut(0)).toBeCloseTo(0, 6); + expect(easeInOut(0.5)).toBeCloseTo(0.5, 6); + expect(easeInOut(1)).toBeCloseTo(1, 6); + // cubic-bezier(0.42, 0, 0.58, 1) at a quarter of the time: about 0.129. + expect(easeInOut(0.25)).toBeCloseTo(0.1291, 3); +}); + +test('the keyframes name the loop and hold a pose per step', () => { + const css = keyframes(LOOPS[1]!); + expect(css.startsWith('@keyframes bots-box-breathe-4000{')).toBe(true); + expect(css.match(/%\{/g)).toHaveLength(stepsOf(LOOPS[1]!) + 1); + expect(css).toContain('50%{transform:rotate(0deg) translateY(0%) scale(1.012,1.03)}'); +}); diff --git a/src/lib/companion/stepped.ts b/src/lib/companion/stepped.ts new file mode 100644 index 0000000..dec1b82 --- /dev/null +++ b/src/lib/companion/stepped.ts @@ -0,0 +1,131 @@ +/* + * The slow loops a robot keeps up at rest (a breath, a doze), drawn in small + * steps instead of on every frame of the screen. + * + * A breath moves the robot by a pixel or two over seconds. Played as a + * smooth CSS animation, it makes the compositor draw the window on every + * frame of the display: 240 times a second on a 240 Hz screen, for a + * motion nobody could tell from a few dozen positions. Here each loop is + * sampled `STEP_HZ` times a second along its own eased curve, with the + * same rhythm and amplitude, and held between samples (`steps(1, end)`): + * the window is drawn `STEP_HZ` times a second. A loop is stepped only + * when no step moves any point of the robot by more than `MAX_STEP_PX` at + * the largest size it is drawn (`stepped.test.ts` checks it); larger + * motions (a hop, a float, the work) stay smooth. + * + * The keyframes are written once into the page (`useSteppedLoops`), named + * `bots---`; a character's stylesheet plays one with + * `animation: steps(1, end) infinite`. + */ + +/** How many times a second a stepped loop moves on. */ +export const STEP_HZ = 20; +/** The most a step may move a point of the robot, in screen pixels. */ +export const MAX_STEP_PX = 0.25; +/** The largest a stepped robot is drawn: the desktop's 64 px, on a screen scaled to 200 %. */ +export const LARGEST_PX = 128; + +/** One transform, in the units the characters' stylesheets use. */ +export interface Pose { + rotate?: number; + /** In percent of the robot's size. */ + translateY?: number; + scaleX?: number; + scaleY?: number; +} + +export interface Loop { + name: string; + ms: number; + /** Where the robot turns and scales round, in fractions of its square. */ + origin: [number, number]; + /** The keyframes, `at` from 0 to 1, eased in and out between each. */ + keys: { at: number; pose: Pose }[]; +} + +/** CSS's `ease-in-out`, `cubic-bezier(0.42, 0, 0.58, 1)`: progress at time `t`, both 0 to 1. */ +export function easeInOut(t: number): number { + const bezier = (u: number, a: number, b: number) => 3 * a * u * (1 - u) ** 2 + 3 * b * u ** 2 * (1 - u) + u ** 3; + let low = 0; + let high = 1; + for (let round = 0; round < 40; round++) { + const middle = (low + high) / 2; + if (bezier(middle, 0.42, 0.58) < t) low = middle; + else high = middle; + } + return bezier((low + high) / 2, 0, 1); +} + +const full = (pose: Pose): Required => ({ rotate: pose.rotate ?? 0, translateY: pose.translateY ?? 0, scaleX: pose.scaleX ?? 1, scaleY: pose.scaleY ?? 1 }); + +/** The loop's pose at `t` (0 to 1 of its cycle), along its eased curve. */ +export function poseAt(loop: Loop, t: number): Required { + const keys = loop.keys; + let index = keys.findIndex((key) => key.at > t); + if (index <= 0) index = keys.length - 1; + const from = keys[index - 1]!; + const to = keys[index]!; + const share = to.at === from.at ? 1 : easeInOut((t - from.at) / (to.at - from.at)); + const [a, b] = [full(from.pose), full(to.pose)]; + const mix = (x: number, y: number) => x + (y - x) * share; + return { rotate: mix(a.rotate, b.rotate), translateY: mix(a.translateY, b.translateY), scaleX: mix(a.scaleX, b.scaleX), scaleY: mix(a.scaleY, b.scaleY) }; +} + +/** How many samples the loop takes over its cycle. */ +export const stepsOf = (loop: Loop): number => Math.round((loop.ms / 1000) * STEP_HZ); + +const round = (value: number) => Number(value.toFixed(5)); + +/** The loop's keyframes, one per step. */ +export function keyframes(loop: Loop): string { + const steps = stepsOf(loop); + const frames: string[] = []; + for (let step = 0; step <= steps; step++) { + const pose = poseAt(loop, step / steps); + frames.push(`${round((step / steps) * 100)}%{transform:rotate(${round(pose.rotate)}deg) translateY(${round(pose.translateY)}%) scale(${round(pose.scaleX)},${round(pose.scaleY)})}`); + } + return `@keyframes ${loop.name}{${frames.join('')}}`; +} + +const breath = (family: string, ms: number, origin: [number, number], scaleX: number, scaleY: number): Loop => ({ + name: `bots-${family}-breathe-${ms}`, + ms, + origin, + keys: [ + { at: 0, pose: {} }, + { at: 0.5, pose: { scaleX, scaleY } }, + { at: 1, pose: {} } + ] +}); + +/** + * Every stepped loop, as each character's stylesheet had it smooth: the + * box's breath (`CompanionCharacter`), the Jelly's (`JellyCharacter`) and + * the Capsule's doze (`CapsuleCharacter`). + */ +export const LOOPS: Loop[] = [ + breath('box', 3000, [0.5, 0.88], 1.012, 1.03), + breath('box', 4000, [0.5, 0.88], 1.012, 1.03), + breath('box', 6000, [0.5, 0.88], 1.012, 1.03), + breath('jelly', 4000, [0.5, 1], 1.02, 1.04), + breath('jelly', 6000, [0.5, 1], 1.02, 1.04), + { + name: 'bots-capsule-nap-6000', + ms: 6000, + origin: [0.54, 0.96], + keys: [ + { at: 0, pose: { rotate: -9, translateY: 2 } }, + { at: 0.5, pose: { rotate: -7, translateY: 2, scaleX: 1.01, scaleY: 1.02 } }, + { at: 1, pose: { rotate: -9, translateY: 2 } } + ] + } +]; + +/** Writes the stepped loops' keyframes into the page, once. */ +export function useSteppedLoops(): void { + if (typeof document === 'undefined' || document.getElementById('bots-stepped-loops')) return; + const style = document.createElement('style'); + style.id = 'bots-stepped-loops'; + style.textContent = LOOPS.map(keyframes).join('\n'); + document.head.append(style); +} diff --git a/src/lib/core.svelte.ts b/src/lib/core.svelte.ts index a65ce2a..6bb877f 100644 --- a/src/lib/core.svelte.ts +++ b/src/lib/core.svelte.ts @@ -1,8 +1,8 @@ /* - * What Bots's settings window knows of Boite: its providers, accounts, + * What Bots's settings panel knows of Boite: its providers, accounts, * projects, settings and agents, read once connected and kept current from * the core's events. Boite's own `Store` does this for the whole app; the - * settings window needs only this much of it. + * settings panel needs only this much of it, on its own connection. * * `accountsOf` and `modelsOf` follow Boite's store (packages/ui/src/lib/store/ * accounts.svelte.ts and models.svelte.ts at c4a8b0f7), so the settings offer @@ -19,7 +19,7 @@ import { type ProviderSummary, type Settings } from '@boite/contracts'; -import { WsClient, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; +import { WsClient, type Client, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; import { waitForCore, watchEndpoint } from './connection'; const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; @@ -35,6 +35,8 @@ export class CoreLink { projects = $state.raw([]); settings = $state.raw(null); probedModels = $state.raw>({}); + /** The model reads running, by `providerId::accountId`: the model picker says it is reading. */ + probing = $state.raw([]); #off: (() => void)[] = []; #stopped = false; /** The model reads asked of the core, by agent and account, answered or not. */ @@ -49,19 +51,33 @@ export class CoreLink { async start(make: () => Promise = () => this.#open()): Promise { const client = await make(); if (!client || this.#stopped) return client?.close(); - this.client = client; - this.#off.push( - client.onState((state) => { + this.#off.push(this.follow(client)); + await client.connect().catch(() => {}); + } + + /** + * Keeps the lists current from a client someone else opened and closes: the + * island's, which the companion owns. Read at once when it is connected + * already, and again on each connection. Returns the release. + */ + follow(client: Client & { onState?: ObservableClient['onState'] }): () => void { + this.client = client as ObservableClient; + const offs = [ + client.onState?.((state) => { this.connection = state; if (state === 'ready') void this.load(); - }), + }) ?? (() => {}), client.on('providers.updated', ({ loaded }) => (this.providers = loaded)), client.on('accounts.updated', (account) => (this.accounts = [...this.accounts.filter((a) => a.id !== account.id), account])), client.on('accounts.removed', ({ accountId }) => (this.accounts = this.accounts.filter((a) => a.id !== accountId))), client.on('settings.updated', (settings) => (this.settings = settings)), client.on('providers.probed', ({ providerId, accountId, models }) => (this.probedModels = { ...this.probedModels, [probeKey(providerId, accountId)]: models })) - ); - await client.connect().catch(() => {}); + ]; + if (client.state === 'ready') { + this.connection = 'ready'; + void this.load(); + } + return () => offs.forEach((off) => off()); } async #open(): Promise { @@ -111,21 +127,38 @@ export class CoreLink { * Reads the models an agent lists with this account. Each read starts the * agent's executable for a moment, so it runs once per window, a failed one * included: account updates come with every quota change and must not start - * it again. + * it again. `refresh` asks the agent again, past the core's cache: the model + * picker's refresh button. */ - probeModels(providerId: ProviderId, accountId: string): Promise { + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { const client = this.client; const key = probeKey(providerId, accountId); - if (!client || this.probedModels[key]) return Promise.resolve(); const known = this.#probes.get(key); - if (known) return known; - const read = client.call('providers.probe', { providerId, accountId }).then(({ models }) => { - if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; - }); + if (!client || (!refresh && this.probedModels[key])) return Promise.resolve(); + if (known && (!refresh || this.probing.includes(key))) return known; + this.probing = [...this.probing, key]; + const read = client + .call('providers.probe', { providerId, accountId, ...(refresh ? { refresh: true } : {}) }) + .then(({ models }) => { + if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; + }) + .finally(() => (this.probing = this.probing.filter((entry) => entry !== key))); this.#probes.set(key, read); return read; } + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && this.probing.includes(probeKey(providerId, accountId)); + } + + /** Stores the starred models in the core's settings, where Boite reads them. */ + async saveSettings(patch: Partial & Record): Promise { + const client = this.client; + if (!client) return; + const settings = await client.call('settings.set', patch as Partial); + if (client === this.client) this.settings = settings; + } + stop(): void { this.#stopped = true; this.#off.forEach((off) => off()); diff --git a/src/lib/fake-client.ts b/src/lib/fake-client.ts index e490abc..c483fa1 100644 --- a/src/lib/fake-client.ts +++ b/src/lib/fake-client.ts @@ -34,7 +34,7 @@ import { seedHudDemo } from './fake-client/hud-demo'; import { seedAssistantDemo } from './fake-client/assistant-demo'; import { seedSessionsDemo } from './fake-client/sessions-demo'; import { seedAllowDemo } from './fake-client/allow-demo'; -import { seedServerDemo } from './fake-client/server-demo'; +import { seedMachineDemo } from './fake-client/server-demo'; import { groupMethods } from '@boite/ui/lib/fake-client/group'; import { pairingMethods } from '@boite/ui/lib/fake-client/pairing'; import { projectMethods } from '@boite/ui/lib/fake-client/projects'; @@ -78,8 +78,8 @@ export type BotsFakeOptions = FakeClientOptions & { hudDemo?: boolean; sessionsDemo?: boolean; assistantDemo?: boolean; - /** The other machine of the group's demo (`fake-client/server-demo.ts`); `ask` adds its request. */ - serverDemo?: 'on' | 'ask'; + /** Another machine of the group's demo (`fake-client/server-demo.ts`): the server, `ask` adding its request, or the laptop. */ + serverDemo?: 'on' | 'ask' | 'laptop'; }; /** @@ -119,7 +119,7 @@ export class FakeClient implements ObservableClient { if (options.assistantDemo) seedAssistantDemo(ctx, this.#methods); if (options.sessionsDemo) seedSessionsDemo(ctx); if (options.sessionsDemo && typeof location !== 'undefined') seedAllowDemo(ctx, this.#methods, new URLSearchParams(location.search)); - if (options.serverDemo) seedServerDemo(ctx, options.serverDemo === 'ask'); + if (options.serverDemo) seedMachineDemo(ctx, options.serverDemo === 'laptop' ? 'laptop' : 'server', options.serverDemo === 'ask'); if (options.uninstalled) { ctx.providers = ctx.providers.filter(provider => provider.id !== 'echo').map(provider => ({ ...provider, available: false, executable: null, diff --git a/src/lib/fake-client/assistant-demo.ts b/src/lib/fake-client/assistant-demo.ts index d2e5af4..2579482 100644 --- a/src/lib/fake-client/assistant-demo.ts +++ b/src/lib/fake-client/assistant-demo.ts @@ -59,7 +59,7 @@ function idle(model: Thread, over: Partial): Thread { return { ...structuredClone(model), status: 'idle', progress: null, runningSince: null, requestSince: null, doneAt: null, archived: false, unread: false, ...over } as Thread; } -function profile(id: string, name: string, domain: string, model: string | null, createdAt: number): AgentProfile { +export function profile(id: string, name: string, domain: string, model: string | null, createdAt: number): AgentProfile { return { id, revision: 1, createdAt, updatedAt: createdAt, name, domain, instructions: `You are ${name}. Answer briefly and plainly.`, @@ -75,7 +75,7 @@ function profile(id: string, name: string, domain: string, model: string | null, * The agents' rows, written in place: the fake only takes new ones through * calls that give them fresh ids and start a run for every message sent. */ -function rowsOf(ctx: FakeContext): Map { +export function rowsOf(ctx: FakeContext): Map { return (ctx.agents as unknown as { rows: Map }).rows; } @@ -145,6 +145,20 @@ export function seedAssistantDemo(ctx: FakeContext, methods: FakeMethods): void }); } + // Boite's starred models, kept in the core's settings where its picker and Bots's read them. + ctx.settings = { + ...ctx.settings, + modelFavorites: { + at: now, + items: [ + { providerId: 'claude', model: { id: 'claude-opus-5-5', name: 'Opus 5.5' }, account: 'Default login' }, + { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: 'Default login' }, + { providerId: 'opencode', model: { id: 'nvidia/gpt-oss-120b', name: 'GPT-OSS 120B' }, account: 'Default' }, + { providerId: 'opencode', model: { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5' }, account: 'Default' } + ] + } + } as typeof ctx.settings; + // Two subscriptions, the first one under a quarter left on its week. const quotas = (): AccountQuota[] => [ { diff --git a/src/lib/fake-client/machines-demo.ts b/src/lib/fake-client/machines-demo.ts index 56287b9..5d151ad 100644 --- a/src/lib/fake-client/machines-demo.ts +++ b/src/lib/fake-client/machines-demo.ts @@ -1,30 +1,58 @@ /** - * The group's demo, with `?fake=1&machines=1|ask`: a second fake core, - * « Serveur » (`server-demo.ts`), joins this one's group, so Home, the pill - * and the HUD show a thread of another machine (« Corriger le gameplay · - * Petites-Rives · Serveur »). With `ask`, that thread asks to push a moment - * after the island opens: the request shows here and is answered there. + * The group's demo, with `?fake=1&machines=1|ask|away`: two more fake cores, + * « boite » (the server) and « laptop » (`server-demo.ts`), join this one's + * group, « PC ». Home, the pill and the HUD show a thread of each (« Corriger + * le gameplay » on boite, « Write the install guide » on laptop), and the + * island's Chat lists their agents, Forge and Scout, beside this machine's. + * With `ask`, boite's thread asks to push a moment after the island opens: + * the request shows here and is answered there. With `away`, laptop never + * answers: it shows as offline in the deck's machines tile, and nothing of it + * waits on it. * - * Bots reaches the server the way it reaches a real member (`group.ticket`, + * Bots reaches each member the way it reaches a real one (`group.ticket`, * then a client of its own), except that the socket is the fake itself. */ import { GroupClient } from '../group/group-client'; import { memoryKeys } from '../group/member-keys'; import { FakeClient } from '../fake-client'; -const SERVER_CORE = 'fake-core-serveur'; +export const DEMO_MACHINES = { local: 'PC', server: 'boite', laptop: 'laptop' } as const; +export const SERVER_CORE = 'fake-core-boite'; +export const LAPTOP_CORE = 'fake-core-laptop'; +const SERVER_URL = 'http://100.64.0.2:8777'; +const LAPTOP_URL = 'http://100.64.0.3:8777'; + +/** Whether the page asks for the group's demo. */ +export function wantsMachines(machines: string | null): boolean { + return machines === '1' || machines === 'ask' || machines === 'away'; +} /** - * This fake core under the group client; with `machines`, grouped with the - * server first, the way two machines are: one makes the group and invites, - * the other joins. + * This fake core under the group client; with `machines`, grouped with + * boite and laptop first, the way machines are: one makes the group and + * invites, the others join. */ -export async function demoGroup(local: FakeClient, machines: string | null, changed: () => void): Promise { - if (machines !== '1' && machines !== 'ask') return new GroupClient(local, { keys: memoryKeys(), changed }); - const server = new FakeClient({ coreId: SERVER_CORE, coreName: 'Serveur', publicUrl: 'http://100.64.0.2:8777', hudDemo: false, serverDemo: machines === 'ask' ? 'ask' : 'on' }); - await Promise.all([local.connect(), server.connect()]); +export async function demoGroup(local: FakeClient, machines: string | null, changed: () => void, tests: { delayMs?: number; made?: FakeClient[] } = {}): Promise { + if (!wantsMachines(machines)) return new GroupClient(local, { keys: memoryKeys(), changed }); + const delay = tests.delayMs === undefined ? {} : { delayMs: tests.delayMs }; + const server = new FakeClient({ ...delay, coreId: SERVER_CORE, coreName: DEMO_MACHINES.server, publicUrl: SERVER_URL, hudDemo: false, serverDemo: machines === 'ask' ? 'ask' : 'on' }); + const laptop = new FakeClient({ ...delay, coreId: LAPTOP_CORE, coreName: DEMO_MACHINES.laptop, publicUrl: LAPTOP_URL, hudDemo: false, serverDemo: 'laptop' }); + // A test closes them, the laptop out of reach included. + tests.made?.push(server, laptop); + await Promise.all([local.connect(), server.connect(), laptop.connect()]); await local.call('group.create', { name: 'Maison' }); - const { invite } = await local.call('group.invite', {}); - await server.call('group.join', { invite }); - return new GroupClient(local, { keys: memoryKeys(), changed, open: () => server, reach: async (addresses) => addresses[0] ?? null }); + for (const member of [server, laptop]) { + const { invite } = await local.call('group.invite', {}); + await member.call('group.join', { invite }); + } + const byUrl = new Map([ + [SERVER_URL, server], + [LAPTOP_URL, laptop] + ]); + return new GroupClient(local, { + keys: memoryKeys(), + changed, + open: (link) => byUrl.get(link.url) ?? server, + reach: async (addresses) => (machines === 'away' && addresses.includes(LAPTOP_URL) ? null : (addresses[0] ?? null)) + }); } diff --git a/src/lib/fake-client/server-demo.ts b/src/lib/fake-client/server-demo.ts index 5f4a205..f1f8fde 100644 --- a/src/lib/fake-client/server-demo.ts +++ b/src/lib/fake-client/server-demo.ts @@ -1,74 +1,119 @@ /** - * The fake core « Serveur » of the group's demo (`machines-demo.ts`): in place - * of the fake's seed, one project, Petites-Rives, with a thread at work on its - * gameplay; with `ask`, that thread asks to push a moment after it starts. + * The fake cores of the other machines in the group's demo + * (`machines-demo.ts`): in place of the fake's seed, each has one project, a + * thread at work in it and an agent of its own. « boite », the server, fixes + * a game's gameplay (with `ask`, that thread asks to push a moment after it + * starts) and has Forge; « laptop » writes a site's docs and has Scout. */ import type { Message, MessagePart, PermissionRequest, Thread } from '@boite/contracts'; import type { FakeContext } from '@boite/ui/lib/fake-client/context'; +import { profile, rowsOf } from './assistant-demo'; export const SERVER_THREAD = 't-srv-gameplay'; export const SERVER_PROJECT = 'p-srv-rives'; +export const SERVER_AGENT = 'agent-forge'; +export const LAPTOP_THREAD = 't-lap-docs'; +export const LAPTOP_AGENT = 'agent-scout'; const ASK_AFTER_MS = 2_000; +/** What one machine of the demo runs: one project, one thread at work with its steps, one agent. */ +interface MachineSeed { + thread: string; + project: { id: string; name: string; path: string }; + title: string; + branch: string; + steps: [name: string, input: unknown, ago: number][]; + agent: { id: string; name: string; domain: string }; + /** How long the thread has been at work. */ + minutes: number; +} + +const SEEDS = { + server: { + thread: SERVER_THREAD, + project: { id: SERVER_PROJECT, name: 'Petites-Rives', path: '/srv/petites-rives' }, + title: 'Corriger le gameplay', + branch: 'fix/gameplay', + steps: [ + ['Read', { file_path: '/srv/petites-rives/Assets/Scripts/Player/PlayerController.cs' }, 80_000], + ['Grep', { pattern: 'jumpBuffer' }, 50_000], + ['Bash', { command: 'dotnet test Gameplay.Tests' }, 15_000] + ], + agent: { id: SERVER_AGENT, name: 'Forge', domain: 'Builds and deploys' }, + minutes: 5 + }, + laptop: { + thread: LAPTOP_THREAD, + project: { id: 'p-lap-site', name: 'mtsu.dev', path: '/home/demo/mtsu.dev' }, + title: 'Write the install guide', + branch: 'docs/install', + steps: [ + ['Read', { file_path: '/home/demo/mtsu.dev/docs/index.md' }, 60_000], + ['Edit', { file_path: '/home/demo/mtsu.dev/docs/install.md' }, 20_000] + ], + agent: { id: LAPTOP_AGENT, name: 'Scout', domain: 'Research on the go' }, + minutes: 2 + } +} satisfies Record; + type ToolPart = Extract; function tool(toolId: string, name: string, input: unknown, status: ToolPart['status'], at: number): ToolPart { return { type: 'tool', toolId, name, input, output: null, status, startedAt: at, finishedAt: status === 'running' ? null : at + 800 }; } -/** The server's own seed in place of the fake's: one project, one thread at work, and in `ask` a request on its way. */ -export function seedServerDemo(ctx: FakeContext, ask: boolean): void { +/** A machine's own seed in place of the fake's: `server` or `laptop`; with `ask`, the server's request on its way. */ +export function seedMachineDemo(ctx: FakeContext, machine: keyof typeof SEEDS, ask = false): void { + const spec: MachineSeed = SEEDS[machine]; const model = [...ctx.threads.values()].find((thread) => !thread.parentThreadId); const project = ctx.projects[0]; if (!model || !project) return; const now = Date.now(); - const started = now - 5 * 60_000 - 12_000; - const turnId = 'turn-srv-gameplay'; - const cwd = '/srv/petites-rives'; + const started = now - spec.minutes * 60_000 - 12_000; + const turnId = `turn-${spec.thread}`; ctx.threads.clear(); ctx.pendingPermissions.clear(); ctx.pendingQuestions.clear(); ctx.importable = []; ctx.scheduler = { ...ctx.scheduler, running: [], queued: [] }; - ctx.projects = [{ ...structuredClone(project), id: SERVER_PROJECT, name: 'Petites-Rives', path: cwd }]; + ctx.projects = [{ ...structuredClone(project), ...spec.project }]; const steps: Message = { - id: 'm-srv-1', - threadId: SERVER_THREAD, + id: `m-${spec.thread}`, + threadId: spec.thread, turnId, role: 'assistant', state: 'streaming', createdAt: started + 1_000, - parts: [ - tool('sv-1', 'Read', { file_path: `${cwd}/Assets/Scripts/Player/PlayerController.cs` }, 'done', now - 80_000), - tool('sv-2', 'Grep', { pattern: 'jumpBuffer' }, 'done', now - 50_000), - tool('sv-3', 'Bash', { command: 'dotnet test Gameplay.Tests' }, 'running', now - 15_000) - ] + parts: spec.steps.map(([name, input, ago], index) => tool(`${spec.thread}-${index}`, name, input, index === spec.steps.length - 1 ? 'running' : 'done', now - ago)) }; + const last = spec.steps.at(-1)!; const thread: Thread = { ...structuredClone(model), - id: SERVER_THREAD, - projectId: SERVER_PROJECT, - title: 'Corriger le gameplay', + id: spec.thread, + projectId: spec.project.id, + title: spec.title, titleSource: 'user', - cwd, - branch: 'fix/gameplay', + cwd: spec.project.path, + branch: spec.branch, status: 'running', archived: false, pinned: false, unread: false, - sessionId: 'sess-srv-gameplay', + sessionId: `sess-${spec.thread}`, load: null, context: null, createdAt: started - 1_000, updatedAt: started, - turns: [{ id: turnId, threadId: SERVER_THREAD, status: 'running', queuedAt: started - 500, startedAt: started, finishedAt: null, usage: null, error: null }], + turns: [{ id: turnId, threadId: spec.thread, status: 'running', queuedAt: started - 500, startedAt: started, finishedAt: null, usage: null, error: null }], messages: [steps], background: [], - progress: { turnId, phase: 'tool', detail: 'Bash', at: now - 15_000, providerAt: now - 15_000 } + progress: { turnId, phase: 'tool', detail: last[0], at: now - last[2], providerAt: now - last[2] } }; delete thread.parentThreadId; ctx.threads.set(thread.id, thread); ctx.scheduler.running.push({ turnId, threadId: thread.id, startedAt: started }); + const agent = profile(spec.agent.id, spec.agent.name, spec.agent.domain, null, now - 3 * 24 * 3_600_000); + rowsOf(ctx).set(`profile:${agent.id}`, agent); if (ask) setTimeout(() => askToPush(ctx, thread), ASK_AFTER_MS); } @@ -76,7 +121,7 @@ function askToPush(ctx: FakeContext, thread: Thread): void { const request: PermissionRequest = { id: 'perm-srv-push', threadId: thread.id, - turnId: thread.turns.at(-1)?.id ?? 'turn-srv-gameplay', + turnId: thread.turns.at(-1)?.id ?? `turn-${thread.id}`, toolName: 'Bash', input: { command: 'git push origin fix/gameplay', description: 'Push the gameplay fix' }, description: 'git push origin fix/gameplay', diff --git a/src/lib/group/agents.ts b/src/lib/group/agents.ts new file mode 100644 index 0000000..5e0295f --- /dev/null +++ b/src/lib/group/agents.ts @@ -0,0 +1,68 @@ +/* + * The agents of every machine of the group, as the island's assistant lists + * them: this machine's first, then each member's in roster order, each tagged + * with its machine. Pure: the crew reads the snapshots (`crew.svelte.ts`), + * the group client routes the calls (`GroupClient.at`). + * + * An agent is known by its machine and its id together: two machines may + * hold the same id. This machine's agents keep their bare id as their key, so + * the agent kept in Bots's settings before machines were named still opens. + */ +import type { AgentProfile, AgentsSnapshot } from '@boite/contracts'; + +/** One member's agents, as its core answered `agents.snapshot`. */ +export interface MachineAgents { + coreId: string; + /** The member's name in the group's roster. */ + machine: string; + snapshot: AgentsSnapshot; +} + +/** An agent of some machine of the group. */ +export interface PlacedAgent { + /** Unique across machines: the id for this machine's, `coreId/id` for another's. */ + key: string; + /** Null for this machine's. */ + coreId: string | null; + /** The roster's name for its machine; null for this machine's. */ + machine: string | null; + profile: AgentProfile; +} + +export function agentKey(coreId: string | null, agentId: string): string { + return coreId === null ? agentId : `${coreId}/${agentId}`; +} + +const active = (snapshot: AgentsSnapshot | null) => snapshot?.profiles.filter((profile) => profile.status === 'active') ?? []; + +/** The active agents of every machine: this one's first, then each member's. */ +export function placedAgents(local: AgentsSnapshot | null, elsewhere: readonly MachineAgents[]): PlacedAgent[] { + return [ + ...active(local).map((profile) => ({ key: profile.id, coreId: null, machine: null, profile })), + ...elsewhere.flatMap(({ coreId, machine, snapshot }) => active(snapshot).map((profile) => ({ key: agentKey(coreId, profile.id), coreId, machine, profile }))) + ]; +} + +/** The snapshot an agent's conversation, work and replies are read in: its own machine's. */ +export function snapshotAt(coreId: string | null, local: AgentsSnapshot | null, elsewhere: readonly MachineAgents[]): AgentsSnapshot | null { + return coreId === null ? local : (elsewhere.find((entry) => entry.coreId === coreId)?.snapshot ?? null); +} + +/** + * The members' agents after a read: each that answered gives its new + * snapshot; one still connected but slow keeps its last, so a late answer + * does not empty the list; one out of reach gives none. + */ +export function mergeElsewhere(previous: readonly MachineAgents[], answers: readonly MachineAgents[], connected: readonly { coreId: string }[]): MachineAgents[] { + const answered = new Map(answers.map((entry) => [entry.coreId, entry])); + return connected.flatMap(({ coreId }) => answered.get(coreId) ?? previous.find((entry) => entry.coreId === coreId) ?? []); +} + +/** The first of the wanted keys still listed, else the first agent. */ +export function pickPlaced(agents: readonly PlacedAgent[], ...wanted: (string | null | undefined)[]): PlacedAgent | null { + for (const key of wanted) { + const found = key ? agents.find((agent) => agent.key === key) : undefined; + if (found) return found; + } + return agents[0] ?? null; +} diff --git a/src/lib/group/group-client.ts b/src/lib/group/group-client.ts index 47385d2..b53d8da 100644 --- a/src/lib/group/group-client.ts +++ b/src/lib/group/group-client.ts @@ -16,17 +16,20 @@ * member slow to answer gives its last list, one that is off gives none. * - A call that names a thread goes to that thread's machine, an answer to a * permission to the machine that asked (`routing.ts`); the rest stays here. - * - Events about threads are heard from every member; the rest from here only. + * - An agent's calls go to its own machine through `at(coreId)`: agents are + * keyed by machine and id, as the same id may stand on two machines. + * - Events about threads and agents are heard from every member; the rest + * from here only. * * Nothing waits on a member: this machine's calls and events never pass * through one, and no group, or a core older than groups, is the old Bots. * A timer runs only while a member is out of reach, once a minute. */ import { RPC_PATH, RpcErrorCode, type CoreInfo, type Group, type GroupCore, type PermissionRequest, type Principal, type QuestionRequest, type RpcEventName, type RpcMethodName, type RpcParams, type RpcResult, type ThreadSummary } from '@boite/contracts'; -import { RpcFailure, WsClient, type CallOptions, type ClientState, type EventHandler, type ObservableClient, type Session } from '@boite/ui/lib/client'; +import { RpcFailure, WsClient, type CallOptions, type Client, type ClientState, type EventHandler, type ObservableClient, type Session } from '@boite/ui/lib/client'; import { fill, strings } from '../strings'; import type { MemberKeys } from './member-keys'; -import { isMerged, memberBackoff, membersOf, requestOfCall, THREAD_EVENTS, threadOfCall, threadOfEvent, type MergedMethod } from './routing'; +import { heardEverywhere, isMerged, memberBackoff, membersOf, requestOfCall, threadOfCall, threadOfEvent, type MergedMethod } from './routing'; /** Between two tickets for one member, and between two tries of one that is out of reach (Boite's group links wait as long). */ export const RETRY_MS = 60_000; @@ -219,7 +222,7 @@ export class GroupClient implements ObservableClient { set = new Set(); this.#handlers.set(event, set); this.#off.push(this.local.on(event, (payload) => this.#dispatch(event, payload, null))); - if (THREAD_EVENTS.has(event)) for (const member of this.#members.values()) this.#hear(member, event); + if (heardEverywhere(event)) for (const member of this.#members.values()) this.#hear(member, event); } const erased = handler as (payload: unknown) => void; set.add(erased); @@ -230,6 +233,44 @@ export class GroupClient implements ObservableClient { if (this.#members.size > 0 && isMerged(method, params)) return this.#merged(method, params, options) as Promise>; const coreId = this.#placeOf(method, params); if (coreId === null) return this.local.call(method, params, options); + return this.#callOn(coreId, method, params, options); + } + + /** + * A client bound to one machine: this one for null or this core's own id, else + * that member, refused while it is out of reach. What an agent's calls go + * through, since an agent id does not say where it lives. Its events are the + * group's: the agents and threads of every member are heard here already. + */ + at(coreId: string | null): Client { + if (coreId === null || coreId === this.#group?.self) return this.local; + const client = () => this.#members.get(coreId)?.client ?? null; + return { + connect: () => this.connect(), + call: (method, params, options) => this.#callOn(coreId, method, params, options), + on: (event, handler) => this.on(event, handler), + // The member's connection is the group client's to close. + close: () => undefined, + get state(): ClientState { + return client()?.state ?? 'closed'; + }, + get principal(): Principal | null { + return client()?.principal ?? null; + } + }; + } + + /** This machine's id in the group, null outside one. */ + selfId(): string | null { + return this.#group?.self ?? null; + } + + /** The name the roster gives a member; null for one not in it. */ + nameOf(coreId: string): string | null { + return this.#members.get(coreId)?.core.name ?? null; + } + + #callOn(coreId: string, method: M, params: RpcParams, options?: CallOptions): Promise> { const member = this.#members.get(coreId); if (!member?.client || member.client.state !== 'ready') { const machine = member?.core.name ?? coreId; @@ -462,7 +503,7 @@ export class GroupClient implements ObservableClient { } }) ); - for (const event of this.#handlers.keys()) if (THREAD_EVENTS.has(event)) this.#hear(member, event); + for (const event of this.#handlers.keys()) if (heardEverywhere(event)) this.#hear(member, event); void client.connect().catch(() => undefined); } diff --git a/src/lib/group/machines.test.ts b/src/lib/group/machines.test.ts new file mode 100644 index 0000000..986c679 --- /dev/null +++ b/src/lib/group/machines.test.ts @@ -0,0 +1,207 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import type { AgentProfile, AgentsSnapshot, PermissionRequest, ThreadSummary } from '@boite/contracts'; +import type { Client } from '@boite/ui/lib/client'; +import { Crew } from '../companion/crew.svelte'; +import { writeCompanionPrefs, readCompanionPrefs } from '../companion/prefs'; +import { FakeClient } from '../fake-client'; +import { ASSISTANT_DEMO_AGENT } from '../fake-client/assistant-demo'; +import { DEMO_MACHINES, demoGroup, LAPTOP_CORE, SERVER_CORE } from '../fake-client/machines-demo'; +import { LAPTOP_AGENT, LAPTOP_THREAD, SERVER_AGENT, SERVER_THREAD } from '../fake-client/server-demo'; +import { Assistant, type AssistantHost } from '../island/assistant.svelte'; +import { machineLoads } from '../island/deck-data'; +import { fill, strings } from '../strings'; +import { agentKey, mergeElsewhere, pickPlaced, placedAgents, snapshotAt, type MachineAgents } from './agents'; +import { GroupClient } from './group-client'; +import { AGENT_EVENTS, heardEverywhere, unplacedName } from './routing'; + +const made: FakeClient[] = []; +const groups: GroupClient[] = []; +afterEach(() => { + groups.splice(0).forEach((group) => group.close()); + made.splice(0).forEach((client) => client.close()); + localStorage.clear(); +}); + +/** The demo's group of three: « PC » here, « boite » and « laptop » (`away`: laptop never answers). */ +async function threeMachines(mode: '1' | 'ask' | 'away' = '1') { + const local = new FakeClient({ delayMs: 0, assistantDemo: true, hudDemo: false, coreName: DEMO_MACHINES.local }); + made.push(local); + const group = await demoGroup(local, mode, () => {}, { delayMs: 0, made }); + groups.push(group); + await group.connect(); + const reachable = mode === 'away' ? 1 : 2; + await vi.waitFor(() => expect(group.machines().filter((machine) => machine.state === 'ready')).toHaveLength(reachable)); + const localCall = vi.spyOn(local, 'call'); + return { local, group, localCall }; +} + +function crewOf(group: GroupClient): Crew { + writeCompanionPrefs({ agents: [ASSISTANT_DEMO_AGENT], crewMade: true }); + // No projects yet: the companion's roles and first agent stay out of these tests. + return new Crew({ client: () => group, prefs: readCompanionPrefs, projects: () => null, waiting: () => false, answered: () => {}, loaded: () => {} }); +} + +const ids = (threads: ThreadSummary[]) => threads.map((thread) => thread.id); + +test('the agents of every machine are listed, each with its machine, this one’s first', async () => { + const { group } = await threeMachines(); + const crew = crewOf(group); + await crew.load(); + await vi.waitFor(() => expect(crew.elsewhere).toHaveLength(2)); + const listed = placedAgents(crew.snapshot, crew.elsewhere).map((agent) => [agent.profile.name, agent.machine, agent.key]); + expect(listed).toEqual([ + ['Atlas', null, ASSISTANT_DEMO_AGENT], + ['Ledger', null, 'agent-ledger'], + ['Forge', DEMO_MACHINES.server, agentKey(SERVER_CORE, SERVER_AGENT)], + ['Scout', DEMO_MACHINES.laptop, agentKey(LAPTOP_CORE, LAPTOP_AGENT)] + ]); + // The companion's own row stays this machine's. + expect(crew.members.map((agent) => agent.id)).toEqual([ASSISTANT_DEMO_AGENT]); + crew.dispose(); +}); + +test('the assistant talks to an agent of another machine on that machine', async () => { + const { group, localCall } = await threeMachines(); + const crew = crewOf(group); + await crew.load(); + await vi.waitFor(() => expect(crew.elsewhere).toHaveLength(2)); + let kept: string | null = null; + const assistant = new Assistant({ + client: () => group, + snapshot: () => crew.snapshot, + elsewhere: () => crew.elsewhere, + reload: () => void crew.load(), + remembered: () => kept, + remember: (key) => (kept = key), + fallback: () => null, + outgoing: () => [], + keep: async () => [], + sent: () => {}, + giveBack: () => {} + }); + const forge = agentKey(SERVER_CORE, SERVER_AGENT); + assistant.pick(forge); + expect(kept).toBe(forge); + expect(assistant.agent?.name).toBe('Forge'); + expect(assistant.machine).toBe(DEMO_MACHINES.server); + expect(assistant.machines).toBe(true); + assistant.draft = 'Deploy the gameplay fix'; + await assistant.send(); + expect(assistant.problem).toBe(''); + // The message is boite's: in its agents' snapshot, never in this machine's. + const there = await group.at(SERVER_CORE).call('agents.snapshot', {}); + expect(there.messages.some((message) => message.text.startsWith('Deploy the gameplay fix'))).toBe(true); + expect(localCall.mock.calls.some(([method]) => method === 'agents.message.send')).toBe(false); + expect(localCall.mock.calls.some(([method]) => method === 'providers.probe')).toBe(false); + crew.dispose(); +}); + +test('the same agent id on two machines does not collide: each call goes to the one picked', async () => { + const profile = (id: string, name: string) => ({ id, name, status: 'active', revision: 1, selection: { providerId: 'p', accountId: 'a', model: null, effort: 'high' } }) as unknown as AgentProfile; + const snapshot = (name: string) => ({ profiles: [profile('a1', name)], sessions: [], messages: [], work: [], deliveries: [], more: { message: false } }) as unknown as AgentsSnapshot; + const local = snapshot('Here'); + const elsewhere: MachineAgents[] = [{ coreId: 'core-b', machine: 'boite', snapshot: snapshot('There') }]; + const placed = placedAgents(local, elsewhere); + expect(placed.map((agent) => agent.key)).toEqual(['a1', 'core-b/a1']); + expect(pickPlaced(placed, 'core-b/a1')?.profile.name).toBe('There'); + expect(snapshotAt('core-b', local, elsewhere)).toBe(elsewhere[0]!.snapshot); + expect(snapshotAt(null, local, elsewhere)).toBe(local); + + const sent: [string | null, string, unknown][] = []; + const answer = (method: string) => (method === 'agents.message.send' ? { id: `m${sent.length}` } : method === 'providers.probe' ? { models: [] } : {}); + const bound = (coreId: string | null): Client => ({ call: async (method: string, params: unknown) => (sent.push([coreId, method, params]), answer(method)), on: () => () => {} }) as unknown as Client; + const group = Object.assign(Object.create(GroupClient.prototype) as GroupClient, { ...bound(null), at: (coreId: string | null) => bound(coreId) }); + const host: AssistantHost = { client: () => group, snapshot: () => local, elsewhere: () => elsewhere, reload: () => {}, remembered: () => null, remember: () => {}, fallback: () => null, outgoing: () => [], keep: async () => [], sent: () => {}, giveBack: () => {} }; + const assistant = new Assistant(host); + assistant.pick('core-b/a1'); + assistant.draft = 'one'; + await assistant.send(); + assistant.pick('a1'); + assistant.draft = 'two'; + await assistant.send(); + const messages = sent.filter(([, method]) => method === 'agents.message.send').map(([coreId, , params]) => [coreId, (params as { text: string }).text]); + expect(messages).toEqual([ + ['core-b', 'one'], + [null, 'two'] + ]); +}); + +test('every thread call of the island for a thread of another machine goes to that machine', async () => { + const { group, localCall } = await threeMachines('ask'); + const threads = await group.call('threads.list', { includeArchived: true }); + expect(ids(threads)).toEqual(expect.arrayContaining([SERVER_THREAD, LAPTOP_THREAD])); + expect(group.machineOf(LAPTOP_THREAD)).toBe(DEMO_MACHINES.laptop); + + // Sessions, the HUD, recap and finished cards, sub-agents, PR watch, archive. + await group.call('threads.subscribe', { threadId: SERVER_THREAD }); + expect((await group.call('threads.get', { threadId: SERVER_THREAD, limit: 6 })).title).toBe('Corriger le gameplay'); + await group.call('delegation.get', { threadId: SERVER_THREAD }); + await group.call('questions.list', { threadId: SERVER_THREAD }); + await group.call('threads.unsubscribe', { threadId: SERVER_THREAD }); + await group.call('threads.pullRequestReview', { threadId: SERVER_THREAD, url: 'https://example.com/pull/1' }).catch(() => null); + await group.call('threads.archive', { threadId: LAPTOP_THREAD }); + const thread = (method: string) => localCall.mock.calls.some(([called, params]) => called === method && [SERVER_THREAD, LAPTOP_THREAD].includes((params as { threadId?: string }).threadId ?? '')); + for (const method of ['threads.subscribe', 'threads.get', 'delegation.get', 'questions.list', 'threads.unsubscribe', 'threads.pullRequestReview', 'threads.archive']) expect(thread(method)).toBe(false); + expect((await group.at(LAPTOP_CORE).call('threads.list', { includeArchived: true })).find((entry) => entry.id === LAPTOP_THREAD)?.archived).toBe(true); + + // boite's thread asks to push: the request shows here, the answer goes back there. + let request: PermissionRequest | undefined; + await vi.waitFor(async () => { + request = (await group.call('permissions.list', {})).find((entry) => entry.threadId === SERVER_THREAD); + expect(request).toBeDefined(); + }, { timeout: 4_000 }); + await group.call('permissions.answer', { requestId: request!.id, decision: 'allow' }); + expect(localCall.mock.calls.some(([method]) => method === 'permissions.answer')).toBe(false); + expect(await group.at(SERVER_CORE).call('permissions.list', {})).toEqual([]); +}); + +test('a machine out of reach is shown offline in one place and holds nothing up', async () => { + const { group } = await threeMachines('away'); + expect(group.machines().map((machine) => [machine.name, machine.state])).toEqual([ + [DEMO_MACHINES.server, 'ready'], + [DEMO_MACHINES.laptop, 'unreached'] + ]); + const threads = await group.call('threads.list', {}); + expect(ids(threads)).toContain(SERVER_THREAD); + expect(ids(threads)).not.toContain(LAPTOP_THREAD); + const crew = crewOf(group); + await crew.load(); + await vi.waitFor(() => expect(crew.elsewhere.map((entry) => entry.machine)).toEqual([DEMO_MACHINES.server])); + await expect(group.at(LAPTOP_CORE).call('agents.snapshot', {})).rejects.toThrow(fill(strings.bots.machineAway, { machine: DEMO_MACHINES.laptop })); + expect(group.at(LAPTOP_CORE).state).toBe('closed'); + // The deck's machines tile is where it says so. + const loads = machineLoads(threads, (threadId) => group.coreIdOf(threadId), group.machines(), 'Ce PC'); + expect(loads.map((load) => [load.name, load.online])).toEqual([ + ['Ce PC', true], + [DEMO_MACHINES.server, true], + [DEMO_MACHINES.laptop, false] + ]); + crew.dispose(); +}); + +test('a client bound to a machine: this one’s own core, or the member', async () => { + const { local, group } = await threeMachines(); + expect(group.at(null)).toBe(local); + expect(group.at(group.selfId())).toBe(local); + expect(group.nameOf(SERVER_CORE)).toBe(DEMO_MACHINES.server); + expect(group.at(SERVER_CORE).state).toBe('ready'); + const heard = vi.fn(); + const off = group.at(SERVER_CORE).on('agents.changed', heard); + await group.at(SERVER_CORE).call('agents.profile.save', { value: { name: 'Mason', domain: '', instructions: '', avatar: '', status: 'active', tools: [], accountIntegration: 'provider', selection: { providerId: 'claude', accountId: 'a-claude-main', model: null, effort: 'high', permissionMode: 'default' } } } as never); + await vi.waitFor(() => expect(heard).toHaveBeenCalled()); + off(); +}); + +test('agent events are heard from every machine; a member slow to answer keeps its agents while connected', () => { + expect(AGENT_EVENTS.has('agents.changed')).toBe(true); + expect(heardEverywhere('agents.changed')).toBe(true); + expect(heardEverywhere('thread.updated')).toBe(true); + expect(heardEverywhere('quotas.updated' as never)).toBe(false); + const of = (coreId: string) => ({ coreId, machine: coreId, snapshot: { profiles: [] } as unknown as AgentsSnapshot }); + const before = [of('a'), of('b')]; + const fresh = of('a'); + expect(mergeElsewhere(before, [fresh], [{ coreId: 'a' }, { coreId: 'b' }])).toEqual([fresh, before[1]]); + expect(mergeElsewhere(before, [fresh], [{ coreId: 'a' }])).toEqual([fresh]); + expect(unplacedName('Petites-Rives · boite', 'boite')).toBe('Petites-Rives'); + expect(unplacedName('Petites-Rives', null)).toBe('Petites-Rives'); +}); diff --git a/src/lib/group/routing.ts b/src/lib/group/routing.ts index 025ae9f..b164f23 100644 --- a/src/lib/group/routing.ts +++ b/src/lib/group/routing.ts @@ -6,8 +6,11 @@ * `questions.list` without a thread) are asked of every machine and merged. * - A call that names a thread goes to the machine that thread runs on; an * answer to a permission, to the machine that asked it. - * - Everything else is this machine's own: projects, the crew and the - * assistant chat, settings, quotas, resources, new threads. + * - An agent's calls go to the machine that owns it: the group client hands + * a client bound to that machine (`GroupClient.at`), since an agent id says + * nothing of where it lives and two machines may hold the same one. + * - Everything else is this machine's own: projects, the companion's crew, + * settings, quotas, resources, new threads. */ import type { GroupCore, RpcEventName, RpcMethodName } from '@boite/contracts'; import { usableAddresses } from '@boite/ui/lib/group-addresses'; @@ -64,6 +67,14 @@ export const THREAD_EVENTS: ReadonlySet = new Set([ 'threads.pullRequestsChanged' ]); +/** The events about agents, heard from every machine: the assistant lists the agents of all of them. */ +export const AGENT_EVENTS: ReadonlySet = new Set(['agents.changed']); + +/** An event every member is listened to for, not this machine alone. */ +export function heardEverywhere(event: RpcEventName): boolean { + return THREAD_EVENTS.has(event) || AGENT_EVENTS.has(event); +} + /** The thread an event is about: a summary carries its id, the others a `threadId`. */ export function threadOfEvent(event: RpcEventName, payload: unknown): string | null { if (!payload || typeof payload !== 'object') return null; @@ -86,3 +97,10 @@ export function memberBackoff(attempt: number): number { export function placedName(project: string, machine: string): string { return `${project} · ${machine}`; } + +/** The project's own name, for a row that shows its machine apart, as a chip. */ +export function unplacedName(label: string, machine: string | null): string { + if (machine === null) return label; + const suffix = ` · ${machine}`; + return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label; +} diff --git a/src/lib/island/assistant-pose.ts b/src/lib/island/assistant-pose.ts index 539942c..13b5082 100644 --- a/src/lib/island/assistant-pose.ts +++ b/src/lib/island/assistant-pose.ts @@ -4,7 +4,11 @@ * * - `running`: an answer streaming in, a tool at work and Stop showing; * - `chips`: two files waiting for the next message; - * - `menu` / `agents`: the model and effort menu, or the agents menu, open; + * - `menu` / `agents`: the model picker open (on the favorites, as Boite + * opens it while some are starred), or the agents menu; + * - `models`: the picker on the agent's provider, its models listed; + * - `search`: the picker on OpenCode's long list, searched for `nvidia`; + * - `effort`: the effort chip's popover open; * - `quotas`: the quotas pill's panel open; * - `recap`: Home scrolled down to the Today card. */ @@ -42,8 +46,12 @@ function streaming(): Message { const file = (name: string, mimeType: string): Attachment => ({ kind: 'file', mimeType, data: btoa('demo'), name }) as Attachment; function click(testId: string): Promise { - return until(() => document.querySelector(`[data-testid="${testId}"]`) !== null).then(() => { - document.querySelector(`[data-testid="${testId}"]`)?.click(); + return clickSelector(`[data-testid="${testId}"]`); +} + +function clickSelector(selector: string): Promise { + return until(() => document.querySelector(selector) !== null).then(() => { + document.querySelector(selector)?.click(); }); } @@ -57,7 +65,18 @@ export async function strikeAssistantPose(companion: Companion, params: URLSearc assistant.sending = true; assistant.stream = [streaming()]; } - if (scenes.has('menu')) await click('assistant-model'); + if (scenes.has('menu') || scenes.has('models') || scenes.has('search')) await click('composer-picker'); + if (scenes.has('models')) await clickSelector('[data-testid="composer-picker-menu"] [data-provider="claude"]'); + if (scenes.has('search')) { + await clickSelector('[data-testid="composer-picker-menu"] [data-provider="opencode"]'); + await until(() => document.querySelector('[data-testid="picker-search"]') !== null); + const search = document.querySelector('[data-testid="picker-search"]'); + if (search) { + search.value = 'nvidia'; + search.dispatchEvent(new Event('input', { bubbles: true })); + } + } + if (scenes.has('effort')) await click('composer-effort'); if (scenes.has('agents')) await click('assistant-agent'); if (scenes.has('quotas')) await click('island-quotas'); if (scenes.has('recap')) { diff --git a/src/lib/island/assistant.svelte.test.ts b/src/lib/island/assistant.svelte.test.ts index b278f0e..91eb436 100644 --- a/src/lib/island/assistant.svelte.test.ts +++ b/src/lib/island/assistant.svelte.test.ts @@ -2,6 +2,7 @@ import type { AgentConversationMessage, AgentProfile, AgentsSnapshot, AgentWork, import { expect, test, vi } from 'vitest'; import { FakeClient } from '../fake-client'; import { ASSISTANT_DEMO_AGENT } from '../fake-client/assistant-demo'; +import { pickPlaced, placedAgents } from '../group/agents'; import { applyCompleted, applyDelta, @@ -10,13 +11,11 @@ import { Assistant, bubbleOf, conversation, - effortOf, - effortsOf, failureOf, + levelsOf, liveText, offeredModels, openWork, - pickAgent, selectionSave, settled, splitFiles, @@ -98,11 +97,12 @@ test('the newest request that failed says why', () => { }); test('the agent in use is the chosen one, else the kept one, else the first active one', () => { - const agents = [profile('a'), profile('b'), profile('c')]; - expect(pickAgent(agents, 'b', 'c')?.id).toBe('b'); - expect(pickAgent(agents, null, 'gone', 'c')?.id).toBe('c'); - expect(pickAgent(agents, 'gone')?.id).toBe('a'); - expect(pickAgent([], 'a')).toBeNull(); + const agents = placedAgents(snapshotOf({ profiles: [profile('a'), profile('b'), profile('c'), profile('d', { status: 'archived' } as Partial)] }), []); + expect(pickPlaced(agents, 'b', 'c')?.key).toBe('b'); + expect(pickPlaced(agents, null, 'gone', 'c')?.key).toBe('c'); + expect(pickPlaced(agents, 'gone')?.key).toBe('a'); + expect(pickPlaced(agents, 'd')?.key).toBe('a'); + expect(pickPlaced([], 'a')).toBeNull(); }); test('the tool line names the running call and its target', () => { @@ -134,12 +134,11 @@ test('the stream builds the answer: started, deltas, a part replaced, completed' expect(messages).toHaveLength(1); }); -test('efforts are low, medium and high, none for the default, narrowed to what the model takes', () => { - expect(effortOf(null)).toBeNull(); - expect(effortOf('xhigh')).toBeNull(); - expect(effortOf('low')).toBe('low'); - expect(effortsOf(undefined)).toEqual(['low', 'medium', 'high']); - expect(effortsOf({ id: 'm', name: 'M', effort: { levels: [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }], default: 'high' } })).toEqual(['medium', 'high']); +test('the levels are the model’s own, none when it names none', () => { + expect(levelsOf(undefined)).toEqual([]); + expect(levelsOf({ id: 'm', name: 'M' })).toEqual([]); + const levels = [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }]; + expect(levelsOf({ id: 'm', name: 'M', effort: { levels, default: 'high' } })).toEqual(levels); const models = [{ id: 'default', name: 'Default' }, { id: 'new', name: 'New' }, { id: 'old', name: 'Old', legacy: true }]; expect(offeredModels(models, 'new').map((model) => model.id)).toEqual(['new']); expect(offeredModels(models, 'old').map((model) => model.id)).toEqual(['new', 'old']); @@ -247,16 +246,30 @@ test('the model and the effort change on the agent itself', async () => { client.close(); }); +test('a model of another provider moves the agent there, on that model’s own level', async () => { + const client = new FakeClient({ delayMs: 0, assistantDemo: true }); + await client.connect(); + const { assistant, load, close } = harness(client); + assistant.open(ASSISTANT_DEMO_AGENT); + await load(); + expect(assistant.effort).toBe('high'); + await assistant.setModel('nvidia/gpt-oss-120b', { providerId: 'opencode', accountId: 'a-opencode' }); + await vi.waitFor(() => expect(assistant.agent?.selection).toMatchObject({ providerId: 'opencode', accountId: 'a-opencode', model: 'nvidia/gpt-oss-120b', effort: null })); + expect(assistant.problem).toBe(''); + close(); + client.close(); +}); + test('picking another agent keeps it for the next launch and starts clean', async () => { const client = new FakeClient({ delayMs: 0, assistantDemo: true }); await client.connect(); const { assistant, load, kept, close } = harness(client); assistant.open(ASSISTANT_DEMO_AGENT); await load(); - const other = assistant.agents.find((agent) => agent.id !== ASSISTANT_DEMO_AGENT)!; - assistant.pick(other.id); - expect(kept()).toBe(other.id); - expect(assistant.agentId).toBe(other.id); + const other = assistant.agents.find((agent) => agent.key !== ASSISTANT_DEMO_AGENT)!; + assistant.pick(other.key); + expect(kept()).toBe(other.key); + expect(assistant.agentId).toBe(other.profile.id); expect(assistant.bubbles.every((bubble) => bubble.id.startsWith('m-atlas') === false)).toBe(true); close(); client.close(); diff --git a/src/lib/island/assistant.svelte.ts b/src/lib/island/assistant.svelte.ts index ef73ab9..75b281c 100644 --- a/src/lib/island/assistant.svelte.ts +++ b/src/lib/island/assistant.svelte.ts @@ -5,8 +5,11 @@ * only engine; Bots reads and acts through the core alone, after Boite's own * `AgentConversation.svelte`. * - * - Which agent: an active profile of `agents.snapshot`, picked in the chat; - * its id is kept in Bots's settings (`CompanionPrefs.assistantAgent`). + * - Which agent: an active profile of `agents.snapshot`, on this machine or + * another of the group (`group/agents.ts`), picked in the chat; its key is + * kept in Bots's settings (`CompanionPrefs.assistantAgent`). Every call for + * it goes to its own machine (`GroupClient.at`), and its conversation is + * read in that machine's snapshot. * - Sending: `agents.message.send` to the direct conversation, the dropped * files kept on disk where the agent can open them, their paths at the end. * Typing during a run sends a follow-up, as Boite does. @@ -16,19 +19,20 @@ * - The answer as it comes, and the tool at work: the thread the agent's * session works in (`AgentSession.threadId`), through `message.*` and * `thread.updated`. Stop cancels the agent's open work (`agents.work.control`). - * - Model and effort: the agent's own `selection`, changed for the agent - * everywhere with `agents.profile.save`, the call Boite's agent editor makes. + * - Model and effort: the agent's own `selection` (its provider, account, + * model and level), changed for the agent everywhere with + * `agents.profile.save`, the call Boite's agent editor makes. */ -import type { AgentConversationMessage, AgentProfile, AgentSave, AgentsSnapshot, AgentWork, Attachment, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; +import type { AgentConversationMessage, AgentProfile, AgentSave, AgentSelection, AgentsSnapshot, AgentWork, Attachment, EffortLevel, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; import { directScope, replyTo, sessionThreadOf, workFor } from '../companion/crew'; +import { pickPlaced, placedAgents, snapshotAt, type MachineAgents, type PlacedAgent } from '../group/agents'; +import { GroupClient } from '../group/group-client'; import { visibleReply } from '../companion/directives'; import { describeTool } from '@boite/ui/lib/tool-summary'; import { fill, strings } from '../strings'; import { toolLabel } from './session-text'; -export const EFFORTS = ['low', 'medium', 'high'] as const; -export type Effort = (typeof EFFORTS)[number]; /** Messages per page of history, each scroll up. */ export const ASSISTANT_PAGE = 40; /** Fewer messages than this in hand: the conversation reads a page back, once per agent. */ @@ -126,15 +130,6 @@ export function failureOf(snapshot: AgentsSnapshot | null, messages: readonly Ag return work && ENDED.has(work.status) ? fill(copy().failed, { reason: work.error ?? work.status }) : null; } -/** The first of the wanted agents still active, else the first active one. */ -export function pickAgent(agents: readonly AgentProfile[], ...wanted: (string | null | undefined)[]): AgentProfile | null { - for (const id of wanted) { - const found = id ? agents.find((agent) => agent.id === id) : undefined; - if (found) return found; - } - return agents[0] ?? null; -} - // --------------------------------------------------------------------------- // The answer as it comes, from the agent's thread // --------------------------------------------------------------------------- @@ -209,17 +204,13 @@ export function applyCompleted(messages: Message[], { messageId, state }: RpcEve // Models and efforts // --------------------------------------------------------------------------- -/** One of the three efforts, null for the provider's default or another level. */ -export function effortOf(value: string | null | undefined): Effort | null { - return EFFORTS.includes(value as Effort) ? (value as Effort) : null; +/** The reasoning levels the model offers, as Boite's effort chip shows them; none when it names none. */ +export function levelsOf(model: ModelInfo | null | undefined): EffortLevel[] { + return model?.effort?.levels ?? []; } -/** The efforts the model takes among low, medium and high; all three when it names none. */ -export function effortsOf(model: ModelInfo | undefined): Effort[] { - const levels = model?.effort?.levels.map((level) => level.id) ?? []; - const offered = EFFORTS.filter((effort) => levels.includes(effort)); - return offered.length > 0 ? offered : [...EFFORTS]; -} +/** What a change of model or level touches in the agent's selection. */ +export type SelectionChange = Partial>; /** The models worth offering: the current ones, and a legacy one only when it is in use. */ export function offeredModels(models: ModelInfo[], current: string | null): ModelInfo[] { @@ -227,7 +218,7 @@ export function offeredModels(models: ModelInfo[], current: string | null): Mode } /** The agent saved with another model or effort, everything else as it stands. */ -export function selectionSave(profile: AgentProfile, change: { model?: string; effort?: Effort }): AgentSave { +export function selectionSave(profile: AgentProfile, change: SelectionChange): AgentSave { const { name, domain, instructions, avatar, selection, status, tools, accountIntegration } = profile; return { id: profile.id, expectedRevision: profile.revision, value: { name, domain, instructions, avatar, status, tools, accountIntegration, selection: { ...selection, ...change } } }; } @@ -238,13 +229,15 @@ export function selectionSave(profile: AgentProfile, change: { model?: string; e export interface AssistantHost { client(): Client | null; - /** The agents as the companion last read them. */ + /** This machine's agents as the companion last read them. */ snapshot(): AgentsSnapshot | null; + /** The other machines' agents, as last read; none without a group. */ + elsewhere?(): readonly MachineAgents[]; /** Reads the snapshot again now, after a change made here. */ reload(): void; - /** The agent kept in Bots's settings, null when none. */ + /** The agent kept in Bots's settings (its key, `group/agents.ts`), null when none. */ remembered(): string | null; - remember(agentId: string): void; + remember(key: string): void; /** The agent to talk to when none is chosen: the row's leader. */ fallback(): string | null; /** What goes with the next message, the dropped files and the capture, or why they cannot go. */ @@ -260,7 +253,7 @@ export interface AssistantHost { } export class Assistant { - /** The agent picked for this session; the kept one, else the leader, else the first. */ + /** The agent picked for this session, by key; the kept one, else the leader, else the first. */ chosen = $state(null); /** Pages read back through `agents.history`. */ older = $state.raw([]); @@ -281,10 +274,21 @@ export class Assistant { awaiting = $state([]); // Read lazily, once the host is in: through a function, as TypeScript sees the order. - readonly snapshot = $derived.by(() => this.host.snapshot()); - readonly agents = $derived(this.snapshot?.profiles.filter((profile) => profile.status === 'active') ?? []); - readonly agent = $derived.by(() => pickAgent(this.agents, this.chosen, this.host.remembered(), this.host.fallback())); + readonly #local = $derived.by(() => this.host.snapshot()); + readonly #elsewhere = $derived.by(() => this.host.elsewhere?.() ?? []); + /** The active agents of every machine, this one's first. */ + readonly agents = $derived(placedAgents(this.#local, this.#elsewhere)); + readonly placed = $derived.by(() => pickPlaced(this.agents, this.chosen, this.host.remembered(), this.host.fallback())); + readonly agent = $derived(this.placed?.profile ?? null); readonly agentId = $derived(this.agent?.id ?? null); + /** The machine the agent lives on: null for this one. */ + readonly coreId = $derived(this.placed?.coreId ?? null); + /** Its machine's name, shown beside it only when it is another machine's. */ + readonly machine = $derived(this.placed?.machine ?? null); + /** Shown beside each agent: there is more than one machine to tell apart. */ + readonly machines = $derived(this.#elsewhere.length > 0); + /** The agent's own machine's snapshot: its conversation, work and replies. */ + readonly snapshot = $derived(snapshotAt(this.coreId, this.#local, this.#elsewhere)); /** The thread the agent works its conversation with the user in, once it has worked there. */ readonly threadId = $derived(this.agentId ? sessionThreadOf(this.snapshot, this.agentId) : null); readonly messages = $derived(this.agentId ? conversation(this.snapshot, this.older, this.agentId) : []); @@ -299,9 +303,12 @@ export class Assistant { readonly failure = $derived(this.agentId && !this.running ? failureOf(this.snapshot, this.messages, this.agentId) : null); readonly hasOlder = $derived(this.more ?? this.snapshot?.more.message ?? false); readonly model = $derived(this.agent?.selection.model ?? null); - readonly effort = $derived(effortOf(this.agent?.selection.effort)); + /** The level the agent runs, null for its model's own default. */ + readonly effort = $derived(this.agent?.selection.effort ?? null); readonly offered = $derived(offeredModels(this.models, this.model)); - readonly efforts = $derived(effortsOf(this.models.find((model) => model.id === this.model))); + readonly current = $derived(this.models.find((model) => model.id === this.model) ?? null); + /** The levels of the agent's model, none when it names none: the effort chip is left out then. */ + readonly efforts = $derived(levelsOf(this.current)); /** What `follow` last saw, so a change of agent, thread or engine is acted on once. */ private followed: string | null = null; @@ -325,9 +332,9 @@ export class Assistant { return () => offs.forEach((off) => off()); } - /** Opens on `agentId` for this session without keeping it (the demo's), else on the kept agent. */ - open(agentId?: string): void { - if (agentId) this.chosen = agentId; + /** Opens on `key` for this session without keeping it (the demo's), else on the kept agent. */ + open(key?: string): void { + if (key) this.chosen = key; this.follow(); } @@ -337,24 +344,32 @@ export class Assistant { this.follow(); } - /** The user picked another agent: it is kept for the next launch. */ - pick(agentId: string): void { - if (agentId === this.agentId) return; - this.chosen = agentId; - this.host.remember(agentId); + /** The user picked another agent, by key: it is kept for the next launch. */ + pick(key: string): void { + if (key === this.placed?.key) return; + this.chosen = key; + this.host.remember(key); this.follow(); } + /** The client the agent's calls go through: its own machine's. */ + #client(): Client | null { + const client = this.host.client(); + if (!client || this.coreId === null) return client; + return client instanceof GroupClient ? client.at(this.coreId) : null; + } + /** * After each read of the snapshot: a new agent starts clean, its thread is * followed, its models read, and a short conversation reads a page back. */ follow(): void { - const client = this.host.client(); + const client = this.#client(); + const placed = this.placed; const agent = this.agent; - if (!client || !agent) return; - if (this.followed !== agent.id) { - this.followed = agent.id; + if (!client || !placed || !agent) return; + if (this.followed !== placed.key) { + this.followed = placed.key; this.older = []; this.more = null; this.stream = []; @@ -364,10 +379,10 @@ export class Assistant { } const threadId = this.threadId; if (threadId && threadId !== this.subscribed) void this.watch(client, threadId); - const engine = `${agent.selection.providerId}/${agent.selection.accountId}`; + const engine = `${placed.coreId ?? ''}/${agent.selection.providerId}/${agent.selection.accountId}`; if (engine !== this.probed) void this.readModels(client, engine, agent.selection.providerId, agent.selection.accountId); - if (this.messages.length < ASSISTANT_FILL && this.hasOlder && !this.filled.has(agent.id)) { - this.filled.add(agent.id); + if (this.messages.length < ASSISTANT_FILL && this.hasOlder && !this.filled.has(placed.key)) { + this.filled.add(placed.key); void this.loadOlder(); } } @@ -401,7 +416,7 @@ export class Assistant { /** Older messages, a page more, when the reader scrolls to the top. */ async loadOlder(): Promise { - const client = this.host.client(); + const client = this.#client(); const agentId = this.agentId; if (!client || !agentId || this.loadingOlder || !this.hasOlder) return; this.loadingOlder = true; @@ -429,7 +444,7 @@ export class Assistant { const attachments = this.host.outgoing(); if (typeof attachments === 'string') return void (this.problem = attachments); if (!prompt && attachments.length === 0) return; - const client = this.host.client(); + const client = this.#client(); if (!client) return void (this.problem = strings.companion.unreachable); const agent = this.agent; if (!agent) return void (this.problem = copy().noAgent); @@ -460,7 +475,7 @@ export class Assistant { /** Cancels the agent's open work in this conversation; a message the core has not made work of yet is let go. */ async stop(): Promise { - const client = this.host.client(); + const client = this.#client(); const agentId = this.agentId; if (!client || !agentId || this.stopping) return; this.stopping = true; @@ -477,20 +492,31 @@ export class Assistant { } } - async setModel(model: string): Promise { + /** + * Another model, on the agent's own provider and account or on the ones the + * picker names. On another provider or account the level goes back to that + * model's default, as in Boite's agent editor; on the same one it stays when + * the new model takes it. + */ + async setModel(model: string | null, engine?: { providerId: string; accountId: string }): Promise { + const selection = this.agent?.selection; + if (!selection) return; + if (engine && (engine.providerId !== selection.providerId || engine.accountId !== selection.accountId)) { + return this.update({ providerId: engine.providerId, accountId: engine.accountId, model, effort: null }); + } if (model === this.model) return; - const efforts = effortsOf(this.models.find((entry) => entry.id === model)); - const effort = this.effort === null || efforts.includes(this.effort) ? this.effort : efforts.at(-1)!; - await this.update(effort === null ? { model } : { model, effort }); + const levels = levelsOf(this.models.find((entry) => entry.id === model)).map((level) => level.id); + const effort = this.effort === null || levels.length === 0 || levels.includes(this.effort) ? this.effort : null; + await this.update({ model, effort }); } - async setEffort(effort: Effort): Promise { + async setEffort(effort: string): Promise { if (effort !== this.effort) await this.update({ effort }); } /** The agent's own model or effort, changed for it everywhere. */ - private async update(change: { model?: string; effort?: Effort }): Promise { - const client = this.host.client(); + private async update(change: SelectionChange): Promise { + const client = this.#client(); const agent = this.agent; if (!client || !agent) return; try { diff --git a/src/lib/island/bar.ts b/src/lib/island/bar.ts index 2a17d9f..8467743 100644 --- a/src/lib/island/bar.ts +++ b/src/lib/island/bar.ts @@ -5,8 +5,8 @@ * sound). Settings stays there whatever happens: it is the way back here. * * Kept in `localStorage` under `bots.bar`, apart from the companion's prefs, - * so the settings window and the island share it and a write from one - * reaches the other through `storage`. A missing or odd field takes the + * so the settings panel and the island share it, and a write from another + * page reaches this one through `storage`. A missing or odd field takes the * default: every tab in `DECK_TABS` order, every control. * * The bar is edited on the island itself, in its edit mode (`DeckBar`, diff --git a/src/lib/island/contract.ts b/src/lib/island/contract.ts index f99d224..3dfef0b 100644 --- a/src/lib/island/contract.ts +++ b/src/lib/island/contract.ts @@ -15,6 +15,9 @@ * Permissions and questions are the frame's own: they need no call. * - `hold(key, on)`: the view wants the island to stay open while the pointer * is away (a field with the keyboard, a menu, a drag over it); `false` lets it go. + * - `resized()`: something of the view that takes clicks (`data-hit`) came, + * went or moved outside the island, a popover over its edge: the window + * reads its hit areas again. * - `reveal(threadId)`: shows a thread in Boite's window. * - `show(tab)`: turns the deck to another of its tabs (`deck-tabs.ts`). * - `sessions`: the live sessions the threads tab lists and the peek speaks of, read-only. @@ -53,6 +56,7 @@ export interface ViewContext { readonly island: IslandState; attention(key: string, options?: { quiet?: boolean }): () => void; hold(key: string, on: boolean): void; + resized?(): void; reveal(threadId: string): Promise; show(tab: IslandTab): void; /** The live sessions (`lib/island/sessions.svelte.ts`): one row per thread at work or waiting, its last steps, the lead one. */ diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index ea21c20..106c934 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -40,17 +40,27 @@ * shield shows (pair it with `calm=1`); * - `pr=green|red`: the tests thread opens PR #12, whose CI runs, then goes * green or red (the fake core's side: `fake-client/allow-demo.ts`); - * - `machines=1|ask`: a second machine of the group, « Serveur », whose - * « Corriger le gameplay » thread works among this machine's (`ask`: it - * asks to push two seconds in); read by the companion, the fake side in - * `fake-client/machines-demo.ts`. + * - `machines=1|ask|away`: two more machines of the group, « boite » and + * « laptop », whose threads work among this machine's and whose agents + * the Chat lists (`ask`: boite's thread asks to push two seconds in; + * `away`: laptop is out of reach); read by the companion, the fake side + * in `fake-client/machines-demo.ts`. * - `todo=1`: a to-do list in the today tab, one item ticked; + * - `tasks=1`: the tests thread keeps a task list, two of five done, so the + * collapsed pill's live line shows its goal bar (wait for the ended + * threads' line to pass); * - `mission=ask|list`: a mission waiting for the user's word, or three set * (one paused, one whose project is gone) for Settings; * - `bar=custom`: the bar arranged by hand: two tabs left out, one renamed, * two apps and a link added, the pin taken away (`bar.ts`); + * - `skin=box|jelly|bubble|capsule|retro`: the lead robot wears a robot of + * that family as its mascot (`SKIN_POSES`); * - `edit=1`: with `pose=open` or `pose=deck`, the bar in its edit mode, what * it can take under it (`deck/BarPalette.svelte`); + * - `pose=settings§ion=`: the open island with its settings panel on + * that section, one of `settings-panel.svelte.ts` (place, mascot, crew, + * brain, island, clipboard, focus, reminders, missions, permissions); pair + * it with `calm=1`, `mission=list` for missions and `rule=1` for permissions; * - `pose=decktab&tab=apps|clipboard|wallpaper|system`: one of the deck's * computer tabs alone, in a deck-wide black frame, on a fake computer; its * own states (`q`, `clip`, `source`, `engine`, `press`), in @@ -63,12 +73,14 @@ import { activeLocale } from '@boite/ui/lib/i18n.svelte'; import type { Companion } from '../companion/companion.svelte'; import { nextAt, type Repeat } from '../companion/missions'; import { PILL_RESTS, writeCompanionPrefs, type PillRest } from '../companion/prefs'; +import type { Robot, RobotFamily } from '../robots'; import { DEMO_COMMAND, DEMO_THREAD } from '../fake-client/allow-demo'; import { addApp, addLink, DEFAULT_BAR, removeItem, removeTool, renameItem, writeBar, writeBarEdit, type BarLayout } from './bar'; import { FAKE_APPS } from '../windows/fake'; import { isDeckTab } from './deck-tabs'; import type { IslandFrame } from './frame.svelte'; import { posed } from './posed.svelte'; +import { settingsPanel } from './settings-panel.svelte'; import { prLine } from './pr-checks'; import type { PressureReason } from './pressure'; import { unfolding } from './sessions.svelte'; @@ -151,6 +163,13 @@ function poseTodo(companion: Companion): void { companion.todos.apply([{ kind: 'done', index: 2 }]); } +/** The tests thread's task list, two of five done, the third at work. */ +async function poseTasks(companion: Companion): Promise { + const texts = ['Pull the merge', 'Install the packages', 'Run the unit tests', 'Run the end-to-end tests', 'Sum up the failures']; + const status = (index: number) => (index < 2 ? 'completed' : index === 2 ? 'in_progress' : 'pending') as 'completed' | 'in_progress' | 'pending'; + await companion.client?.call('threads.tasks.set', { threadId: DEMO_THREAD, tasks: texts.map((text, index) => ({ id: `task-pose-${index}`, text, status: status(index) })) }).catch(() => {}); +} + /** A mission waiting for the user's word (`ask`), or three set, one paused and one whose project is gone (`list`, for Settings). */ function poseMissions(companion: Companion, kind: 'ask' | 'list'): void { const found = [...companion.projects].find(([, name]) => companion.projectNames?.includes(name)); @@ -194,6 +213,15 @@ function poseAway(): void { }; } +/** The mascot a `skin=` pose puts on the lead robot: one per family, each wearing something on top. */ +const SKIN_POSES: Record = { + box: { family: 'box', shape: 6, color: 4, eyes: 2, top: 3 }, + jelly: { family: 'jelly', shape: 0, color: 3, eyes: 0, top: 3 }, + bubble: { family: 'bubble', shape: 0, color: 5, eyes: 2, top: 1 }, + capsule: { family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 }, + retro: { family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 }, +}; + const WALLPAPER = 'html[data-companion] body { background: linear-gradient(160deg, #34466e, #7d6797 55%, #d49c78) !important; }'; async function until(ready: () => boolean, tries = 60): Promise { @@ -242,8 +270,11 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param } if (params.get('sessions') === 'none') posed.idle = true; if (params.get('todo') === '1') poseTodo(companion); + if (params.get('tasks') === '1') await poseTasks(companion); const mission = params.get('mission'); if (mission === 'ask' || mission === 'list') poseMissions(companion, mission); + const skin = params.get('skin'); + if (skin && Object.hasOwn(SKIN_POSES, skin)) companion.prefs = { ...companion.prefs, mascot: SKIN_POSES[skin as RobotFamily] }; const rest = params.get('rest') as PillRest | null; if (rest && PILL_RESTS.includes(rest)) companion.prefs = { ...companion.prefs, pillRest: rest }; if (params.get('rule') === '1') { @@ -259,6 +290,9 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param if (deck) await import('../windows/pose').then(({ poseComputer }) => poseComputer(params)); frame.show(isDeckTab(tab) ? tab : 'threads'); if (params.get('edit') === '1') writeBarEdit(true); + } else if (pose === 'settings') { + frame.show(isDeckTab(tab) ? tab : 'threads'); + settingsPanel.show(params.get('section')); } else if (pose === 'desktop') companion.prefs = { ...companion.prefs, robots: 'desktop' }; else if (pose === 'decktab') await import('../windows/pose').then(({ poseDeckTab }) => poseDeckTab(companion, params)); else if (pose === 'away') poseAway(); diff --git a/src/lib/island/settings-panel.svelte.test.ts b/src/lib/island/settings-panel.svelte.test.ts new file mode 100644 index 0000000..ad64e06 --- /dev/null +++ b/src/lib/island/settings-panel.svelte.test.ts @@ -0,0 +1,79 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { answerSettings, FIRST_SECTION, isSettingsSection, PANEL_ABOVE, PANEL_BELOW, PANEL_HEIGHT, panelHeight, panelWindowHeight, requestSettings, SETTINGS_SECTIONS, SettingsPanel, settingsPanel, stepSection } from './settings-panel.svelte'; + +afterEach(() => { + settingsPanel.close(); + settingsPanel.pick(FIRST_SECTION); +}); + +test('the gear opens the panel on the last section shown, and closes it', () => { + const panel = new SettingsPanel(); + expect(panel.open).toBe(false); + panel.toggle(); + expect(panel.open).toBe(true); + expect(panel.section).toBe(FIRST_SECTION); + panel.pick('focus'); + panel.toggle(); + expect(panel.open).toBe(false); + panel.toggle(); + expect(panel.section).toBe('focus'); +}); + +test('a request names a section, or keeps the last one when it names none it knows', () => { + const panel = new SettingsPanel(); + panel.show('crew'); + expect(panel.section).toBe('crew'); + panel.close(); + panel.show('nowhere'); + expect([panel.open, panel.section]).toEqual([true, 'crew']); + panel.show(null); + expect(panel.section).toBe('crew'); +}); + +test('the sections are the rail, the arrows move along it and wrap', () => { + expect(SETTINGS_SECTIONS[0]).toBe(FIRST_SECTION); + expect(SETTINGS_SECTIONS).toHaveLength(10); + expect(isSettingsSection('brain')).toBe(true); + expect(isSettingsSection('Brain')).toBe(false); + expect(isSettingsSection(3)).toBe(false); + expect(stepSection('place', 1)).toBe('mascot'); + expect(stepSection('place', -1)).toBe('permissions'); + expect(stepSection('permissions', 1)).toBe('place'); +}); + +test('the page answers the requests; without it the panel opens alone', () => { + requestSettings('missions'); + expect([settingsPanel.open, settingsPanel.section]).toEqual([true, 'missions']); + settingsPanel.close(); + + const heard = vi.fn(); + const stop = answerSettings(heard); + requestSettings('crew'); + requestSettings('bogus'); + expect(heard.mock.calls).toEqual([['crew'], [null]]); + expect(settingsPanel.open).toBe(false); + stop(); + requestSettings(); + expect(settingsPanel.open).toBe(true); +}); + +test('a stale stop leaves the newer answer in place', () => { + const first = vi.fn(); + const second = vi.fn(); + const stopFirst = answerSettings(first); + const stopSecond = answerSettings(second); + stopFirst(); + requestSettings('island'); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('island'); + stopSecond(); +}); + +test('the panel takes its full height on a usual screen, less on a short one, and keeps a floor', () => { + expect(panelHeight(1040)).toBe(PANEL_HEIGHT.most); + expect(panelHeight(728)).toBe(PANEL_HEIGHT.most); + expect(panelHeight(600)).toBe(600 - PANEL_ABOVE - PANEL_BELOW); + expect(panelHeight(300)).toBe(PANEL_HEIGHT.least); + expect(panelHeight(Number.NaN)).toBe(PANEL_HEIGHT.most); + expect(panelWindowHeight(1040)).toBe(PANEL_ABOVE + PANEL_HEIGHT.most + PANEL_BELOW); +}); diff --git a/src/lib/island/settings-panel.svelte.ts b/src/lib/island/settings-panel.svelte.ts new file mode 100644 index 0000000..a3e3f03 --- /dev/null +++ b/src/lib/island/settings-panel.svelte.ts @@ -0,0 +1,100 @@ +/* + * The settings panel: Bots's settings, drawn inside the island's window as a + * panel that grows out of the open deck, under its bar + * (`components/island/settings/SettingsPanel.svelte`). There is no settings + * window any more: the deck's gear toggles the panel, and the tray, the + * companion's gear and "Make an agent" ask for it through `requestSettings`, + * on a section when they know which one. + * + * The page holds one panel (`settingsPanel`); the companion's window opens the + * island for it, holds it open while it shows, and draws the island for it + * even while the robots are loose on the desktop. + */ + +/** The panel's sections, in the rail's order. */ +export const SETTINGS_SECTIONS = ['place', 'mascot', 'crew', 'brain', 'island', 'clipboard', 'focus', 'reminders', 'missions', 'permissions'] as const; +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; + +/** The section the panel shows first. */ +export const FIRST_SECTION: SettingsSection = 'place'; + +/** The panel's height: the most it takes, and the least on a short screen. */ +export const PANEL_HEIGHT = { most: 560, least: 320 }; +/** The deck's bar the panel hangs under, and the air under it for its shadow. */ +export const PANEL_ABOVE = 52; +export const PANEL_BELOW = 36; + +/** + * The panel's height on a screen `screenHeight` logical pixels tall (its work + * area): decided before it shows, so it never changes as the window grows. + */ +export function panelHeight(screenHeight: number): number { + const room = Number.isFinite(screenHeight) ? screenHeight - PANEL_ABOVE - PANEL_BELOW : PANEL_HEIGHT.most; + return Math.max(PANEL_HEIGHT.least, Math.min(PANEL_HEIGHT.most, Math.floor(room))); +} + +/** The island's window while the panel shows: the bar, the panel and its shadow. */ +export function panelWindowHeight(screenHeight: number): number { + return PANEL_ABOVE + panelHeight(screenHeight) + PANEL_BELOW; +} + +export function isSettingsSection(value: unknown): value is SettingsSection { + return typeof value === 'string' && (SETTINGS_SECTIONS as readonly string[]).includes(value); +} + +/** The section after or before `from` in the rail, wrapping around: the arrow keys move along it. */ +export function stepSection(from: SettingsSection, by: 1 | -1): SettingsSection { + const at = SETTINGS_SECTIONS.indexOf(from); + return SETTINGS_SECTIONS[(at + by + SETTINGS_SECTIONS.length) % SETTINGS_SECTIONS.length]!; +} + +export class SettingsPanel { + open = $state(false); + /** The section shown: the last one picked, kept from one opening to the next. */ + section = $state(FIRST_SECTION); + + /** Opens on `section` when it names one, else on the last one shown. */ + show(section?: string | null): void { + if (isSettingsSection(section)) this.section = section; + this.open = true; + } + + close(): void { + this.open = false; + } + + /** The deck's gear: opens on the last section, or closes. */ + toggle(): void { + if (this.open) this.close(); + else this.show(); + } + + pick(section: SettingsSection): void { + this.section = section; + } +} + +/** The page's panel. */ +export const settingsPanel = new SettingsPanel(); + +type Request = (section: SettingsSection | null) => void; +let handler: Request | null = null; + +/** + * Asks the page for the panel, on `section` when it names one (anything else + * opens the last section). The companion's window answers it (`App.svelte`): + * it opens the island first. Without a page to answer, the panel opens alone. + */ +export function requestSettings(section?: string | null): void { + const wanted = isSettingsSection(section) ? section : null; + if (handler) handler(wanted); + else settingsPanel.show(wanted); +} + +/** The page answers the requests. Returns the function that stops it. */ +export function answerSettings(next: Request): () => void { + handler = next; + return () => { + if (handler === next) handler = null; + }; +} diff --git a/src/lib/island/spring.svelte.test.ts b/src/lib/island/spring.svelte.test.ts new file mode 100644 index 0000000..170f957 --- /dev/null +++ b/src/lib/island/spring.svelte.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'vitest'; +import { advance, FrameSpring, MAX_FRAME_MS, STEP_MS, type Frames, type SpringOptions, type SpringState } from './spring.svelte'; + +/** The island's own spring. */ +const OPTIONS: SpringOptions = { stiffness: 0.18, damping: 0.72, precision: 0.25 }; +const FROM = { w: 200, h: 32 }; +const TO = { w: 560, h: 400 }; + +/** Runs the spring at `hz` and records what it shows at each frame. */ +function run(hz: number, until = 1500): number[] { + let state: SpringState = { last: FROM, current: FROM, carry: 0 }; + const shown = [FROM.w]; + for (let frame = 1; frame <= (until * hz) / 1000; frame++) { + const moved = advance(state, TO, 1000 / hz, OPTIONS); + state = moved.state; + shown.push(moved.shown.w); + if (moved.settled) break; + } + return shown; +} + +/** What a run shows at `ms`, a time where a frame falls at every rate measured. */ +const at = (frames: number[], hz: number, ms: number) => frames[Math.round((ms * hz) / 1000)] ?? TO.w; + +test('the island springs along the same curve at 60, 144 and 240 Hz', () => { + const sixty = run(60); + for (const hz of [144, 240]) { + const fast = run(hz); + // Every twelfth of a second, a frame falls at 60, 144 and 240 Hz alike. + for (let ms = 1000 / 12; ms < 1000; ms += 1000 / 12) expect(at(fast, hz, ms)).toBeCloseTo(at(sixty, 60, ms), 1); + // It settles in the same time, give or take a frame of the slower screen. + expect(Math.abs((fast.length * 1000) / hz - (sixty.length * 1000) / 60)).toBeLessThan(STEP_MS + 1); + } +}); + +test('at 60 Hz it moves as Svelte\'s spring did, frame for frame', () => { + let last = FROM.w; + let current = FROM.w; + const svelte = [current]; + while (current !== TO.w) { + const velocity = current - last; + const move = velocity + OPTIONS.stiffness * (TO.w - current) - OPTIONS.damping * velocity; + last = current; + current = Math.abs(move) < OPTIONS.precision && Math.abs(TO.w - current) < OPTIONS.precision ? TO.w : current + move; + svelte.push(current); + } + const ours = run(60); + expect(ours).toHaveLength(svelte.length); + ours.forEach((w, index) => expect(w).toBeCloseTo(svelte[index]!, 6)); +}); + +test('a 240 Hz screen gets frames between the steps, each a little further on', () => { + const frames = run(240, 200); + for (let index = 1; index < frames.length; index++) expect(frames[index]!).toBeGreaterThan(frames[index - 1]!); +}); + +test('a stalled frame moves the spring no further than two steps', () => { + const state: SpringState = { last: FROM, current: FROM, carry: 0 }; + const stalled = advance(state, TO, 5000, OPTIONS); + const twice = advance(advance(state, TO, MAX_FRAME_MS / 2, OPTIONS).state, TO, MAX_FRAME_MS / 2, OPTIONS); + expect(stalled.shown.w).toBeCloseTo(twice.shown.w, 6); +}); + +test('the spring asks for frames while it moves, none once settled or set at once', () => { + const queue: ((now: number) => void)[] = []; + const frames: Frames = { request: (callback) => queue.push(callback), cancel: () => queue.splice(0) }; + const spring = new FrameSpring(FROM, OPTIONS, frames); + spring.set(FROM); + expect(queue).toHaveLength(0); + spring.set(TO); + let now = performance.now(); + for (let index = 0; index < 400 && queue.length; index++) queue.shift()!((now += STEP_MS)); + expect(queue).toHaveLength(0); + expect(spring.current).toEqual(TO); + spring.set(FROM, { instant: true }); + expect(queue).toHaveLength(0); + expect(spring.current).toEqual(FROM); +}); diff --git a/src/lib/island/spring.svelte.ts b/src/lib/island/spring.svelte.ts new file mode 100644 index 0000000..f71405c --- /dev/null +++ b/src/lib/island/spring.svelte.ts @@ -0,0 +1,127 @@ +/* + * The island's spring, the same motion on a 60 Hz screen as on a 240 Hz one. + * + * Svelte's `Spring` (`svelte/motion`) steps once per frame and its velocity + * gains a whole acceleration on every step, whatever the frame's length, so + * its curve depends on the screen: opening the island from the pill settles + * in about 370 ms at 60 Hz, 440 ms at 144 Hz and 460 ms at 240 Hz. This one + * runs Svelte's own step at a fixed 60 steps a second, the curve Svelte's + * numbers are given for, and shows a faster screen's frames part of the way + * to the next step: more frames of the same curve. + */ + +export type Box = Record; + +export interface SpringOptions { + stiffness: number; + damping: number; + /** Below this, in both distance and step, a value is settled. */ + precision: number; +} + +/** One step of the simulation: a sixtieth of a second, as Svelte's at 60 Hz. */ +export const STEP_MS = 1000 / 60; +/** A frame longer than this (a stalled page) moves no further, as with Svelte's spring. */ +export const MAX_FRAME_MS = 1000 / 30; + +export interface SpringState { + /** The step before `current`: their gap is the velocity. */ + last: T; + current: T; + /** Time past `current` not yet stepped, in milliseconds. */ + carry: number; +} + +function each(of: T, value: (key: keyof T & string) => number): T { + const next: Box = {}; + for (const key of Object.keys(of)) next[key] = value(key); + return next as T; +} + +/** Svelte's `tick_spring` for one number, at dt = 1: the next value, or the target once settled. */ +export function stepValue(last: number, current: number, target: number, options: SpringOptions): number { + const delta = target - current; + const velocity = current - last; + const move = velocity + options.stiffness * delta - options.damping * velocity; + return Math.abs(move) < options.precision && Math.abs(delta) < options.precision ? target : current + move; +} + +/** + * Moves the spring on by `elapsed` milliseconds of wall time: whole steps, + * then what to show between the last two. `settled` once every value has + * reached its target. + */ +export function advance(state: SpringState, target: T, elapsed: number, options: SpringOptions): { state: SpringState; shown: T; settled: boolean } { + let { last, current } = state; + let carry = state.carry + Math.min(Math.max(elapsed, 0), MAX_FRAME_MS); + while (carry >= STEP_MS) { + carry -= STEP_MS; + const next = each(current, (key) => stepValue(last[key]!, current[key]!, target[key]!, options)); + last = current; + current = next; + if (Object.keys(current).every((key) => current[key] === target[key])) { + return { state: { last: target, current: target, carry: 0 }, shown: target, settled: true }; + } + } + // Shown part of the way to the next step, not from the last one: no step behind. + const between = carry / STEP_MS; + const shown = each(current, (key) => current[key]! + (stepValue(last[key]!, current[key]!, target[key]!, options) - current[key]!) * between); + return { state: { last, current, carry }, shown, settled: false }; +} + +/** Where frames come from: the page's `requestAnimationFrame`, or a test's. */ +export interface Frames { + request(callback: (now: number) => void): number; + cancel(id: number): void; +} + +const pageFrames: Frames = { + request: (callback) => requestAnimationFrame(callback), + cancel: (id) => cancelAnimationFrame(id) +}; + +/** A box of numbers that springs to where it is set, read through `current`. */ +export class FrameSpring { + current = $state.raw() as T; + #state: SpringState; + #target: T; + #frame: number | null = null; + #then = 0; + + constructor( + value: T, + private readonly options: SpringOptions, + private readonly frames: Frames = pageFrames + ) { + this.current = { ...value }; + this.#target = { ...value }; + this.#state = { last: this.#target, current: this.#target, carry: 0 }; + } + + set(target: T, { instant = false }: { instant?: boolean } = {}): void { + this.#target = { ...target }; + if (instant) { + this.stop(); + this.#state = { last: this.#target, current: this.#target, carry: 0 }; + this.current = this.#target; + return; + } + if (this.#frame !== null) return; + if (Object.keys(target).every((key) => this.#state.current[key] === target[key])) return; + this.#then = performance.now(); + this.#frame = this.frames.request(this.#tick); + } + + stop(): void { + if (this.#frame !== null) this.frames.cancel(this.#frame); + this.#frame = null; + } + + #tick = (now: number): void => { + const moved = advance(this.#state, this.#target, now - this.#then, this.options); + this.#then = now; + this.#state = moved.state; + this.current = moved.shown; + this.#frame = moved.settled ? null : this.frames.request(this.#tick); + }; +} diff --git a/src/lib/model-picker/companion.ts b/src/lib/model-picker/companion.ts new file mode 100644 index 0000000..20c7303 --- /dev/null +++ b/src/lib/model-picker/companion.ts @@ -0,0 +1,29 @@ +/* + * The companion's model as Boite's picker shows and edits it. The companion's + * prefs (`lib/companion/prefs.ts`) keep a provider, an account, a model and a + * level; a null provider is "Automatic": the first agent that is on and signed + * in, on its small model. A null model on a chosen provider is that + * provider's small model too, so the picker marks it as the one in use. + */ +import { defaultTitleModel } from '@boite/contracts'; +import type { CompanionPrefs } from '../companion/prefs'; +import type { PickerChoice, PickPatch, PickerStore } from './store.svelte'; + +type Choosing = Pick; + +/** What the picker shows as chosen; null while the companion chooses by itself or the provider has no account here. */ +export function companionChoice(prefs: Choosing, store: Pick): PickerChoice | null { + if (prefs.providerId === null) return null; + const accounts = store.accountsOf(prefs.providerId); + const account = accounts.find((entry) => entry.id === prefs.accountId) ?? store.usableAccountOf(prefs.providerId) ?? accounts[0]; + if (!account) return null; + const provider = store.providerOf(prefs.providerId); + const small = provider ? defaultTitleModel(provider, store.modelsOf(provider.id, account.id).filter((model) => model.id !== 'default')) : null; + return { providerId: prefs.providerId, accountId: account.id, model: prefs.model ?? small, effort: prefs.effort }; +} + +/** The prefs a pick writes: a model starts on its own default level, as in Boite's agent editor. */ +export function companionPick(patch: PickPatch | 'auto'): Choosing { + if (patch === 'auto') return { providerId: null, accountId: null, model: null, effort: null }; + return { providerId: patch.providerId, accountId: patch.accountId, model: patch.model, effort: null }; +} diff --git a/src/lib/model-picker/favorites.ts b/src/lib/model-picker/favorites.ts new file mode 100644 index 0000000..5ef0711 --- /dev/null +++ b/src/lib/model-picker/favorites.ts @@ -0,0 +1,141 @@ +/* + * The starred models, Boite's own list: every core keeps it in its settings + * (`Settings.modelFavorites`) and Boite's model picker reads and writes it + * there, so a star set in Boite shows in Bots and back. + * + * From Boite at 4b6335b1: the types (packages/contracts/src/index.ts), + * `compareModelFavorites` and the check of a stored list (packages/contracts/ + * src/settings-validation.ts), `resolveFavorites` (packages/ui/src/lib/ + * model-favorites.svelte.ts) and the star toggle (packages/ui/src/lib/store/ + * models.svelte.ts). The contracts Bots vendors are older and do not carry them. + */ +import type { Account, ProviderId, Settings } from '@boite/contracts'; + +/** One starred model. Accounts are named by label: an id means nothing on another machine. */ +export interface ModelFavorite { + providerId: ProviderId; + model: { id: string; name: string }; + /** The label of the account it was starred under. Missing: the account the picker uses for that provider. */ + account?: string; +} + +/** The starred models, with the time they last changed. */ +export interface ModelFavorites { + /** Milliseconds since the epoch of the last change: between two lists, the later one wins. */ + at: number; + items: ModelFavorite[]; +} + +/** One favorite as this machine's picker offers it: on one of its accounts. */ +export interface FavoriteModel { + providerId: ProviderId; + accountId: string; + model: { id: string; name: string }; +} + +/** One favorite as one machine's picker offers it. */ +export interface ResolvedFavorite extends FavoriteModel { + /** Every shared item shown as this row: unstarring it removes them all. */ + items: ModelFavorite[]; +} + +/** What resolving a favorite reads of one machine. */ +export interface FavoriteMachine { + providerOn(id: ProviderId): boolean; + accountsOf(providerId: ProviderId): Account[]; + usableAccountOf(providerId: ProviderId): Account | null; +} + +/** Starred models kept for all of the owner's machines. */ +export const MODEL_FAVORITES_MAX = 200; +const FAVORITE_TEXT_MAX = 200; +export const EMPTY_FAVORITES: ModelFavorites = { at: 0, items: [] }; + +/** One line of printable text of 1 to `FAVORITE_TEXT_MAX` characters, trimmed, or null. */ +function favoriteText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text.length > 0 && text.length <= FAVORITE_TEXT_MAX && !/[\u0000-\u001f]/.test(text) ? text : null; +} + +/** The list as a core stores it: each field trimmed, unknown keys dropped, a repeat kept once. Null when malformed. */ +export function cleanFavorites(value: unknown): ModelFavorites | null { + if (typeof value !== 'object' || value === null) return null; + const { at, items } = value as { at?: unknown; items?: unknown }; + if (typeof at !== 'number' || !Number.isSafeInteger(at) || at < 0 || !Array.isArray(items) || items.length > MODEL_FAVORITES_MAX) return null; + const kept: ModelFavorite[] = []; + const seen = new Set(); + for (const raw of items as Partial[]) { + if (typeof raw !== 'object' || raw === null) return null; + const providerId = favoriteText(raw.providerId), id = favoriteText(raw.model?.id), name = favoriteText(raw.model?.name); + const account = raw.account === undefined ? undefined : favoriteText(raw.account); + if (providerId === null || id === null || name === null || account === null) return null; + const key = itemKey({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + if (seen.has(key)) continue; + seen.add(key); + kept.push({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + } + return { at, items: kept }; +} + +/** The favorites a core's settings hold; the empty list on an older core or before the first star. */ +export function favoritesIn(settings: Settings | null): ModelFavorites { + return cleanFavorites((settings as { modelFavorites?: unknown } | null)?.modelFavorites) ?? EMPTY_FAVORITES; +} + +/** + * Which of two favorite lists wins, above zero when `a` does: the later `at`, + * then the greater content, so two lists of the same time never flip back and + * forth. Core and clients decide alike. + */ +export function compareFavorites(a: ModelFavorites, b: ModelFavorites): number { + if (a.at !== b.at) return a.at - b.at; + const content = (list: ModelFavorites) => JSON.stringify(list.items.map((item) => [item.providerId, item.model.id, item.model.name, item.account ?? null])); + const x = content(a), y = content(b); + return x < y ? -1 : x > y ? 1 : 0; +} + +function itemKey(item: ModelFavorite): string { + return JSON.stringify([item.providerId, item.model.id, item.account ?? null]); +} + +/** + * The list after a star (`add`) or an unstar (`remove`, every shared item the + * row stands for), as a change made at `now`: later than the list it replaces. + */ +export function toggledFavorites(current: ModelFavorites, add: ModelFavorite | null, remove: ModelFavorite[], now = Date.now()): ModelFavorites { + const gone = new Set(remove.map(itemKey)); + let items = current.items.filter((item) => !gone.has(itemKey(item))); + if (add && !items.some((item) => itemKey(item) === itemKey(add))) items = [...items, add]; + return { at: Math.max(now, current.at + 1), items: items.slice(-MODEL_FAVORITES_MAX) }; +} + +/** + * The favorites one machine offers. A provider that is off or has no account + * there offers none. Each runs on the account with the same label, else on + * the account the picker uses for that provider; two that land on the same + * account and model make one row, the exact label first. + */ +export function resolveFavorites(items: ModelFavorite[], machine: FavoriteMachine): ResolvedFavorite[] { + const placed = items.flatMap((item) => { + if (!machine.providerOn(item.providerId)) return []; + const accounts = machine.accountsOf(item.providerId); + const exact = item.account === undefined ? undefined : accounts.find((account) => account.label === item.account); + const account = exact ?? machine.usableAccountOf(item.providerId) ?? accounts[0]; + return account ? [{ item, accountId: account.id, exact: exact !== undefined }] : []; + }); + const rows = new Map(); + for (const pass of [true, false]) { + for (const { item, accountId, exact } of placed) { + if (exact !== pass) continue; + const key = JSON.stringify([item.providerId, accountId, item.model.id]); + const row = rows.get(key); + if (row) row.items.push(item); + else rows.set(key, { providerId: item.providerId, accountId, model: item.model, items: [item] }); + } + } + // The shared order, not the passes' order. + const order = new Map(items.map((item, index) => [item, index])); + const first = (row: ResolvedFavorite) => Math.min(...row.items.map((item) => order.get(item)!)); + return [...rows.values()].sort((a, b) => first(a) - first(b)); +} diff --git a/src/lib/model-picker/link.ts b/src/lib/model-picker/link.ts new file mode 100644 index 0000000..4b88e48 --- /dev/null +++ b/src/lib/model-picker/link.ts @@ -0,0 +1,41 @@ +/* + * The island's link to the core for the model picker: the companion owns the + * client, so one `CoreLink` per client follows it (`CoreLink.follow`) and + * keeps the providers, accounts, settings and model lists the picker reads, + * for as long as that client lives. An agent on another machine of the group + * reads that machine's providers and models (`GroupClient.at`), one link per + * machine. + */ +import type { Client } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { GroupClient } from '../group/group-client'; +import { PickerStore } from './store.svelte'; + +const stores = new WeakMap(); +const machines = new WeakMap>(); + +/** The picker's store over this client, made the first time it is asked for. */ +export function pickerStoreFor(client: Client): PickerStore { + const known = stores.get(client); + if (known) return known; + const link = new CoreLink(); + link.follow(client); + const store = new PickerStore(link); + stores.set(client, store); + return store; +} + +/** The picker's store for an agent's machine: this client's own, or the group member's that `coreId` names. */ +export function pickerStoreAt(client: Client, coreId: string | null): PickerStore | null { + if (coreId === null) return pickerStoreFor(client); + if (!(client instanceof GroupClient)) return null; + let byMachine = machines.get(client); + if (!byMachine) machines.set(client, (byMachine = new Map())); + const known = byMachine.get(coreId); + if (known) return known; + const link = new CoreLink(); + link.follow(client.at(coreId)); + const store = new PickerStore(link); + byMachine.set(coreId, store); + return store; +} diff --git a/src/lib/model-picker/store.svelte.test.ts b/src/lib/model-picker/store.svelte.test.ts new file mode 100644 index 0000000..1f7b8fe --- /dev/null +++ b/src/lib/model-picker/store.svelte.test.ts @@ -0,0 +1,151 @@ +/* + * The model picker's store over Bots's core link: Boite's starred models go + * through the core's settings both ways, the agents' lists are read and + * refreshed, and the companion's "Automatic" choice. + */ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { ObservableClient } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { FakeClient } from '../fake-client'; +import { readCompanionPrefs, type CompanionPrefs } from '../companion/prefs'; +import { strings } from '../strings'; +import CompanionModelPicker from '../../components/model-picker/CompanionModelPicker.svelte'; +import { companionChoice, companionPick } from './companion'; +import { favoritesIn } from './favorites'; +import { PickerStore } from './store.svelte'; + +const opus = { id: 'claude-opus-5-5', name: 'Opus 5.5' }; + +async function linked() { + const client = new FakeClient({ delayMs: 0 }); + await client.connect(); + const link = new CoreLink(); + const call = vi.spyOn(client, 'call'); + link.follow(client); + await vi.waitFor(() => expect(link.providers.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(link.settings).not.toBeNull()); + return { client, link, call, store: new PickerStore(link) }; +} + +let mounted: { component: Record; target: HTMLElement } | null = null; +afterEach(() => { + if (mounted) unmount(mounted.component); + mounted?.target.remove(); + mounted = null; + localStorage.clear(); +}); + +test('a star goes to the core’s settings, where Boite reads it, and Boite’s stars come back', async () => { + const { client, link, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + expect(store.favorites).toEqual([]); + + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id])); + const written = call.mock.calls.find(([method]) => method === 'settings.set')?.[1] as { modelFavorites: { at: number; items: unknown[] } }; + expect(written.modelFavorites.items).toEqual([{ providerId: 'claude', model: opus, account: account.label }]); + + // Boite reads the same list from the core. + const boite = favoritesIn(await client.call('settings.get', {})); + expect(boite.items).toEqual(written.modelFavorites.items); + + // Boite stars another model later: the core tells Bots, whose picker shows both. + const sonnet = { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: account.label }; + await client.call('settings.set', { modelFavorites: { at: boite.at + 1, items: [...boite.items, sonnet] } } as never); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id, sonnet.model.id])); + expect(store.favorites[1]!.accountId).toBe(account.id); + + // Unstarring here removes it there too. + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(favoritesIn(link.settings).items.map((item) => item.model.id)).toEqual([sonnet.model.id])); + expect(favoritesIn(await client.call('settings.get', {})).items.map((item) => item.model.id)).toEqual([sonnet.model.id]); + client.close(); +}); + +test('a paired device reads the stars and never writes them', async () => { + const { link, call } = await linked(); + Object.defineProperty(link, 'owner', { get: () => false }); + const store = new PickerStore(link); + store.toggleFavorite('claude', 'a-claude-main', opus); + expect(call.mock.calls.some(([method]) => method === 'settings.set')).toBe(false); +}); + +test('an agent’s list is read once, shown as pending meanwhile, and the refresh asks it again', async () => { + const { client, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + // Claude has no catalogue of its own: nothing to offer before it is asked. + expect(store.modelsOf('claude', account.id)).toEqual([]); + expect(store.modelsPending('claude', account.id)).toBe(false); + + const read = store.probeModels('claude', account.id); + expect(store.isProbing('claude', account.id)).toBe(true); + expect(store.modelsPending('claude', account.id)).toBe(true); + await read; + expect(store.modelsPending('claude', account.id)).toBe(false); + expect(store.modelsOf('claude', account.id).map((model) => model.id)).toContain(opus.id); + expect(store.catalogHas('claude', account.id, opus.id)).toBe(true); + expect(store.modelOf({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })?.name).toBe('Opus 5.5'); + + await store.probeModels('claude', account.id); + const probes = () => call.mock.calls.filter(([method]) => method === 'providers.probe'); + expect(probes()).toHaveLength(1); + await store.probeModels('claude', account.id, true); + expect(probes()).toHaveLength(2); + expect(probes()[1]![1]).toMatchObject({ providerId: 'claude', accountId: account.id, refresh: true }); + client.close(); +}); + +test('a failed refresh is told, a failed background read is not', async () => { + const link = new CoreLink(); + const client = { principal: 'owner', state: 'ready', call: vi.fn(async () => { throw new Error('the agent did not answer'); }), on: () => () => {}, connect: async () => {}, close: () => {} }; + link.follow(client as unknown as ObservableClient); + const store = new PickerStore(link); + await store.probeModels('opencode', 'a2'); + expect(store.error).toBeNull(); + await store.probeModels('opencode', 'a2', true); + expect(store.error).toBe('the agent did not answer'); +}); + +test('Automatic is a null provider; a chosen provider without a model shows its small one', async () => { + const { client, store } = await linked(); + const none = { providerId: null, accountId: null, model: null, effort: null }; + expect(companionChoice(none, store)).toBeNull(); + expect(companionPick('auto')).toEqual(none); + + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + const chosen = companionChoice({ providerId: 'claude', accountId: null, model: null, effort: 'high' }, store); + expect(chosen).toMatchObject({ providerId: 'claude', accountId: account.id, effort: 'high' }); + expect(chosen!.model).toMatch(/haiku/); + expect(companionChoice({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }, store)?.model).toBe(opus.id); + // A model picked starts on its own default level. + expect(companionPick({ providerId: 'claude', accountId: account.id, model: opus.id })).toEqual({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }); + client.close(); +}); + +test('the companion’s picker shows Automatic, and a model picked in it is written to the prefs', async () => { + const { client, link, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + await store.probeModels('claude', account.id); + const changes: CompanionPrefs[] = []; + const target = document.body.appendChild(document.createElement('div')); + const component = mount(CompanionModelPicker, { target, props: { store: link, prefs: readCompanionPrefs(), onchange: (next: CompanionPrefs) => changes.push(next) } }); + mounted = { component, target }; + flushSync(); + const trigger = target.querySelector('[data-testid="composer-picker"]')!; + expect(trigger.textContent).toContain(strings.modelPicker.auto); + + trigger.click(); + flushSync(); + // Open on the automatic column, its row marked as the one in use. + expect(document.querySelector('[data-provider="auto"]')?.classList.contains('current')).toBe(true); + expect(document.querySelector('[data-testid="picker-auto"]')?.classList.contains('current')).toBe(true); + + document.querySelector('[data-provider="claude"]')!.click(); + flushSync(); + await vi.waitFor(() => expect(document.querySelector(`[data-model="${opus.id}"]`)).not.toBeNull()); + document.querySelector(`[data-model="${opus.id}"]`)!.click(); + await vi.waitFor(() => expect(changes.at(-1)).toMatchObject({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })); + expect(readCompanionPrefs()).toMatchObject({ providerId: 'claude', model: opus.id }); + client.close(); +}); diff --git a/src/lib/model-picker/store.svelte.ts b/src/lib/model-picker/store.svelte.ts new file mode 100644 index 0000000..e487270 --- /dev/null +++ b/src/lib/model-picker/store.svelte.ts @@ -0,0 +1,176 @@ +/* + * What Boite's model picker asks of Boite's `Store`, on top of Bots's own + * link to the core (`lib/core.svelte.ts`): the providers the picker may + * offer, their accounts, the models each agent lists, the reads of those + * lists, and Boite's starred models. + * + * Each member follows the one of the same name in Boite at 4b6335b1: + * packages/ui/src/lib/store/accounts.svelte.ts (offeredProviders, providerOf, + * providerOn, accountsOf, accountOf, usableAccountOf, installOf) and + * packages/ui/src/lib/store/models.svelte.ts (modelsOf, modelOf, + * modelsPending, isProbing, probeModels, catalogHas, defaultModelOf, + * favorites, toggleFavorite). + * + * The favorites are Boite's own list, read from the core's settings and + * written back with `settings.set { modelFavorites }`, as Boite writes them: + * a star set in either app shows in the other. Boite's settings pages cannot + * be opened from here (`ui.reveal` shows a thread, an agent or the updates), + * so `opensSettings` is false and the picker leaves out what leads there. + */ +import { providerEnabled, type Account, type ModelInfo, type ProviderId, type ProviderInstallState, type ProviderSummary } from '@boite/contracts'; +import { DEFAULT_MODEL_NAMES, INITIAL_MODEL_DEFAULTS, BUILT_IN_MODELS, fallbackModelDefault, resolveModelDefault } from '@boite/ui/lib/model-defaults'; +import type { CoreLink } from '../core.svelte'; +import { favoritesIn, resolveFavorites, toggledFavorites, type ResolvedFavorite } from './favorites'; + +/** What a picker shows as chosen: Boite's `Choice` without the permission mode. */ +export interface PickerChoice { + providerId: ProviderId; + accountId: string; + model: string | null; + /** A level id of that model, or null for the model's own default. */ + effort: string | null; +} + +/** What the picker hands back: the instance and its model together. */ +export interface PickPatch { + providerId: ProviderId; + accountId: string; + model: string | null; +} + +/** The part of `CoreLink` the picker reads. */ +export type PickerLink = Pick< + CoreLink, + 'client' | 'owner' | 'providers' | 'accounts' | 'settings' | 'probedModels' | 'accountsOf' | 'modelsOf' | 'probeModels' | 'isProbing' | 'saveSettings' +>; + +const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; + +export class PickerStore { + /** A pick the picker could not make: shown under it, cleared by the next pick. */ + error = $state(null); + /** Boite counts its reconnections and page changes to drop a late pick; Bots's picker lives in one place. */ + readonly clientGeneration = 0; + readonly navigationGeneration = 0; + /** Boite's Providers page is not reachable from Bots. */ + readonly opensSettings = false; + + readonly favorites: ResolvedFavorite[] = $derived.by(() => resolveFavorites(favoritesIn(this.link.settings).items, this)); + + constructor(private readonly link: PickerLink) {} + + get owner(): boolean { + return this.link.owner; + } + + get providers(): ProviderSummary[] { + return this.link.providers; + } + + /** The providers a picker may offer: the ones turned on, in the core's order. */ + get offeredProviders(): ProviderSummary[] { + return this.link.providers.filter((provider) => providerEnabled(provider)); + } + + providerOf(id: ProviderId): ProviderSummary | null { + return this.link.providers.find((provider) => provider.id === id) ?? null; + } + + /** Whether this provider is known here and turned on. */ + providerOn(id: ProviderId): boolean { + return providerEnabled(this.providerOf(id)); + } + + accountsOf(providerId: ProviderId): Account[] { + return this.link.accountsOf(providerId); + } + + accountOf(id: string): Account | null { + const account = this.link.accounts.find((entry) => entry.id === id); + if (!account) return null; + return this.accountsOf(account.providerId).find((entry) => entry.id === id) ?? account; + } + + /** The account a turn of this provider runs on, as the core picks it: `preferred` unless signed out, else the best signed-in one. */ + usableAccountOf(providerId: ProviderId, preferred: string | null = null): Account | null { + const accounts = this.accountsOf(providerId); + const own = accounts.find((account) => account.id === preferred); + if (own && own.status !== 'unauthenticated') return own; + const rank = { ok: 0, unknown: 1, error: 2, unauthenticated: 3 } as const; + return accounts.filter((account) => account.id !== preferred && account.status !== 'unauthenticated').sort((a, b) => rank[a.status] - rank[b.status])[0] ?? null; + } + + /** Null when this provider ships no release for Boite to install. */ + installOf(id: ProviderId): ProviderInstallState | null { + return this.providerOf(id)?.install ?? null; + } + + modelsOf(providerId: ProviderId, accountId: string | null): ModelInfo[] { + return this.link.modelsOf(providerId, accountId); + } + + /** The model a choice runs on, out of what its own instance offers; the built-in default by name before a read. */ + modelOf(choice: PickerChoice | null): ModelInfo | null { + if (!choice) return null; + const offered = this.modelsOf(choice.providerId, choice.accountId).find((model) => model.id === choice.model); + if (offered) return offered; + if (!choice.model || choice.model !== INITIAL_MODEL_DEFAULTS[choice.providerId]?.model) return null; + const builtIn = BUILT_IN_MODELS[choice.providerId]; + return builtIn?.id === choice.model ? builtIn : { id: choice.model, name: DEFAULT_MODEL_NAMES[choice.model] ?? choice.model }; + } + + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return this.link.isProbing(providerId, accountId); + } + + /** The first read of this instance runs and nothing has answered yet: the picker shows it reading. */ + modelsPending(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && !this.link.probedModels[probeKey(providerId, accountId)] && this.isProbing(providerId, accountId); + } + + /** Asks the agent what it can run; a failure leaves the list as it was, as in Boite. */ + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { + const account = this.accountOf(accountId); + if (!refresh && (account?.status === 'unauthenticated' || account?.status === 'error')) return Promise.resolve(); + return this.link.probeModels(providerId, accountId, refresh).catch((error: unknown) => { + if (refresh) this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** The core already listed this model for this account: a pick lands at once. */ + catalogHas(providerId: ProviderId, accountId: string, model: string): boolean { + return this.link.probedModels[probeKey(providerId, accountId)]?.some((entry) => entry.id === model) ?? false; + } + + /** Boite keeps the user's own defaults in its window; Bots goes by the built-in ones and the catalogue. */ + defaultModelOf(provider: ProviderSummary, accountId?: string): string | null { + const models = accountId ? this.modelsOf(provider.id, accountId) : provider.models; + if (accountId && this.link.probedModels[probeKey(provider.id, accountId)]) { + const fallback = fallbackModelDefault(provider.id, models); + if (fallback) return fallback.model; + } + return INITIAL_MODEL_DEFAULTS[provider.id]?.model ?? resolveModelDefault(provider.id, models, {})?.model ?? null; + } + + /** + * Stars or unstars a model of this account, in Boite's list. The star names + * the account by label; unstarring removes every shared item the row stands + * for. A paired device reads the favorites and never writes them. + */ + toggleFavorite(providerId: ProviderId, accountId: string, model: { id: string; name: string }): void { + if (!this.owner) return; + const current = favoritesIn(this.link.settings); + const shown = this.favorites.find((entry) => entry.providerId === providerId && entry.accountId === accountId && entry.model.id === model.id); + const label = this.accountOf(accountId)?.label; + const next = shown + ? toggledFavorites(current, null, shown.items) + : toggledFavorites(current, { providerId, model: { id: model.id, name: model.name }, ...(label ? { account: label } : {}) }, []); + this.link.saveSettings({ modelFavorites: next }).catch((error: unknown) => { + this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** Boite's Providers page and connection dialog live in Boite's window: nothing opens from here. */ + showSettings(_page: 'accounts'): void {} + openConnect(): void {} +} diff --git a/src/lib/reveal.test.ts b/src/lib/reveal.test.ts index ca44e03..7184914 100644 --- a/src/lib/reveal.test.ts +++ b/src/lib/reveal.test.ts @@ -56,15 +56,66 @@ test('without a client yet, the reveal fails quietly too', async () => { expect(failed).toHaveBeenCalledTimes(1); }); -test("a thread of another machine is not revealed here: the notice names where it runs", async () => { +/** A group client whose thread `t-srv` runs on boite, `core-srv`; `features` is what this machine's core said at hello. */ +function groupWith(call: (method: string, params: unknown) => Promise, features: Record = {}): GroupClient { + const group = Object.create(GroupClient.prototype) as GroupClient; + Object.defineProperty(group, 'core', { value: { features } }); + return Object.assign(group, { + call, + selfId: () => 'core-here', + coreIdOf: (threadId: string) => (threadId === 't-srv' ? 'core-srv' : null), + machineOf: (threadId: string) => (threadId === 't-srv' ? 'boite' : null), + nameOf: (coreId: string) => (coreId === 'core-srv' ? 'boite' : null) + }); +} + +test('a thread of another machine goes to this machine’s Boite with its machine named', async () => { const call = vi.fn(async () => ({ delivered: 1 })); const failed = vi.fn(); - const group = Object.assign(Object.create(GroupClient.prototype) as GroupClient, { call, machineOf: (threadId: string) => (threadId === 't-srv' ? 'Serveur' : null) }); - useReveal(group, failed); + useReveal(groupWith(call), failed); + expect(await reveal({ kind: 'thread', threadId: 't-srv' })).toBeNull(); + expect(call).toHaveBeenCalledWith('ui.reveal', { target: { kind: 'thread', threadId: 't-srv', coreId: 'core-srv' } }); + // This machine's own thread names none, as an older core expects. + await showMain('t-1'); + expect(call).toHaveBeenLastCalledWith('ui.reveal', { target: { kind: 'thread', threadId: 't-1' } }); + expect(failed).not.toHaveBeenCalled(); +}); + +test('a Boite that does not know machines refuses the thread: the notice says where it runs', async () => { + const call = vi.fn(async () => { + throw new RpcFailure({ code: RpcErrorCode.InvalidParams, message: 'ui.reveal target.threadId t-srv is not a thread of this core' }); + }); + const failed = vi.fn(); + useReveal(groupWith(call), failed); const told = await reveal({ kind: 'thread', threadId: 't-srv' }); - expect(told).toBe(fill(strings.bots.revealElsewhere, { machine: 'Serveur' })); + expect(told).toBe(fill(strings.bots.revealElsewhere, { machine: 'boite' })); expect(failed).toHaveBeenCalledWith(told); +}); + +test('an agent of another machine goes only to a Boite known to take machines', async () => { + // Unknown: an older core would open an agent of its own with that id. Said instead of sent. + const call = vi.fn(async () => ({ delivered: 1 })); + const failed = vi.fn(); + useReveal(groupWith(call), failed); + await showAgent('agent-forge', 'core-srv'); expect(call).not.toHaveBeenCalled(); - await showMain('t-1'); - expect(call).toHaveBeenCalledWith('ui.reveal', { target: { kind: 'thread', threadId: 't-1' } }); -}); \ No newline at end of file + expect(failed).toHaveBeenCalledWith(fill(strings.bots.revealAgentElsewhere, { machine: 'boite' })); + + // Its hello says so. + const said = vi.fn(async () => ({ delivered: 1 })); + useReveal(groupWith(said, { revealMachines: true }), () => {}); + await showAgent('agent-forge', 'core-srv'); + expect(said).toHaveBeenCalledWith('ui.reveal', { target: { kind: 'agent', agentId: 'agent-forge', coreId: 'core-srv' } }); + + // Or it already showed a thread of another machine. + const shown = vi.fn(async () => ({ delivered: 1 })); + const group = groupWith(shown); + useReveal(group, () => {}); + await showMain('t-srv'); + await showAgent('agent-forge', 'core-srv'); + expect(shown).toHaveBeenLastCalledWith('ui.reveal', { target: { kind: 'agent', agentId: 'agent-forge', coreId: 'core-srv' } }); + + // This machine's agent names no machine. + await showAgent('agent-1', null); + expect(shown).toHaveBeenLastCalledWith('ui.reveal', { target: { kind: 'agent', agentId: 'agent-1' } }); +}); diff --git a/src/lib/reveal.ts b/src/lib/reveal.ts index 1a0871b..6ef3f9f 100644 --- a/src/lib/reveal.ts +++ b/src/lib/reveal.ts @@ -3,6 +3,14 @@ * `ui.reveal`, which the core passes to Boite's open windows and answers with * how many took it. Bots's windows have no page of their own for a thread. * + * A thread or an agent of another machine of the group goes to this + * machine's core all the same, its machine named by `coreId`: Boite's window + * here opens it under that machine. A core older than that refuses a thread + * it does not hold, and the notice says where it runs instead, as Bots did + * before. It would take an agent's id for one of its own, so an agent of + * another machine goes only to a core that says it knows machines + * (`features.revealMachines`), or that already showed a thread of another. + * * A Boite older than the RPC answers "method not found": that, like any other * failure, comes back as a short sentence for the window to show, never as a * thrown error. @@ -13,11 +21,13 @@ import { GroupClient } from './group/group-client'; import { fill, strings } from './strings'; /** - * `update`, Boite's app updates in its settings, came with beboite/boite#418: - * the contract in vendor/boite does not name it yet. An older core refuses it - * as invalid params. + * What Boite's window may be asked to show. `update`, Boite's app updates in + * its settings, came with beboite/boite#418; `coreId`, the machine a thread or + * an agent lives on, with the reveal across machines. The contract in + * vendor/boite names neither yet: an older core refuses `update` as invalid + * params, and a thread of another machine as not its own. */ -export type RevealTarget = UiRevealTarget | { kind: 'update' }; +export type RevealTarget = { kind: 'thread'; threadId: string; coreId?: string } | { kind: 'agent'; agentId: string; coreId?: string } | { kind: 'update' }; let client: Client | null = null; let onfail: ((message: string) => void) | null = null; @@ -37,23 +47,55 @@ export function revealProblem(error: unknown, target?: RevealTarget): string { return fill(strings.bots.revealFailed, { reason: reason.length > 120 ? `${reason.slice(0, 119)}…` : reason }); } +/** The cores that showed a thread of another machine: they know machines, whatever their features say. */ +const knowsMachines = new WeakSet(); + +function takesMachines(client: Client): boolean { + const features = (client as { core?: { features?: Record } | null }).core?.features; + return features?.['revealMachines'] === true || knowsMachines.has(client); +} + +/** The machine the target lives on, when it is another of the group: its id and its name. */ +function machineOf(target: RevealTarget, group: GroupClient | null): { coreId: string; name: string } | null { + if (target.kind === 'update' || !group) return null; + const coreId = target.coreId ?? (target.kind === 'thread' ? group.coreIdOf(target.threadId) : null); + if (coreId === null || coreId === group.selfId()) return null; + return { coreId, name: group.nameOf(coreId) ?? (target.kind === 'thread' ? group.machineOf(target.threadId) : null) ?? coreId }; +} + +/** The target as a core of any age takes it: a thread or an agent of this machine names no machine. */ +function here(target: RevealTarget): UiRevealTarget { + if (target.kind === 'thread') return { kind: 'thread', threadId: target.threadId }; + if (target.kind === 'agent') return { kind: 'agent', agentId: target.agentId }; + return target as unknown as UiRevealTarget; +} + +function told(message: string): string { + onfail?.(message); + return message; +} + /** Asks Boite to show the target. Null when it went out, else the sentence that was told. */ export async function reveal(target: RevealTarget): Promise { try { if (!client) throw new Error(strings.bots.waiting); - // A thread of another machine: this machine's Boite takes `ui.reveal` for its own threads only, - // and that machine's core would bring Boite up over there. Said instead of sent. - const machine = target.kind === 'thread' && client instanceof GroupClient ? client.machineOf(target.threadId) : null; - if (machine !== null) { - const message = fill(strings.bots.revealElsewhere, { machine }); - onfail?.(message); - return message; + const machine = machineOf(target, client instanceof GroupClient ? client : null); + if (machine === null) { + await client.call('ui.reveal', { target: here(target) }); + return null; + } + const elsewhere = fill(target.kind === 'agent' ? strings.bots.revealAgentElsewhere : strings.bots.revealElsewhere, { machine: machine.name }); + if (target.kind === 'agent' && !takesMachines(client)) return told(elsewhere); + try { + await client.call('ui.reveal', { target: { ...target, coreId: machine.coreId } as never }); + } catch (error) { + // A core that does not know that machine, or does not know machines yet: where it runs is said instead. + if (error && typeof error === 'object' && (error as { code?: unknown }).code === RpcErrorCode.InvalidParams) return told(elsewhere); + throw error; } - await client.call('ui.reveal', { target: target as UiRevealTarget }); + if (target.kind === 'thread') knowsMachines.add(client); return null; } catch (error) { - const message = revealProblem(error, target); - onfail?.(message); - return message; + return told(revealProblem(error, target)); } } diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index 3b8c711..e223b49 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -17,9 +17,12 @@ export const strings = { revealUnsupported: 'This version of Boite cannot show it. Update Boite, then try again.', /** A thread of another machine of the group: Boite's window here cannot be pointed at it yet. */ revealElsewhere: 'This thread runs on {machine}: open it in Boite, under that machine.', + /** An agent of another machine, on a Boite that cannot be pointed at it yet. */ + revealAgentElsewhere: 'This agent lives on {machine}: open it in Boite, under that machine.', + /** The chip on a thread or an agent of another machine of the group. */ + onMachine: 'On {machine}', /** A call for a thread whose machine is not connected now. */ machineAway: '{machine} is out of reach right now.', - settingsTitle: 'B.O.T.S settings', tray: { tooltip: 'B.O.T.S', settings: 'Settings', quit: 'Quit' }, demo: 'Demo mode: a fake Boite, nothing is sent.', /** What the demo asks the lead robot first, for a chat exchange to show. */ @@ -30,6 +33,22 @@ export const strings = { island: { label: 'B.O.T.S', settings: 'Settings', + /** The settings, a panel of the island under the deck's bar (`island/settings/SettingsPanel.svelte`). */ + settingsPanel: { + sections: 'Settings sections', + names: { + place: 'Place', + mascot: 'Mascot', + crew: 'Crew', + brain: 'Brain and model', + island: 'Island', + clipboard: 'Clipboard', + focus: 'Focus', + reminders: 'Reminders', + missions: 'Missions', + permissions: 'Permissions' + } + }, mute: 'Mute the sounds', unmute: 'Turn the sounds on', pin: 'Keep the island open', @@ -121,14 +140,6 @@ export const strings = { notKept: 'The files could not be passed on outside the app.', notStopped: 'The answer could not be stopped: {reason}', notChanged: 'The model could not be changed: {reason}', - picker: 'Model and effort', - model: 'Model', - effort: 'Effort', - efforts: { low: 'Low', medium: 'Medium', high: 'High' }, - defaultModel: 'Default model', - defaultEffort: 'Default effort', - everywhere: 'This applies to {name} everywhere, in Boite too.', - noModels: 'The agent lists no models yet.', quotas: { label: 'Quotas: {percent}% left on {name}, {window}. Show every account.', title: 'Quotas', @@ -482,7 +493,6 @@ export const strings = { }, companion: { - heading: 'B.O.T.S', moods: { idle: 'Resting', working: 'At work', calling: 'Needs you', happy: 'Done', worried: 'Boite is not answering' }, open: 'Open B.O.T.S', close: 'Close B.O.T.S', @@ -693,8 +703,6 @@ export const strings = { snooze: 'In 10 min', snoozeLabel: 'Remind me again in 10 minutes', settings: { - intro: 'B.O.T.S puts your agents on your screen: one robot per agent. Click one to ask it something or to answer your agents, drag it to move it. Each robot talks in its own conversation, which you also find in Boite’s Agents page.', - desktopOnly: 'The robots run in the B.O.T.S app: these settings take effect there.', place: 'Place', screen: 'Screen', screenHint: 'The screen B.O.T.S sits on. A screen that is unplugged sends it back to the main one.', @@ -758,7 +766,6 @@ export const strings = { bar: 'Island bar', barHint: 'Add, rename, move or take out the tabs, controls, apps and links of the open island’s bar, right on the island. A right-click on the bar does it too.', barEdit: 'Edit', - barDone: 'Done', pillRest: 'Pill at rest', pillRestHint: 'What the closed island shows when nothing else needs it. Requests and finished threads still show over it.', pillRests: { live: 'Live line', quota: 'Quota', music: 'Music', timer: 'Timer', robot: 'Robot only' }, @@ -821,6 +828,38 @@ export const strings = { attachTotalTooLarge: 'The files of one turn weigh {max} at most together, so {name} was left out.' }, + modelPicker: { + connectProvider: 'Connect a provider', + picker: 'Provider and model', + models: 'Models', + noModels: 'No model matches', + noModelsAvailable: 'The agent has not listed any models. Refresh to retry.', + showAllModels: 'Show all {count} models', + legacyModels: 'Legacy models', + refreshModels: 'Refresh models', + probing: "Reading the agent's models", + notInstalled: 'Not installed on this machine', + installInSettings: 'Install in Settings', + favorites: 'Favorites', + favorite: 'Add to favorites', + unfavorite: 'Remove from favorites', + favoritesEmpty: 'Star a model to find it here.', + favoriteUnavailable: 'This favorite is no longer offered by the account. Choose another model.', + newBadge: 'new', + lockedHint: 'A thread keeps its provider and account', + unavailable: 'not installed', + noAccount: 'no account', + signedOut: 'not signed in', + noProvider: 'No AI connected', + accounts: 'Providers', + back: 'Back', + effortTitle: 'Reasoning effort', + standardSpeed: 'Standard speed', + speed: 'Response speed', + auto: 'Automatic', + autoHint: 'The first agent that is on and signed in, on its small model.' + }, + quotas: { remaining: '{percent}% left' } diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 1caed5f..9a2e64e 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -13,8 +13,9 @@ export const fr: Messages = { revealFailed: 'Boite n’a pas pu l’afficher : {reason}', revealUnsupported: 'Cette version de Boite ne sait pas encore l’afficher. Mettez Boite à jour, puis réessayez.', revealElsewhere: 'Ce fil tourne sur {machine} : ouvrez-le dans Boite, sous cette machine.', + revealAgentElsewhere: 'Cet agent vit sur {machine} : ouvrez-le dans Boite, sous cette machine.', + onMachine: 'Sur {machine}', machineAway: '{machine} est injoignable pour le moment.', - settingsTitle: 'Réglages de B.O.T.S', tray: { tooltip: 'B.O.T.S', settings: 'Réglages', quit: 'Quitter' }, demo: 'Mode démo : un faux Boite, rien n’est envoyé.', demoQuestion: 'Sur quoi travaillent mes agents ?' @@ -23,6 +24,21 @@ export const fr: Messages = { island: { label: 'B.O.T.S', settings: 'Réglages', + settingsPanel: { + sections: 'Rubriques des réglages', + names: { + place: 'Emplacement', + mascot: 'Mascotte', + crew: 'Équipe', + brain: 'Cerveau et modèle', + island: 'Île', + clipboard: 'Presse-papiers', + focus: 'Focus', + reminders: 'Rappels', + missions: 'Missions', + permissions: 'Autorisations' + } + }, mute: 'Couper les sons', unmute: 'Remettre les sons', pin: 'Garder l’île ouverte', @@ -109,14 +125,6 @@ export const fr: Messages = { notKept: 'Les fichiers ne peuvent pas être transmis hors de l’application.', notStopped: 'Impossible d’arrêter la réponse : {reason}', notChanged: 'Impossible de changer de modèle : {reason}', - picker: 'Modèle et effort', - model: 'Modèle', - effort: 'Effort', - efforts: { low: 'Faible', medium: 'Moyen', high: 'Élevé' }, - defaultModel: 'Modèle par défaut', - defaultEffort: 'Effort par défaut', - everywhere: 'Ce choix s’applique à {name} partout, dans Boite aussi.', - noModels: 'L’agent n’indique encore aucun modèle.', quotas: { label: 'Quotas : il reste {percent} % sur {name}, {window}. Afficher tous les comptes.', title: 'Quotas', @@ -460,7 +468,6 @@ export const fr: Messages = { }, companion: { - heading: 'B.O.T.S', moods: { idle: 'Au repos', working: 'Au travail', calling: 'A besoin de vous', happy: 'Terminé', worried: 'Boite ne répond pas' }, open: 'Ouvrir B.O.T.S', close: 'Fermer B.O.T.S', @@ -671,8 +678,6 @@ export const fr: Messages = { snooze: 'Dans 10 min', snoozeLabel: 'Me le rappeler dans 10 minutes', settings: { - intro: 'B.O.T.S met vos agents sur votre écran : un robot par agent. Cliquez sur l’un d’eux pour lui demander quelque chose ou pour répondre à vos agents, faites-le glisser pour le déplacer. Chaque robot parle dans sa propre conversation, que vous retrouvez aussi dans la page Agents de Boite.', - desktopOnly: 'Les robots tournent dans l’application B.O.T.S : ces réglages s’y appliquent.', place: 'Emplacement', screen: 'Écran', screenHint: 'L’écran où se tient B.O.T.S. S’il est débranché, B.O.T.S revient sur l’écran principal.', @@ -736,7 +741,6 @@ export const fr: Messages = { bar: 'Barre de l’île', barHint: 'Ajoutez, renommez, déplacez ou retirez les onglets, contrôles, applis et liens de la barre de l’île ouverte, directement sur l’île. Un clic droit sur la barre y mène aussi.', barEdit: 'Modifier', - barDone: 'Terminé', pillRest: 'Pilule au repos', pillRestHint: 'Ce que montre l’île fermée quand rien d’autre ne la demande. Les demandes et les fils terminés passent toujours devant.', pillRests: { live: 'Ligne en direct', quota: 'Quota', music: 'Musique', timer: 'Minuteur', robot: 'Robot seul' }, @@ -799,6 +803,38 @@ export const fr: Messages = { attachTotalTooLarge: 'Les fichiers d’un tour pèsent {max} au maximum à eux tous, donc {name} a été laissé de côté.' }, + modelPicker: { + connectProvider: 'Connecter un fournisseur', + picker: 'Fournisseur et modèle', + models: 'Modèles', + noModels: 'Aucun modèle ne correspond', + noModelsAvailable: "L'agent n'a renvoyé aucun modèle. Actualisez pour réessayer.", + showAllModels: 'Afficher les {count} modèles', + legacyModels: 'Anciens modèles', + refreshModels: 'Actualiser les modèles', + probing: "Lecture des modèles de l'agent", + notInstalled: 'Pas installé sur cette machine', + installInSettings: 'Installer dans les réglages', + favorites: 'Favoris', + favorite: 'Ajouter aux favoris', + unfavorite: 'Retirer des favoris', + favoritesEmpty: 'Mettez une étoile à un modèle pour le retrouver ici.', + favoriteUnavailable: "Ce favori n'est plus proposé par le compte. Choisissez un autre modèle.", + newBadge: 'nouveau', + lockedHint: 'Une conversation garde son fournisseur et son compte', + unavailable: 'pas installé', + noAccount: 'aucun compte', + signedOut: 'non connecté', + noProvider: 'Aucune IA connectée', + accounts: 'Fournisseurs', + back: 'Retour', + effortTitle: 'Niveau de raisonnement', + standardSpeed: 'Vitesse normale', + speed: 'Vitesse de réponse', + auto: 'Automatique', + autoHint: 'Le premier agent activé et connecté, sur son petit modèle.' + }, + quotas: { remaining: '{percent} % restants' } diff --git a/src/lib/tray.svelte.ts b/src/lib/tray.svelte.ts index d761834..2020e43 100644 --- a/src/lib/tray.svelte.ts +++ b/src/lib/tray.svelte.ts @@ -1,8 +1,8 @@ /* - * The tray menu's labels, and the settings window's title, in the language - * the app speaks: the shell builds the menu in English before any page has - * loaded (`src-tauri/src/lib.rs`), and the companion's window renames it once - * its language is known, then again whenever it changes. + * The tray menu's labels, in the language the app speaks: the shell builds + * the menu in English before any page has loaded (`src-tauri/src/tray.rs`), + * and the companion's window renames it once its language is known, then + * again whenever it changes. */ import { activeLocale } from '@boite/ui/lib/i18n.svelte'; import { inShell } from './companion/shell'; @@ -11,8 +11,8 @@ import { strings } from './strings'; export async function nameTray(): Promise { if (!inShell()) return; const { invoke } = await import('@tauri-apps/api/core'); - const { tray, settingsTitle } = strings.bots; - await invoke('bots_tray_labels', { tooltip: tray.tooltip, settings: tray.settings, quit: tray.quit, title: settingsTitle }).catch(() => {}); + const { tray } = strings.bots; + await invoke('bots_tray_labels', { tooltip: tray.tooltip, settings: tray.settings, quit: tray.quit }).catch(() => {}); } /** Names the tray now and after every language change; returns the cleanup. */ diff --git a/src/main.ts b/src/main.ts index 20ef2d4..c254a5c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ /* - * The page's entry: the companion's transparent window, or Bots's settings - * window on `?view=settings`. The language comes first, so nothing is drawn - * in English and swapped a tick later. + * The page's entry: the companion's transparent window, which draws the + * island, the robots and the settings panel (the dev bundle adds + * `?view=mascots`, every companion family side by side). The language comes + * first, so nothing is drawn in English and swapped a tick later. */ import '@boite/ui/app.css'; import './bots.css'; @@ -10,11 +11,11 @@ import { startLocale } from '@boite/ui/lib/i18n.svelte'; import { followTrayLocale } from './lib/tray.svelte'; const target = document.getElementById('app')!; -const settings = new URLSearchParams(location.search).get('view') === 'settings'; -document.documentElement.dataset.view = settings ? 'settings' : 'bots'; +// Every companion family side by side, for captures: the dev bundle only. +const mascots = import.meta.env.DEV && new URLSearchParams(location.search).get('view') === 'mascots'; void startLocale().then(async () => { - const page = settings ? (await import('./SettingsApp.svelte')).default : (await import('./App.svelte')).default; + const page = mascots ? (await import('./MascotGallery.svelte')).default : (await import('./App.svelte')).default; mount(page, { target }); - if (!settings) followTrayLocale(); + if (!mascots) followTrayLocale(); });