Skip to content
5 changes: 5 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Plugins are administration of the machine that hosts the core, so every
`plugins.*` method is owner only and the phone settings leave the page to the
desktop app ([phone](phone.md)).

Each core has its own plugins. The page opens on this PC when the window has
one, since a desktop app shows its window there, whichever machine the open
conversation runs on. With several machines connected, a menu beside the
heading names the machine shown and switches to another one this window owns.

## The manifest

Put `boite-plugin.json` at the root of the repository:
Expand Down
646 changes: 646 additions & 0 deletions packages/ui/src/components/PluginsMachine.svelte

Large diffs are not rendered by default.

654 changes: 29 additions & 625 deletions packages/ui/src/components/PluginsPage.svelte

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions packages/ui/src/components/PluginsPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PluginState } from '@boite/contracts';
import PluginsPage from './PluginsPage.svelte';
import type { Store } from '../lib/store.svelte';
import { FakeClient } from '../lib/fake-client';
import { workspace } from '../lib/workspace.svelte';

let mounted: ReturnType<typeof mount> | undefined;
let client: FakeClient | undefined;
Expand Down Expand Up @@ -269,3 +270,54 @@ test('Douane\'s row shows its gateway on loopback, stops and starts it, and its
expect(strip()).not.toBeNull();
ask.mockRestore();
});

test('opens on this PC rather than on the server the chat is on, and switches machine from its header', async () => {
const pc = new FakeClient({ delayMs: 0 });
const server = new FakeClient({ delayMs: 0 });
await Promise.all([pc.connect(), server.connect()]);
const pcCall = pc.call.bind(pc);
// The PC answers Stop only when the test lets it, after the page has moved to the server.
let answer: () => void = () => {};
const held = new Promise<void>((resolve) => { answer = resolve; });
vi.spyOn(pc, 'call').mockImplementation((async (method: string, params: unknown) => {
if (method === 'plugins.app') await held;
return pcCall(method as never, params as never);
}) as never);
const serverList = server.call.bind(server);
// Nothing is installed on the server.
vi.spyOn(server, 'call').mockImplementation((async (method: string, params: unknown) => {
const result = await serverList(method as never, params as never);
return method === 'plugins.list' ? (result as PluginState[]).filter((plugin) => plugin.origin === 'recommended').map((plugin) => ({ ...plugin, version: null, status: 'not-installed', app: null, gateway: null, pools: [] })) : result;
}) as never);
// Class instances, as real stores are: the workspace's state does not proxy them.
class Fake {}
const pcStore = Object.assign(new Fake(), { client: pc, providers: [], owner: true, localCore: true, endpointUrl: null, core: { hostname: 'nuno-pc' } }) as unknown as Store;
const serverStore = Object.assign(new Fake(), { client: server, providers: [], owner: true, endpointUrl: 'https://server.test', core: { hostname: 'server' } }) as unknown as Store;
workspace.machines = [
{ id: 'local', label: 'nuno-pc', store: pcStore },
{ id: 'https://server.test', label: 'server', store: serverStore },
];
try {
mounted = mount(PluginsPage, { target: document.body, props: { store: serverStore } });
await vi.waitFor(() => expect(row('bots')?.closest('[data-testid="plugins-installed"]') ?? null).not.toBeNull());
expect(q('[data-testid="plugins-machine"]')?.textContent).toContain('nuno-pc');
await vi.waitFor(() => expect(appButton('bots', 'stop')).not.toBeNull());
appButton('bots', 'stop')!.click();

q('[data-testid="plugins-machine"]')!.click();
flushSync();
q('[data-testid="plugins-machine-menu"] [data-value="https://server.test"]')!.click();
await vi.waitFor(() => expect(row('bots')?.closest('[data-testid="plugins-recommended"]') ?? null).not.toBeNull());
expect(q('[data-testid="plugins-machine"]')?.textContent).toContain('server');

// The PC's late answer stays out of the server's page.
answer();
await new Promise((resolve) => setTimeout(resolve, 50));
flushSync();
expect(row('bots')?.closest('[data-testid="plugins-recommended"]') ?? null).not.toBeNull();
expect(app('bots')).toBeNull();
} finally {
workspace.machines = [];
pc.close(); server.close();
}
});
29 changes: 10 additions & 19 deletions packages/ui/src/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,6 @@
>{#if rollup.kind === 'working'}<LoaderCircle size={11} class="spinner" aria-hidden="true" />{:else}<span class="dot" aria-hidden="true"></span>{/if}{#if rollup.count > 1}<span class="ui-label">{rollup.count}</span>{/if}</span
>{/if}
</button>
<button
class="ghost small icon project-actions"
data-testid="project-menu"
title={strings.sidebar.projectMenu}
aria-label={strings.sidebar.projectMenu}
onclick={(e) => openProjectMenu(e, machine, project)}><Ellipsis size={15} /></button
>
{#each residentsOf(owner, project.id).slice(0, 2) as resident (resident.id)}
<!-- An agent the owner entrusted this project to: its robot, and the way to its conversation. -->
<button type="button" class="ghost icon small resident-mark" title={fill(strings.resident.lookedAfterBy, { name: resident.name })} aria-label={fill(strings.resident.lookedAfterBy, { name: resident.name })}
Expand All @@ -250,6 +243,13 @@
</button>
{/each}
<ProjectThreadCounters {entry} working={lists.working.length} {controls} {collapsed} {away} />
<button
class="ghost small icon project-actions"
data-testid="project-menu"
title={strings.sidebar.projectMenu}
aria-label={strings.sidebar.projectMenu}
onclick={(e) => openProjectMenu(e, machine, project)}><Ellipsis size={15} /></button
>
</div>
<div class="disclosure-fold" class:open={!collapsed} inert={collapsed}>
<div>
Expand Down Expand Up @@ -448,22 +448,16 @@
color: var(--color-danger);
}
.resident-mark { flex: none; width: var(--control-sm); height: var(--control-sm); padding: 0; }
/* The menu takes no width at rest: it lands over the end of the header, which
makes room for it, so the counters never move. */
/* The menu keeps its slot at the right end even while hidden, so it sits at
the same place on every project and the counters never move. */
.project-actions {
position: relative;
z-index: 1;
margin-left: calc(-2px - var(--control-sm));
flex: none;
opacity: 0;
}
.head:hover .project-actions,
.head:focus-within .project-actions {
opacity: 1;
}
.head:hover .toggle,
.head:focus-within .toggle {
padding-right: calc(var(--control-sm) + 4px);
}
.none {
padding: 4px 10px;
color: var(--color-subtle);
Expand All @@ -479,8 +473,5 @@
.project-actions {
opacity: 1;
}
.toggle {
padding-right: calc(var(--control-sm) + 4px);
}
}
</style>
2 changes: 2 additions & 0 deletions packages/ui/src/lib/strings/en/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const strings = {
plugins: {
heading: 'Plugins',
/** The machine whose plugins the page shows. */
machine: 'Machine',
/** A desktop app asked to show a thread or an agent on a machine this window does not have. */
revealUnknownMachine: 'The thread or agent an app asked to show is on a machine this window is not connected to. Add it in Settings, Machines and updates.',
revealMachineOffline: 'The thread or agent an app asked to show is on {machine}, which is not connected right now.',
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/strings/fr/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Translation } from '../../i18n.svelte';
export const fr: Translation = {
plugins: {
heading: 'Extensions',
machine: 'Machine',
revealUnknownMachine: "Le fil ou l'agent qu'une application demande d'afficher est sur une machine à laquelle cette fenêtre n'est pas connectée. Ajoutez-la dans Réglages, Machines et mises à jour.",
revealMachineOffline: "Le fil ou l'agent qu'une application demande d'afficher est sur {machine}, qui n'est pas connectée pour l'instant.",
intro: 'Une extension est un programme qui tourne sur la machine hébergeant votre cœur, vérifié contre le SHA-256 publié par son manifeste.',
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/project-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ test('copy path stays readable with a long working directory in conversation men

test('projects follow user activity, then keep a dragged custom order after reload', async () => {
await showThreadView(page, 'projects');
// The menu ends every header at the same place, whatever counters sit before it.
const menuEdges = await page.evaluate<number[]>(`Array.from(document.querySelectorAll('${id('project-menu')}')).map(e => Math.round(e.getBoundingClientRect().right))`);
expect(menuEdges.length).toBeGreaterThan(1);
expect(new Set(menuEdges).size).toBe(1);
await page.evaluate(`(() => { const s = globalThis.__boiteTest.workspace.machines[1].store; s.threads = s.threads.map(t => t.projectId === 'p-notes' ? {...t, lastUserMessageAt: Date.now() + 1000} : t); })()`);
const keys = () => page.evaluate<string[]>(`Array.from(document.querySelectorAll('${id('project')}')).map(e => JSON.stringify([e.dataset.machineId, e.dataset.projectId]))`);
await page.waitFor(`document.querySelector('${id('project')}')?.dataset.projectId === 'p-notes'`);
Expand Down
Loading