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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.