Official

chat

Built-in chat 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/chat@0.2.0

Permissions in 0.2.0

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

Files

chat-queue.test.ts4.1 KB
// A queued draft stays a draft, its prompt in the composer, until no chat
// runs; then the queue sends it and it becomes a running chat, one at a time.
import assert from "node:assert/strict";
import test from "node:test";
import * as Effect from "effect/Effect";
import { InputLedger, withController } from "./testing.ts";
import { onSubmit, queueChat } from "./composer.ts";
import { onChatChanged } from "./events.ts";
import { chat } from "./ui-for-tests.test.ts";
import type { Controller } from "./controller.ts";
import type { Chat } from "convergence";

const settle = Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 20)));
const change = (c: Controller, id: string, overrides: Partial<Chat>) =>
  onChatChanged(c, { event: "chat_changed", chat: { ...c.state.chats[id]!, ...overrides, updatedAt: new Date().toISOString() } });
const queued = (c: Controller) => c.state.chatQueue.map((entry) => entry.chatId);

test("a queued draft keeps its prompt and starts only when no chat runs", async () => {
  const ledger = new InputLedger(["busy", "d1"]);
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.state.chats.busy = chat({ id: "busy", sessionId: "s", status: "running" });
      c.state.chats.d1 = chat({ id: "d1" });
      c.composer("d1").loading = false;
      c.composer("d1").text = "Fix the tests";
      yield* queueChat(c, "d1");
      yield* settle;
      assert.deepEqual(queued(c), ["d1"]);
      assert.equal(c.composer("d1").text, "Fix the tests", "the prompt stays in the draft");
      assert.equal(ledger.deliveries.length, 0, "another chat runs");
      // Still editable while it waits: the edit is what is sent.
      c.composer("d1").text = "Fix the failing tests";
      yield* change(c, "busy", { status: "idle" });
      yield* settle;
      assert.deepEqual(queued(c), []);
      assert.deepEqual(ledger.deliveries.map((d) => [d.chatId, d.blocks]), [
        ["d1", [{ type: "text", text: "Fix the failing tests" }]],
      ]);
    }),
  );
});

test("queued drafts start one at a time, in order", async () => {
  const ledger = new InputLedger(["d1", "d2"]);
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      for (const id of ["d1", "d2"]) {
        c.state.chats[id] = chat({ id });
        c.composer(id).loading = false;
        c.composer(id).text = `prompt ${id}`;
      }
      // Nothing runs: the first starts at once, the second waits for it.
      yield* queueChat(c, "d1");
      yield* queueChat(c, "d2");
      yield* settle;
      assert.deepEqual(ledger.deliveries.map((d) => d.chatId), ["d1"]);
      assert.deepEqual(queued(c), ["d2"]);
      yield* change(c, "d1", { status: "running" });
      yield* settle;
      assert.deepEqual(ledger.deliveries.map((d) => d.chatId), ["d1"]);
      yield* change(c, "d1", { status: "idle", sessionId: "s1" });
      yield* settle;
      assert.deepEqual(ledger.deliveries.map((d) => d.chatId), ["d1", "d2"]);
      assert.deepEqual(queued(c), []);
    }),
  );
});

test("sending a queued draft by hand takes it out of the queue", async () => {
  const ledger = new InputLedger(["busy", "d1"]);
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      c.state.chats.busy = chat({ id: "busy", sessionId: "s", status: "running" });
      c.state.chats.d1 = chat({ id: "d1" });
      c.composer("d1").loading = false;
      c.composer("d1").text = "Now";
      yield* queueChat(c, "d1");
      yield* onSubmit(c, "d1", { value: "Now", secondary: false, shift: false });
      assert.deepEqual(queued(c), []);
      assert.deepEqual(ledger.deliveries.map((d) => d.chatId), ["d1"]);
    }),
  );
});

test("a queue saved with its prompt puts the prompt back in the draft", async () => {
  const ledger = new InputLedger(["d1"]);
  ledger.storage.queues = { chats: [{ chatId: "d1", text: "Old prompt", started: false }] };
  await withController(ledger, (c) =>
    Effect.gen(function* () {
      yield* c.restoreQueues();
      assert.deepEqual(queued(c), ["d1"]);
      assert.equal(c.composer("d1").text, "Old prompt");
      assert.deepEqual(ledger.storage.queues, { prompts: {}, chats: [{ chatId: "d1" }] });
    }),
  );
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.