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

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Add and remove workspaces, and create, rename, check off and archive chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List workspaces and chats, and follow them as agents work
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the sidebarSlots: left

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <320.2 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.