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
requests.ts4.8 KB
// Request failures recover at the same notification/logging boundaries as the JavaScript baseline.
// Native approvals remain kernel-owned; chat questions and checkpoint actions use host services.
// Cached attachment ids prevent image bytes from travelling through streaming renders.
import * as Effect from "effect/Effect";
import type { Controller } from "./controller.ts";
import type { Question } from "./state.ts";
import { errorMessage, textChanged } from "./data.ts";
import { allTasks } from "./subagents.ts";
export const respondQuestion = Effect.fn("Chat.respondQuestion")(function* (
c: Controller,
chatId: string,
req: Question,
cancelled: boolean,
) {
const transcript = c.transcript(chatId),
answers = (transcript.answers[req.id] ??= {});
if (req.submitting) return;
req.submitting = true;
c.view.update();
const values = cancelled
? {}
: Object.fromEntries(Object.entries(answers).filter(([, value]) => value !== "" && value != null));
yield* c.host.call("host/chats.respond_question", { chatId, questionId: req.id, answer: { values, cancelled } }).pipe(
Effect.tap(() =>
Effect.sync(() => {
transcript.questions = transcript.questions.filter((question) => question.id !== req.id);
delete transcript.answers[req.id];
}),
),
Effect.catch((error) => c.notify.error(`Answer failed: ${errorMessage(error)}`)),
Effect.ensuring(
Effect.sync(() => {
req.submitting = false;
c.view.update();
}),
),
);
});
export const openQuestionLink = Effect.fn("Chat.openQuestionLink")(function* (
c: Controller,
chatId: string,
req: Question,
) {
if (req.url) yield* Effect.forkIn(c.kernel.call("open_url", { url: req.url }), c.scope, { startImmediately: true });
yield* respondQuestion(c, chatId, req, false);
});
export const submitEdit = Effect.fn("Chat.submitEdit")(function* (c: Controller, chatId: string, itemId: string) {
const transcript = c.transcript(chatId),
text = transcript.editText.trim();
if (!text) return;
transcript.editing = null;
c.view.update();
yield* c.host
.call("host/chats.edit_message", { chatId, itemId, text })
.pipe(Effect.catch((error) => c.notify.error(`Edit failed: ${errorMessage(error)}`)));
});
export const revert = Effect.fn("Chat.revert")(function* (c: Controller, chatId: string, itemId: string) {
const item = c.transcript(chatId).items.find((item) => item.id === itemId);
const text = item?.role === "user" ? c.plainText(item.blocks) : "";
yield* Effect.gen(function* () {
const coverage = yield* c.host.call("host/chats.checkpoint_coverage", { chatId, itemId });
if (coverage.unknownPaths.length) {
const names = coverage.unknownPaths.slice(0, 5).join(", "),
more = coverage.unknownPaths.length > 5 ? ` and ${coverage.unknownPaths.length - 5} more` : "";
yield* c.notify.warning(`These files cannot be reverted and are left as they are: ${names}${more}`);
}
yield* c.host.call("host/chats.restore_checkpoint", { chatId, itemId });
const composer = c.composer(chatId);
if (!composer.text.trim()) composer.text = text;
yield* textChanged(c, chatId);
c.view.update();
}).pipe(Effect.catch((error) => c.notify.error(`Restore failed: ${errorMessage(error)}`)));
});
export const stopTask = Effect.fn("Chat.stopTask")(function* (c: Controller, chat: { id: string }, taskId: string) {
yield* c.host
.call("host/chats.cancel_task", { chatId: chat.id, taskId })
.pipe(Effect.catch((error) => c.notify.error(`Could not stop the subagent: ${errorMessage(error)}`)));
});
export const loadImages = Effect.fn("Chat.loadImages")(function* (c: Controller, chatId: string) {
const transcript = c.transcript(chatId);
const lists = [transcript.items, ...allTasks(transcript.items).map((row) => row.item.items ?? [])];
for (const items of lists)
for (const item of items) {
if (item.role !== "user" || c.state.itemImages[item.id]) continue;
const blocks = item.blocks.filter((block) => block.type === "image");
if (!blocks.length) continue;
c.state.itemImages[item.id] = "pending";
yield* Effect.forkIn(
Effect.all(
blocks.map((block) =>
c.kernel.call("attach_image", {
data: block.data,
name: `Image.${(block.mimeType ?? "image/png").split("/")[1]}`,
}),
),
{ concurrency: "unbounded" },
).pipe(
Effect.tap((images) =>
Effect.sync(() => {
c.state.itemImages[item.id] = images.map((image) => image.id);
c.view.update();
}),
),
Effect.catch((error) => {
c.state.itemImages[item.id] = [];
return c.notify.warning(`Could not show an attached image: ${errorMessage(error)}`);
}),
),
c.scope,
{ 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.