+ ({ 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"
}
},