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
composer.ts19.9 KB
// Request failures recover at the same notification/logging boundaries as the JavaScript baseline.
// Composer actions preserve per-chat drafts, inline mention segments and both prompt queues.
// Structured bytes are captured before durable enqueue; delivery errors leave a
// host-held copy, while capture/enqueue errors leave the entire draft untouched.
import * as Effect from "effect/Effect";
import type { AttachedImage, Chat, PasteEvent, Skill, SubmitEvent } from "convergence";
import type { Prompt } from "./state.ts";
import type { Controller, ChatError } from "./controller.ts";
import type { MentionTag, Mention } from "./mentions.ts";
import {
acceptMention as withMention,
findTags,
keepSegments,
matchMentions,
removeTag,
} from "./mentions.ts";
import { amendedInput, blocksFor, inputId, steeringReason } from "./composition.ts";
import {
announceQueued,
errorMessage,
loadOptions,
persistQueues,
textChanged,
} from "./data.ts";
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|bmp|tiff?|ico|svg)$/i;
export const onChange = Effect.fn("Chat.onChange")(function* (c: Controller, chatId: string, text: string) {
const composer = c.composer(chatId),
mentions = c.api.mentions.list();
const kept = keepSegments(composer.text, text, mentions),
tagsBefore = findTags(composer.text, mentions).length;
composer.text = kept;
yield* textChanged(c, chatId);
if (kept !== text || findTags(kept, mentions).length !== tagsBefore) {
if (composer.picker) composer.picker = null;
c.view.update();
if (kept !== text) return;
}
text = kept;
if (text.startsWith("/") && !/\s/.test(text)) {
composer.picker = { kind: "commands", query: text.slice(1) };
c.view.update();
return;
}
if (text.startsWith("$") && !/\s/.test(text)) {
composer.picker = { kind: "skills", query: text.slice(1) };
c.view.update();
return;
}
const last = text.split(/\s/).pop() ?? "";
if (last.startsWith("@")) {
const query = last.slice(1);
composer.picker = { kind: "files", query };
const chat = c.state.chats[chatId];
if (chat)
yield* Effect.forkIn(
c.host.call("host/fs.search", { workspaceId: chat.workspaceId, query, limit: 12 }).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (composer.picker?.kind === "files" && composer.picker.query === query) {
composer.files = result.paths;
c.view.update();
}
}),
),
),
c.scope,
{ startImmediately: true },
);
c.view.update();
return;
}
if (composer.picker) {
composer.picker = null;
c.view.update();
}
});
export const acceptSkill = Effect.fn("Chat.acceptSkill")(function* (c: Controller, chatId: string, skill: Skill) {
const composer = c.composer(chatId);
composer.text = `$${skill.name} `;
composer.picker = null;
yield* textChanged(c, chatId);
c.view.update();
});
export const acceptFile = Effect.fn("Chat.acceptFile")(function* (c: Controller, chatId: string, path: string) {
const composer = c.composer(chatId),
at = composer.text.lastIndexOf("@");
composer.text = `${at >= 0 ? composer.text.slice(0, at) : composer.text}@${path} `;
composer.picker = null;
yield* textChanged(c, chatId);
c.view.update();
});
export const acceptMention = Effect.fn("Chat.acceptMention")(function* (
c: Controller,
chatId: string,
mention: Mention,
) {
const composer = c.composer(chatId);
composer.text = withMention(composer.text, mention);
composer.picker = null;
yield* textChanged(c, chatId);
c.view.update();
});
export const dropTag = Effect.fn("Chat.dropTag")(function* (c: Controller, chatId: string, tag: MentionTag) {
const composer = c.composer(chatId);
composer.text = removeTag(composer.text, tag);
yield* textChanged(c, chatId);
c.view.update();
});
export const acceptCommand = Effect.fn("Chat.acceptCommand")(function* (
c: Controller,
chatId: string,
command: { name: string; inputHint?: string | null },
) {
const composer = c.composer(chatId);
composer.text = `/${command.name} `;
composer.picker = null;
yield* textChanged(c, chatId);
if (!command.inputHint) yield* Effect.forkIn(send(c, chatId), c.scope, { startImmediately: true });
else c.view.update();
});
export const keepNewImages = Effect.fn("Chat.keepNewImages")(function* (
c: Controller,
chatId: string,
images: readonly AttachedImage[],
) {
const composer = c.composer(chatId);
for (const image of images) {
if (composer.attachments.some((held) => held.id === image.id))
yield* c.kernel.call("forget_image", { id: image.id });
else composer.attachments.push(image);
}
});
export const refuseImages = Effect.fn("Chat.refuseImages")(function* (
c: Controller,
chatId: string,
attached: readonly AttachedImage[],
) {
for (const image of attached) yield* c.kernel.call("forget_image", { id: image.id });
const chat = c.state.chats[chatId],
name = chat ? (c.state.agents[chat.agentId]?.name ?? "This agent") : "This agent";
yield* c.notify.warning(`${name} cannot read images`);
});
export const attachFiles = Effect.fn("Chat.attachFiles")(function* (
c: Controller,
chatId: string,
paths: readonly string[],
) {
const composer = c.composer(chatId),
mentions = paths.filter((path) => !IMAGE_FILE.test(path));
if (mentions.length) {
const separator = composer.text && !composer.text.endsWith(" ") ? " " : "";
composer.text = `${composer.text}${separator}${mentions.map((path) => `@${path}`).join(" ")} `;
yield* textChanged(c, chatId);
}
const images = paths.filter((path) => IMAGE_FILE.test(path));
if (images.length && !c.takesImages(chatId)) {
c.view.update();
return yield* refuseImages(c, chatId, []);
}
c.view.update();
for (const path of images)
yield* c.kernel.call("attach_image", { path }).pipe(
Effect.flatMap((image) => keepNewImages(c, chatId, [image])),
Effect.catch((error) => c.notify.error(`Could not attach ${path}: ${errorMessage(error)}`)),
);
c.view.update();
});
export const onPasteFiles = Effect.fn("Chat.onPasteFiles")(function* (
c: Controller,
chatId: string,
event: PasteEvent,
) {
const images = event.images ?? [];
if (images.length) yield* c.takesImages(chatId) ? keepNewImages(c, chatId, images) : refuseImages(c, chatId, images);
if (event.paths?.length)
yield* Effect.forkIn(attachFiles(c, chatId, event.paths), c.scope, { startImmediately: true });
c.view.update();
});
export const removeAttachment = Effect.fn("Chat.removeAttachment")(function* (
c: Controller,
chatId: string,
id: string,
) {
const composer = c.composer(chatId);
composer.attachments = composer.attachments.filter((image) => image.id !== id);
yield* c.kernel.call("forget_image", { id });
c.view.update();
});
export const pickAttachments = Effect.fn("Chat.pickAttachments")(function* (c: Controller, chatId: string) {
if (!c.takesImages(chatId)) return yield* refuseImages(c, chatId, []);
yield* c.kernel.call("pick_images", {}).pipe(
Effect.flatMap(({ paths }) => (paths.length ? attachFiles(c, chatId, paths) : Effect.void)),
Effect.catch((error) => c.notify.error(`Could not attach: ${errorMessage(error)}`)),
);
});
export function pickerKey(c: Controller, chatId: string, key: string) {
const composer = c.composer(chatId), picker = composer.picker;
if (!picker) return;
if (key === "escape") composer.picker = null;
else if (key === "up" || key === "down") {
const count = picker.kind === "files" ? matchMentions(c.api.mentions.list(), picker.query).length + composer.files.length :
picker.kind === "commands" ? c.matchingCommands(composer, picker.query).length : c.matchingSkills(composer, picker.query).length;
if (count) picker.index = ((picker.index ?? 0) + (key === "up" ? -1 : 1) + count) % count;
} else return;
c.refresh(chatId);
}
export const onSubmit = Effect.fn("Chat.onSubmit")(function* (
c: Controller,
chatId: string,
event?: SubmitEvent,
): Effect.fn.Return<void, ChatError> {
const composer = c.composer(chatId);
if (event?.shift) return;
if (event && typeof event.value === "string") composer.text = event.value;
if (event?.secondary) {
// This never accepts a highlighted candidate on the user's behalf.
if (composer.picker?.kind === "commands" && !c.matchingCommands(composer, "").some((command) => composer.text.trim() === `/${command.name}`))
return yield* c.notify.warning("Choose a complete command before queueing");
// A draft has no prompt queue: its first prompt queues the chat.
const chat = c.state.chats[chatId];
if (chat && c.isDraft(chat))
return c.state.chatQueue.some((queued) => queued.chatId === chatId) ? undefined : yield* queueChat(c, chatId);
return yield* queuePrompt(c, chatId);
}
const selected = composer.picker?.index ?? 0;
if (composer.picker?.kind === "files") {
const mentions = matchMentions(c.api.mentions.list(), composer.picker.query), mention = mentions[selected];
if (mention) return yield* acceptMention(c, chatId, mention);
const file = composer.files[selected - mentions.length];
if (file) return yield* acceptFile(c, chatId, file);
}
if (composer.picker?.kind === "commands") {
const first = c.matchingCommands(composer, composer.picker.query)[selected];
if (first) return yield* acceptCommand(c, chatId, first);
}
if (composer.picker?.kind === "skills") {
const first = c.matchingSkills(composer, composer.picker.query)[selected];
if (first) return yield* acceptSkill(c, chatId, first);
}
yield* send(c, chatId);
});
export const sendBlocks = Effect.fn("Chat.sendBlocks")(function* (
c: Controller,
chatId: string,
text: string,
attachments: Prompt["attachments"],
) {
const blocks = yield* blocksFor(c, chatId, text, attachments);
if (!blocks.length) return;
if (c.scheduler.available === undefined) yield* c.scheduler.load(chatId);
if (c.scheduler.available === false) yield* c.host.call("host/chats.send", { chatId, blocks });
else {
const id = inputId(), snapshot = yield* c.scheduler.enqueue(chatId, id, blocks, c.isRunning(c.state.chats[chatId]) ? "steer" : "send");
const entry = amendedInput(snapshot, id);
if (entry) yield* c.scheduler.manualDispatch(chatId, entry.id, snapshot.revision);
}
});
export const submitComposition = Effect.fn("Chat.submitComposition")(function* (
c: Controller,
chatId: string,
queue: boolean,
): Effect.fn.Return<void, ChatError> {
const composer = c.composer(chatId),
chat = c.state.chats[chatId];
if (!chat || composer.submitting || composer.loading) return;
const text = composer.text.trim(),
originalText = composer.text,
attachments = [...composer.attachments];
if (!text && !attachments.length) return;
if (c.scheduler.available === undefined) yield* c.scheduler.load(chatId);
const reason = !queue && steeringReason(c, chat);
if (reason) return yield* c.notify.warning(reason);
composer.submitting = true;
composer.error = undefined;
c.refresh(chatId);
yield* Effect.gen(function* () {
const blocks = yield* blocksFor(c, chatId, text, attachments);
const signature = JSON.stringify([blocks, queue]);
if (composer.submission?.signature !== signature) composer.submission = { id: inputId(), signature };
const id = composer.submission.id;
let dispatch: { id: string; revision: number } | undefined;
if (c.scheduler.available === false) {
if (queue) {
composer.queue.push({ text, attachments: [], blocks, id });
const saved = yield* persistQueues(c).pipe(Effect.result);
if (saved._tag === "Failure") {
composer.queue = composer.queue.filter((input) => input.id !== id);
return yield* Effect.fail(saved.failure);
}
} else {
// Alpha's original idle send path. Never fall back on an ambiguous new
// host failure: only a positively unknown method selects this path.
const [name = "", ...rest] = text.slice(1).split(/\s+/);
if (text.startsWith("/") && c.api.slashCommands().some((command) => command.name === name)) {
const result = yield* importSlash(c, name, rest.join(" "), { chatId, workspaceId: chat.workspaceId, agentId: chat.agentId });
if (result.action === "prompt") yield* c.host.call("host/chats.send", { chatId, blocks: [{ type: "text", text: result.text }, ...blocks.filter((block) => block.type !== "text")] });
else if (result.action === "message") yield* c.notify.info(result.text);
} else yield* c.host.call("host/chats.send", { chatId, blocks });
}
} else {
// Slash text is durable intent. The host resolves commands/hooks once,
// including deferred non-prompt commands; the UI never resolves it twice.
const snapshot = yield* c.scheduler.enqueue(chatId, id, blocks, queue ? "queue" : c.isRunning(chat) ? "steer" : "send");
const entry = amendedInput(snapshot, id);
if (!queue && entry) dispatch = { id: entry.id, revision: snapshot.revision };
}
// A user may type or paste during persistence. Acknowledging the captured
// composition must not erase that later draft or forget its images.
if (!queue) yield* unqueueChat(c, chatId);
const unchanged = composer.text === originalText;
if (unchanged) {
composer.text = "";
composer.attachments = composer.attachments.filter((image) => !attachments.some((sent) => sent.id === image.id));
}
composer.picker = null;
composer.submission = undefined;
yield* textChanged(c, chatId);
for (const image of unchanged ? attachments : []) yield* c.kernel.call("forget_image", { id: image.id }).pipe(
Effect.catch((error) => c.notify.warning(`Image cleanup: ${errorMessage(error)}`)),
);
c.refresh(chatId);
if (dispatch) {
const id = dispatch.id;
composer.dispatching = id;
yield* c.scheduler.manualDispatch(chatId, id, dispatch.revision).pipe(
Effect.ensuring(Effect.sync(() => { if (composer.dispatching === id) composer.dispatching = undefined; })),
);
}
}).pipe(
// The draft is unchanged before acknowledgement; after it, the durable
// entry remains visible even when admission is rejected or uncertain.
Effect.catch((error) => Effect.gen(function* () {
composer.error = `Input could not be submitted: ${errorMessage(error)}`;
yield* c.notify.error(composer.error);
if (c.scheduler.available === true) yield* c.scheduler.load(chatId);
})),
Effect.ensuring(Effect.sync(() => { composer.submitting = false; c.refresh(chatId); })),
);
});
export const send = Effect.fn("Chat.send")((c: Controller, chatId: string) => submitComposition(c, chatId, false));
// Slash dispatch is a plugin-local callback API rather than a host method.
import { promise } from "convergence/effect";
const importSlash = Effect.fn("Chat.runSlash")(
(c: Controller, name: string, input: string, context: { chatId: string; workspaceId: string; agentId: string }) =>
promise("slash.run", () => c.api.runSlash(name, input, context)),
);
export const queuePrompt = Effect.fn("Chat.queuePrompt")((c: Controller, chatId: string) => submitComposition(c, chatId, true));
export const dropQueuedPrompt = Effect.fn("Chat.dropQueuedPrompt")(function* (
c: Controller,
chatId: string,
index: number,
) {
const composer = c.composer(chatId), removed = composer.queue.splice(index, 1);
yield* persistQueues(c).pipe(Effect.catch((error) => Effect.gen(function* () {
composer.queue.splice(index, 0, ...removed);
yield* c.notify.error(`Queue could not be saved: ${errorMessage(error)}`);
})));
c.view.update();
});
export const sendText = Effect.fn("Chat.sendText")(function* (c: Controller, chatId: string, text: string) {
const chat = c.state.chats[chatId];
yield* c.host
.call("host/chats.send", { chatId, text })
.pipe(Effect.catch((error) => c.notify.error(`Send failed: ${errorMessage(error)}`)));
return chat;
});
// A queued draft stays a draft: its prompt stays in the composer, where it
// can still change, and is sent when no chat runs.
export const queueChat = Effect.fn("Chat.queueChat")(function* (c: Controller, chatId: string) {
const composer = c.composer(chatId);
if (!composer.text.trim() && !composer.attachments.length) return;
if (c.state.chatQueue.some((queued) => queued.chatId === chatId)) return;
c.state.chatQueue.push({ chatId });
yield* Effect.forkIn(persistQueues(c), c.scope, { startImmediately: true });
yield* announceQueued(c);
c.view.update();
yield* Effect.forkIn(pumpChatQueue(c), c.scope, { startImmediately: true });
});
export const unqueueChat = Effect.fn("Chat.unqueueChat")(function* (c: Controller, chatId: string) {
if (!c.state.chatQueue.some((queued) => queued.chatId === chatId)) return;
c.state.chatQueue = c.state.chatQueue.filter((queued) => queued.chatId !== chatId);
yield* Effect.forkIn(persistQueues(c), c.scope, { startImmediately: true });
yield* announceQueued(c);
c.view.update();
});
// Starts the first queued draft once no chat runs. Called whenever a chat
// changes; one draft starts at a time, as the started one then runs.
export const pumpChatQueue = Effect.fn("Chat.pumpChatQueue")(function* (c: Controller) {
while (true) {
if (c.state.releasing || Object.values(c.state.chats).some((chat) => !chat.archived && c.isRunning(chat))) return;
const next = c.state.chatQueue[0];
// The chat list is not read yet: whether a chat runs is not known.
if (!next || !c.state.chats[next.chatId]) return;
yield* unqueueChat(c, next.chatId);
const composer = c.composer(next.chatId);
// A prompt emptied while it waited has nothing to start.
if (!composer.text.trim() && !composer.attachments.length) continue;
c.state.releasing = next.chatId;
if (composer.loading) yield* loadOptions(c, next.chatId);
yield* send(c, next.chatId);
// A failed send leaves the draft and its error in place; the queue
// moves on. A sent one holds it until the host reports it running.
if (!composer.error) return;
c.state.releasing = null;
}
});
export const setOption = Effect.fn("Chat.setOption")(function* (
c: Controller,
chatId: string,
optionId: string,
value: string,
) {
const composer = c.composer(chatId);
if (composer.options.find((option) => option.id === optionId)?.value === value) return;
const changed = yield* c.host.call("host/chats.set_option", { chatId, optionId, value }).pipe(
Effect.tap((result) =>
Effect.sync(() => {
composer.options = result.options;
c.view.update();
}),
),
Effect.as(true),
Effect.catch((error) =>
c.notify.error(`Could not change the option: ${errorMessage(error)}`).pipe(Effect.as(false)),
),
);
if (!changed) return;
const agentId = c.state.chats[chatId]?.agentId;
for (const [id, draft] of Object.entries(c.state.composers)) {
const chat = c.state.chats[id];
if (id !== chatId && !draft.loading && chat?.agentId === agentId && !chat?.sessionId)
yield* Effect.forkIn(loadOptions(c, id), c.scope, { startImmediately: true });
}
});
export const changeAgent = Effect.fn("Chat.changeAgent")(function* (c: Controller, chatId: string, agentId: string) {
if (c.state.chats[chatId]?.agentId === agentId) return;
yield* c.host.call("host/chats.update", { chatId, agentId }).pipe(
Effect.tap((chat) =>
Effect.sync(() => {
c.state.chats[chatId] = chat;
}),
),
Effect.flatMap(() => Effect.forkIn(loadOptions(c, chatId), c.scope, { startImmediately: true })),
Effect.catch((error) => c.notify.error(`Could not change the agent: ${errorMessage(error)}`)),
);
});
export const moveDraft = Effect.fn("Chat.moveDraft")(function* (c: Controller, chat: Chat, workspaceId: string) {
if (workspaceId === chat.workspaceId) return;
yield* c.host.call("host/chats.update", { chatId: chat.id, workspaceId }).pipe(
Effect.tap((moved) =>
Effect.sync(() => {
c.state.chats[moved.id] = moved;
}),
),
Effect.flatMap((moved) => c.app.select(moved.id, moved.workspaceId)),
Effect.catch((error) => c.notify.error(`Could not move the chat: ${errorMessage(error)}`)),
);
});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.