Official
tabs
The tabbed detail pane beside the chat: opened files and diffs (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/tabs@0.2.0
Permissions in 0.2.0
Files
service.test.ts8.2 KB
// The convergence/tabs service methods and the legacy shared requests.
// Real activation against a fake service bus: calls go through the provided
// implementation, events through its handle, legacy requests through app state.
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
Api,
AppState,
AppStatePatch,
HostEvent,
RenderResult,
SlotOptions,
ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
type ServiceImpl = Record<string, (params: never, ctx: unknown) => Promise<unknown>>;
type Emitted = { event: string; payload: unknown };
function fakeApi() {
let state: AppState = {};
const appListeners = new Set<(state: AppState) => void>();
const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
const calls: { method: string; params: unknown }[] = [];
const notices: string[] = [];
let dirty = true;
const provided = new Map<string, ServiceImpl>();
const emitted: Emitted[] = [];
const update = (patch: AppStatePatch) => {
state = {
...state,
...patch,
workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
route: patch.route ?? state.route,
shared: { ...state.shared, ...patch.shared },
};
dirty = true;
for (const listener of appListeners) listener(state);
};
const call = async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === "host/fs.read") return { text: `# ${(params as { path: string }).path}\nbody` };
throw new Error(`Unexpected host method ${method}`);
};
const broker = testLayer();
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "tabs",
id: "convergence/tabs",
ui,
services: {
provide: async (def: { name: string }, impl: ServiceImpl) => {
provided.set(def.name, impl);
return {
emit: (event: string, payload: unknown) => {
emitted.push({ event, payload });
},
remove: () => {
provided.delete(def.name);
},
};
},
get: (name: string) => {
throw new Error(`no provider for ${name}`);
},
available: () => false,
when: () => () => {},
},
host: { ...broker.api.host, call, kernel: call },
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
onChange: (fn: (state: AppState) => void) => {
appListeners.add(fn);
return () => {
appListeners.delete(fn);
};
},
},
slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
const view = { render, active: true };
views.push(view);
dirty = true;
return {
id: views.length,
update: () => {
dirty = true;
},
remove: () => {
view.active = false;
},
};
},
on: (name: string, fn: (event: HostEvent) => unknown) => {
const subscribers = listeners.get(name) ?? new Set();
listeners.set(name, subscribers);
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
command: () => ({ remove() {} }),
notify: (message: string) => {
notices.push(message);
},
focus: () => {},
},
{
get(target, key, receiver) {
if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
throw new Error(`Unexpected API ${String(key)}`);
},
},
) as unknown as Api;
const render = () =>
views
.filter((view) => view.active)
.map((view) =>
view.render({
state: {},
app: state,
plugin: "tabs",
update: () => {
dirty = true;
},
}),
);
let trees: RenderResult[] = [];
const settle = async () => {
for (let round = 0; round < 50; round++) {
await tick();
if (!dirty) {
await tick();
if (!dirty) return;
}
dirty = false;
trees = render();
}
throw new Error("tabs did not settle");
};
const ctx = { caller: { plugin: "test/client", id: "test/client" }, signal: AbortSignal.timeout(5000) };
return {
api,
calls,
notices,
callsReset: () => {
calls.length = 0;
},
emitted,
provided,
update,
settle,
trees: () => stable(trees),
state: () => state,
callService: (method: string, params: unknown) =>
provided.get("convergence/tabs")?.[method]?.(params as never, ctx),
emit: (event: HostEvent) => {
for (const name of [event.event, "*"]) for (const fn of listeners.get(name) ?? []) fn(event);
},
};
}
test("the tabs service opens, reports and closes tabs without shared writes", async () => {
const fake = fakeApi();
const dispose = activate(fake.api);
try {
await fake.settle();
assert.ok(fake.provided.has("convergence/tabs"));
assert.deepEqual(await fake.callService("active", {}), null);
assert.deepEqual(await fake.callService("open", { kind: "file", workspaceId: "w1", path: "a.ts" }), {
id: "file:w1:a.ts",
});
await fake.settle();
assert.deepEqual(fake.emitted, [{ event: "activeChanged", payload: { kind: "file", path: "a.ts" } }]);
// The visible tab left the shared keys behind: nothing announces through them.
assert.equal(fake.state().shared?.activeTab, undefined);
assert.equal(fake.state().shared?.activeTabPath, undefined);
assert.deepEqual(await fake.callService("active", {}), { kind: "file", path: "a.ts" });
assert.ok(fake.trees().includes("file:w1:a.ts"));
fake.callsReset();
assert.deepEqual(await fake.callService("close", { kind: "file", path: "a.ts" }), null);
await fake.settle();
assert.deepEqual(fake.emitted.at(-1), { event: "activeChanged", payload: null });
assert.deepEqual(await fake.callService("active", {}), null);
assert.ok(!fake.trees().includes("file:w1:a.ts"));
} finally {
await dispose();
}
});
test("the tabs service opens view tabs and ignores unknown closes", async () => {
const fake = fakeApi();
const dispose = activate(fake.api);
try {
await fake.settle();
assert.deepEqual(
await fake.callService("open", {
kind: "view",
workspaceId: "w1",
path: "agents",
title: "Agents",
icon: "network",
view: 3,
}),
{ id: "view:w1:agents" },
);
await fake.settle();
assert.ok(fake.trees().includes("Agents"));
const seen = fake.emitted.length;
assert.deepEqual(await fake.callService("close", { kind: "diff", path: "missing.ts" }), null);
assert.equal(fake.emitted.length, seen);
} finally {
await dispose();
}
});
test("legacy shared open and close requests still work", async () => {
const fake = fakeApi();
const dispose = activate(fake.api);
try {
await fake.settle();
fake.update({ shared: { openTab: { kind: "diff", workspaceId: "w1", path: "b.ts", diff: "@@\n", nonce: 1 } } });
await fake.settle();
assert.equal(fake.state().shared?.openTab, null);
assert.deepEqual(fake.emitted, [{ event: "activeChanged", payload: { kind: "diff", path: "b.ts" } }]);
assert.ok(fake.trees().includes("diff:w1:b.ts"));
fake.update({ shared: { closeTab: { kind: "diff", path: "b.ts", nonce: 2 } } });
await fake.settle();
assert.equal(fake.state().shared?.closeTab, null);
assert.deepEqual(fake.emitted.at(-1), { event: "activeChanged", payload: null });
} finally {
await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 18.8 KB | 3 permissions | Listed |
No comments yet.