Official
sidebar
Built-in sidebar 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/sidebar@0.2.0
Permissions in 0.2.0
Files
controller.ts15.4 KB
// Sidebar actions: the refresh that loads workspaces and chats, the chat
// writes (new, rename, archive), and the lazy transcript hydration the rows
// trigger. One layer owns the state.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as z from "zod";
import { App, Host, Kernel, Notify, Plugin, Services, hostError, parse, promise } from "convergence/effect";
import type { EffectClient, HostError } from "convergence/effect";
import type { Chat as ChatService } from "../chat/contract.ts";
import type { Chat, HostEvents, PresetColor, ViewHandle, Workspace } from "convergence";
import type { SectionId } from "./sections.ts";
import {
COLLAPSED_KEY,
SECTIONS,
applyAgentEvent,
applyChatChange,
applyChatRemoved,
applyTranscriptReset,
initialState,
listedChats,
recentAgent,
} from "./sections.ts";
import { createPreview, transcriptPreview } from "./preview.ts";
const describe = (error: unknown): string =>
error instanceof Error && error.message ? error.message : "unknown error";
const makeSidebar = Effect.fn("Sidebar.make")(function* () {
const host = yield* Host;
const kernel = yield* Kernel;
const app = yield* App;
const notify = yield* Notify;
const plugin = yield* Plugin;
const services = yield* Services;
const scope = yield* Effect.scope;
const state = initialState();
const views: ViewHandle[] = [];
const redraw = (): void => {
for (const view of views) view.update();
};
// Storage may hold values the JavaScript original wrote without a schema;
// a bad shape keeps the default rather than failing the refresh.
const stored = <S extends z.ZodType>(key: string, schema: S): Effect.Effect<z.infer<S> | undefined, HostError> =>
host.call("host/storage.get", { key }).pipe(
Effect.flatMap((result) => parse(key, schema, result.value)),
Effect.catchTag("ParseFailed", () => Effect.succeed(undefined)),
);
// The agents list leaves synchronously, like the baseline's fire-and-forget
// `api.host.agents.list({}).then(...)` in refresh(): the call must be
// recorded before the first render's transcript reads. A forked
// `host.call` would leave after those reads, flipping the host-call order.
const loadAgentsFrom = Effect.fn("Sidebar.loadAgents")(function* (pending: Promise<unknown>) {
const result = yield* Effect.tryPromise({
try: () => pending as Promise<{ agents: typeof state.agents }>,
catch: (error) => hostError("host/agents.list", error),
});
state.agents = [...result.agents];
const defaultAgent = yield* stored("defaultAgent", z.string());
if (defaultAgent && state.agents.some((a) => a.id === defaultAgent)) state.lastAgent = defaultAgent;
const first = state.agents[0];
if (!state.lastAgent && first) state.lastAgent = first.id;
redraw();
});
// The folded sections come back from storage after the first draw.
const loadCollapsed = Effect.fn("Sidebar.loadCollapsed")(function* () {
const saved = yield* stored(COLLAPSED_KEY, z.array(z.string()));
for (const section of SECTIONS) if (saved?.includes(section.id)) state.collapsed[section.id] = true;
redraw();
});
const refresh = Effect.fn("Sidebar.refresh")(function* () {
const [workspaces, chats] = yield* loadLists();
state.workspaces = [...workspaces.workspaces];
state.colors = workspaces.colors ?? [];
// Run events can advance a chat while the list request is in flight.
const live = new Map(state.chats.map((chat) => [chat.id, chat]));
state.chats = chats.chats.map((chat) => {
const current = live.get(chat.id);
return current && current.updatedAt > chat.updatedAt ? current : chat;
});
state.filter = state.filter.filter((id) => state.workspaces.some((w) => w.id === id));
if (!state.loaded) yield* Effect.forkIn(loadCollapsed(), scope);
state.loaded = true;
const pendingAgents = yield* Effect.sync(() => plugin.api.host.agents.list({}));
yield* Effect.forkIn(loadAgentsFrom(pendingAgents), scope);
const current = yield* app.state;
const firstWorkspace = state.workspaces[0];
if (!state.workspaces.some((w) => w.id === current.workspaceId) && firstWorkspace) {
yield* app.update({ workspaceId: firstWorkspace.id });
}
redraw();
});
// --- chats -------------------------------------------------------------------
// The list calls leave synchronously, in one block: the registration
// flush that follows activate must already see them. Failures map to
// HostError exactly as Host.call would.
const loadLists = Effect.fn("Sidebar.loadLists")(function* () {
const pending = yield* Effect.sync(
() => [plugin.api.host.workspaces.list({}), plugin.api.host.chats.list({})] as const,
);
const adopt = <A>(method: string, task: Promise<A>): Effect.Effect<A, HostError> =>
Effect.tryPromise({ try: () => task, catch: (error) => hostError(method, error) });
return yield* Effect.all([adopt("host/workspaces.list", pending[0]), adopt("host/chats.list", pending[1])], {
concurrency: "unbounded",
});
});
const defaultWorkspace = Effect.fn("Sidebar.defaultWorkspace")(function* () {
const current = yield* app.state;
return (state.filter.length === 1 ? state.filter[0] : undefined) ?? current.workspaceId ?? state.workspaces[0]?.id ?? null;
});
const pickAgent = Effect.fn("Sidebar.pickAgent")(function* (workspaceId: string) {
const first = state.agents[0];
const agent = recentAgent(state, workspaceId) ?? state.lastAgent ?? first?.id;
if (!agent) {
yield* notify.warning("Enable an agent in Settings first.");
return null;
}
yield* host.call("host/storage.set", { key: "defaultAgent", value: agent }).pipe(Effect.ignore);
return agent;
});
const newChat = Effect.fn("Sidebar.newChat")(function* (workspaceId: string) {
const agent = yield* pickAgent(workspaceId);
if (!agent) return;
// A draft with typed text or a place in the queue is in use: New chat
// from it opens another draft rather than the same one.
const inUse = [...Object.keys(state.draftTitles), ...state.queued];
const chat = yield* host.call("host/chats.create", { workspaceId, agentId: agent, inUse });
yield* app.select(chat.id, workspaceId);
yield* refresh();
});
const addWorkspace = Effect.fn("Sidebar.addWorkspace")(function* () {
const picked = yield* promise("pickFolder", () => plugin.api.pickFolder());
if (!picked?.path) return;
const workspace = yield* host.call("host/workspaces.add", { path: picked.path });
// The list shows the new folder alone, and the center opens a draft
// there: the chat a user adds a folder for.
state.filter = [workspace.id];
yield* newChat(workspace.id);
});
const removeWorkspace = Effect.fn("Sidebar.removeWorkspace")(function* (workspace: Workspace) {
// The workspace-changed event that follows triggers the refresh and redraw.
yield* host.call("host/workspaces.remove", { workspaceId: workspace.id });
state.filter = state.filter.filter((id) => id !== workspace.id);
});
// A workspace's mark: a folder in `color`, or (`null`) its project icon.
// The workspace-changed event that follows redraws.
const setColor = Effect.fn("Sidebar.setColor")(function* (workspaceId: string, color: PresetColor | null) {
yield* host
.call("host/workspaces.set_color", { workspaceId, color })
.pipe(Effect.catch((error) => notify.error(`Could not change the workspace's icon: ${describe(error)}`)));
});
const setFilter = Effect.fn("Sidebar.setFilter")(function* (workspaceIds: readonly string[]) {
state.filter = [...workspaceIds];
state.showAll = {};
const [only] = workspaceIds;
if (only && workspaceIds.length === 1) yield* app.update({ workspaceId: only });
redraw();
});
// Folding a section hides its rows under the heading; the folded set is
// kept so it survives a restart.
const toggleSection = Effect.fn("Sidebar.toggleSection")(function* (id: SectionId) {
if (state.collapsed[id]) delete state.collapsed[id];
else state.collapsed[id] = true;
redraw();
yield* host
.call("host/storage.set", { key: COLLAPSED_KEY, value: SECTIONS.filter((s) => state.collapsed[s.id]).map((s) => s.id) })
.pipe(Effect.catch((error: unknown) => notify.error(`Could not save the folded sections: ${describe(error)}`)));
});
// --- renaming and archiving --------------------------------------------------
const commitRename = Effect.fn("Sidebar.commitRename")(function* () {
const editing = state.editing;
if (!editing) return;
state.editing = null;
redraw();
yield* host
.call("host/chats.update", { chatId: editing.id, title: editing.title.trim() || "New Chat" })
.pipe(Effect.catch((error: unknown) => notify.error(`Rename failed: ${describe(error)}`)));
});
// Checking off takes at once: the chat is complete here from the click
// and moves to Complete, and the host is told.
const toggleCompleted = Effect.fn("Sidebar.toggleCompleted")(function* (chat: Chat) {
const completed = !chat.completedAt;
const index = state.chats.findIndex((c) => c.id === chat.id);
const next: Chat = { ...chat, completedAt: completed ? new Date().toISOString() : undefined };
if (index >= 0) state.chats[index] = next;
redraw();
yield* host
.call("host/chats.update", { chatId: chat.id, completed })
.pipe(Effect.catch((error: unknown) => notify.error(`Could not update the chat: ${describe(error)}`)));
});
// An archived chat leaves the list; its transcript stays with the host.
const archive = Effect.fn("Sidebar.archive")(function* (chat: Chat) {
yield* host
.call("host/chats.update", { chatId: chat.id, archived: true })
.pipe(Effect.catch((error: unknown) => notify.error(`Could not archive the chat: ${describe(error)}`)));
});
// Latest model activity for a chat's live line; the transcript is read
// once, and only for chats a row actually showed.
const ensurePreview = Effect.fn("Sidebar.ensurePreview")(function* (chatId: string) {
const preview = (state.previews[chatId] ??= createPreview());
if (preview.loading || preview.revision) return;
preview.loading = true;
yield* host.call("host/chats.transcript", { chatId }).pipe(
Effect.tap((result) =>
Effect.sync(() => {
// Streaming activity or a reset since this read takes precedence.
if (state.previews[chatId] === preview && !preview.revision) {
Object.assign(preview, transcriptPreview(result.items), { loading: true });
redraw();
}
}),
),
Effect.catch((error: unknown) => notify.error(`Chat preview unavailable: ${describe(error)}`)),
);
});
// --- events and commands -----------------------------------------------------
// Queued chats and draft titles come from the chat plugin while it runs:
// read once, then followed through its events. One episode per provider:
// the previous follow ends when the provider goes.
const followChat = Effect.fn("Sidebar.followChat")(function* () {
yield* services.when<typeof ChatService>("convergence/chat").pipe(
Stream.runForEach((option) =>
Effect.gen(function* () {
if (state.follow) {
yield* Fiber.interrupt(state.follow);
state.follow = undefined;
}
if (Option.isNone(option)) {
state.chat = undefined;
return;
}
const client: EffectClient<typeof ChatService> = option.value;
state.chat = client;
state.follow = yield* Effect.forkIn(
Effect.all(
[
client.on("queuedChanged").pipe(
Stream.runForEach((queued) =>
Effect.sync(() => {
state.queued = new Set(queued);
redraw();
}),
),
),
client.on("draftTitlesChanged").pipe(
Stream.runForEach((drafts) =>
Effect.sync(() => {
state.draftTitles = { ...drafts };
redraw();
}),
),
),
],
{ concurrency: "unbounded", discard: true },
),
scope,
{ startImmediately: true },
);
// A provider that goes away mid-read leaves the last titles.
yield* Effect.all(
[
client.queued({}).pipe(
Effect.tap((queued) =>
Effect.sync(() => {
state.queued = new Set(queued);
}),
),
Effect.ignore,
),
client.draftTitles({}).pipe(
Effect.tap((drafts) =>
Effect.sync(() => {
state.draftTitles = { ...drafts };
}),
),
Effect.ignore,
),
],
{ concurrency: "unbounded", discard: true },
);
redraw();
}),
),
Effect.forkIn(scope, { startImmediately: true }),
);
});
yield* followChat();
const chatChanged = Effect.fn("Sidebar.chatChanged")(function* (event: HostEvents["chat_changed"]) {
applyChatChange(state, event.chat);
redraw();
});
const chatRemoved = Effect.fn("Sidebar.chatRemoved")(function* (event: HostEvents["chat_removed"]) {
applyChatRemoved(state, event.chatId);
redraw();
});
const transcriptReset = Effect.fn("Sidebar.transcriptReset")(function* (event: HostEvents["transcript_reset"]) {
applyTranscriptReset(state, event.chatId);
redraw();
});
const agentEvent = Effect.fn("Sidebar.agentEvent")(function* (event: HostEvents["agent"]) {
if (applyAgentEvent(state, event)) redraw();
});
const newChatCommand = Effect.fn("Sidebar.newChatCommand")(function* () {
const workspaceId = yield* defaultWorkspace();
if (workspaceId) yield* newChat(workspaceId);
});
const toggleCompletedCommand = Effect.fn("Sidebar.toggleCompletedCommand")(function* () {
const current = yield* app.state;
const chat = state.chats.find((c) => c.id === current.chatId);
if (chat) yield* toggleCompleted(chat);
});
// Cmd+1 to Cmd+9: the chat in that row of the list, top to bottom.
const selectListedCommand = (index: number): Effect.Effect<void> =>
Effect.suspend(() => {
const chat = listedChats(state)[index];
return chat ? app.select(chat.id, chat.workspaceId) : Effect.void;
});
return {
state,
views,
refresh,
runCommand: (id: string) => kernel.call("run_command", { id }).pipe(Effect.asVoid),
select: (chatId: string, workspaceId: string): Effect.Effect<void> => app.select(chatId, workspaceId),
route: (name: string): Effect.Effect<void> => app.route(name),
newChat,
addWorkspace,
removeWorkspace,
setColor,
setFilter,
toggleSection,
commitRename,
archive,
toggleCompleted,
toggleCompletedCommand,
ensurePreview,
chatChanged,
chatRemoved,
transcriptReset,
agentEvent,
newChatCommand,
selectListedCommand,
};
});
export class Sidebar extends Context.Service<Sidebar, Effect.Success<ReturnType<typeof makeSidebar>>>()(
"sidebar/Sidebar",
) {
static readonly layer = Layer.effect(Sidebar, makeSidebar());
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 20.2 KB | 3 permissions | Listed |
No comments yet.