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
sections.ts10.7 KB
// The sidebar's pure model: the chats the list holds, their status and the
// section it puts them in, titles and matches. Functions take the state they
// read; nothing here starts work.
import type { EffectClient } from "convergence/effect";
import type * as Fiber from "effect/Fiber";
import type { AgentInfo, Chat, ChatStatus, HostEvents, PresetColor, TaskStatus, Workspace } from "convergence";
import type { Chat as ChatService } from "../chat/contract.ts";
import { createPreview, updatePreview } from "./preview.ts";
import type { Preview } from "./preview.ts";
/** A chat's status as the sidebar shows it: blocked > running > review > idle. */
export type ChatRowStatus = "blocked" | "running" | "review" | "idle";
export type SectionId = "drafts" | "queued" | "needs-you" | "running" | "finished" | "open" | "settled";
export interface Section {
readonly id: SectionId;
readonly label: string;
readonly tone: string;
readonly chats: Chat[];
}
/** What the status rules need from a chat. */
export interface ChatLike {
readonly id: string;
readonly status: ChatStatus;
readonly sessionId?: string;
}
export interface Editing {
id: string;
title: string;
}
export interface SidebarState {
workspaces: Workspace[];
/// The preset colours a workspace's folder can take (from the host).
colors: readonly PresetColor[];
chats: Chat[];
agents: AgentInfo[];
filter: string[]; // the chosen workspace ids; none means all
query: string;
lastAgent: string | null;
editing: Editing | null;
showAll: Partial<Record<SectionId, boolean>>;
collapsed: Partial<Record<SectionId, boolean>>; // sections folded to their heading; kept in plugin storage
queued: ReadonlySet<string>; // chats the chat plugin queued (its service)
draftTitles: Record<string, string>; // chat id -> title from the prompt being typed
finished: Record<string, number>; // chat id -> when its run ended, this session
previews: Record<string, Preview>; // latest model activity, hydrated only for visible chats
subagents: Record<string, Map<string, TaskStatus | undefined>>; // chat id -> subagent id -> status
loaded: boolean;
// The convergence/chat client while its provider runs, and the fiber
// following its queue and draft titles.
chat: EffectClient<typeof ChatService> | undefined;
follow: Fiber.Fiber<void> | undefined;
}
export function initialState(): SidebarState {
return {
workspaces: [],
colors: [],
chats: [],
agents: [],
filter: [],
query: "",
lastAgent: null,
editing: null,
showAll: {},
collapsed: {},
queued: new Set(),
draftTitles: {},
finished: {},
previews: {},
subagents: {},
loaded: false,
chat: undefined,
follow: undefined,
};
}
// Drafts and Complete are paged; the rest show everything.
export const PAGE = 5;
export const RETAINED_SECTIONS: readonly SectionId[] = ["drafts", "settled"];
/** Plugin storage key of the folded sections. */
export const COLLAPSED_KEY = "collapsedSections";
export const SECTIONS: ReadonlyArray<{ id: SectionId; label: string; tone: string }> = [
{ id: "drafts", label: "Drafts", tone: "muted" },
{ id: "queued", label: "Queued", tone: "info" },
{ id: "needs-you", label: "Needs you", tone: "warning" },
{ id: "running", label: "Running", tone: "info" },
{ id: "finished", label: "Finished", tone: "muted" },
{ id: "open", label: "Open", tone: "muted" },
{ id: "settled", label: "Complete", tone: "muted" },
];
// Subagents of the chat that still work, background ones included after
// the chat's own turn ended.
export function workingSubagents(state: SidebarState, chatId: string): number {
let n = 0;
for (const status of state.subagents[chatId]?.values() ?? []) if (status === "running" || status === "waiting") n++;
return n;
}
// blocked > running > review (finished this session) > idle
export function chatStatus(state: SidebarState, chat: ChatLike): ChatRowStatus {
if (chat.status === "waiting_approval" || chat.status === "waiting_question") return "blocked";
if (chat.status === "running" || workingSubagents(state, chat.id)) return "running";
if (state.finished[chat.id]) return "review";
return "idle";
}
// A chat nobody has prompted yet. The host marks a chat running the
// moment its first prompt is sent; the session comes seconds later.
export function isDraft(state: SidebarState, chat: ChatLike): boolean {
return !chat.sessionId && chat.status === "idle" && !state.queued.has(chat.id);
}
// The section a chat shows in. A checked-off chat is in Complete at once.
export function sectionOf(state: SidebarState, chat: ChatLike & { readonly completedAt?: string }): SectionId {
if (chat.completedAt) return "settled";
if (isDraft(state, chat)) return "drafts";
if (state.queued.has(chat.id)) return "queued";
const status = chatStatus(state, chat);
if (status === "blocked") return "needs-you";
if (status === "running") return "running";
if (status === "review") return "finished";
return "open";
}
// A draft shows the title its prompt will give it as the user types.
export function chatTitleOf(state: SidebarState, chat: Chat): string {
return state.draftTitles[chat.id] ?? chat.title;
}
export function matches(state: SidebarState, chat: Chat): boolean {
const q = state.query.trim().toLowerCase();
return !q || chatTitleOf(state, chat).toLowerCase().includes(q);
}
/** The chats the list shows: of the chosen workspaces, not archived, matching the search, the most recently active first. */
export function visibleChats(state: SidebarState): Chat[] {
return state.chats
.filter((c) => !c.archived && (!state.filter.length || state.filter.includes(c.workspaceId)) && matches(state, c))
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
}
const newest = (a?: string, b?: string) => ((a ?? "") < (b ?? "") ? 1 : (a ?? "") > (b ?? "") ? -1 : 0);
/** The visible chats by section, in section order; empty sections left out. Complete is newest-checked first, Finished newest-finished first, the rest most recently active first. */
export function sections(state: SidebarState): Section[] {
const chats = visibleChats(state);
const out: Section[] = [];
for (const section of SECTIONS) {
const members = chats.filter((c) => sectionOf(state, c) === section.id);
if (!members.length) continue;
if (section.id === "settled") members.sort((a, b) => newest(a.completedAt, b.completedAt));
else if (section.id === "finished")
members.sort((a, b) => (state.finished[b.id] ?? 0) - (state.finished[a.id] ?? 0));
out.push({ ...section, chats: members });
}
return out;
}
/** A section's rows as the list shows them: none while it is folded, a page of a paged section until "Show more". A search shows every match. */
export function shownChats(state: SidebarState, section: Section): Chat[] {
if (state.collapsed[section.id] && !state.query) return [];
const all = !!state.showAll[section.id] || !!state.query || !RETAINED_SECTIONS.includes(section.id);
return all ? section.chats : section.chats.slice(0, PAGE);
}
/** Every chat row the list shows, top to bottom: what Cmd+1 to Cmd+9 open. */
export function listedChats(state: SidebarState): Chat[] {
return sections(state).flatMap((section) => shownChats(state, section));
}
/// The agent the user worked with most recently in this workspace: what
/// the composer switched a chat to counts, not only what New chat used.
export function recentAgent(state: SidebarState, workspaceId: string): string | undefined {
const enabled = new Set(state.agents.map((a) => a.id));
return state.chats
.filter((c) => c.workspaceId === workspaceId && !c.archived && enabled.has(c.agentId))
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))[0]?.agentId;
}
export function elapsed(now: number, iso: string): string {
const ms = now - new Date(iso).getTime();
if (!(ms >= 0)) return "";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
// --- event mutators ----------------------------------------------------------
// These keep the controller's state current between renders. They mutate
// the state object in place.
export function applyChatChange(state: SidebarState, chat: Chat): void {
const index = state.chats.findIndex((c) => c.id === chat.id);
if (index >= 0) state.chats[index] = chat;
else state.chats.unshift(chat);
}
export function applyChatRemoved(state: SidebarState, chatId: string): void {
state.chats = state.chats.filter((chat) => chat.id !== chatId);
delete state.previews[chatId];
}
export function applyTranscriptReset(state: SidebarState, chatId: string): void {
delete state.previews[chatId];
}
/** Applies an agent event; returns whether the sidebar must redraw. */
export function applyAgentEvent(state: SidebarState, event: HostEvents["agent"]): boolean {
const { chatId, taskId, kind } = event;
if (kind.event === "task") {
const tasks = (state.subagents[chatId] ??= new Map());
const before = workingSubagents(state, chatId);
tasks.set(kind.id, kind.status);
return workingSubagents(state, chatId) !== before;
}
// A subagent's words are not the chat's latest line.
if (taskId) return false;
const preview = (state.previews[chatId] ??= createPreview());
const changed = updatePreview(preview, kind);
if (kind.event === "run_finished") state.finished[chatId] = Date.now();
return changed || kind.event === "run_finished";
}
/// A workspace's mark: its project icon, else the icon the app gives it
/// (the Plugins puzzle), else a folder in its colour; the coloured folder
/// too when the user chose it over the icon.
export function workspaceMarkOf(w: Workspace | undefined): { image?: string; icon?: string; color?: PresetColor } {
if (!w) return { icon: "folder" };
if (w.icon && !w.useColor) return { image: w.icon };
if (w.mark && !w.useColor) return { icon: w.mark };
return w.color ? { icon: "folder-solid", color: w.color } : { icon: "folder" };
}
/// What a workspace's mark can be, for a picker: its project icon (or the
/// app's mark), when it has one, then the folder in each preset colour;
/// `value` is the one shown now.
export function workspaceLooks(w: Workspace, colors: readonly PresetColor[]) {
const own = w.icon ? { image: w.icon } : w.mark ? { icon: w.mark } : null;
const value = own && !w.useColor ? "icon" : (w.color ?? "");
const name = (color: string) => color.charAt(0).toUpperCase() + color.slice(1);
return {
value,
options: [
...(own ? [{ value: "icon", label: w.icon ? "Project icon" : "App icon", ...own }] : []),
...colors.map((color) => ({ value: color, label: name(color), icon: "folder-solid", color })),
],
};
}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.