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
main.ts5.2 KB
// Register title then center synchronously, preserving retained view identity across reloads.
// The convergence/chat service is provided last; the tabs client is
// followed while its provider runs. The plugin scope owns every listener,
// background load and elapsed-time loop.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import { App, Host, Kernel, Notify, Plugin, Services, Settings, Views, runPlugin } from "convergence/effect";
import { Controller } from "./controller.ts";
import { Chat as ChatService } from "./contract.ts";
import type { Tabs } from "../tabs/contract.ts";
import { createModelPicker } from "./models.ts";
import { renderCenter } from "./views/panes.ts";
import { renderTitle } from "./views/title.ts";
import { registerEarlyCommands } from "./commands.ts";
import { listen } from "./events.ts";
import { InputScheduler } from "./scheduler-contract.ts";
import { bindSettings, renderSettings } from "./settings.ts";
import { policyOf } from "./scheduler-policy.ts";
const make = Effect.fn("Chat.controller")(function* () {
return new Controller({
api: (yield* Plugin).api,
host: yield* Host,
kernel: yield* Kernel,
app: yield* App,
notify: yield* Notify,
views: yield* Views,
scope: yield* Effect.scope,
});
});
class ChatController extends Context.Service<ChatController, Controller>()("chat/Controller") {
static readonly layer = Layer.effect(ChatController, make());
}
const program = Effect.gen(function* () {
const c = yield* ChatController;
yield* registerEarlyCommands(c);
c.titleView = yield* c.views.slot("title", (ctx) => renderTitle({ controller: c, ctx }), {
on: ["chat_changed", "agents_changed", "app_state", "workspaces_changed"],
});
c.center = yield* c.views.slot("center", (ctx) => renderCenter({ controller: c, ctx }), {
on: ["chat_changed", "agents_changed", "app_state"],
});
yield* listen(c);
const services = yield* Services;
// Detail views need the tabs plugin; without it they stay closed, as the
// shared request they replaced would have had no reader.
yield* services.when<typeof Tabs>("convergence/tabs").pipe(
Stream.runForEach((option) =>
Effect.sync(() => {
c.tabs = option._tag === "Some" ? option.value : undefined;
}),
),
Effect.forkScoped({ startImmediately: true }),
);
const fork = <A, E>(effect: Effect.Effect<A, E>) => Effect.forkIn(effect, c.scope, { startImmediately: true });
c.modelPicker = createModelPicker(c);
yield* fork(c.modelPicker.load);
yield* fork(c.loadRecent());
yield* fork(c.restoreQueues());
yield* fork(c.restoreUnsent());
// The service goes last: providing waits for the host, and the loads above
// must not wait for it. A consumer reads the state with queued() and
// draftTitles() once the service is there; events count from then on.
const handle = yield* services.provide(ChatService, {
queued: () => Effect.succeed(c.state.chatQueue.map((queued) => queued.chatId)),
draftTitles: () => Effect.succeed({ ...c.state.draftTitles }),
});
c.emitQueued = (queued) => handle.emit("queuedChanged", queued);
c.emitDrafts = (drafts) => handle.emit("draftTitlesChanged", drafts);
// Restores forked above may have set queue/draft state before the service
// existed, when their announces were no-ops. Emit what they found, like
// the baseline's shares did, so consumers see the restored state.
// Restores still running will emit themselves, since the service is up;
// this only covers what is already there, so there is no duplicate.
if (c.state.chatQueue.length) c.emitQueued(c.state.chatQueue.map((queued) => queued.chatId));
if (Object.keys(c.state.draftTitles).length) c.emitDrafts({ ...c.state.draftTitles });
yield* services.provide(InputScheduler, {
policy: () => Effect.succeed({ version: 1 as const, release: "settled-success-fifo" as const }),
});
c.settingsView = yield* c.views.slot("chat-settings", (ctx) => renderSettings({ controller: c, ctx }), {
on: ["agents_changed", "plugin_settings_changed"],
});
yield* Effect.acquireRelease(
Effect.sync(() => bindSettings(c.settingsView?.id)),
() => Effect.sync(() => bindSettings(undefined)),
);
const settings = yield* Settings;
yield* settings.changes.pipe(
Stream.runForEach(() => fork(c.scheduler.updatePreferences())),
Effect.forkScoped({ startImmediately: true }),
);
yield* fork(c.scheduler.restore());
// A bounded scoped clock reconstructs deadlines after reload and catches
// sleep/wake. It never polls with prompts and does no work without a wait.
yield* fork(
Effect.gen(function* () {
while (true) {
yield* Effect.sleep("1 second");
yield* c.scheduler.evaluate();
for (const snapshot of Object.values(c.scheduler.snapshots))
if (c.scheduler.activeLimits[snapshot.chatId] || policyOf(snapshot).wait) {
// Only visible wait notices need countdown repainting.
if (c.api.app.state().chatId === snapshot.chatId) c.refresh(snapshot.chatId);
}
}
}),
);
});
export const activate = runPlugin(ChatController.layer, program);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.