From 18b2803f4a8577292584a23d6af4d5fc175823da Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:57:52 +0200 Subject: [PATCH 1/5] feat(agents): B.O.T.S's face by default and as a choice in the robot picker --- src/components/agents/RobotPicker.svelte | 17 +++++++++++++---- src/lib/companion/companion-crew.test.ts | 14 ++++++++++---- src/lib/companion/crew.ts | 16 +++++++++++++--- src/lib/fake-client/assistant-demo.ts | 3 ++- src/lib/strings.en.ts | 1 + src/lib/strings.fr.ts | 1 + 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/components/agents/RobotPicker.svelte b/src/components/agents/RobotPicker.svelte index c98ab76..7262d59 100644 --- a/src/components/agents/RobotPicker.svelte +++ b/src/components/agents/RobotPicker.svelte @@ -1,6 +1,7 @@ + +
+ + + + {#if said}{said}{/if} + +
+ + diff --git a/src/components/island/deck/DeckBar.svelte b/src/components/island/deck/DeckBar.svelte index 2dcb9cf..a46fa5d 100644 --- a/src/components/island/deck/DeckBar.svelte +++ b/src/components/island/deck/DeckBar.svelte @@ -1,6 +1,7 @@
- +
+ ({ id, revision: 1, createdAt: 0, updatedAt: 0, name, domain: '', instructions: '', avatar: '', selection: { providerId: 'claude', accountId: 'a', model: null, effort: null, permissionMode: 'default' }, status: 'active', tools: [], accountIntegration: 'provider' }) as AgentProfile; + +const snapshotOf = (...profiles: AgentProfile[]) => ({ profiles, messages: [], sessions: [], work: [], memories: [], deliveries: [], more: { message: false } }) as unknown as AgentsSnapshot; + +class FakeCompanion { + kept: string | null = null; + pokes: string[] = []; + bounces: string[] = []; + readonly assistant: Assistant; + readonly members: Member[]; + readonly active: Member; + dropped = false; + boings: Record = {}; + dizzy: Record = {}; + notes = { alarms: [] }; + mood = { mood: 'idle', busy: false }; + pointerOn = false; + look = {}; + blocking = 0; + senses = { pointer: null }; + dragging = false; + + constructor(snapshot: AgentsSnapshot, kept: string | null = null) { + this.kept = kept; + this.assistant = new Assistant({ + client: () => null, + snapshot: () => snapshot, + reload: () => {}, + remembered: () => this.kept, + remember: (key: string) => void (this.kept = key), + fallback: () => null, + outgoing: () => [], + keep: async () => [], + sent: () => {}, + giveBack: () => {}, + context: () => '' + } as unknown as ConstructorParameters[0]); + const lead = snapshot.profiles[0]!; + this.active = { id: lead.id, name: lead.name, skin: null, talk: { thinking: false } } as unknown as Member; + this.members = [this.active]; + } + + poke(id: string) { + this.pokes.push(id); + } + bounce(id: string) { + this.bounces.push(id); + } + onpress() {} + onmove() {} + onrelease() {} +} + +let shown: { part: Record; target: HTMLElement } | null = null; + +function show(companion: FakeCompanion): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + const props = { companion: companion as unknown as Companion, lead: companion.members[0]!, cheering: false, reduced: true }; + shown = { part: mount(DeckAgent, { target, props }), target }; + flushSync(); + return target; +} + +afterEach(() => { + if (shown) { + unmount(shown.part); + shown.target.remove(); + shown = null; + } +}); + +const robotOf = (target: HTMLElement) => target.querySelector('[data-testid=island-robot]')!; + +test('a click swaps to the next agent, which the Chat talks to and keeps, then round to the first', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno'))); + const target = show(companion); + const robot = robotOf(target); + expect(robot.dataset.agent).toBe('bots'); + expect(robot.getAttribute('aria-label')).toBe(fill(strings.island.agentSwap.label, { name: 'Bots', next: 'Nuno' })); + expect(robot.title).toBe(fill(strings.island.agentSwap.tip, { next: 'Nuno' })); + + robot.click(); + flushSync(); + expect(companion.assistant.agentId).toBe('nuno'); + expect(companion.kept).toBe('nuno'); + expect(companion.bounces).toEqual(['nuno']); + expect(robotOf(target).dataset.agent).toBe('nuno'); + expect(target.querySelector('[data-testid=island-agent-name]')?.textContent).toContain('Nuno'); + expect(robotOf(target).getAttribute('aria-label')).toBe(fill(strings.island.agentSwap.label, { name: 'Nuno', next: 'Bots' })); + + robotOf(target).click(); + flushSync(); + expect(companion.assistant.agentId).toBe('bots'); + expect(companion.kept).toBe('bots'); + expect(companion.pokes).toEqual([]); +}); + +test('the kept agent shows first at the next launch', () => { + const target = show(new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno')), 'nuno')); + expect(robotOf(target).dataset.agent).toBe('nuno'); +}); + +test('with one agent, a click pokes it as before and nothing swaps', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'))); + const target = show(companion); + const robot = robotOf(target); + expect(robot.getAttribute('aria-label')).toBe(fill(strings.island.robot, { name: 'Bots' })); + robot.click(); + flushSync(); + expect(companion.pokes).toEqual(['bots']); + expect(companion.kept).toBeNull(); + expect(robotOf(target).dataset.agent).toBe('bots'); +}); + +test('a drag that just ended is no click', () => { + const companion = new FakeCompanion(snapshotOf(profile('bots', 'Bots'), profile('nuno', 'Nuno'))); + const target = show(companion); + companion.dropped = true; + robotOf(target).click(); + flushSync(); + expect(companion.assistant.agentId).toBe('bots'); + expect(companion.kept).toBeNull(); +}); diff --git a/src/lib/fake-client/assistant-demo.ts b/src/lib/fake-client/assistant-demo.ts index 9a90345..c3430ba 100644 --- a/src/lib/fake-client/assistant-demo.ts +++ b/src/lib/fake-client/assistant-demo.ts @@ -82,7 +82,7 @@ export function rowsOf(ctx: FakeContext): Map { function seedAgents(ctx: FakeContext, now: number): void { const rows = rowsOf(ctx); const made = at(now, 600); - // Atlas chose no face (it wears B.O.T.S's), Ledger a retro robot. + // Atlas chose no face (it wears B.O.T.S's, or the mascot on the island while no row is made), Ledger a retro robot. const agents = [ profile(ASSISTANT_DEMO_AGENT, 'Atlas', 'Research and notes', 'claude-sonnet-5-5', made), { ...profile(OTHER_AGENT, 'Ledger', 'Invoices and accounts', null, made + MINUTE), avatar: 'bot:c.1.6.2.1' } diff --git a/src/lib/island/swap.test.ts b/src/lib/island/swap.test.ts new file mode 100644 index 0000000..3021f97 --- /dev/null +++ b/src/lib/island/swap.test.ts @@ -0,0 +1,65 @@ +/* + * The deck's top-left character: the next agent a click turns to, and the + * look each one wears on the island. + */ +import { expect, test } from 'vitest'; +import type { AgentProfile } from '@boite/contracts'; +import { CLASSIC_ROBOT } from '../companion/crew'; +import { encodeRobot, type Robot } from '../robots'; +import { islandMember, islandSkin, nextAgent } from './swap'; + +const keys = (...names: string[]) => names.map((key) => ({ key })); + +test('a click turns to the next agent, round to the first after the last', () => { + const agents = keys('bots', 'nuno', 'core-b/scout'); + expect(nextAgent(agents, 'bots')?.key).toBe('nuno'); + expect(nextAgent(agents, 'nuno')?.key).toBe('core-b/scout'); + expect(nextAgent(agents, 'core-b/scout')?.key).toBe('bots'); + // Every agent is reached once before the first comes back. + const seen: string[] = []; + let at: string | null = 'bots'; + for (let step = 0; step < agents.length; step++) seen.push((at = nextAgent(agents, at)!.key)); + expect(seen).toEqual(['nuno', 'core-b/scout', 'bots']); +}); + +test('two agents swap back and forth; with one or none there is nothing to turn to', () => { + const pair = keys('bots', 'nuno'); + expect(nextAgent(pair, 'bots')?.key).toBe('nuno'); + expect(nextAgent(pair, 'nuno')?.key).toBe('bots'); + expect(nextAgent(keys('bots'), 'bots')).toBeNull(); + expect(nextAgent([], null)).toBeNull(); +}); + +test('an agent no longer listed turns to the one after the first', () => { + expect(nextAgent(keys('bots', 'nuno', 'atlas'), 'gone')?.key).toBe('nuno'); + expect(nextAgent(keys('bots', 'nuno'), null)?.key).toBe('nuno'); +}); + +const profile = (id: string, avatar = ''): AgentProfile => + ({ id, revision: 1, createdAt: 0, updatedAt: 0, name: id, domain: '', instructions: '', avatar, selection: { providerId: 'claude', accountId: 'a', model: null, effort: null, permissionMode: 'default' }, status: 'active', tools: [], accountIntegration: 'provider' }) as AgentProfile; +const local = (id: string, avatar = '') => ({ key: id, coreId: null, profile: profile(id, avatar) }); + +const mascot: Robot = { family: 'jelly', shape: 1, color: 2, eyes: 3, top: 0 }; +const retro: Robot = { family: 'retro', shape: 1, color: 6, eyes: 2, top: 1 }; + +test('on the island, an agent of the row wears its look there (the lead the mascot), another its own face', () => { + const members = [{ id: 'bots', skin: mascot }, { id: 'nuno', skin: retro }]; + expect(islandSkin(local('bots'), members)).toEqual(mascot); + expect(islandSkin(local('nuno', encodeRobot(retro)), members)).toEqual(retro); + // Outside the row: its own face, B.O.T.S's when it chose none. + expect(islandSkin(local('atlas'), members)).toEqual(CLASSIC_ROBOT); + expect(islandSkin(local('ledger', encodeRobot(retro)), members)).toEqual(retro); + // Another machine's agent of the same id is not this machine's lead. + expect(islandSkin({ key: 'core-b/bots', coreId: 'core-b', profile: profile('bots') }, members)).toEqual(CLASSIC_ROBOT); +}); + +test('with no row made, the agent the Chat lands on unasked is the island’s lone character', () => { + const lone = [{ id: '', skin: mascot }]; + expect(islandMember(local('atlas'), lone, 'atlas')).toBe(lone[0]); + expect(islandSkin(local('atlas'), lone, 'atlas')).toEqual(mascot); + // Any other agent wears its own face. + expect(islandMember(local('ledger', encodeRobot(retro)), lone, 'atlas')).toBeUndefined(); + expect(islandSkin(local('ledger', encodeRobot(retro)), lone, 'atlas')).toEqual(retro); + // No mascot chosen: B.O.T.S's own face, as the pill shows. + expect(islandSkin(local('atlas'), [{ id: '', skin: null }], 'atlas')).toEqual(CLASSIC_ROBOT); +}); diff --git a/src/lib/island/swap.ts b/src/lib/island/swap.ts new file mode 100644 index 0000000..4f93a38 --- /dev/null +++ b/src/lib/island/swap.ts @@ -0,0 +1,39 @@ +/* + * The deck's top-left character: the agent the Chat talks to + * (`assistant.svelte.ts`), which a click turns to the next of the user's + * agents (`deck/DeckAgent.svelte`). The Chat keeps the one chosen. Pure, so + * it is tested without a core. + */ +import { skinOf } from '../companion/crew'; +import type { PlacedAgent } from '../group/agents'; +import type { Robot } from '../robots'; + +type Standing = readonly (M & { id: string })[]; + +/** The agent after `current`, back to the first after the last; null when there is no other to turn to. */ +export function nextAgent(agents: readonly T[], current: string | null): T | null { + if (agents.length < 2) return null; + const at = Math.max(0, agents.findIndex((agent) => agent.key === current)); + return agents[(at + 1) % agents.length]!; +} + +/** + * The companion's row member an agent is on the island: the one of its id on + * this machine. With no row made yet, the island's lone character (id '') is + * the agent the Chat lands on unasked, `first`. + */ +export function islandMember(agent: Pick, members: Standing, first: string | null = null): (M & { id: string }) | undefined { + if (agent.coreId !== null) return undefined; + const lone = members.length === 1 && members[0]!.id === '' ? members[0] : undefined; + if (lone) return agent.key === first ? lone : undefined; + return members.find((one) => one.id === agent.profile.id); +} + +/** + * What an agent wears on the island: its look in the companion's row when it + * stands there (the lead wears the mascot chosen in Settings), else its own + * face, B.O.T.S's when it chose none. + */ +export function islandSkin(agent: Pick, members: Standing<{ skin: Robot | null }>, first: string | null = null): Robot { + return islandMember(agent, members, first)?.skin ?? skinOf(agent.profile); +} diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index 18199b3..b919e27 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -103,6 +103,7 @@ export const strings = { }, talking: '{name} is answering…', robot: '{name}. Click to poke it, drag it out to set it loose on the desktop.', + agentSwap: { label: '{name}, the agent the Chat talks to. Click to switch to {next}, drag it out to set it loose on the desktop.', tip: 'Switch to {next}' }, chat: { empty: 'Ask your bots anything: they answer here.', to: 'Talk to', diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 5b5dd60..60ddaf2 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -88,6 +88,7 @@ export const fr: Messages = { }, talking: '{name} répond…', robot: '{name}. Cliquez pour le taquiner, faites-le glisser hors de l’île pour le poser sur le bureau.', + agentSwap: { label: '{name}, l’agent à qui parle le Chat. Cliquez pour passer à {next}, faites-le glisser hors de l’île pour le poser sur le bureau.', tip: 'Passer à {next}' }, chat: { empty: 'Demandez ce que vous voulez à vos bots : ils répondent ici.', to: 'Parler à', From 57383157450f19b01659b9f2acba7d381c57b1a8 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:05:20 +0200 Subject: [PATCH 4/5] chore: point the repository links at Skollheim/bots --- README.md | 4 ++-- boite-plugin.json | 2 +- scripts/manifest.test.ts | 2 +- scripts/targets.ts | 2 +- src-tauri/Cargo.toml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d468eca..51ec094 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Everything else is in **Settings**, a panel that grows out of the open island un ## Install -In Boite, open **Settings > Plugins**, add `https://github.com/beboite/bots`, and confirm. Boite downloads the executable for your system from this repository's releases, checks its sha256 against [`boite-plugin.json`](boite-plugin.json), and starts it. +In Boite, open **Settings > Plugins**, add `https://github.com/Skollheim/bots`, and confirm. Boite downloads the executable for your system from this repository's releases, checks its sha256 against [`boite-plugin.json`](boite-plugin.json), and starts it. The tray icon has **Settings** and **Quit**. @@ -159,7 +159,7 @@ A second launch brings the running Bots forward instead of starting another one. Bots is Tauri 2, Svelte 5, Vite and TypeScript, built with [Bun](https://bun.sh) (the version in `package.json`'s `packageManager`) and stable Rust. On Linux, install the WebKit dependencies first: `libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev`. ```sh -git clone --recurse-submodules https://github.com/beboite/bots +git clone --recurse-submodules https://github.com/Skollheim/bots cd bots bun install --frozen-lockfile bun run check # svelte-check, then the scripts' types diff --git a/boite-plugin.json b/boite-plugin.json index e605977..c183d8e 100644 --- a/boite-plugin.json +++ b/boite-plugin.json @@ -4,7 +4,7 @@ "name": "B.O.T.S", "version": "0.1.17", "description": "Little robots on your desktop that follow your Boite agents, answer your questions and keep you company while you work.", - "homepage": "https://github.com/beboite/bots", + "homepage": "https://github.com/Skollheim/bots", "executable": "bots", "artifacts": { "win32-x64": { diff --git a/scripts/manifest.test.ts b/scripts/manifest.test.ts index 580e2c4..7029398 100644 --- a/scripts/manifest.test.ts +++ b/scripts/manifest.test.ts @@ -40,7 +40,7 @@ test('a release writes its version, URLs and hashes for the executables it built expect(next.version).toBe('1.2.3'); expect(Object.keys(next.artifacts)).toEqual(['win32-x64', 'darwin-arm64']); expect(next.artifacts['win32-x64']).toEqual({ - url: 'https://github.com/beboite/bots/releases/download/v1.2.3/bots-x86_64-pc-windows-msvc.exe', + url: 'https://github.com/Skollheim/bots/releases/download/v1.2.3/bots-x86_64-pc-windows-msvc.exe', sha256: 'a'.repeat(64), }); expect(next.provides).toEqual({ desktopApp: {} }); diff --git a/scripts/targets.ts b/scripts/targets.ts index 3acdcf4..352fca1 100644 --- a/scripts/targets.ts +++ b/scripts/targets.ts @@ -4,7 +4,7 @@ * `release-manifest.ts` writes their URLs and hashes into `boite-plugin.json`. */ -export const REPOSITORY = 'beboite/bots'; +export const REPOSITORY = 'Skollheim/bots'; export interface Target { /** Boite's platform key, `${process.platform}-${process.arch}`. */ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59ae883..6e3858e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" rust-version = "1.88" publish = false license = "MIT" -repository = "https://github.com/beboite/bots" +repository = "https://github.com/Skollheim/bots" [lib] name = "bots_lib" From 86f474a5d302ee2390631692c202e1a87456e1d3 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:14:37 +0200 Subject: [PATCH 5/5] chore: point the v0.1.17 manifest's downloads at Skollheim/bots --- boite-plugin.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/boite-plugin.json b/boite-plugin.json index c183d8e..422cd69 100644 --- a/boite-plugin.json +++ b/boite-plugin.json @@ -8,27 +8,27 @@ "executable": "bots", "artifacts": { "win32-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-pc-windows-msvc.exe", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-pc-windows-msvc.exe", "sha256": "00ab737511e9a52fae92cca0454b2b02e6100f5d36af45ae227e8c02cb651f25" }, "win32-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-pc-windows-msvc.exe", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-pc-windows-msvc.exe", "sha256": "f4c79a827e476869e490ce2eed1097419dc335de016a11a46cc3c13445a8907f" }, "darwin-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-apple-darwin", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-apple-darwin", "sha256": "0786aad2e6b87160ec701c14c5a17100dbcb1d4bba8f24dec8e3545fa00aabcd" }, "darwin-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-apple-darwin", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-apple-darwin", "sha256": "4892cb34487df1129ab5e50bade922c5573db8ac6090518a81b56202ab5e6919" }, "linux-x64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-x86_64-unknown-linux-gnu", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-x86_64-unknown-linux-gnu", "sha256": "84303ed17b86d37d18ece1c45f023c52a926339b0cd54de4d87b84028a65485f" }, "linux-arm64": { - "url": "https://github.com/beboite/bots/releases/download/v0.1.17/bots-aarch64-unknown-linux-gnu", + "url": "https://github.com/Skollheim/bots/releases/download/v0.1.17/bots-aarch64-unknown-linux-gnu", "sha256": "aec98c7780abcfc1291f0d6985dcd42451d97d57a71fd9009a44ce383568eb7e" } },