Official
chat
Built-in chat 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/chat@0.2.0
Permissions in 0.2.0
Files
views/tasks.ts21.3 KB
// Tasks rendering preserves the chat's retained tree and interaction keys.
import { html } from "convergence";
import {
allTasks,
counts,
countsLabel,
elapsed,
findTask,
groupLabel,
isLive,
metaLine,
nameOf,
resultOf,
statusOf,
tokens,
} from "../subagents.ts";
import {
displayStatus,
modelName,
phaseDetail,
phaseOpen,
phasesOf,
progressLabel,
resultView,
stripOf,
} from "../workflows.ts";
import type { Element, Chat } from "convergence";
import type { TaskInfo } from "convergence/protocol";
import type { Transcript } from "../state.ts";
import type { ChatView } from "../controller.ts";
import type { TaskGroup, TaskItem } from "../grouping.ts";
import type { Phase } from "../workflows.ts";
import { activityRow, renderItems } from "./rows.ts";
import { renderApproval, renderQuestion } from "./questions.ts";
import {
column,
row,
fill,
full,
HOVER,
clickable,
revealOnHover,
tint,
ROW_TEXT,
PHASE_MARK,
AGENT_MARK,
} from "./styles.ts";
export function statusMark(_v: ChatView, task: TaskInfo): Element | null {
const status = statusOf(task);
const mark =
task.status === "running"
? html`<spinner />`
: html`<div ...${column} gap="small" padding="small" width=${6} flexShrink=${0} height=${6} background=${tint(status.tone)} borderRadius=${3} />`;
return html`
<div ...${row} gap=${5} padding="small">
${mark}
<text style="body" size="xs" weight=${560} tone=${status.tone === "primary" ? "muted" : status.tone}>${status.label}</text>
</div>`;
}
export function taskMeta(_v: ChatView, task: TaskInfo): Element | null {
const meta = metaLine(task, Date.now());
const activity = isLive(task) && task.activity ? `▸ ${task.activity}` : null;
const line = [meta || null, activity].filter(Boolean).join(" ");
return line ? html`<text style="body" size="xs" tone="faint" truncate>${line}</text>` : null;
}
export function taskHeader(
v: ChatView,
task: TaskInfo,
props: { open?: boolean; onClick?: () => void },
): Element | null {
const { open, onClick } = props;
const summary = task.summary ? task.summary.split("\n").find((l) => l.trim()) : null;
return html`
<div ...${column} ...${onClick ? clickable : null} gap=${2} padding="small" paddingX="medium" paddingY="small" borderRadius="lg"
hover=${onClick ? HOVER : undefined} onClick=${onClick}>
<div ...${row} gap="small" padding="small">
${onClick ? html`<icon name=${open ? "chevron-down" : "chevron-right"} small tone="muted" />` : html`<icon name="bot" small tone="muted" />`}
${task.name ? nameChip(v, task.name) : null}
${task.phase ? html`<text style="body" size="xs" tone="muted">${task.phase}</text>` : null}
<text style="body" ...${ROW_TEXT} tone="foreground" truncate>${task.title || nameOf(task)}</text>
${summary && !open ? html`<text style="body" ...${ROW_TEXT} tone="muted" truncate>${`· ${summary}`}</text>` : null}
<spacer />
${task.background ? html`<text style="body" size="xs" tone="faint">Background</text>` : null}
${statusMark(v, task)}
</div>
<div ...${row} gap="small" padding="small" paddingX=${onClick ? 20 : 0}>${taskMeta(v, task)}</div>
</div>`;
}
export function nameChip(_v: ChatView, name: string): Element | null {
return html`
<div ...${row} gap="small" padding="small" paddingX="small" background="muted" borderRadius="sm">
<text style="body" size="xs" weight=${560} tone="primary">${name}</text>
</div>`;
}
export function taskBody(v: ChatView, chat: Chat, t: Transcript, item: TaskItem): (Element | null)[] {
const rows = item.items?.length ? renderItems(v, chat, t, item.items) : [];
const result = resultOf(item, t.items);
if (result) {
rows.push(
html`
<panel>
<text style="body" size="xs" weight=${560} tone="muted">Result</text>
<markdown id=${`task-result-${item.task.id}`} text=${result} />
</panel>`,
);
}
if (rows.length) return rows;
if (isLive(item.task)) return [activityRow(v, { icon: html`<spinner />`, label: "Waiting for subagent output…" })];
return [html`<text style="body" size="xs" tone="muted">The subagent left no transcript.</text>`];
}
export function renderTask(v: ChatView, chat: Chat, t: Transcript, item: TaskItem): Element | null {
const task = item.task;
const id = `task-${task.id}`;
const open = t.expanded.has(id);
return html`
<div ...${column} padding="small" background="subtle" borderRadius="lg">
${taskHeader(v, task, { open, onClick: () => v.controller.toggle(t.expanded, id) })}
${
open
? html`
<div ...${column} padding="small" paddingX="medium" paddingY="small" maxHeight=${360} overflowY="scroll">
${taskBody(v, chat, t, item)}
</div>`
: null
}
${
open
? html`
<div ...${row} gap="small" padding="small" paddingX="medium" paddingY=${3} background="subtle" borderRadius="lg">
<spacer />
${
v.controller.canStopTask(chat) && isLive(task) && !v.controller.inWorkflow(chat.id, task)
? html`<button id=${`stop-${task.id}`} icon="circle-stop" label="Stop" variant="ghost" small onClick=${() => v.ctx.run(v.controller.stopTask(chat, task.id))} />`
: null
}
<button id=${`open-${task.id}`} icon="panel-right" label="Open" variant="ghost" small onClick=${() => v.ctx.run(v.controller.openSubagent(chat, task.id))} />
</div>`
: null
}
</div>`;
}
export function renderTaskGroup(v: ChatView, chat: Chat, t: Transcript, entry: TaskGroup): Element | null {
const tasks = entry.items.map((item) => item.task);
const live = tasks.some(isLive);
const open = t.folds.get(entry.id) ?? live;
return html`
<div ...${column} padding="small">
${activityRow(v, {
icon: "network",
label: groupLabel(tasks),
detail: countsLabel(counts(tasks)),
status: live ? html`<spinner />` : null,
open,
onClick: () => {
t.folds.set(entry.id, !open);
v.controller.view.update();
},
})}
${
open
? html`<div ...${column} gap="small" padding="small" paddingY=${2}>${entry.items.map((item) => renderTask(v, chat, t, item))}</div>`
: null
}
</div>`;
}
export function mark(_v: ChatView, look?: { icon: string; tone: string } | null): Element | null {
return look ? html`<icon name=${look.icon} small tone=${look.tone} />` : html`<spinner />`;
}
export function workflowStrip(_v: ChatView, phases: Phase[], height: number): Element | null {
const parts: Element[] = [];
stripOf(phases).forEach((group, i) => {
if (i)
parts.push(html`<div ...${column} gap="small" padding="small" width=${4} flexShrink=${0} height=${height} />`);
for (const segment of group.segments) {
const background = segment.surface === "tint" ? tint(segment.tone) : segment.surface;
parts.push(
html`
<div ...${column} ...${fill} gap="small" padding="small" height=${height} opacity=${segment.opacity}
background=${background} borderRadius="full" />`,
);
}
});
return html`<div ...${row} ...${fill} gap=${2} padding=${0}>${parts}</div>`;
}
export function workflowHeader(
v: ChatView,
item: TaskItem,
phases: Phase[],
props: { open?: boolean; onClick?: () => void; large?: boolean },
): Element | null {
const { open, onClick, large } = props;
const task = item.task;
const inset = onClick ? 20 : 0;
const summary = !isLive(task) && task.summary ? task.summary.split("\n").find((l) => l.trim()) : null;
const meta = [progressLabel(item, phases), metaLine(task, Date.now())].filter(Boolean).join(" · ");
const title = large
? { style: "heading", truncate: true }
: { style: "body", ...ROW_TEXT, tone: "foreground", truncate: true };
return html`
<div ...${column} ...${onClick ? clickable : null} gap=${3} padding="small" paddingX="medium" paddingY="small" borderRadius="lg"
hover=${onClick ? HOVER : undefined} onClick=${onClick}>
<div ...${row} gap="small" padding="small">
${onClick ? html`<icon name=${open ? "chevron-down" : "chevron-right"} small tone="muted" />` : null}
<icon name="workflow" small tone=${isLive(task) ? "primary" : "muted"} />
${task.name ? nameChip(v, task.name) : null}
<text ...${title}>${task.title || "Workflow"}</text>
${summary && !open ? html`<text style="body" ...${ROW_TEXT} tone="muted" truncate>${`· ${summary}`}</text>` : null}
<spacer />
${statusMark(v, { ...task, status: displayStatus(item) })}
</div>
${meta ? html`<div ...${row} gap="small" padding="small" paddingX=${inset}><text style="body" size="xs" tone="faint" truncate>${meta}</text></div>` : null}
<div ...${row} gap="small" padding="small" paddingX=${inset} paddingY=${2}>${workflowStrip(v, phases, large ? 6 : 4)}</div>
</div>`;
}
export function workflowAgent(v: ChatView, chat: Chat, agent: TaskItem): Element | null {
const task = agent.task;
const model = modelName(
v.controller.state.composers[chat.id]?.options.find((o) => o.category === "model"),
task.model,
);
const note = isLive(task)
? task.status === "queued"
? "Waiting for a slot"
: task.status === "waiting"
? "Needs your answer"
: task.activity
: task.summary?.split("\n").find((l) => l.trim());
const spend = [tokens(task.usage?.usedTokens), task.status === "queued" ? null : elapsed(task, Date.now())]
.filter(Boolean)
.join(" · ");
return html`
<div ...${row} ...${clickable} gap="small" padding="small" paddingX=${6} paddingY=${4} borderRadius="md" hover=${HOVER}
onClick=${() => v.ctx.run(v.controller.openSubagent(chat, task.id))}>
<div ...${row} gap="small" padding="small" width=${14} flexShrink=${0} justifyContent="center">${mark(v, task.status === "running" ? undefined : task.status ? AGENT_MARK[task.status] : undefined)}</div>
<text style="body" ...${ROW_TEXT} tone=${task.status === "queued" ? "muted" : "foreground"}>${task.title || nameOf(task)}</text>
${model ? html`<text style="body" size="xs" tone="muted">${model}</text>` : null}
<div ...${row} ...${fill} gap="small" padding=${0}>
${note ? html`<text style="body" size="xs" tone=${task.status === "failed" ? "danger" : "faint"} truncate>${note}</text>` : null}
</div>
${spend ? html`<text style="body" size="xs" tone="faint">${spend}</text>` : null}
<div ...${column} ...${revealOnHover} gap="small" padding=${0}><icon name="chevron-right" small tone="muted" /></div>
</div>`;
}
export function workflowPhase(
v: ChatView,
chat: Chat,
t: Transcript,
item: TaskItem,
phase: Phase,
pane?: boolean,
): Element | null {
const key = `wf-phase-${item.task.id}-${phase.index}`;
const agents = phase.agents.length > 0;
const open = agents && (t.folds.get(key) ?? (pane || phaseOpen(phase)));
// Open, it is indented so each agent's mark sits under the phase's title.
return html`
<div ...${column} padding="small">
<div ...${row} ...${agents ? clickable : null} gap="small" padding="small" paddingX=${6} paddingY=${4} borderRadius="md"
opacity=${phase.state === "pending" ? 0.7 : undefined} hover=${agents ? HOVER : undefined}
onClick=${
agents
? () => {
t.folds.set(key, !open);
v.controller.redraw(chat.id);
}
: undefined
}>
<div ...${row} gap="small" padding="small" width=${14} flexShrink=${0} justifyContent="center">${mark(v, phase.state === "running" ? null : PHASE_MARK[phase.state])}</div>
<text style="body" ...${ROW_TEXT} tone="foreground">${phase.title || "Agents"}</text>
<div ...${row} ...${fill} gap="small" padding=${0}>
<text style="body" size="xs" tone="faint" truncate text=${String(phaseDetail(phase) ?? "")} />
</div>
${
agents
? html`<div ...${column} ...${revealOnHover} gap="small" padding=${0}><icon name=${open ? "chevron-down" : "chevron-right"} small tone="muted" /></div>`
: null
}
</div>
${open ? html`<div ...${column} padding="small" paddingX=${18}>${phase.agents.map((agent) => workflowAgent(v, chat, agent))}</div>` : null}
</div>`;
}
export function workflowOutcome(_v: ChatView, task: TaskInfo): Element | null {
if (task.status === "failed" && task.summary) {
return html`
<panel>
<div ...${row} gap="small" padding="small">
<icon name="circle-x" small tone="danger" />
<text style="body" size="xs" weight=${560} tone="danger">Failed</text>
</div>
<text style="body" size="xs" tone="muted">${task.summary}</text>
</panel>`;
}
const result = resultView(task.workflow?.result);
if (!result) return null;
return html`
<panel>
<div ...${row} gap="small" padding="small">
<icon name="circle-check" small tone="success" />
<text style="body" size="xs" weight=${560} tone="muted">Result</text>
</div>
${result.kind === "text" ? html`<markdown id=${`wf-result-${task.id}`} text=${result.text} />` : html`<code text=${result.text} language="json" />`}
</panel>`;
}
export function renderWorkflow(v: ChatView, chat: Chat, t: Transcript, item: TaskItem, pane = false): Element | null {
const task = item.task;
const id = `task-${task.id}`;
const phases = phasesOf(item);
const open = pane || (t.folds.get(id) ?? isLive(task));
const logs = task.workflow?.logs ?? [];
const scriptKey = `wf-script-${task.id}`;
const logKey = `wf-log-${task.id}`;
const flip = (key: string) => () => {
v.controller.toggle(t.expanded, key);
v.controller.redraw(chat.id);
};
// In the chat the card is capped and scrolls; its pane is all of it.
const capped = (height: number) => (pane ? {} : { maxHeight: height, overflowY: "scroll" });
const body = open
? [
...phases.map((phase) => workflowPhase(v, chat, t, item, phase, pane)),
workflowOutcome(v, task),
t.expanded.has(logKey) && logs.length
? html`<panel><code text=${logs.join("\n")} language="text" /></panel>`
: null,
t.expanded.has(scriptKey) && task.prompt
? html`<div ...${column} gap="small" padding="small" ...${capped(320)}><code text=${task.prompt} language="javascript" /></div>`
: null,
]
: [];
const celebrating = t.finished?.has(task.id);
return html`
<div ...${column} id=${`workflow-${task.id}`} padding="small" background=${pane ? undefined : "subtle"} borderRadius="lg"
celebrate=${celebrating ? { token: `workflow-${task.id}`, onDone: () => t.finished?.delete(task.id) } : undefined}>
${workflowHeader(v, item, phases, {
open,
large: pane,
onClick: pane
? undefined
: () => {
t.folds.set(id, !open);
v.controller.redraw(chat.id);
},
})}
${
open
? html`<div ...${column} id=${`workflow-body-${task.id}`} gap=${2} padding="small" paddingX="small" paddingY="small" ...${capped(440)}>${body}</div>`
: null
}
${
open
? html`
<div ...${row} gap="small" padding="small" paddingX="medium" paddingY=${3}>
${
task.prompt
? html`<button id=${`${scriptKey}-button`} icon="file-code" label=${t.expanded.has(scriptKey) ? "Hide script" : "Script"} variant="ghost" small onClick=${flip(scriptKey)} />`
: null
}
${logs.length ? html`<button id=${`${logKey}-button`} icon="scroll-text" label=${`Log · ${logs.length}`} variant="ghost" small onClick=${flip(logKey)} />` : null}
<spacer />
${
v.controller.canStopTask(chat) && isLive(task)
? html`<button id=${`stop-${task.id}`} icon="circle-stop" label="Stop workflow" variant="ghost" small onClick=${() => v.ctx.run(v.controller.stopTask(chat, task.id))} />`
: null
}
${pane ? null : html`<button id=${`open-${task.id}`} icon="panel-right" label="Open" variant="ghost" small onClick=${() => v.ctx.run(v.controller.openWorkflow(chat, task.id))} />`}
</div>`
: null
}
</div>`;
}
export function renderWorkflowPane(v: ChatView, chatId: string, taskId: string): Element | null {
const chat = v.controller.state.chats[chatId];
const t = v.controller.transcript(chatId);
const item = findTask(t.items, taskId);
if (!chat || !item) return html`<empty>This workflow is no longer in the chat.</empty>`;
// What its agents wait for, answered here as in the chat.
const agents = new Set(allTasks(item.items ?? []).map((r) => r.task.id));
const rows = [renderWorkflow(v, chat, t, item, true)];
for (const a of t.approvals) if (a.taskId && agents.has(a.taskId)) rows.push(renderApproval(v, chatId, a));
for (const q of t.questions) if (q.taskId && agents.has(q.taskId)) rows.push(renderQuestion(v, chatId, t, q));
return html`<scroller id=${`workflow-${chatId}-${taskId}`}>${rows.map((row) => html`<div ...${column} gap="small" padding="small" paddingX="medium" paddingY="small">${row}</div>`)}</scroller>`;
}
export function renderSubagentPane(v: ChatView, chatId: string, taskId: string): Element | null {
const chat = v.controller.state.chats[chatId];
const t = v.controller.transcript(chatId);
const item = findTask(t.items, taskId);
if (!chat || !item) return html`<empty>This subagent is no longer in the chat.</empty>`;
const task = item.task;
const rows = [
html`
<div ...${column} gap="small" padding="small" paddingY="small">
${taskHeader(v, task, {})}
${
task.prompt
? html`<div ...${column} gap="small" padding="small" background="subtle" borderRadius="md"><markdown id=${`task-prompt-${task.id}`} text=${task.prompt} /></div>`
: null
}
${
v.controller.canStopTask(chat) && isLive(task) && !v.controller.inWorkflow(chatId, task)
? html`
<div ...${row} gap="small" padding="small">
<button id=${`pane-stop-${task.id}`} icon="circle-stop" label="Stop subagent" variant="outline" small onClick=${() => v.ctx.run(v.controller.stopTask(chat, task.id))} />
</div>`
: null
}
</div>`,
...taskBody(v, chat, t, item),
];
// What this subagent waits for, answered here as in the chat.
for (const a of t.approvals) if (a.taskId === taskId) rows.push(renderApproval(v, chatId, a));
for (const q of t.questions) if (q.taskId === taskId) rows.push(renderQuestion(v, chatId, t, q));
return html`<scroller id=${`subagent-${chatId}-${taskId}`}>${rows.map((row) => html`<div ...${column} gap="small" padding="small" paddingX="medium">${row}</div>`)}</scroller>`;
}
export function renderAgentsPane(v: ChatView, chatId: string): Element | null {
const chat = v.controller.state.chats[chatId];
const t = v.controller.transcript(chatId);
const rows = allTasks(t.items);
if (!chat || !rows.length) return html`<empty>No subagents in this chat.</empty>`;
const footer = countsLabel(counts(rows.map((r) => r.task)));
return html`
<div ...${column} ...${fill} ...${full} padding="small">
<div ...${column} ...${fill} gap=${2} padding="small" overflowY="scroll">
${rows.map(
({ task, depth }) =>
html`
<div ...${column} ...${clickable} gap=${2} padding="small" paddingX="small" paddingY="small" borderRadius="md" hover=${HOVER}
onClick=${() => v.ctx.run(v.controller.openSubagent(chat, task.id))}>
<div ...${row} gap="small" padding="small" paddingX=${depth * 14}>
${statusMark(v, task)}
${task.workflow ? html`<icon name="workflow" small tone="muted" />` : null}
<text style="body" ...${ROW_TEXT} tone="foreground" truncate>${task.title || nameOf(task)}</text>
${task.name ? html`<text style="body" size="xs" tone="muted">${task.name}</text>` : null}
${task.phase ? html`<text style="body" size="xs" tone="faint">${task.phase}</text>` : null}
<spacer />
${task.background ? html`<text style="body" size="xs" tone="faint">Background</text>` : null}
</div>
<div ...${row} gap="small" padding="small" paddingX=${depth * 14}>${taskMeta(v, task)}</div>
${
!isLive(task) && task.summary
? html`
<div ...${row} gap="small" padding="small" paddingX=${depth * 14}>
<text style="body" size="xs" tone=${task.status === "failed" ? "danger" : "muted"} truncate
text=${task.summary.split("\n").find((l) => l.trim()) ?? ""} />
</div>`
: null
}
</div>`,
)}
</div>
${footer ? html`<div ...${row} gap="small" padding="small" paddingX="medium" paddingY="small"><text style="body" size="xs" tone="muted">${footer}</text></div>` : null}
</div>`;
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.