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

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Add and remove workspaces, and create, rename, check off and archive chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List workspaces and chats, and follow them as agents work
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the sidebarSlots: left

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <320.2 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.