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
panes.ts5.4 KB
// Each chat has one pane view, kept while it is one of the last few
// shown so switching back is instant. Detail views (a subagent, a
// workflow, the agents list) open in tabs through the convergence/tabs
// service.
import * as Scope from "effect/Scope";
import * as Effect from "effect/Effect";
import type { Chat, Element, ViewHandle } from "convergence";
import type { Controller, ChatError, ChatView } from "./controller.ts";
import { renderPane } from "./views/panes.ts";
import { renderAgentsPane, renderSubagentPane, renderWorkflowPane } from "./views/tasks.ts";
import { findTask, nameOf } from "./subagents.ts";
import { loadOptions, loadTranscript, loadRequests, noteRecent, refreshHistory } from "./data.ts";
/** Chats whose panes stay mounted (the one shown and the last before it). */
const RETAINED = 3;
export const ensureComposer = Effect.fn("Chat.ensureComposer")(function* (c: Controller, chatId: string) {
if (!c.state.composers[chatId]) {
c.composer(chatId);
if (c.state.restored) delete c.state.restored[chatId];
yield* Effect.forkIn(loadOptions(c, chatId), c.scope, { startImmediately: true });
}
});
export const ensureChat = Effect.fn("Chat.ensureChat")(function* (c: Controller, chatId: string) {
if (!c.state.transcripts[chatId]) {
c.transcript(chatId);
yield* Effect.forkIn(loadTranscript(c, chatId), c.scope, { startImmediately: true });
yield* Effect.forkIn(loadRequests(c, chatId), c.scope, { startImmediately: true });
}
});
// The chat's pane view, made the first time it shows; the oldest beyond
// the retained few goes.
export const ensurePane = Effect.fn("Chat.ensurePane")(function* (c: Controller, chatId: string) {
if (!c.state.panes[chatId]) {
const view = yield* c.views
.slot("pane", (ctx) => renderPane({ controller: c, ctx }, chatId), {
key: `chat:${chatId}`,
on: ["chat_changed", "agents_changed"],
})
.pipe(Effect.provideService(Scope.Scope, c.scope));
c.state.panes[chatId] = { chatId, view };
}
c.state.recentPanes = [chatId, ...c.state.recentPanes.filter((id) => id !== chatId)];
for (const old of c.state.recentPanes.splice(RETAINED)) removePane(c, old);
});
export function removePane(c: Controller, chatId: string) {
c.state.panes[chatId]?.view.remove();
delete c.state.panes[chatId];
c.state.recentPanes = c.state.recentPanes.filter((id) => id !== chatId);
}
export const followApp = Effect.fn("Chat.followApp")(function* (c: Controller): Effect.fn.Return<void, ChatError> {
const app = c.api.app.state();
if (app.route !== "chat" || !app.chatId) return;
const chat = c.state.chats[app.chatId];
if (!chat) return;
yield* Effect.forkIn(noteRecent(c, chat.id), c.scope, { startImmediately: true });
if (chat.id !== c.state.promptedChat && !c.state.switcher) {
c.state.promptedChat = chat.id;
yield* c.views.focus(`prompt-${chat.id}`);
}
if (chat.id !== c.state.historyChat) {
c.state.historyChat = chat.id;
yield* Effect.forkIn(refreshHistory(c, chat.id), c.scope, { startImmediately: true });
}
yield* ensurePane(c, chat.id);
});
const paneView = Effect.fn("Chat.paneView")(function* (
c: Controller,
key: string,
chatId: string,
render: (v: ChatView) => Element | null,
) {
let entry = c.state.paneViews[key];
if (!entry) {
const view = yield* c.views
.slot("subagent", (ctx) => render({ controller: c, ctx }), { key, on: ["chat_changed", "agents_changed"] })
.pipe(Effect.provideService(Scope.Scope, c.scope));
entry = c.state.paneViews[key] = { chatId, view };
}
return entry.view;
});
const showInPane = Effect.fn("Chat.showInPane")(function* (
c: Controller,
chat: Chat,
key: string,
title: string,
icon: string,
pane: ViewHandle,
) {
const client = c.tabs;
// Without the tabs plugin there is no detail pane; the shared request the
// baseline wrote would have had no reader either.
if (!client) return;
yield* client.open({ kind: "view", workspaceId: chat.workspaceId, path: key, title, icon, view: pane.id }).pipe(
// A call that loses its provider on the way behaves like no reader.
Effect.ignore,
);
});
export const openWorkflow = Effect.fn("Chat.openWorkflow")(function* (c: Controller, chat: Chat, taskId: string) {
const key = `workflow/${chat.id}/${taskId}`,
pane = yield* paneView(c, key, chat.id, (v) => renderWorkflowPane(v, chat.id, taskId));
const item = findTask(c.transcript(chat.id).items, taskId);
yield* showInPane(
c,
chat,
key,
item ? item.task.name || item.task.title || "Workflow" : "Workflow",
"workflow",
pane,
);
});
export const openSubagent = Effect.fn("Chat.openSubagent")(function* (
c: Controller,
chat: Chat,
taskId: string,
): Effect.fn.Return<void, ChatError> {
if (findTask(c.transcript(chat.id).items, taskId)?.task.workflow) return yield* openWorkflow(c, chat, taskId);
const key = `subagent/${chat.id}/${taskId}`,
pane = yield* paneView(c, key, chat.id, (v) => renderSubagentPane(v, chat.id, taskId));
const item = findTask(c.transcript(chat.id).items, taskId);
yield* showInPane(c, chat, key, item ? item.task.title || nameOf(item.task) : "Subagent", "bot", pane);
});
export const openAgents = Effect.fn("Chat.openAgents")(function* (c: Controller, chat: Chat) {
const key = `agents/${chat.id}`;
yield* showInPane(
c,
chat,
key,
"Agents",
"network",
yield* paneView(c, key, chat.id, (v) => renderAgentsPane(v, chat.id)),
);
});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.