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/questions.ts6.5 KB
// Questions rendering preserves the chat's retained tree and interaction keys.
import { html } from "convergence";
import type { Element, ApprovalRequest, SecurityRequest, ValueEvent } from "convergence";
import type { Transcript, Question } from "../state.ts";
import type { ChatView } from "../controller.ts";
import type { FoldEntry } from "../grouping.ts";
import { column, row, fill, HOVER, clickable, CARD_SPACE } from "./styles.ts";
export function renderApproval(_v: ChatView, chatId: string, req: ApprovalRequest): Element | null {
return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="approval" id=${req.id} chatId=${chatId} /></div>`;
}
export function renderRequest(_v: ChatView, req: SecurityRequest): Element | null {
return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="request" id=${req.id} /></div>`;
}
export function renderPublishRow(_v: ChatView, chatId: string, req: SecurityRequest): Element | null {
return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="publish" id=${req.id} chatId=${chatId} /></div>`;
}
export function placeRequests(
v: ChatView,
entries: FoldEntry[],
rows: (Element | null)[],
requests: readonly SecurityRequest[],
): (Element | null)[] {
const at = new Map<string, number>();
entries.forEach((entry, i) => {
for (const item of entry.kind === "item" ? [entry.item] : entry.items) at.set(item.id, i);
});
const after: (Element | null)[][] = rows.map(() => []);
const end: (Element | null)[] = [];
for (const req of requests) {
const index = req.afterItemId ? at.get(req.afterItemId) : undefined;
(index === undefined ? end : (after[index] ?? end)).push(renderRequest(v, req));
}
return [...rows.flatMap((row, i) => [row, ...(after[i] ?? [])]), ...end];
}
export function renderQuestion(v: ChatView, chatId: string, t: Transcript, req: Question): Element | null {
const answers = t.answers[req.id] ?? {};
const respond = (cancelled: boolean) => v.ctx.run(v.controller.respondQuestion(chatId, req, cancelled));
const fields = req.fields.map((f) => {
const offered = f.options ?? [];
const parts = [
html`<text style="heading" text=${String(f.label ?? "")} />`,
f.description ? html`<text style="body" size="xs" tone="muted">${f.description}</text>` : null,
];
if (f.kind === "text") {
parts.push(
html`<input id=${`q-${req.id}-${f.id}`} value=${answers[f.id] ?? ""} placeholder="Type your answer" onChange=${(
e: ValueEvent<string>,
) => {
t.answers[req.id] = answers;
answers[f.id] = e.value;
}} />`,
);
} else {
const options =
f.kind === "boolean" && !offered.length
? [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
]
: offered;
const chosen = answers[f.id];
const isChosen = (v: string) =>
Array.isArray(chosen) ? chosen.includes(v) : f.kind === "boolean" ? String(chosen) === v : chosen === v;
const choose = (o: { value: string }) => () => {
t.answers[req.id] = answers;
if (f.kind === "multi_select") {
const set = new Set(Array.isArray(chosen) ? chosen : []);
set.has(o.value) ? set.delete(o.value) : set.add(o.value);
answers[f.id] = [...set];
} else if (f.kind === "boolean") answers[f.id] = o.value === "true";
else answers[f.id] = o.value;
v.controller.view.update();
};
// The label column takes the rest of the row so long options wrap
// instead of pushing past both edges of the card.
parts.push(
html`
<div ...${column} padding="small">
${options.map(
(o, i) =>
html`
<div ...${row} ...${clickable} gap="medium" padding="small" paddingX="medium" paddingY="small" alignItems="start"
background=${isChosen(o.value) ? "accent" : undefined} hover=${HOVER} borderRadius="md" onClick=${choose(o)}>
<div ...${column} gap="small" padding="small" paddingX="small" alignItems="center" background=${isChosen(o.value) ? "accent" : "subtle"} borderRadius="sm">
<text style="body" size="xs" bold>${isChosen(o.value) ? "✓" : String(i + 1)}</text>
</div>
<div ...${column} ...${fill} padding="small">
<text style="body" size="sm" bold text=${String(o.label ?? "")} />
${o.description ? html`<text style="body" size="xs" tone="muted">${o.description}</text>` : null}
</div>
</div>`,
)}
</div>`,
);
if (f.allowOther) {
const other = typeof chosen === "string" && !options.some((o) => o.value === chosen) ? chosen : "";
parts.push(
html`<input id=${`q-${req.id}-${f.id}-other`} value=${other} placeholder="Something else…" onChange=${(
e: ValueEvent<string>,
) => {
t.answers[req.id] = answers;
answers[f.id] = e.value;
}} />`,
);
}
}
return html`<div ...${column} gap="small" padding="small">${parts}</div>`;
});
return html`
<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}>
<div ...${column} gap="medium" padding="medium" borderRadius="lg" border=${{ width: 1, color: "info/35" }}>
${req.responseMode === "message" ? html`<text style="body" size="xs" tone="muted">Answer when ready. The agent can keep working.</text>` : null}
${req.message ? html`<markdown text=${req.message} />` : null}
${req.url ? html`<text style="body" size="xs" tone="muted" truncate text=${req.url} />` : null}
${fields}
<div ...${row} gap="small" padding="small" justifyContent="end">
<button id=${`q-cancel-${req.id}`} disabled=${req.submitting} label="Dismiss" variant="ghost" small onClick=${() => respond(true)} />
${
req.url
? html`<button id=${`q-open-${req.id}`} disabled=${req.submitting} label="Open link" variant="primary" small
onClick=${() => {
return v.ctx.run(v.controller.openQuestionLink(chatId, req));
}} />`
: html`<button id=${`q-submit-${req.id}`} disabled=${req.submitting} label="Answer" variant="primary" small onClick=${() => respond(false)} />`
}
</div>
</div>
</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.