Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="docs/media/sessions.png" width="720" alt="The deck open on its threads tab, listing the sessions at work, one of them unfolded on its sub-agents">
Expand All @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions src/components/island/MachineChip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!--
The machine a thread or an agent of another machine of the group runs on,
as a small quiet chip: its name in the group's roster. This machine's own
get none, and nothing shows while the user has one machine.
-->
<script lang="ts">
import { fill, strings } from '../../lib/strings';

let { machine }: { machine: string } = $props();
</script>

<span class="machine" title={fill(strings.bots.onMachine, { machine })} data-testid="machine-chip">{machine}</span>

<style>
.machine {
display: inline-block;
flex: none;
max-width: 9em;
padding: 0 6px;
border: 1px solid var(--island-line);
border-radius: 999px;
color: var(--island-muted);
font-size: 10.5px;
font-weight: 600;
line-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
</style>
23 changes: 14 additions & 9 deletions src/components/island/assistant/AgentMenu.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<!--
Which of the user's Boite agents the assistant talks to: the one in use
(its robot and name) as a quiet button, and a menu of the active agents
with their name and domain. A custom menu: arrows move, Enter picks,
of every machine of the group with their name and domain, another
machine's with its name in a chip. A custom menu: arrows move, Enter picks,
Escape closes and gives the focus back. The island stays open while it is.
-->
<script lang="ts">
Expand All @@ -11,6 +12,7 @@
import { useView } from '../../../lib/island/contract';
import { fill, strings } from '../../../lib/strings';
import RobotFace from '../../agents/RobotFace.svelte';
import MachineChip from '../MachineChip.svelte';

let { assistant, open = $bindable(false) }: { assistant: Assistant; open?: boolean } = $props();
const view = useView();
Expand All @@ -20,6 +22,7 @@
let menu = $state<HTMLDivElement | null>(null);

const agent = $derived(assistant.agent);
const current = $derived(assistant.placed?.key ?? null);

$effect(() => {
view.hold('assistant-agents', open);
Expand Down Expand Up @@ -51,8 +54,8 @@
items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
}

function pick(id: string) {
assistant.pick(id);
function pick(key: string) {
assistant.pick(key);
close();
}
</script>
Expand All @@ -71,20 +74,22 @@
>
<span class="avatar"><RobotFace robot={skinOf(agent)} state="still" /></span>
<span class="name">{agent.name}</span>
{#if assistant.machine}<MachineChip machine={assistant.machine} />{/if}
<ChevronDown size={13} />
</button>

{#if open}
<div bind:this={menu} class="menu" role="menu" aria-label={copy.agents} tabindex="-1" onkeydown={keys} data-testid="assistant-agent-menu">
<p class="label">{copy.agents}</p>
{#each assistant.agents as entry (entry.id)}
<button role="menuitemradio" aria-checked={entry.id === agent.id} class="item" onclick={() => pick(entry.id)}>
<span class="avatar"><RobotFace robot={skinOf(entry)} state="still" /></span>
{#each assistant.agents as entry (entry.key)}
<button role="menuitemradio" aria-checked={entry.key === current} class="item" onclick={() => pick(entry.key)} data-testid="assistant-agent-item">
<span class="avatar"><RobotFace robot={skinOf(entry.profile)} state="still" /></span>
<span class="who">
<span class="item-name">{entry.name}</span>
{#if entry.domain}<span class="domain">{entry.domain}</span>{/if}
<span class="item-name">{entry.profile.name}</span>
{#if entry.profile.domain}<span class="domain">{entry.profile.domain}</span>{/if}
</span>
{#if entry.id === agent.id}<Check size={13} />{/if}
{#if entry.machine}<MachineChip machine={entry.machine} />{/if}
{#if entry.key === current}<Check size={13} />{/if}
</button>
{/each}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/island/assistant/AssistantChat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
class="ghost icon reveal"
aria-label={fill(copy.openAgent, { name })}
title={fill(copy.openAgent, { name })}
onclick={() => agent && void showAgent(agent.id).catch(() => {})}
onclick={() => agent && void showAgent(agent.id, assistant.coreId).catch(() => {})}
data-testid="assistant-reveal"
>
<SquareArrowOutUpRight size={13} />
Expand Down
10 changes: 7 additions & 3 deletions src/components/island/sessions/SessionRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
here. An ended row offers to start the thread again, archive it, or show the
changes in its folder (`NoticeActions.svelte`); archived, it can be undone
for a few seconds (`archive-undo.ts`). A row can be followed in the pill
(`followed.ts`): a small eye before its time says so. The actions show on
(`followed.ts`): a small eye before its time says so. A thread of another
machine of the group names it in a chip after its project. The actions show on
the row under the pointer or the focus, over the row's end and over its
second line, so they take no room and the list never moves. The time
reads calm (`session-time.ts`): seconds under a minute only.
Expand All @@ -25,12 +26,15 @@
import { strings } from '../../../lib/strings';
import RobotFace from '../../agents/RobotFace.svelte';
import CompanionReply from '../../companion/CompanionReply.svelte';
import MachineChip from '../MachineChip.svelte';
import NoticeActions from './NoticeActions.svelte';
import SubAgentList from './SubAgentList.svelte';

interface Props {
row: SessionRow;
project: string;
/** The machine of the group it runs on, when it is not this one. */
machine?: string | null;
now: number;
/** The sub-agents of its current run, in start order (`sub-agents.ts`). */
agents: readonly SubAgent[];
Expand All @@ -47,7 +51,7 @@
onarchive(): Promise<void>;
}

let { row, project, now, agents, foldSpace, notice, notes, followed, onreveal, onfollow, onarchive }: Props = $props();
let { row, project, machine = null, now, agents, foldSpace, notice, notes, followed, onreveal, onfollow, onarchive }: Props = $props();

const copy = strings.island.sessions;
const foldable = $derived(agents.length > 0);
Expand All @@ -70,7 +74,7 @@
<div class="head">
<span class="avatar" aria-hidden="true"><RobotFace {robot} state={face} /></span>
<button class="title" title={copy.reveal} onclick={() => onreveal(row.id)}>
<span class="name">{row.title}{#if project}<span class="project">&nbsp;· {project}</span>{/if}</span>
<span class="name">{row.title}{#if project}<span class="project">&nbsp;· {project}</span>{/if}{#if machine}&nbsp;<MachineChip {machine} />{/if}</span>
<span class="sub" data-testid="session-line">
<span class="state">{state}</span>{#if line}<span class="step">&nbsp;· {line}</span>{/if}
</span>
Expand Down
10 changes: 8 additions & 2 deletions src/components/island/sessions/SessionsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
the lead robot naps beside "Nothing running": a few breaths, then still.
An archived row leaves a line in its place for a few seconds, to undo it
(`archive-undo.ts`); a row followed in the pill says so (`followed.ts`).
A row with sub-agents counts the ones at work and unfolds them all.
A row with sub-agents counts the ones at work and unfolds them all. A
thread of another machine of the group names it in a chip.
-->
<script lang="ts">
import { GroupClient } from '../../../lib/group/group-client';
import { unplacedName } from '../../../lib/group/routing';
import { listed } from '../../../lib/island/archive-undo';
import { useView } from '../../../lib/island/contract';
import { posed } from '../../../lib/island/posed.svelte';
Expand All @@ -27,6 +30,7 @@
const ended = $derived(new Set(sessions.ended));
const notices = $derived(new Map(notes.notices.filter((notice) => ended.has(notice.threadId)).map((notice) => [notice.threadId, notice])));
const copy = strings.island.sessions;
const group = $derived(view.core.client instanceof GroupClient ? view.core.client : null);
const reveal = (threadId: string) => void view.reveal(threadId).catch(() => {});
/** Some row has sub-agents: every row keeps the chevron's place, so that the times line up. */
const foldSpace = $derived(rows.some((row) => (sessions.agents.get(row.id)?.length ?? 0) > 0));
Expand All @@ -51,9 +55,11 @@
<ArchiveUndoLine line={undo} onundo={() => void sessions.undo.undo(row.id)} />
{:else}
{@const notice = notices.get(row.id) ?? null}
{@const machine = group?.machineOf(row.id) ?? null}
<SessionRow
{row}
project={(row.projectId && projects.get(row.projectId)) || ''}
project={unplacedName((row.projectId && projects.get(row.projectId)) || '', machine)}
{machine}
now={sessions.now}
agents={sessions.agents.get(row.id) ?? []}
{foldSpace}
Expand Down
14 changes: 9 additions & 5 deletions src/lib/companion/companion.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/companion/crew.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
* 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
* computer before it had agents.
*/
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';
Expand Down Expand Up @@ -70,6 +76,8 @@ export async function releaseAgent(client: Client, profile: AgentProfile): Promi

export class Crew {
snapshot = $state.raw<AgentsSnapshot | null>(null);
/** The agents of the group's other connected machines, in roster order. */
elsewhere = $state.raw<MachineAgents[]>([]);
/** Why the companion has no agent of its own yet: none could run, or making it failed. */
problem = $state<string | null>(null);

Expand Down Expand Up @@ -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;
Expand All @@ -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<void> {
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
Expand Down
6 changes: 3 additions & 3 deletions src/lib/companion/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ export async function showMain(threadId: string | null): Promise<void> {
await reveal({ kind: 'thread', threadId });
}

/** Shows an agent's conversation in Boite's Agents page (`ui.reveal`). */
export async function showAgent(agentId: string): Promise<void> {
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<void> {
await reveal(coreId === null ? { kind: 'agent', agentId } : { kind: 'agent', agentId, coreId });
}

/** Opens Boite's app updates, where a newer Boite installs (`ui.reveal`). */
Expand Down
8 changes: 4 additions & 4 deletions src/lib/fake-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
};

/**
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/fake-client/assistant-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function idle(model: Thread, over: Partial<Thread>): 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.`,
Expand All @@ -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<string, AgentRecord> {
export function rowsOf(ctx: FakeContext): Map<string, AgentRecord> {
return (ctx.agents as unknown as { rows: Map<string, AgentRecord> }).rows;
}

Expand Down
Loading
Loading