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

views.ts13.7 KB
// The left sidebar's trees: search and actions, the workspace picker, the list
// of chats, and the footer. Handlers only run controller effects or mutate
// state and update; the preview hydration fires from rendering, guarded so
// each chat reads once.
// HTM whitespace is significant; keep these templates out of Prettier's
// HTML formatter.
import { html } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Chat, Element, PresetColor, Workspace } from "convergence";
import type { Sidebar } from "./controller.ts";
import {
  PAGE,
  chatStatus,
  chatTitleOf,
  elapsed,
  isDraft,
  sections,
  shownChats,
  workingSubagents,
  workspaceLooks,
  workspaceMarkOf,
} from "./sections.ts";
import type { ChatRowStatus } from "./sections.ts";

// Layout for the templates below: a flex column, a row that centres its
// children across, a child that takes the free space (and may shrink below
// its content), the whole of the parent, the hover wash, and the hover
// group and pointer of something clickable.
const column = { display: "flex", flexDirection: "column" };
const row = { display: "flex", flexDirection: "row", alignItems: "center" };
const fill = { flexGrow: 1, flexShrink: 1, flexBasis: "0%", minWidth: 0, minHeight: 0 };
const full = { width: "100%", height: "100%" };
const HOVER = { background: "hover" };
const clickable = { group: "hover", cursor: "pointer" };
// Shown only while the pointer is on a chat's status mark (the mark is the
// hover group `glyph`), or only while it is not: the check shows only
// where a click checks the chat off.
const revealOnGlyph = { visibility: "hidden", groupHover: { group: "glyph", visibility: "visible" } };
const HIDE_ON_GLYPH = { group: "glyph", visibility: "hidden" };
// A layer over the whole of its parent that centres what it holds.
const layer = {
  position: "absolute",
  top: 0,
  right: 0,
  bottom: 0,
  left: 0,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
};
const TONE: Record<ChatRowStatus, "warning" | "info" | "success" | "faint"> = {
  blocked: "warning",
  running: "info",
  review: "success",
  idle: "faint",
};
const LIVE_TONE: Record<ChatRowStatus, "warning" | "info" | "muted"> = {
  blocked: "warning",
  running: "info",
  review: "muted",
  idle: "muted",
};

export function renderSidebar(ctx: EffectViewContext<object>, sidebar: Sidebar["Service"]): Element | null {
  const { state } = sidebar;
  const app = ctx.app;
  if (!state.loaded) {
    return html`<div ...${column} gap="small" padding="medium"><text style="muted">Loading…</text></div>`;
  }

  // Latest model activity; status is the fallback for an empty transcript.
  function liveLine(chat: Chat): string {
    // The chat plugin holds it until the agent is free.
    if (state.queued.has(chat.id)) return "Queued";
    const working = workingSubagents(state, chat.id);
    if (working && chat.status !== "running") return `${working} ${working === 1 ? "agent" : "agents"} working`;
    // The transcript is read once per chat, when a row first shows it. The
    // check runs here, synchronously: a forked check could run after the
    // chat was removed and resurrect its preview with a stray read.
    const preview = state.previews[chat.id];
    if (!preview?.loading && !preview?.revision) ctx.run(sidebar.ensurePreview(chat.id));
    if (preview?.line) return preview.line;
    const status = chatStatus(state, chat);
    const base =
      status === "blocked"
        ? "Waiting on you"
        : status === "running"
          ? "Working…"
          : status === "review"
            ? "Finished"
            : chat.sessionId
              ? "Idle"
              : "Not started";
    // A review always stamped its finish when the run ended; an idle chat
    // shows how long ago it was touched.
    const finishedAt = state.finished[chat.id];
    const since =
      status === "review" && finishedAt
        ? new Date(finishedAt).toISOString()
        : status === "idle" && chat.sessionId
          ? chat.updatedAt
          : null;
    const ago = since ? elapsed(Date.now(), since) : "";
    return ago ? `${base} · ${ago}` : base;
  }

  // --- rows -----------------------------------------------------------------

  // Each workspace's mark (its project icon, the app's mark, or its
  // coloured folder), for the rows of its chats.
  const workspaceMarks = new Map<string, Workspace>(state.workspaces.map((w) => [w.id, w]));
  const workspaceMark = (w: Workspace | undefined) => {
    if (!w) return null;
    const mark = workspaceMarkOf(w);
    return mark.image
      ? html`<image path=${mark.image} width=${14} height=${14} radius=${3} />`
      : html`<icon name=${mark.icon ?? "folder"} color=${mark.color} small tone="faint" />`;
  };

  function renameField(editing: { id: string; title: string }) {
    return html`
      <input id=${`rename-${editing.id}`} value=${editing.title}
        onChange=${(e: { value: string }) => {
          editing.title = e.value;
        }}
        onSubmit=${() => ctx.run(sidebar.commitRename())} />`;
  }

  // The status mark: a dot or a spinner, and a check while the pointer is
  // on the mark; clicking the check checks the chat off (or opens it again).
  function glyph(chat: Chat, status: ChatRowStatus) {
    const done = !!chat.completedAt;
    // An empty circle reads as a box to tick; its colour says what the
    // chat is doing. Running shows the spinner, complete the ticked circle.
    const mark = done
      ? html`<icon name="circle-check" small tone="muted" />`
      : status === "running"
        ? html`<spinner />`
        : html`<icon name="circle" small tone=${TONE[status]} />`;
    // The mark and the check lie over each other; the pointer on the mark
    // swaps them.
    return html`
      <div ...${column} ...${clickable} group="glyph" id=${`glyph-${chat.id}`} width=${17} height=${17} flexShrink=${0}
        borderRadius="full" hover=${HOVER} position="relative" stopPropagation
        onClick=${() => ctx.run(sidebar.toggleCompleted(chat))}>
        <div ...${layer}>
          <div ...${column} groupHover=${done ? undefined : HIDE_ON_GLYPH}>${mark}</div>
        </div>
        ${
          done
            ? null
            : html`
          <div ...${layer}>
            <div ...${column} ...${revealOnGlyph}><icon name="circle-check" small tone="muted" /></div>
          </div>`
        }
      </div>`;
  }

  // A chat: its status mark (the check), its title and live line, its
  // agent at the end, and rename and archive while the pointer is on it. A
  // chat being renamed is the rename field.
  function chatRow(chat: Chat) {
    const selected = app.chatId === chat.id;
    const status = chatStatus(state, chat);
    const editing = state.editing?.id === chat.id ? state.editing : null;
    const agent = state.agents.find((a) => a.id === chat.agentId);
    // A checked-off chat is one struck line, a draft one plain line: no
    // live line under either.
    const done = !!chat.completedAt;
    const compact = done || isDraft(state, chat);
    const rename = () => {
      state.editing = { id: chat.id, title: chatTitleOf(state, chat) };
      ctx.update();
    };
    return html`
      <item id=${`row-${chat.id}`} label=${editing ? "" : chatTitleOf(state, chat)} selected=${selected}
        sublabel=${editing || compact ? undefined : liveLine(chat)} sublabelTone=${LIVE_TONE[status]} strike=${done} stacked
        actions=${
          editing
            ? undefined
            : [
                { icon: "pencil", tooltip: "Rename", onClick: rename },
                { icon: "archive", tooltip: "Archive", onClick: () => ctx.run(sidebar.archive(chat)) },
              ]
        }
        onClick=${() => ctx.run(sidebar.select(chat.id, chat.workspaceId))}>
        <slot name="leading">${glyph(chat, status)}</slot>
        ${
          editing
            ? renameField(editing)
            : html`
          <div ...${row} gap=${6}>
            ${workspaceMark(workspaceMarks.get(chat.workspaceId))}
            <icon name="bot" small tone="faint" svg=${agent?.icon} />
          </div>`
        }
      </item>`;
  }

  // The chats by status: Drafts, Queued, Needs you, Running, Finished,
  // Open, Complete. Drafts and Complete show a page, then "Show more".
  function renderSections() {
    const out = sections(state).map((section) => {
      // A search shows its matches even in a folded section.
      const folded = !!state.collapsed[section.id] && !state.query;
      const shown = shownChats(state, section);
      const more = folded ? 0 : section.chats.length - shown.length;
      return html`
        <div ...${column} gap=${0} padding="small">
          <item heading id=${`section-${section.id}`} label=${section.label} tone=${section.tone}
            onClick=${() => ctx.run(sidebar.toggleSection(section.id))}>
            <div ...${row} gap=${4}>
              <text style="body" size=${9.84} tone="muted">${String(section.chats.length)}</text>
              <icon name=${folded ? "chevron-right" : "chevron-down"} small tone="faint" />
            </div>
          </item>
          ${shown.map(chatRow)}
          ${
            more > 0
              ? html`
              <item id=${`more-${section.id}`} icon="chevron-down" label="Show more" badge=${String(more)}
                onClick=${() => {
                  state.showAll[section.id] = true;
                  ctx.update();
                }} />`
              : !folded && state.showAll[section.id] && !state.query && section.chats.length > PAGE
                ? html`
              <item id=${`less-${section.id}`} icon="chevron-up" label="Show less"
                onClick=${() => {
                  state.showAll[section.id] = false;
                  ctx.update();
                }} />`
                : null
          }
        </div>`;
    });
    if (!out.length) {
      out.push(
        html`<div ...${column} gap="small" padding="medium"><text style="body" size="xs" tone="muted">${state.query ? "No matches" : "No chats yet"}</text></div>`,
      );
    }
    return out;
  }

  // --- chips and top actions ---------------------------------------------------

  // The workspaces to list, a row like the ones around it: none chosen is
  // all of them (every box checked, the folders mark); a choice shows its
  // workspaces' own marks and names.
  function renderWorkspacePicker() {
    const chosen = state.workspaces.filter((w: Workspace) => state.filter.includes(w.id));
    return html`
      <combobox id="workspace-filter" look="row" multiple values=${state.filter} all="All workspaces"
        label=${chosen.length ? chosen.map((w: Workspace) => w.name).join(", ") : "All workspaces"}
        marks=${chosen.length ? chosen.slice(0, 3).map(workspaceMarkOf) : [{ icon: "folders" }]}
        search=${state.workspaces.length > 6 ? "Search workspaces…" : undefined}
        options=${state.workspaces.map((w: Workspace) => ({
          value: w.id,
          label: w.name,
          description: w.path,
          ...workspaceMarkOf(w),
          submenu: state.colors.length ? { context: true, ...workspaceLooks(w, state.colors) } : undefined,
        }))}
        actions=${[{ label: "Add workspace folder…", icon: "folder-plus", onClick: () => ctx.run(sidebar.addWorkspace()) }]}
        onChange=${(e: { values?: readonly string[]; value?: string; option?: string }) =>
          // A choice in a workspace's right-click menu is its look; any
          // other change is which workspaces show.
          e.option
            ? ctx.run(sidebar.setColor(e.option, e.value === "icon" ? null : (e.value as PresetColor)))
            : ctx.run(sidebar.setFilter([...(e.values ?? [])]))} />`;
  }

  // A top action such as New chat: 12.2px medium text, a key cap at the end.
  // An action row's icon and label sit where a text field puts its icon
  // and text, so the search field above lines up.
  function actionRow({
    icon,
    label,
    key,
    trailing,
    onClick,
  }: {
    icon: string;
    label: string;
    key?: string;
    trailing?: Element;
    onClick: () => void;
  }) {
    return html`
      <item icon=${icon} label=${label} onClick=${onClick}>${key ? html`<text keys>${key}</text>` : (trailing ?? null)}</item>`;
  }

  const [onlyId] = state.filter;
  const filtered = state.filter.length === 1 ? (state.workspaces.find((w) => w.id === onlyId) ?? null) : null;
  const onSettings = app.route === "settings";
  // The search field's edge reads level with the top of the chat pane
  // beside it: a 1px outline at the pane's very top looks higher than the
  // pane's filled edge, so it sits a point lower.
  return html`
      <div ...${column} ...${fill} ...${full} padding="small" paddingX="small" paddingTop=${0}>
        <div ...${column} gap="small" padding="small" paddingY="small" paddingTop=${2}>
          <input id="chat-search" value=${state.query} placeholder="Search chats..." icon="search"
            onChange=${(e: { value: string }) => {
              state.query = e.value;
              ctx.update();
            }} />
          ${actionRow({ icon: "plus-large", label: "New chat", key: "⌘N", onClick: () => ctx.run(sidebar.runCommand("sidebar.newChat")) })}
          ${renderWorkspacePicker()}
        </div>
        <div ...${column} ...${fill} gap="small" padding="small" overflowY="scroll">${renderSections()}</div>
        <div ...${column} padding="small" paddingY="small">
          ${
            filtered
              ? html`
              <item id="remove-workspace" icon="folder-x" label=${`Remove ${filtered.name}`} onClick=${() => ctx.run(sidebar.removeWorkspace(filtered))} />`
              : null
          }
          <item id="sidebar-settings" icon=${onSettings ? "arrow-left" : "settings"} label=${onSettings ? "Back" : "Settings"}
            onClick=${() => ctx.run(sidebar.route(onSettings ? "chat" : "settings"))} />
        </div>
      </div>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.