diff --git a/docs/plugins.md b/docs/plugins.md
index 9d798afdd..3e01c10e2 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -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:
diff --git a/packages/ui/src/components/PluginsMachine.svelte b/packages/ui/src/components/PluginsMachine.svelte
new file mode 100644
index 000000000..22ec79ff5
--- /dev/null
+++ b/packages/ui/src/components/PluginsMachine.svelte
@@ -0,0 +1,646 @@
+
+
+{#snippet refusal(rejected: PluginRejected)}
+
+
+
+
+
+ {plugin.name}
+ {#if plugin.version ?? plugin.availableVersion}v{plugin.version ?? plugin.availableVersion} {/if}
+ {#if plugin.homepage && !plugin.source}
+
+ {/if}
+
+ {#if state}
+
+ {state}
+
+ {/if}
+ {#if plugin.description}
{plugin.description}
{/if}
+
+
+ {#if plugin.status === 'installing'}
+ void change(plugin, 'plugins.cancel')}>{strings.common.cancel}
+ {:else if plugin.status === 'not-installed'}
+ void change(plugin, 'plugins.install')}>{t.install}
+ {:else}
+ {#if plugin.status === 'installed' && plugin.availableVersion && plugin.availableVersion !== plugin.version}
+ void change(plugin, 'plugins.install')}>{fill(t.update, { version: `v${plugin.availableVersion}` })}
+ {/if}
+ {#if plugin.status === 'error' || (plugin.status === 'rejected' && plugin.origin === 'recommended')}
+ void change(plugin, 'plugins.install')}>{plugin.status === 'error' ? strings.common.retry : t.reinstall}
+ {/if}
+ {#if plugin.updates && plugin.version !== null}
+ {@const looking = plugin.updates.checking || checking[plugin.id] === true}
+ void checkUpdates(plugin)}>
+ {/if}
+ void change(plugin, 'plugins.uninstall')}>{t.remove}
+ {/if}
+
+
+ {#if plugin.status === 'installing'}
+
+
+
+ {/if}
+ {#if plugin.source}
+
+ {bare(plugin.source.url)}
+ {fill(t.atCommit, { ref: plugin.source.ref, commit: short(plugin.source.commit) })}
+
+ {/if}
+ {#if plugin.updates && plugin.version !== null}
+ {@const line = updatesLine(plugin.updates)}
+ {#if line}
+
{line}
+ {/if}
+ {/if}
+ {#if plugin.error}
{plugin.error}
{/if}
+ {#if errors[plugin.id]}
{errors[plugin.id]}
{/if}
+ {#if plugin.rejected}{@render refusal(plugin.rejected)}{/if}
+ {#if plugin.status === 'installed' && plugin.app}
+ {@const app = plugin.app}
+ {@const live = app.status === 'running' || app.status === 'starting'}
+
+
+
+ {t.app} · {appLine(app)}
+
+
+ {#if live}
+
void appAction(plugin, 'restart')}>{t.appRestart}
+
void appAction(plugin, 'stop')}>{t.appStop}
+ {:else}
+
void appAction(plugin, 'start')}>{t.appStart}
+ {/if}
+
+
+ {@render autostartSwitch(plugin, app.autostart)}
+ {#if app.error}
{app.error}
{/if}
+
+ {/if}
+ {#if plugin.status === 'installed' && plugin.gateway}
+ {@const gateway = plugin.gateway}
+ {@const live = gateway.status !== 'stopped' && gateway.status !== 'crashed'}
+ {@const dashboard = gatewayDashboard(gateway)}
+
+
+
+ {t.gateway} · {gatewayLine(gateway)}
+
+
+
+ {#if live}
+
void gatewayAction(plugin, 'restart')}>{t.appRestart}
+
void gatewayAction(plugin, 'stop')}>{t.appStop}
+ {:else}
+
void gatewayAction(plugin, 'start')}>{t.appStart}
+ {/if}
+
+
+ {#if !plugin.app}{@render autostartSwitch(plugin, gateway.autostart)}{/if}
+ {#if gateway.error}
{gateway.error}
{/if}
+ {#if gateway.status === 'running' && (dashboard || plugin.id === 'douane')}
+
+ {#if dashboard} void openExternal(dashboard)}>{fill(strings.subscriptionProxy.openGateway, { name: plugin.name })} {/if}
+ {#if plugin.id === 'douane' && store.core?.features?.gatewayMove === true}
+ { moving = true; }}>{strings.subscriptionProxy.move.open}
+ {/if}
+
+ {/if}
+
+ {/if}
+ {#if plugin.status === 'installed' && plugin.pools.length > 0}
+
+
+
{t.pools}
+ void loadPools(plugin.id, true)}>{strings.common.refresh}
+
+ {#each pools[plugin.id] ?? [] as pool (pool.provider)}
+
+
+
{providerName(pool.provider)}
+ void accountAction(plugin, pool.provider, 'add')}>{t.add}
+
+ {#if pool.accounts.length === 0}
{t.empty}
{/if}
+ {#each pool.accounts as account (account.email)}
+
+
+ {account.email}
+ {#if account.active}{t.active} {/if}
+
+ {#if !account.active}
+ void accountAction(plugin, pool.provider, 'switch', account.email)}>{t.switch}
+ {/if}
+ void accountAction(plugin, pool.provider, 'remove', account.email)}>{t.forget}
+
+
+
+
+ {/each}
+
+ {/each}
+
+ {/if}
+
+{/snippet}
+
+
+
+
{t.heading}
+ {@render machineMenu?.()}
+
+ {#if error}
{error}
{/if}
+
+ {#if installed.length > 0}
+
+ {t.installedHeading}
+
+ {#each installed as plugin (plugin.id)}{@render row(plugin)}{/each}
+
+
+ {/if}
+
+ {#if recommended.length > 0}
+
+ {t.recommendedHeading}
+
+ {#each recommended as plugin (plugin.id)}{@render row(plugin)}{/each}
+
+
+ {/if}
+
+
+ {t.addHeading}
+
+
+ {#if inspectError}
{inspectError}
{/if}
+ {#if preview}
+
+ {#if preview.manifest}
+
+
+
+
{preview.manifest.name}v{preview.manifest.version}
+
{preview.manifest.description}
+
+
+ {/if}
+ {#if preview.rejected}
+ {@render refusal(preview.rejected)}
+ {/if}
+
+ {t.previewSource}
+ {bare(preview.source.url)} {fill(t.atCommit, { ref: preview.source.ref, commit: short(preview.source.commit) })}
+ {#if preview.replaces}{t.previewReplaces} v{preview.replaces} {/if}
+ {#if preview.rejected === null && preview.artifact}
+ {t.previewDownloads} {preview.artifact.url}
+ {t.previewDigest} {preview.artifact.sha256}
+ {t.previewRuns}
+ {#each preview.commands as command (command)}{command} {/each}
+ {#if preview.manifest?.provides.accountPools}
+ {t.previewPools}
+ {preview.manifest.provides.accountPools.providers.map(providerName).join(', ')}
+ {/if}
+ {#if preview.manifest?.provides.desktopApp}
+ {t.previewApp}
+ {t.previewAppBody}
+ {/if}
+ {#if preview.manifest?.provides.gateway}
+ {t.previewGateway}
+ {t.previewGatewayBody}
+ {/if}
+ {/if}
+
+ {#if preview.rejected === null}
+
{t.trust}
+ {/if}
+
+ { preview = null; }}>{preview.rejected ? strings.common.dismiss : strings.common.cancel}
+ {#if preview.previewId && preview.manifest}
+ void add()}>{fill(t.confirmAdd, { name: preview.manifest.name })}
+ {/if}
+
+
+ {/if}
+
+
+
+
+ { moving = false; }} />
+
+
diff --git a/packages/ui/src/components/PluginsPage.svelte b/packages/ui/src/components/PluginsPage.svelte
index d9aeb6f83..a721fd96f 100644
--- a/packages/ui/src/components/PluginsPage.svelte
+++ b/packages/ui/src/components/PluginsPage.svelte
@@ -1,639 +1,43 @@
-{#snippet refusal(rejected: PluginRejected)}
-
-
{t.refusedTitle}
-
- {t.refusedFile} {rejected.file}
- {t.refusedField} {rejected.field}
- {t.refusedExpected} {rejected.expected}
-
-
-{/snippet}
-
-{#snippet autostartSwitch(plugin: PluginState, on: boolean | undefined)}
-
- {#if on !== undefined}
-
- { event.currentTarget.checked = on; void setAutostart(plugin, !on); }} />
- {t.startWithBoite}
-
+{#snippet machineMenu()}
+ {#if machines.length > 1}
+ { picked = id; }}>
+ {chosen.label}
+
{/if}
{/snippet}
-{#snippet row(plugin: PluginState)}
- {@const working = busy[plugin.id] === true}
- {@const state = stateLine(plugin)}
-
-
-
-
-
- {plugin.name}
- {#if plugin.version ?? plugin.availableVersion}v{plugin.version ?? plugin.availableVersion} {/if}
- {#if plugin.homepage && !plugin.source}
-
- {/if}
-
- {#if state}
-
- {state}
-
- {/if}
- {#if plugin.description}
{plugin.description}
{/if}
-
-
- {#if plugin.status === 'installing'}
- void change(plugin, 'plugins.cancel')}>{strings.common.cancel}
- {:else if plugin.status === 'not-installed'}
- void change(plugin, 'plugins.install')}>{t.install}
- {:else}
- {#if plugin.status === 'installed' && plugin.availableVersion && plugin.availableVersion !== plugin.version}
- void change(plugin, 'plugins.install')}>{fill(t.update, { version: `v${plugin.availableVersion}` })}
- {/if}
- {#if plugin.status === 'error' || (plugin.status === 'rejected' && plugin.origin === 'recommended')}
- void change(plugin, 'plugins.install')}>{plugin.status === 'error' ? strings.common.retry : t.reinstall}
- {/if}
- {#if plugin.updates && plugin.version !== null}
- {@const looking = plugin.updates.checking || checking[plugin.id] === true}
- void checkUpdates(plugin)}>
- {/if}
- void change(plugin, 'plugins.uninstall')}>{t.remove}
- {/if}
-
-
- {#if plugin.status === 'installing'}
-
-
-
- {/if}
- {#if plugin.source}
-
- {bare(plugin.source.url)}
- {fill(t.atCommit, { ref: plugin.source.ref, commit: short(plugin.source.commit) })}
-
- {/if}
- {#if plugin.updates && plugin.version !== null}
- {@const line = updatesLine(plugin.updates)}
- {#if line}
-
{line}
- {/if}
- {/if}
- {#if plugin.error}
{plugin.error}
{/if}
- {#if errors[plugin.id]}
{errors[plugin.id]}
{/if}
- {#if plugin.rejected}{@render refusal(plugin.rejected)}{/if}
- {#if plugin.status === 'installed' && plugin.app}
- {@const app = plugin.app}
- {@const live = app.status === 'running' || app.status === 'starting'}
-
-
-
- {t.app} · {appLine(app)}
-
-
- {#if live}
-
void appAction(plugin, 'restart')}>{t.appRestart}
-
void appAction(plugin, 'stop')}>{t.appStop}
- {:else}
-
void appAction(plugin, 'start')}>{t.appStart}
- {/if}
-
-
- {@render autostartSwitch(plugin, app.autostart)}
- {#if app.error}
{app.error}
{/if}
-
- {/if}
- {#if plugin.status === 'installed' && plugin.gateway}
- {@const gateway = plugin.gateway}
- {@const live = gateway.status !== 'stopped' && gateway.status !== 'crashed'}
- {@const dashboard = gatewayDashboard(gateway)}
-
-
-
- {t.gateway} · {gatewayLine(gateway)}
-
-
-
- {#if live}
-
void gatewayAction(plugin, 'restart')}>{t.appRestart}
-
void gatewayAction(plugin, 'stop')}>{t.appStop}
- {:else}
-
void gatewayAction(plugin, 'start')}>{t.appStart}
- {/if}
-
-
- {#if !plugin.app}{@render autostartSwitch(plugin, gateway.autostart)}{/if}
- {#if gateway.error}
{gateway.error}
{/if}
- {#if gateway.status === 'running' && (dashboard || plugin.id === 'douane')}
-
- {#if dashboard} void openExternal(dashboard)}>{fill(strings.subscriptionProxy.openGateway, { name: plugin.name })} {/if}
- {#if plugin.id === 'douane' && store.core?.features?.gatewayMove === true}
- { moving = true; }}>{strings.subscriptionProxy.move.open}
- {/if}
-
- {/if}
-
- {/if}
- {#if plugin.status === 'installed' && plugin.pools.length > 0}
-
-
-
{t.pools}
- void loadPools(plugin.id, true)}>{strings.common.refresh}
-
- {#each pools[plugin.id] ?? [] as pool (pool.provider)}
-
-
-
{providerName(pool.provider)}
- void accountAction(plugin, pool.provider, 'add')}>{t.add}
-
- {#if pool.accounts.length === 0}
{t.empty}
{/if}
- {#each pool.accounts as account (account.email)}
-
-
- {account.email}
- {#if account.active}{t.active} {/if}
-
- {#if !account.active}
- void accountAction(plugin, pool.provider, 'switch', account.email)}>{t.switch}
- {/if}
- void accountAction(plugin, pool.provider, 'remove', account.email)}>{t.forget}
-
-
-
-
- {/each}
-
- {/each}
-
- {/if}
-
-{/snippet}
-
-
-
- {#if error}
{error}
{/if}
-
- {#if installed.length > 0}
-
- {t.installedHeading}
-
- {#each installed as plugin (plugin.id)}{@render row(plugin)}{/each}
-
-
- {/if}
-
- {#if recommended.length > 0}
-
- {t.recommendedHeading}
-
- {#each recommended as plugin (plugin.id)}{@render row(plugin)}{/each}
-
-
- {/if}
-
-
- {t.addHeading}
-
-
- {#if inspectError}
{inspectError}
{/if}
- {#if preview}
-
- {#if preview.manifest}
-
-
-
-
{preview.manifest.name}v{preview.manifest.version}
-
{preview.manifest.description}
-
-
- {/if}
- {#if preview.rejected}
- {@render refusal(preview.rejected)}
- {/if}
-
- {t.previewSource}
- {bare(preview.source.url)} {fill(t.atCommit, { ref: preview.source.ref, commit: short(preview.source.commit) })}
- {#if preview.replaces}{t.previewReplaces} v{preview.replaces} {/if}
- {#if preview.rejected === null && preview.artifact}
- {t.previewDownloads} {preview.artifact.url}
- {t.previewDigest} {preview.artifact.sha256}
- {t.previewRuns}
- {#each preview.commands as command (command)}{command} {/each}
- {#if preview.manifest?.provides.accountPools}
- {t.previewPools}
- {preview.manifest.provides.accountPools.providers.map(providerName).join(', ')}
- {/if}
- {#if preview.manifest?.provides.desktopApp}
- {t.previewApp}
- {t.previewAppBody}
- {/if}
- {#if preview.manifest?.provides.gateway}
- {t.previewGateway}
- {t.previewGatewayBody}
- {/if}
- {/if}
-
- {#if preview.rejected === null}
-
{t.trust}
- {/if}
-
- { preview = null; }}>{preview.rejected ? strings.common.dismiss : strings.common.cancel}
- {#if preview.previewId && preview.manifest}
- void add()}>{fill(t.confirmAdd, { name: preview.manifest.name })}
- {/if}
-
-
- {/if}
-
-
-
-
- { moving = false; }} />
+{#key chosen.store}
+
+{/key}
diff --git a/packages/ui/src/components/PluginsPage.test.ts b/packages/ui/src/components/PluginsPage.test.ts
index c5f711860..f50723ff5 100644
--- a/packages/ui/src/components/PluginsPage.test.ts
+++ b/packages/ui/src/components/PluginsPage.test.ts
@@ -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 | undefined;
let client: FakeClient | undefined;
@@ -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((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();
+ }
+});
diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte
index 9652fe07d..33ce5067d 100644
--- a/packages/ui/src/components/Sidebar.svelte
+++ b/packages/ui/src/components/Sidebar.svelte
@@ -235,13 +235,6 @@
>{#if rollup.kind === 'working'} {:else} {/if}{#if rollup.count > 1}{rollup.count} {/if}{/if}
- openProjectMenu(e, machine, project)}>
{#each residentsOf(owner, project.id).slice(0, 2) as resident (resident.id)}
{/each}
+ openProjectMenu(e, machine, project)}>
@@ -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);
@@ -479,8 +473,5 @@
.project-actions {
opacity: 1;
}
- .toggle {
- padding-right: calc(var(--control-sm) + 4px);
- }
}
diff --git a/packages/ui/src/lib/strings/en/plugins.ts b/packages/ui/src/lib/strings/en/plugins.ts
index a79d56fb6..fbd5c8463 100644
--- a/packages/ui/src/lib/strings/en/plugins.ts
+++ b/packages/ui/src/lib/strings/en/plugins.ts
@@ -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.',
diff --git a/packages/ui/src/lib/strings/fr/plugins.ts b/packages/ui/src/lib/strings/fr/plugins.ts
index bc8cf3a61..cc11eaa4e 100644
--- a/packages/ui/src/lib/strings/fr/plugins.ts
+++ b/packages/ui/src/lib/strings/fr/plugins.ts
@@ -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.',
diff --git a/tests/e2e/project-views.test.ts b/tests/e2e/project-views.test.ts
index ad0ac4036..a9fac16db 100644
--- a/tests/e2e/project-views.test.ts
+++ b/tests/e2e/project-views.test.ts
@@ -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(`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(`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'`);