Official
sidebar
Built-in sidebar UI (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/sidebar@0.2.0
Permissions in 0.2.0
Files
chat.test.ts14.1 KB
// The sidebar follows the chat plugin's queue and draft titles through
// the convergence/chat service. Real activation with a canned chat
// client: the initial read fills the rows, events update them, and without
// a provider the rows show the stored titles.
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
Api,
AppState,
AppStatePatch,
Chat,
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));
function fakeApi(withChat: boolean) {
let state: AppState = { workspaceId: "w1" };
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 chatCalls: { method: string; params: unknown }[] = [];
const creates: unknown[] = [];
const chatSubs = new Map<string, Set<(payload: unknown) => void>>();
let dirty = true;
let queued: string[] = ["c1"];
let drafts: Record<string, string> = { c1: "Typed!" };
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 chatClient = {
queued: Object.assign(async () => [...queued], {
// The Effect adapter calls through `result`, never the direct call.
result: async () => {
chatCalls.push({ method: "queued", params: {} });
return { ok: true as const, value: [...queued] };
},
}),
draftTitles: Object.assign(async () => ({ ...drafts }), {
result: async () => {
chatCalls.push({ method: "draftTitles", params: {} });
return { ok: true as const, value: { ...drafts } };
},
}),
on: (event: string, fn: (payload: unknown) => void) => {
const key = `convergence/chat|${event}`;
const set = chatSubs.get(key) ?? new Set<(payload: unknown) => void>();
chatSubs.set(key, set);
set.add(fn);
return () => {
set.delete(fn);
};
},
};
const hostCall = async (method: string, params: unknown) => {
if (method === "host/storage.get") return { value: undefined };
if (method === "host/storage.set") return {};
if (method === "host/workspaces.add") {
const { path } = params as { path: string };
const workspace = { id: `w${workspaces.length + 1}`, name: path.split("/").pop()!, path, position: workspaces.length };
workspaces.push(workspace);
return workspace;
}
if (method === "host/chats.create") {
creates.push(params);
const { workspaceId, agentId } = params as { workspaceId: string; agentId: string };
const chat: Chat = { ...chats[0]!, id: `c${chats.length + 1}`, workspaceId, agentId };
chats.push(chat);
return chat;
}
throw new Error(`Unexpected host method ${method} ${JSON.stringify(params)}`);
};
const commands = new Map<string, (api: Api) => unknown>();
const kernelCall = async (method: string, params: unknown) => {
throw new Error(`Unexpected kernel method ${method} ${JSON.stringify(params)}`);
};
const broker = testLayer();
const workspaces = [{ id: "w1", name: "Workspace", path: "/fixture", position: 0 }];
const chats: Chat[] = [
{
id: "c1",
workspaceId: "w1",
agentId: "a",
title: "New Chat",
createdAt: "",
updatedAt: "",
archived: false,
options: {},
status: "idle",
},
];
let pendingList: Promise<{ chats: Chat[] }> | undefined;
const tasks = [
{
id: "t1",
workspaceId: "w1",
kind: "chat",
userTitle: false,
completed: false,
pinned: false,
createdAt: "",
updatedAt: "",
sortOrder: 0,
chatIds: ["c1"],
},
];
const promiseApi = {
workspaces: { list: async () => ({ workspaces }) },
chats: {
list: async () => pendingList ?? { chats },
transcript: async () => ({ items: [] }),
},
tasks: { list: async () => ({ tasks }) },
agents: { list: async () => ({ agents: [{ id: "a", name: "A" }] }) },
};
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "sidebar",
id: "convergence/sidebar",
ui,
services: {
provide: async () => ({ emit: () => {}, remove: () => {} }),
get: (name: string) => {
if (name === "convergence/chat" && withChat) return chatClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/chat" && withChat,
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/chat" && withChat ? chatClient : undefined);
return () => {};
},
},
host: { ...broker.api.host, ...promiseApi, call: hostCall, kernel: kernelCall },
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
select: (chatId: string, workspaceId: string) => update({ chatId, workspaceId }),
route: (route: string) => update({ route }),
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: (command: { id: string; run: (api: Api) => unknown }) => {
commands.set(command.id, command.run);
return { remove() {} };
},
pickFolder: async () => ({ path: "/fixture/tide-pool" }),
notify: () => {},
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: "sidebar",
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("sidebar did not settle");
};
return {
api,
app: () => state,
run: (id: string) => commands.get(id)!(api),
chatCalls,
creates,
update,
settle,
trees: () => stable(trees),
chats,
hostEvent: (event: HostEvent) => {
for (const fn of listeners.get(event.event) ?? []) fn(event);
},
holdList: () => {
const snapshot = chats.map((chat) => ({ ...chat }));
let release!: () => void;
pendingList = new Promise((resolve) => {
release = () => {
pendingList = undefined;
resolve({ chats: snapshot });
};
});
return release;
},
chatEvent: (event: string, payload: unknown) => {
for (const fn of chatSubs.get(`convergence/chat|${event}`) ?? []) fn(payload);
},
providerData: (next: { queued?: string[]; drafts?: Record<string, string> }) => {
if (next.queued) queued = next.queued;
if (next.drafts) drafts = next.drafts;
},
};
}
test("the queued chats and draft titles come from the chat service", async () => {
const fake = fakeApi(true);
const dispose = activate(fake.api);
try {
await fake.settle();
// The initial read fills the rows: c1 is queued with a typed title.
assert.ok(fake.chatCalls.some((call) => call.method === "queued"));
assert.ok(fake.chatCalls.some((call) => call.method === "draftTitles"));
assert.ok(fake.trees().includes("Typed!"));
assert.ok(fake.trees().includes("Queued"));
// Events update the rows without shared state.
fake.providerData({ queued: [], drafts: {} });
fake.chatEvent("queuedChanged", []);
fake.chatEvent("draftTitlesChanged", {});
await fake.settle();
assert.ok(!fake.trees().includes("Typed!"));
assert.ok(!fake.trees().includes("Queued"));
assert.ok(fake.trees().includes("New Chat"));
} finally {
await dispose();
}
});
test("without the chat plugin the rows show the stored titles", async () => {
const fake = fakeApi(false);
const dispose = activate(fake.api);
try {
await fake.settle();
assert.deepEqual(fake.chatCalls, []);
assert.ok(fake.trees().includes("New Chat"));
assert.ok(!fake.trees().includes("Queued"));
} finally {
await dispose();
}
});
test("a list refresh cannot mark a new running turn finished", async () => {
const fake = fakeApi(false);
fake.chats[0] = { ...fake.chats[0]!, sessionId: "session", updatedAt: "2026-10-04T17:00:00Z" };
const dispose = activate(fake.api);
try {
await fake.settle();
fake.hostEvent({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } });
await fake.settle();
assert.ok(fake.trees().includes('"label":"Finished"'));
const release = fake.holdList();
fake.hostEvent({ event: "agents_changed" });
await fake.settle();
const running: Chat = { ...fake.chats[0]!, status: "running", updatedAt: "2026-10-04T17:01:00Z" };
fake.hostEvent({ event: "chat_changed", chat: running });
fake.hostEvent({
event: "agent",
chatId: "c1",
kind: {
event: "tool_call_started",
id: "tool",
name: "Bash",
kind: "execute",
title: "Run tests",
status: "running",
},
});
await fake.settle();
assert.ok(fake.trees().includes('"label":"Running"'));
release();
await fake.settle();
assert.ok(
fake.trees().includes('"label":"Running"'),
"a stale idle snapshot must not replace the live running state",
);
assert.ok(!fake.trees().includes('"label":"Finished"'));
} finally {
await dispose();
}
});
for (const status of ["idle", "failed", "waiting_approval", "waiting_question"] as const) {
test(`a stale running list cannot overwrite ${status}`, async () => {
const fake = fakeApi(false);
fake.chats[0] = { ...fake.chats[0]!, sessionId: "s", status: "running", updatedAt: "2026-10-04T17:00:00Z" };
const dispose = activate(fake.api);
try {
await fake.settle();
const release = fake.holdList();
fake.hostEvent({ event: "agents_changed" });
await fake.settle();
const current: Chat = { ...fake.chats[0]!, status, updatedAt: "2026-10-04T17:01:00Z" };
fake.hostEvent({ event: "chat_changed", chat: current });
if (status === "idle" || status === "failed")
fake.hostEvent({
event: "agent",
chatId: "c1",
kind: { event: "run_finished", outcome: { status: "completed" } },
});
release();
await fake.settle();
const label = status.startsWith("waiting_") ? "Needs you" : "Finished";
assert.ok(fake.trees().includes(`"label":"${label}"`));
assert.ok(!fake.trees().includes('"label":"Running"'));
} finally {
await dispose();
}
});
}
test("a newer list still updates a chat, including metadata at the same timestamp", async () => {
const fake = fakeApi(false);
fake.chats[0] = { ...fake.chats[0]!, sessionId: "s", status: "running", updatedAt: "2026-10-04T17:00:00Z" };
const dispose = activate(fake.api);
try {
await fake.settle();
fake.chats[0] = { ...fake.chats[0]!, title: "Updated title", status: "idle", updatedAt: "2026-10-04T17:01:00Z" };
fake.hostEvent({ event: "agents_changed" });
await fake.settle();
assert.ok(fake.trees().includes("Updated title"));
assert.ok(!fake.trees().includes('"label":"Running"'));
fake.chats[0] = { ...fake.chats[0]!, title: "Same time metadata" };
fake.hostEvent({ event: "agents_changed" });
await fake.settle();
assert.ok(fake.trees().includes("Same time metadata"));
} finally {
await dispose();
}
});
test("adding a folder shows it alone and opens a draft there", async () => {
const fake = fakeApi(false);
const dispose = activate(fake.api);
try {
await fake.settle();
await fake.run("sidebar.addWorkspace");
await fake.settle();
const draft = fake.chats.find((chat) => chat.workspaceId === "w2");
assert.ok(draft, "a draft was created in the new workspace");
assert.equal(fake.app().workspaceId, "w2");
assert.equal(fake.app().chatId, draft.id);
} finally {
await dispose();
}
});
test("New chat from a draft with a typed or queued prompt opens another draft", async () => {
const fake = fakeApi(true);
fake.providerData({ queued: ["c1"], drafts: { c2: "Typed!" } });
const dispose = activate(fake.api);
try {
await fake.settle();
await fake.run("sidebar.newChat");
await fake.settle();
assert.deepEqual(fake.creates, [{ workspaceId: "w1", agentId: "a", inUse: ["c2", "c1"] }]);
} finally {
await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 20.2 KB | 3 permissions | Listed |
No comments yet.