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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.