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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 20.2 KB | 3 permissions | Listed |
No comments yet.