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
events.ts8 KB
// Event listeners update only the affected chat pane, matching the streaming baseline.
// Per-chat FIFO releases only on host settlement. run_finished is transcript
// progress, never evidence that required persistence/checkpoints have finished.
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { Events } from "convergence/effect";
import type { HostEvents } from "convergence";
import type { Controller } from "./controller.ts";
import { applyEvent } from "./transcript.ts";
import { isLive } from "./subagents.ts";
export const onAgent = Effect.fnUntraced(function* (c: Controller, event: HostEvents["agent"]) {
const composer = c.state.composers[event.chatId],
kind = event.kind;
if (composer) {
if (kind.event === "config_options") {
composer.options = kind.options;
c.view.update();
} else if (kind.event === "commands") {
composer.commands = kind.commands;
c.view.update();
} else if (kind.event === "skills") {
composer.skills = kind.skills;
c.view.update();
}
}
if (kind.event === "run_finished") c.view.update();
if (kind.event === "usage_blocked" && !event.taskId) {
c.scheduler.activeLimits[event.chatId] = { runId: c.scheduler.snapshots[event.chatId]?.runId, evidence: kind.recovery };
c.refresh(event.chatId);
}
const transcript = c.state.transcripts[event.chatId];
if (transcript && kind.event === "usage" && !event.taskId) {
transcript.usage = kind;
if (c.api.app.state().chatId === event.chatId) c.titleView?.update();
return;
}
if (!transcript || !applyEvent(transcript, kind, event.taskId)) return;
if (event.taskId || kind.event === "task" || kind.event === "approval_resolved") {
c.refreshSubagentTabs(event.chatId);
if (kind.event === "task") {
c.titleView?.update();
if (isLive(kind)) yield* Effect.forkIn(c.tickSubagents(), c.scope, { startImmediately: true });
}
}
const chat = c.state.chats[event.chatId],
pane = chat ? c.paneOf(chat) : null;
if (pane) pane.view.update();
else if (!pane && c.api.app.state().chatId === event.chatId) c.center?.update();
});
export const onChatChanged = Effect.fn("Chat.onChatChanged")(function* (
c: Controller,
event: HostEvents["chat_changed"],
) {
const before = c.state.chats[event.chat.id];
c.state.chats[event.chat.id] = event.chat;
if (event.chat.archived && !before?.archived) c.removePane(event.chat.id);
const moved = before && (before.agentId !== event.chat.agentId || before.workspaceId !== event.chat.workspaceId);
if (moved && c.state.composers[event.chat.id])
yield* Effect.forkIn(c.loadOptions(event.chat.id), c.scope, { startImmediately: true });
if (c.scheduler.ready && (!c.scheduler.snapshots[event.chat.id] || moved))
yield* Effect.forkIn(c.scheduler.load(event.chat.id), c.scope, { startImmediately: true });
yield* c.syncDraftTitle(event.chat.id);
// A queued draft starts once no chat runs; the one just sent holds the
// queue until the host reports it running.
if (c.state.releasing === event.chat.id && (c.isRunning(event.chat) || event.chat.sessionId || event.chat.archived))
c.state.releasing = null;
if (event.chat.archived) yield* c.unqueueChat(event.chat.id);
yield* Effect.forkIn(c.pumpChatQueue(), c.scope, { startImmediately: true });
// Stream consumers run after the slot listener; repaint with the updated chat metadata.
c.titleView?.update();
c.view.update();
});
export const onAgentsChanged = Effect.fn("Chat.onAgentsChanged")(function* (c: Controller) {
yield* Effect.forkIn(c.loadChat(c.api.app.state().chatId), c.scope, {
startImmediately: true,
});
for (const [chatId, composer] of Object.entries(c.state.composers))
if (!composer.options.length && !composer.loading)
yield* Effect.forkIn(c.loadOptions(chatId), c.scope, { startImmediately: true });
});
export const onChatRemoved = Effect.fn("Chat.onChatRemoved")(function* (
c: Controller,
event: HostEvents["chat_removed"],
) {
delete c.state.transcripts[event.chatId];
delete c.state.composers[event.chatId];
if (c.state.restored) delete c.state.restored[event.chatId];
yield* Effect.forkIn(c.saveUnsent(), c.scope, { startImmediately: true });
delete c.state.chats[event.chatId];
if (c.state.releasing === event.chatId) c.state.releasing = null;
yield* c.unqueueChat(event.chatId);
yield* Effect.forkIn(c.pumpChatQueue(), c.scope, { startImmediately: true });
delete c.scheduler.snapshots[event.chatId];
delete c.scheduler.activeLimits[event.chatId];
c.removePane(event.chatId);
});
// All streams and background listeners end with the owning plugin scope.
export const listen = Effect.fn("Chat.listen")(function* (c: Controller) {
const events = yield* Events;
const fork = <A, E>(effect: Effect.Effect<A, E>) => Effect.forkIn(effect, c.scope, { startImmediately: true });
yield* events.stream("inputs_changed").pipe(
Stream.runForEach((event) => {
c.scheduler.receive(event.snapshot);
return fork(c.scheduler.evaluate());
}),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("run_settled").pipe(
Stream.runForEach((event) => fork(c.scheduler.load(event.chatId).pipe(Effect.andThen(c.scheduler.evaluate())))),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("workspaces_changed").pipe(
Stream.runForEach(() =>
fork(
c.loadWorkspaces().pipe(Effect.catch((error) => Effect.logWarning(`workspaces unavailable: ${error.message}`))),
),
),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("chat_changed").pipe(
Stream.runForEach((event) => fork(onChatChanged(c, event))),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("agents_changed").pipe(
Stream.runForEach(() => fork(onAgentsChanged(c))),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("agent").pipe(
Stream.runForEach((event) => onAgent(c, event)),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("user_message").pipe(
Stream.runForEach((event) =>
fork(
Effect.gen(function* () {
const transcript = c.state.transcripts[event.chatId];
if (!transcript) return;
transcript.items.push(event.item);
transcript.running = true;
transcript.checkpoints.add(event.item.id);
c.view.update();
if (c.api.app.state().chatId === event.chatId) yield* c.loadImages(event.chatId);
}),
),
),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("transcript_reset").pipe(
Stream.runForEach((event) =>
fork(
Effect.gen(function* () {
delete c.state.transcripts[event.chatId];
c.view.update();
if (c.api.app.state().chatId === event.chatId) yield* c.ensureChat(event.chatId);
}),
),
),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("security_request").pipe(
Stream.runForEach(({ request }) =>
Effect.sync(() => {
const transcript = request.chatId ? c.state.transcripts[request.chatId] : null;
if (!transcript) return;
transcript.requests = [...transcript.requests.filter((held) => held.id !== request.id), request];
c.refresh(request.chatId);
}),
),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("security_resolved").pipe(
Stream.runForEach(({ id }) =>
Effect.sync(() => {
for (const [chatId, transcript] of Object.entries(c.state.transcripts)) {
if (!transcript.requests.some((request) => request.id === id)) continue;
transcript.requests = transcript.requests.filter((request) => request.id !== id);
c.refresh(chatId);
}
}),
),
Effect.forkScoped({ startImmediately: true }),
);
yield* events.stream("chat_removed").pipe(
Stream.runForEach((event) => fork(onChatRemoved(c, event))),
Effect.forkScoped({ startImmediately: true }),
);
});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.