// A channel as an editor tab (spec §5.3): its messages, newest at the // bottom, threads opened in place, and a compose box that posts as you. import * as vscode from "vscode"; import type { Message, Messages, Posted } from "./spectra-api"; import { Bridge } from "./bridge"; import { Model } from "./model"; import { esc, when } from "./pure"; const panels = new Map(); export function openChannel(ctx: vscode.ExtensionContext, bridge: Bridge, model: Model, name: string): void { const existing = panels.get(name); if (existing) { existing.panel.reveal(); return; } panels.set(name, new ChannelPanel(ctx, bridge, model, name)); } class ChannelPanel { readonly panel: vscode.WebviewPanel; private subs: vscode.Disposable[] = []; private openThread: string | undefined; constructor(ctx: vscode.ExtensionContext, private readonly bridge: Bridge, private readonly model: Model, private readonly name: string) { const title = name.startsWith("dm:") ? `@${name.slice(3)}` : `#${name}`; this.panel = vscode.window.createWebviewPanel("spectra.channel", title, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true }); this.panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "assets", "spectra.svg"); this.panel.webview.html = page(this.panel.webview, title); this.subs.push( this.panel.webview.onDidReceiveMessage((m) => void this.onMessage(m)), model.onEvent((e) => { if (e.kind === "message.posted" || e.kind === "message.redacted") void this.load(); }), this.panel.onDidDispose(() => { panels.delete(name); this.subs.forEach((s) => s.dispose()); }), ); void this.load(); } private async load(): Promise { try { const m = await this.bridge.request("GET", `/api/v1/channels/${encodeURIComponent(this.name)}/messages?limit=200`); const thread = this.openThread ? await this.bridge.request("GET", `/api/v1/threads/${encodeURIComponent(this.openThread)}`).catch(() => undefined) : undefined; // Each message's latest replies show beneath it, so none is hidden. const replies = new Map(); await Promise.all( m.items .filter((x) => x.replies > 0) .map(async (x) => { const t = await this.bridge.request("GET", `/api/v1/threads/${encodeURIComponent(x.id)}`).catch(() => undefined); if (t) replies.set(x.id, t.items.filter((y) => y.id !== x.id)); }), ); const brief = this.model.briefs.projects.find((b) => b.project === this.name)?.text; const purpose = this.model.channels.find((c) => c.name === this.name)?.purpose ?? ""; // Names for @-completion: every agent, its project, presence; @channel, @here, @all. const names = this.model.agents .filter((a) => !a.disabled) .map((a) => ({ name: a.name, detail: `${a.kind} · #${a.project} · ${a.presence}` })) .concat([ { name: "channel", detail: "every agent who belongs in this channel" }, { name: "here", detail: "every agent online in this channel" }, { name: "all", detail: "every agent, everywhere" }, ]); void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread, replies), brief: brief ?? "", purpose, names }); } catch (e) { void this.panel.webview.postMessage({ type: "error", text: (e as Error).message }); } } private async onMessage(m: { type: string; body?: string; thread?: string }): Promise { if (m.type === "post" && m.body?.trim()) { try { const body = { body: m.body, reply_to: m.thread ?? null }; const p = await this.bridge.request("POST", `/api/v1/channels/${encodeURIComponent(this.name)}/messages`, body); if (p.duplicate) void vscode.window.showInformationMessage("You said exactly that a moment ago."); void this.panel.webview.postMessage({ type: "posted" }); await this.load(); } catch (e) { void vscode.window.showErrorMessage(`Spectra: not posted: ${(e as Error).message}`); void this.panel.webview.postMessage({ type: "failed" }); } } else if (m.type === "thread") { this.openThread = m.thread || undefined; await this.load(); } else if (m.type === "brief") { void vscode.commands.executeCommand("spectra.editProjectBrief", this.name); } } } /** Markdown enough for chat: code, bold, italics, links, line breaks. Escaped first. */ function md(s: string): string { let h = esc(s); h = h.replace(/```([\s\S]*?)```/g, (_m, c) => `
${c.replace(/^\n/, "")}
`); h = h.replace(/`([^`\n]+)`/g, "$1"); h = h.replace(/\*\*([^*\n]+)\*\*/g, "$1"); h = h.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2"); h = h.replace(/(^|\s)(@[a-z0-9][a-z0-9-]*)/g, '$1$2'); return h.replace(/\n/g, "
"); } const INLINE_REPLIES = 3; function one(m: Message, inThread: boolean, latest?: Message[]): string { const who = m.author === "human" ? "you" : m.author_display || m.author; const link = !inThread ? `${m.replies ? "open thread" : "reply"}` : ""; const body = m.redacted ? `
${esc(m.body)}
` : `
${md(m.body)}
`; const kind = m.kind && m.kind !== "chat" ? ` ${esc(m.kind)}` : ""; let under = ""; if (!inThread && latest && latest.length) { const more = latest.length > INLINE_REPLIES ? `${latest.length - INLINE_REPLIES} earlier repl${latest.length - INLINE_REPLIES === 1 ? "y" : "ies"}` : ""; under = `
${more}${latest.slice(-INLINE_REPLIES).map((x) => one(x, true)).join("")}Reply in thread
`; } return `
${esc(who)} · ${esc(when(m.at_ms))}${kind} ${link}
${body}${under}
`; } function render(items: Message[], thread: Message[] | undefined, open: string | undefined, replies: Map): string { if (!items.length && !thread) return `

No messages yet. Say hello below.

`; const list = items.map((m) => one(m, false, replies.get(m.id))).join(""); if (!thread || !open) return list; return `${list}
Thread close
${thread.map((m) => one(m, true)).join("")}
`; } function page(webview: vscode.Webview, title: string): string { const nonce = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); return `

${esc(title)}

Loading…

`; }