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
sections.test.ts9.4 KB
// The sidebar's list rules, without a window: real imports of the pure
// model.
import assert from "node:assert/strict";
import test from "node:test";
import type { Chat } from "convergence";
import {
applyAgentEvent,
applyChatChange,
applyChatRemoved,
applyTranscriptReset,
chatStatus,
elapsed,
initialState,
listedChats,
matches,
recentAgent,
sectionOf,
sections,
visibleChats,
workspaceLooks,
workspaceMarkOf,
} from "./sections.ts";
import type { SidebarState } from "./sections.ts";
let n = 0;
const chat = (over: Partial<Chat> = {}): Chat => ({
id: `chat-${++n}`,
workspaceId: "ws",
agentId: "agent",
title: "Chat",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
archived: false,
options: {},
status: "idle",
...over,
});
test("chat status ranks blocked first, then running, review, idle", () => {
const state = initialState();
state.finished = { r: Date.now() };
assert.equal(chatStatus(state, { id: "a", status: "waiting_approval" }), "blocked");
assert.equal(chatStatus(state, { id: "a", status: "waiting_question" }), "blocked");
assert.equal(chatStatus(state, { id: "a", status: "running" }), "running");
assert.equal(chatStatus(state, { id: "r", status: "idle", sessionId: "s" }), "review");
assert.equal(chatStatus(state, { id: "x", status: "idle" }), "idle");
});
test("a chat whose background subagents still work stays running after its turn", () => {
const state = initialState();
state.subagents = {
a: new Map([
["t1", "running"],
["t2", "completed"],
]),
};
assert.equal(chatStatus(state, { id: "a", status: "idle", sessionId: "s" }), "running");
state.subagents.a?.set("t1", "completed");
assert.equal(chatStatus(state, { id: "a", status: "idle", sessionId: "s" }), "idle");
});
test("each chat shows in the section of its status", () => {
const state = initialState();
state.queued = new Set(["q"]);
state.finished = { f: Date.now() };
assert.equal(sectionOf(state, { id: "d", status: "idle" }), "drafts");
assert.equal(sectionOf(state, { id: "q", status: "idle" }), "queued");
assert.equal(sectionOf(state, { id: "b", status: "waiting_question", sessionId: "s" }), "needs-you");
assert.equal(sectionOf(state, { id: "r", status: "running" }), "running", "a sent first prompt is no draft");
assert.equal(sectionOf(state, { id: "f", status: "idle", sessionId: "s" }), "finished");
assert.equal(sectionOf(state, { id: "o", status: "idle", sessionId: "s" }), "open");
assert.equal(
sectionOf(state, { id: "c", status: "idle", sessionId: "s", completedAt: "2026-01-01T00:00:00Z" }),
"settled",
);
});
test("a chat checked off moves to Complete at once, whatever it was doing", () => {
const state = initialState();
state.finished.c = Date.now();
const done = { id: "c", status: "idle" as const, sessionId: "s", completedAt: "2026-01-01T00:00:00Z" };
assert.equal(sectionOf(state, done), "settled");
});
test("sections leave empty ones out and put the newest checked off first", () => {
const state = initialState();
state.chats = [
chat({ id: "old-done", sessionId: "s", completedAt: "2026-01-01T00:00:00Z" }),
chat({ id: "new-done", sessionId: "s", completedAt: "2026-02-01T00:00:00Z" }),
chat({ id: "open", sessionId: "s" }),
];
assert.deepEqual(
sections(state).map((s) => [s.id, s.chats.map((c) => c.id)]),
[
["open", ["open"]],
["settled", ["new-done", "old-done"]],
],
);
});
test("the list holds the workspace's chats, newest activity first, without archived ones", () => {
const state = initialState();
state.chats = [
chat({ id: "old", updatedAt: "2026-01-01T00:00:00.000Z" }),
chat({ id: "new", updatedAt: "2026-03-01T00:00:00.000Z" }),
chat({ id: "archived", archived: true, updatedAt: "2026-04-01T00:00:00.000Z" }),
chat({ id: "elsewhere", workspaceId: "other", updatedAt: "2026-02-01T00:00:00.000Z" }),
];
assert.deepEqual(
visibleChats(state).map((c) => c.id),
["new", "elsewhere", "old"],
);
state.filter = ["ws"];
assert.deepEqual(
visibleChats(state).map((c) => c.id),
["new", "old"],
);
state.filter = ["ws", "other"];
assert.deepEqual(
visibleChats(state).map((c) => c.id),
["new", "elsewhere", "old"],
);
});
test("the search matches titles, a draft's typed title included", () => {
const state = initialState();
const draft = chat({ id: "d", title: "New Chat" });
state.chats = [chat({ id: "a", title: "Fix the parser" }), draft];
state.draftTitles = { d: "Parser tests" };
state.query = " PARSER ";
assert.deepEqual(
visibleChats(state).map((c) => c.id),
["a", "d"],
);
state.query = "missing";
assert.equal(matches(state, draft), false);
});
test("event mutators keep chats and previews current", () => {
const state = initialState();
const c = chat({ id: "c" });
applyChatChange(state, c);
applyChatChange(state, { ...c, title: "Renamed" });
assert.equal(state.chats[0]?.title, "Renamed");
state.previews.c = { line: "x" } as SidebarState["previews"][string];
applyTranscriptReset(state, "c");
assert.equal("c" in state.previews, false);
state.previews.c = { line: "x" } as SidebarState["previews"][string];
applyChatRemoved(state, "c");
assert.equal(state.chats.length, 0);
assert.equal("c" in state.previews, false);
});
test("recentAgent follows the most recently touched enabled chat", () => {
const state = initialState();
state.agents = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
] as SidebarState["agents"];
state.chats = [
chat({ id: "old", agentId: "a", updatedAt: "2026-01-01T00:00:00.000Z" }),
chat({ id: "new", agentId: "b", updatedAt: "2026-02-01T00:00:00.000Z" }),
chat({ id: "disabled", agentId: "z", updatedAt: "2026-03-01T00:00:00.000Z" }),
];
assert.equal(recentAgent(state, "ws"), "b");
});
test("elapsed renders short relative times", () => {
const now = new Date("2026-01-02T00:00:00.000Z").getTime();
assert.equal(elapsed(now, "2026-01-01T00:00:30.000Z"), "23h");
assert.equal(elapsed(now, "2026-01-01T23:59:10.000Z"), "50s");
assert.equal(elapsed(now, "2026-01-02T00:00:10.000Z"), "");
});
test("the chat service starts unfollowed, with an empty queue and no draft titles", () => {
const state = initialState();
assert.equal(state.chat, undefined);
assert.equal(state.follow, undefined);
assert.deepEqual([...state.queued], []);
assert.deepEqual(state.draftTitles, {});
});
test("agent events redraw only when the visible line changes", () => {
const state = initialState();
assert.equal(
applyAgentEvent(state, {
event: "agent",
chatId: "c",
kind: { event: "task", id: "t1", title: "T", status: "running" },
}),
true,
);
assert.equal(
applyAgentEvent(state, {
event: "agent",
chatId: "c",
kind: { event: "task", id: "t1", title: "T", status: "running" },
}),
false,
);
// A subagent's own turn is not the chat's line.
assert.equal(
applyAgentEvent(state, {
event: "agent",
chatId: "c",
taskId: "t1",
kind: { event: "text_delta", itemId: "a", text: "hi", mode: "append" },
}),
false,
);
assert.equal(
applyAgentEvent(state, {
event: "agent",
chatId: "c",
kind: { event: "text_delta", itemId: "a", text: "hello", mode: "append" },
}),
true,
);
assert.equal(
applyAgentEvent(state, {
event: "agent",
chatId: "c",
kind: { event: "run_finished", outcome: { status: "completed" } },
}),
true,
);
});
test("a workspace's mark is its icon, else its coloured folder, or the folder when chosen", () => {
const base = { id: "w", name: "w", path: "/w", position: 0 };
assert.deepEqual(workspaceMarkOf({ ...base, icon: "/w/icon.png", color: "teal" }), { image: "/w/icon.png" });
assert.deepEqual(workspaceMarkOf({ ...base, icon: "/w/icon.png", color: "teal", useColor: true }), {
icon: "folder-solid",
color: "teal",
});
assert.deepEqual(workspaceMarkOf({ ...base, color: "pink" }), { icon: "folder-solid", color: "pink" });
assert.deepEqual(workspaceMarkOf({ ...base, mark: "puzzle" }), { icon: "puzzle" });
// An older host sends no colour: a plain folder.
assert.deepEqual(workspaceMarkOf(base), { icon: "folder" });
const looks = workspaceLooks({ ...base, icon: "/w/icon.png", color: "teal" }, ["blue", "teal"]);
assert.equal(looks.value, "icon");
assert.deepEqual(
looks.options.map((option) => option.value),
["icon", "blue", "teal"],
);
assert.equal(workspaceLooks({ ...base, color: "teal" }, ["blue", "teal"]).value, "teal");
});
test("the listed chats run top to bottom: section order, folded sections out, paged sections a page", () => {
const state = initialState();
const drafts = Array.from({ length: 7 }, (_, i) => chat({ updatedAt: `2026-01-0${9 - i}T00:00:00.000Z` }));
const running = chat({ status: "running", sessionId: "s1" });
const open = chat({ sessionId: "s2", updatedAt: "2026-01-09T00:00:00.000Z" });
const done = chat({ sessionId: "s3", completedAt: "2026-01-03T00:00:00.000Z" });
state.chats = [done, open, running, ...drafts];
const ids = () => listedChats(state).map((c) => c.id);
assert.deepEqual(ids(), [...drafts.slice(0, 5), running, open, done].map((c) => c.id));
state.showAll.drafts = true;
assert.deepEqual(ids(), [...drafts, running, open, done].map((c) => c.id));
state.collapsed.drafts = true;
assert.deepEqual(ids(), [running, open, done].map((c) => c.id));
});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.