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/pending.ts6.6 KB
// Inline muted pending-input cards and a semantic usage-limit notice. Native
// admission/unknown delivery remains visible without fictitious edit/withdraw.
import * as Effect from "effect/Effect";
import { html } from "convergence";
import type { Chat, ContentBlock, Element, PendingInput } from "convergence";
import type { ChatView } from "../controller.ts";
import { autoSend, editable, inputLabel, pending, policyOf } from "../scheduler-policy.ts";
import { steeringReason } from "../composition.ts";
import { column, row } from "./styles.ts";
import { resetsIn } from "./usage.ts";

const descriptions = (blocks: readonly ContentBlock[]) => blocks.filter((block) => block.type !== "text").map((block) => {
  switch (block.type) {
    case "image": return `Image · ${block.mimeType}`;
    case "audio": return `Audio · ${block.mimeType}`;
    case "file_ref": case "resource": return block.path;
    case "skill": return `$${block.name}${block.input ? ` ${block.input}` : ""}`;
  }
});
export function renderPending(v: ChatView, chat: Chat): Element[] {
  const scheduler = v.controller.scheduler, snapshot = scheduler.snapshots[chat.id], composer = v.controller.composer(chat.id);
  if (!snapshot) return [];
  const act = <A, E>(effect: Effect.Effect<A, E>) => v.ctx.run(effect.pipe(Effect.catch((error) => scheduler.report(chat.id, error))));
  const reason = steeringReason(v.controller, chat);
  // Input handed to the agent is the user's message in the transcript; a
  // card for it too would read as a queue until the agent reports pickup.
  const shown = new Set(v.controller.state.transcripts[chat.id]?.items.map((item) => item.id));
  const handed = (input: PendingInput) =>
    (input.id === composer.dispatching && input.state === "held") || (shown.has(input.id) && !editable(input) && !input.error);
  return pending(snapshot).filter((input) => !handed(input)).map((input) => {
    const canEdit = editable(input), edit = canEdit && composer.pendingEdit?.id === input.id ? composer.pendingEdit : undefined;
    const save = Effect.fn("Chat.savePendingEdit")(function* () {
      if (!edit) return;
      const blocks: ContentBlock[] = [
        ...(edit.text.trim() ? [{ type: "text" as const, text: edit.text }] : []),
        ...input.blocks.filter((block) => block.type !== "text"),
      ];
      if (!blocks.length) return;
      yield* scheduler.change({ chatId: chat.id, inputId: input.id, revision: edit.revision, blocks });
      composer.pendingEdit = undefined;
      v.ctx.update();
    });
    return html`
      <div ...${column} gap="small" padding="medium" background="muted" borderRadius="md">
        <div ...${row} gap="small">
          <icon name=${input.intent === "steer" ? "corner-down-left" : "list-plus"} small tone="muted" />
          <text style="body" size="xs" tone="muted">${inputLabel(input)}</text>
          <spacer />
          ${canEdit ? html`
            <button id=${`promote-${input.id}`} label=${v.controller.isRunning(chat) ? "Steer" : "Send now"} small variant="ghost" disabled=${!!reason} tooltip=${reason ?? "Queue → Steer; preserves your draft"} onClick=${() => act(scheduler.promote(chat.id, input.id, snapshot.revision))} />
            ${input.intent === "steer" ? html`<button id=${`demote-${input.id}`} label="Queue" small variant="ghost" onClick=${() => act(scheduler.change({ chatId: chat.id, inputId: input.id, revision: snapshot.revision, intent: "queue" }))} />` : null}
            <button id=${`edit-input-${input.id}`} icon="pencil" small variant="ghost" tooltip="Edit held input" onClick=${() => { composer.pendingEdit = { id: input.id, revision: snapshot.revision, text: v.controller.plainText(input.blocks) }; v.ctx.update(); }} />
            <button id=${`remove-input-${input.id}`} icon="x" small variant="ghost" tooltip="Remove held input" onClick=${() => act(scheduler.change({ chatId: chat.id, inputId: input.id, revision: snapshot.revision, remove: true }))} />` : null}
        </div>
        ${edit ? html`
          <input id=${`edit-pending-${input.id}`} multiline value=${edit.text} onChange=${(event: { value: string }) => { edit.text = event.value; }} />
          <div ...${row} gap="small">
            <button id=${`save-pending-${input.id}`} label="Save" small onClick=${() => act(save())} />
            <button id=${`cancel-pending-${input.id}`} label="Cancel edit" small variant="ghost" onClick=${() => { composer.pendingEdit = undefined; v.ctx.update(); }} />
          </div>` : html`<text style="body" size="xs">${v.controller.plainText(input.blocks)}</text>`}
        ${descriptions(input.blocks).map((text) => html`<text style="body" size="xs" tone="muted">${text}</text>`)}
        ${input.error ? html`<text style="body" size="xs" tone="danger">${input.error}</text>` : null}
      </div>`;
  });
}

export function renderLimit(v: ChatView, chat: Chat): Element | null {
  const scheduler = v.controller.scheduler, snapshot = scheduler.snapshots[chat.id];
  const state = snapshot ? policyOf(snapshot) : undefined, wait = state?.wait;
  const active = scheduler.activeLimits[chat.id];
  const grace = !!active && (snapshot?.running || v.controller.isRunning(chat)) && (!active.runId || active.runId === snapshot?.runId);
  if (!wait && !grace) return null;
  const enabled = autoSend(scheduler.preferences, chat.agentId);
  const reset = wait?.resetAt ?? (grace ? active?.evidence.resetsAt : undefined);
  const countdown = resetsIn(reset);
  const resetText = countdown && reset ? `Reset ${countdown === "now" ? "reported for now" : `in ${countdown}`} · ${new Date(reset).toLocaleString()}` : "Reset time unavailable";
  const status = grace ? "Usage limit reported · still working / retrying" : scheduler.checking.has(chat.id) ? "Checking availability…" :
    state?.pause || wait?.status === "paused" ? "Automatic send paused" : !enabled ? "Auto-send disabled for this agent" : wait?.status === "attempting" ? wait.reason : wait?.status === "failed" ? "Recovery check failed" : "Waiting for usage-limit recovery";
  return html`
    <div ...${column} gap="small" padding="medium" background="warning/10" borderRadius="md" border=${{ width: 1, color: "warning/30" }}>
      <div ...${row} gap="small"><icon name="clock" small tone="warning" /><text style="body" size="xs" tone="warning">${status}</text></div>
      <text style="body" size="xs" tone="muted">${resetText}</text>
      <text style="body" size="xs" tone="muted">${state?.pause ?? wait?.reason ?? active?.evidence.reason}</text>
      <checkbox id=${`auto-limit-${chat.id}`} label="Auto-send pending input after recovery · all chats for this agent" checked=${enabled}
        onChange=${(event: { value: boolean }) => v.ctx.run(scheduler.setAutoSend(chat.agentId, event.value))} />
    </div>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.