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

data.ts15.2 KB
// Request failures recover at the same notification/logging boundaries as the JavaScript baseline.
// Load persisted chat state and refresh agent history without adding work to streaming deltas.
// Storage schemas validate the untyped values before they enter the per-chat maps.
import * as Effect from "effect/Effect";
import * as z from "zod";
import { parse } from "convergence/effect";
import type { Chat, TranscriptItem } from "convergence";
import type { Controller, ChatError } from "./controller.ts";
import { draftTitle } from "./title.ts";
import { allTasks, isLive } from "./subagents.ts";
import { CYCLE_LIMIT } from "./switcher.ts";
import { contentBlocks, legacyId } from "./input-schema.ts";
import { blocksFor } from "./composition.ts";

const textMap = z.record(z.string(), z.string());
// Restored queues need opaque image ids; historical entries may omit attachment metadata.
const attachment = z.object({ id: z.string(), name: z.string().optional() }).passthrough();
const prompt = z.union([
  z.string().transform((text) => ({ text, attachments: [], blocks: undefined, id: undefined })),
  z.object({
    text: z.string(),
    attachments: z.array(attachment),
    blocks: contentBlocks.optional(),
    id: z.string().optional(),
  }),
]);
const queues = z.object({
  prompts: z.record(z.string(), z.array(prompt)).optional(),
  // `text` is from before a queued draft kept its prompt in the composer.
  chats: z.array(z.object({ chatId: z.string(), text: z.string().optional() })).optional(),
});

// The sidebar follows the queue and the draft titles through the
// convergence/chat service; these snapshot the state for its events. The
// shared writes they replace also re-rendered this plugin's own views
// through app_state, so they refresh the center and the title too.
export const announceQueued = (c: Controller): Effect.Effect<void> =>
  Effect.sync(() => {
    c.emitQueued?.(c.state.chatQueue.map((queued) => queued.chatId));
    c.center?.update();
    c.titleView?.update();
  });
export const announceDrafts = (c: Controller): Effect.Effect<void> =>
  Effect.sync(() => {
    c.emitDrafts?.({ ...c.state.draftTitles });
    c.center?.update();
    c.titleView?.update();
  });
export const loadRecent = Effect.fn("Chat.loadRecent")(function* (c: Controller) {
  const value = (yield* c.host.call("host/storage.get", { key: "recent" })).value;
  if (Array.isArray(value)) c.state.recent = value.filter((id): id is string => typeof id === "string");
});
export const noteRecent = Effect.fn("Chat.noteRecent")(function* (c: Controller, chatId: string) {
  if (c.state.recent[0] === chatId) return;
  c.state.recent = [chatId, ...c.state.recent.filter((id) => id !== chatId)].slice(0, CYCLE_LIMIT);
  yield* c.host.call("host/storage.set", { key: "recent", value: c.state.recent });
});
export const syncDraftTitle = Effect.fn("Chat.syncDraftTitle")(function* (c: Controller, chatId: string) {
  const chat = c.state.chats[chatId];
  const text = c.state.composers[chatId]?.text ?? c.state.restored?.[chatId] ?? "";
  const title =
    chat?.title === "New Chat"
      ? draftTitle(text)
      : null;
  if ((c.state.draftTitles[chatId] ?? null) === title) return;
  if (title) c.state.draftTitles[chatId] = title;
  else delete c.state.draftTitles[chatId];
  yield* announceDrafts(c);
});
export const syncDraftTitles = Effect.fn("Chat.syncDraftTitles")(function* (c: Controller) {
  const ids = new Set([
    ...Object.keys(c.state.composers),
    ...Object.keys(c.state.restored ?? {}),
    ...c.state.chatQueue.map((q) => q.chatId),
  ]);
  for (const id of ids) yield* syncDraftTitle(c, id);
});
export const saveUnsent = Effect.fn("Chat.saveUnsent")(function* (c: Controller): Effect.fn.Return<void, ChatError> {
  c.state.unsaved = true;
  if (c.state.saving || !c.state.restored) return;
  c.state.saving = true;
  yield* Effect.gen(function* () {
    while (c.state.unsaved) {
      c.state.unsaved = false;
      const value = { ...c.state.restored };
      for (const [id, composer] of Object.entries(c.state.composers)) {
        if (composer.text) value[id] = composer.text;
        else delete value[id];
      }
      const result = yield* c.host.call("host/storage.set", { key: "unsent", value }).pipe(
        Effect.as(true),
        Effect.catch((error) =>
          c.notify.error(`Unsent prompts could not be saved: ${errorMessage(error)}`).pipe(Effect.as(false)),
        ),
      );
      if (!result) {
        c.state.unsaved = true;
        break;
      }
    }
  }).pipe(
    Effect.ensuring(
      Effect.sync(() => {
        c.state.saving = false;
      }),
    ),
  );
});
export const textChanged = Effect.fn("Chat.textChanged")(function* (c: Controller, chatId: string) {
  yield* syncDraftTitle(c, chatId);
  yield* Effect.forkIn(saveUnsent(c), c.scope, { startImmediately: true });
});
export const restoreUnsent = Effect.fn("Chat.restoreUnsent")(function* (c: Controller) {
  yield* Effect.gen(function* () {
    const stored = yield* parse(
      "unsent prompts",
      textMap,
      (yield* c.host.call("host/storage.get", { key: "unsent" })).value ?? {},
    );
    c.state.restored = {};
    for (const [id, text] of Object.entries(stored)) {
      const composer = c.state.composers[id];
      if (!composer) c.state.restored[id] = text;
      else if (!composer.text) composer.text = text;
    }
    yield* syncDraftTitles(c);
    if (c.state.unsaved) yield* Effect.forkIn(saveUnsent(c), c.scope, { startImmediately: true });
    c.view.update();
  }).pipe(Effect.catch((error) => c.notify.error(`Unsent prompts could not be read: ${errorMessage(error)}`)));
});
export const persistQueues = Effect.fn("Chat.persistQueues")(function* (c: Controller) {
  const prompts = Object.fromEntries(
    Object.entries(c.state.composers)
      .filter(([, composer]) => composer.queue.length)
      .map(([id, composer]) => [id, composer.queue]),
  );
  yield* c.host.call("host/storage.set", {
    key: "queues",
    value: { prompts, chats: c.state.chatQueue },
  });
});
export const restoreQueues = Effect.fn("Chat.restoreQueues")(function* (c: Controller) {
  const value = (yield* c.host.call("host/storage.get", { key: "queues" })).value;
  if (!value) return;
  const stored = yield* parse("prompt queues", queues, value);
  c.state.chatQueue = (stored.chats ?? []).map(({ chatId }) => ({ chatId }));
  for (const { chatId, text } of stored.chats ?? []) {
    if (!text) continue;
    yield* c.ensureComposer(chatId);
    const composer = c.composer(chatId);
    if (!composer.text) composer.text = text;
  }
  if (stored.chats?.some((queued) => queued.text)) {
    yield* Effect.forkIn(saveUnsent(c), c.scope, { startImmediately: true });
    yield* persistQueues(c);
  }
  for (const [id, queue] of Object.entries(stored.prompts ?? {})) {
    yield* c.ensureComposer(id);
    // Assign stable keys before deleting any source row. A crash after host
    // enqueue but before saving the migration can only repeat an idempotent key.
    c.composer(id).queue = queue.map((entry, index) => ({ ...entry, id: entry.id ?? legacyId(id, index, entry) }));
  }
  if (Object.keys(stored.prompts ?? {}).length) yield* persistQueues(c);
  for (const id of Object.keys(stored.prompts ?? {})) {
    yield* c.scheduler.load(id);
    if (c.scheduler.available !== true) continue;
    const composer = c.composer(id);
    for (const entry of [...composer.queue])
      yield* Effect.gen(function* () {
        const blocks = entry.blocks ?? (yield* blocksFor(c, id, entry.text, entry.attachments));
        const key = entry.id ?? legacyId(id, 0, entry);
        yield* c.scheduler.enqueue(id, key, blocks, "queue", "legacy_queue");
        composer.queue = composer.queue.filter((input) => input.id !== key);
        yield* persistQueues(c);
      }).pipe(Effect.catch((error) => c.scheduler.report(id, error)));
  }
  yield* announceQueued(c);
  yield* syncDraftTitles(c);
  c.view.update();
});
export const loadOptions = Effect.fn("Chat.loadOptions")(function* (c: Controller, chatId: string) {
  const composer = c.composer(chatId);
  const agentId = c.state.chats[chatId]?.agentId ?? null;
  if (composer.agentId !== agentId) {
    // Another agent's settings say nothing about this one's: while they
    // load, and if they fail, the pickers show none, never the last
    // agent's model.
    composer.agentId = agentId;
    composer.options = [];
    composer.commands = [];
    composer.skills = [];
  }
  composer.loading = true;
  c.view.update();
  yield* Effect.gen(function* () {
    const [options, commands] = yield* Effect.all(
      [c.host.call("host/chats.options", { chatId }), c.host.call("host/chats.commands", { chatId })],
      { concurrency: "unbounded" },
    );
    // The chat moved to another agent meanwhile: these are the last
    // agent's, and the new agent's own read is on its way.
    if (composer.agentId !== agentId) return;
    composer.options = options.options;
    composer.commands = commands.commands;
    // Skills are optional in the baseline and cannot delay applying options/commands.
    yield* Effect.forkIn(
      c.host.call("host/chats.skills", { chatId }).pipe(
        Effect.tap((result) =>
          Effect.sync(() => {
            if (composer.agentId !== agentId) return;
            composer.skills = result.skills;
            c.view.update();
          }),
        ),
        Effect.catch(() => Effect.void),
      ),
      c.scope,
      { startImmediately: true },
    );
  }).pipe(
    Effect.catch((error) =>
      composer.agentId === agentId ? c.notify.warning(`Options unavailable: ${errorMessage(error)}`) : Effect.void,
    ),
  );
  if (composer.agentId !== agentId) return;
  composer.loading = false;
  c.view.update();
});
export const loadChat = Effect.fn("Chat.loadChat")(function* (
  c: Controller,
  chatId?: string,
): Effect.fn.Return<Chat | undefined, ChatError> {
  const [chats, workspaces] = yield* Effect.all(
    [c.host.call("host/chats.list", {}), c.host.call("host/workspaces.list", {})],
    { concurrency: "unbounded" },
  );
  // Keep chat changes that arrived after this list snapshot was taken.
  for (const chat of chats.chats) {
    const current = c.state.chats[chat.id];
    if (!current || current.updatedAt <= chat.updatedAt) c.state.chats[chat.id] = chat;
  }
  for (const workspace of workspaces.workspaces) c.state.workspaces[workspace.id] = workspace;
  yield* syncDraftTitles(c);
  yield* Effect.forkIn(c.pumpChatQueue(), c.scope, { startImmediately: true });
  c.center?.update();
  c.view.update();
  c.titleView?.update();
  yield* Effect.forkIn(
    // Every agent, not only the enabled ones: a chat keeps its agent after
    // the user turns that agent off, and its composer still names it with
    // its own mark. The pickers offer only the enabled ones.
    c.host.call("host/agents.all", {}).pipe(
      Effect.tap((result) =>
        Effect.sync(() => {
          c.state.agentList = result.agents.filter((agent) => agent.enabled);
          for (const agent of result.agents) c.state.agents[agent.id] = agent;
          c.view.update();
          c.settingsView?.update();
        }),
      ),
    ),
    c.scope,
    { startImmediately: true },
  );
  return chatId ? c.state.chats[chatId] : undefined;
});
export const loadWorkspaces = Effect.fn("Chat.loadWorkspaces")(function* (c: Controller) {
  const result = yield* c.host.call("host/workspaces.list", {});
  c.state.workspaces = Object.fromEntries(result.workspaces.map((workspace) => [workspace.id, workspace]));
  c.center?.update();
  c.titleView?.update();
  for (const pane of Object.values(c.state.panes)) pane.view.update();
});
export const refreshHistory = Effect.fn("Chat.refreshHistory")(function* (c: Controller, chatId: string) {
  const chat = c.state.chats[chatId];
  if (!chat?.sessionId || c.state.transcripts[chatId]?.running) return;
  const now = Date.now();
  if (now - (c.state.historyRead[chatId] ?? 0) < 2000) return;
  c.state.historyRead[chatId] = now;
  const empty = !c.state.transcripts[chatId]?.items.length;
  yield* c.host
    .call("host/chats.load_history", { chatId })
    .pipe(
      Effect.catch((error) =>
        empty && !c.state.transcripts[chatId]?.items.length
          ? c.notify.warning(`This chat's history is unavailable: ${errorMessage(error)}`)
          : Effect.logWarning(`history unavailable: ${errorMessage(error)}`),
      ),
    );
});
function normalize(items: TranscriptItem[]) {
  for (const item of items) {
    if (item.role === "tool") {
      item.call.content ??= [];
      item.call.locations ??= [];
    }
    if (item.role === "task") normalize((item.items ??= []));
  }
}
export const loadTranscript = Effect.fn("Chat.loadTranscript")(function* (c: Controller, chatId: string) {
  const transcript = c.transcript(chatId);
  // Checkpoints annotate prompts; they must not delay installing the transcript snapshot.
  yield* Effect.forkIn(
    c.host.call("host/chats.checkpoints", { chatId }).pipe(
      Effect.tap((result) =>
        Effect.sync(() => {
          for (const checkpoint of result.checkpoints) transcript.checkpoints.add(checkpoint.itemId);
          c.refresh(chatId);
        }),
      ),
      Effect.catch((error) => c.notify.error(`Checkpoints unavailable: ${errorMessage(error)}`)),
    ),
    c.scope,
    { startImmediately: true },
  );
  yield* Effect.gen(function* () {
    const result = yield* c.host.call("host/chats.transcript", { chatId });
    transcript.items = [...result.items];
    normalize(transcript.items);
    if (allTasks(transcript.items).some((row) => isLive(row.task)))
      yield* Effect.forkIn(c.tickSubagents(), c.scope, { startImmediately: true });
    transcript.approvals = [...result.pendingApprovals];
    transcript.questions = [...result.pendingQuestions];
    transcript.plan = result.plan;
    transcript.usage = result.usage;
    transcript.running = result.running;
    yield* c.loadImages(chatId);
  }).pipe(Effect.catch((error) => c.notify.error(`Transcript unavailable: ${errorMessage(error)}`)));
  transcript.loaded = true;
  c.view.update();
  c.titleView?.update();
  yield* Effect.forkIn(refreshHistory(c, chatId), c.scope, { startImmediately: true });
});
export const loadRequests = Effect.fn("Chat.loadRequests")(function* (c: Controller, chatId: string) {
  yield* c.host.call("host/security.pending", { chatId }).pipe(
    Effect.tap((result) =>
      Effect.sync(() => {
        c.transcript(chatId).requests = result.requests;
        c.refresh(chatId);
      }),
    ),
    Effect.catch((error) => Effect.logWarning(`security requests unavailable: ${errorMessage(error)}`)),
  );
});
export const tickSubagents = Effect.fn("Chat.tickSubagents")(function* (c: Controller) {
  if (c.state.ticking) return;
  c.state.ticking = true;
  yield* Effect.gen(function* () {
    for (;;) {
      yield* Effect.sleep(1000);
      const live = Object.entries(c.state.transcripts).filter(([, transcript]) =>
        allTasks(transcript.items).some((row) => isLive(row.task) && row.task.startedAt),
      );
      if (!live.length) return;
      for (const [chatId] of live) {
        c.refreshSubagentTabs(chatId);
        if (c.api.app.state().chatId === chatId) c.view.update();
      }
    }
  }).pipe(
    Effect.ensuring(
      Effect.sync(() => {
        c.state.ticking = false;
      }),
    ),
  );
});

export function errorMessage(error: { message: string } | { what: string; issues: readonly string[] }): string {
  return "message" in error ? error.message : `${error.what}: ${error.issues.join("; ")}`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.