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

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.