| 1 |
// A channel as an editor tab (spec §5.3): its messages, newest at the |
| 2 |
// bottom, threads opened in place, and a compose box that posts as you. |
| 3 |
import * as vscode from "vscode"; |
| 4 |
import type { Message, Messages, Posted } from "./spectra-api"; |
| 5 |
import { Bridge } from "./bridge"; |
| 6 |
import { Model } from "./model"; |
| 7 |
import { esc, when } from "./pure"; |
| 8 |
|
| 9 |
const panels = new Map<string, ChannelPanel>(); |
| 10 |
|
| 11 |
export function openChannel(ctx: vscode.ExtensionContext, bridge: Bridge, model: Model, name: string): void { |
| 12 |
const existing = panels.get(name); |
| 13 |
if (existing) { |
| 14 |
existing.panel.reveal(); |
| 15 |
return; |
| 16 |
} |
| 17 |
panels.set(name, new ChannelPanel(ctx, bridge, model, name)); |
| 18 |
} |
| 19 |
|
| 20 |
class ChannelPanel { |
| 21 |
readonly panel: vscode.WebviewPanel; |
| 22 |
private subs: vscode.Disposable[] = []; |
| 23 |
private openThread: string | undefined; |
| 24 |
|
| 25 |
constructor(ctx: vscode.ExtensionContext, private readonly bridge: Bridge, private readonly model: Model, private readonly name: string) { |
| 26 |
const title = name.startsWith("dm:") ? `@${name.slice(3)}` : `#${name}`; |
| 27 |
this.panel = vscode.window.createWebviewPanel("spectra.channel", title, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true }); |
| 28 |
this.panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "assets", "spectra.svg"); |
| 29 |
this.panel.webview.html = page(this.panel.webview, title); |
| 30 |
this.subs.push( |
| 31 |
this.panel.webview.onDidReceiveMessage((m) => void this.onMessage(m)), |
| 32 |
model.onEvent((e) => { |
| 33 |
if (e.kind === "message.posted" || e.kind === "message.redacted") void this.load(); |
| 34 |
}), |
| 35 |
this.panel.onDidDispose(() => { |
| 36 |
panels.delete(name); |
| 37 |
this.subs.forEach((s) => s.dispose()); |
| 38 |
}), |
| 39 |
); |
| 40 |
void this.load(); |
| 41 |
} |
| 42 |
|
| 43 |
private async load(): Promise<void> { |
| 44 |
try { |
| 45 |
const m = await this.bridge.request<Messages>("GET", `/api/v1/channels/${encodeURIComponent(this.name)}/messages?limit=200`); |
| 46 |
const thread = this.openThread |
| 47 |
? await this.bridge.request<Messages>("GET", `/api/v1/threads/${encodeURIComponent(this.openThread)}`).catch(() => undefined) |
| 48 |
: undefined; |
| 49 |
// Each message's latest replies show beneath it, so none is hidden. |
| 50 |
const replies = new Map<string, Message[]>(); |
| 51 |
await Promise.all( |
| 52 |
m.items |
| 53 |
.filter((x) => x.replies > 0) |
| 54 |
.map(async (x) => { |
| 55 |
const t = await this.bridge.request<Messages>("GET", `/api/v1/threads/${encodeURIComponent(x.id)}`).catch(() => undefined); |
| 56 |
if (t) replies.set(x.id, t.items.filter((y) => y.id !== x.id)); |
| 57 |
}), |
| 58 |
); |
| 59 |
const brief = this.model.briefs.projects.find((b) => b.project === this.name)?.text; |
| 60 |
const purpose = this.model.channels.find((c) => c.name === this.name)?.purpose ?? ""; |
| 61 |
void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread, replies), brief: brief ?? "", purpose }); |
| 62 |
} catch (e) { |
| 63 |
void this.panel.webview.postMessage({ type: "error", text: (e as Error).message }); |
| 64 |
} |
| 65 |
} |
| 66 |
|
| 67 |
private async onMessage(m: { type: string; body?: string; thread?: string }): Promise<void> { |
| 68 |
if (m.type === "post" && m.body?.trim()) { |
| 69 |
try { |
| 70 |
const body = { body: m.body, reply_to: m.thread ?? null }; |
| 71 |
const p = await this.bridge.request<Posted>("POST", `/api/v1/channels/${encodeURIComponent(this.name)}/messages`, body); |
| 72 |
if (p.duplicate) void vscode.window.showInformationMessage("You said exactly that a moment ago."); |
| 73 |
void this.panel.webview.postMessage({ type: "posted" }); |
| 74 |
await this.load(); |
| 75 |
} catch (e) { |
| 76 |
void vscode.window.showErrorMessage(`Spectra: not posted: ${(e as Error).message}`); |
| 77 |
void this.panel.webview.postMessage({ type: "failed" }); |
| 78 |
} |
| 79 |
} else if (m.type === "thread") { |
| 80 |
this.openThread = m.thread || undefined; |
| 81 |
await this.load(); |
| 82 |
} else if (m.type === "brief") { |
| 83 |
void vscode.commands.executeCommand("spectra.editProjectBrief", this.name); |
| 84 |
} |
| 85 |
} |
| 86 |
} |
| 87 |
|
| 88 |
/** Markdown enough for chat: code, bold, italics, links, line breaks. Escaped first. */ |
| 89 |
function md(s: string): string { |
| 90 |
let h = esc(s); |
| 91 |
h = h.replace(/```([\s\S]*?)```/g, (_m, c) => `<pre>${c.replace(/^\n/, "")}</pre>`); |
| 92 |
h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>"); |
| 93 |
h = h.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>"); |
| 94 |
h = h.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>"); |
| 95 |
h = h.replace(/(^|\s)(@[a-z0-9][a-z0-9-]*)/g, '$1<span class="at">$2</span>'); |
| 96 |
return h.replace(/\n/g, "<br>"); |
| 97 |
} |
| 98 |
|
| 99 |
const INLINE_REPLIES = 3; |
| 100 |
|
| 101 |
function one(m: Message, inThread: boolean, latest?: Message[]): string { |
| 102 |
const who = m.author === "human" ? "you" : m.author_display || m.author; |
| 103 |
const link = !inThread ? `<a class="replies" data-thread="${esc(m.id)}">${m.replies ? "open thread" : "reply"}</a>` : ""; |
| 104 |
const body = m.redacted ? `<div class="redacted">${esc(m.body)}</div>` : `<div class="body">${md(m.body)}</div>`; |
| 105 |
const kind = m.kind && m.kind !== "chat" ? ` <span class="pill">${esc(m.kind)}</span>` : ""; |
| 106 |
let under = ""; |
| 107 |
if (!inThread && latest && latest.length) { |
| 108 |
const more = latest.length > INLINE_REPLIES ? `<a class="more" data-thread="${esc(m.id)}">${latest.length - INLINE_REPLIES} earlier repl${latest.length - INLINE_REPLIES === 1 ? "y" : "ies"}</a>` : ""; |
| 109 |
under = `<div class="inline">${more}${latest.slice(-INLINE_REPLIES).map((x) => one(x, true)).join("")}<a class="replies" data-thread="${esc(m.id)}">Reply in thread</a></div>`; |
| 110 |
} |
| 111 |
return `<div class="msg${m.author === "human" ? " mine" : ""}"><div class="meta"><strong>${esc(who)}</strong> · ${esc(when(m.at_ms))}${kind} ${link}</div>${body}${under}</div>`; |
| 112 |
} |
| 113 |
|
| 114 |
function render(items: Message[], thread: Message[] | undefined, open: string | undefined, replies: Map<string, Message[]>): string { |
| 115 |
if (!items.length && !thread) return `<p class="empty">No messages yet. Say hello below.</p>`; |
| 116 |
const list = items.map((m) => one(m, false, replies.get(m.id))).join(""); |
| 117 |
if (!thread || !open) return list; |
| 118 |
return `${list}<div class="thread"><div class="thead">Thread <a class="close" data-thread="">close</a></div>${thread.map((m) => one(m, true)).join("")}</div>`; |
| 119 |
} |
| 120 |
|
| 121 |
function page(webview: vscode.Webview, title: string): string { |
| 122 |
const nonce = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); |
| 123 |
return `<!doctype html><html><head><meta charset="utf-8"> |
| 124 |
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'nonce-${nonce}'; script-src 'nonce-${nonce}';"> |
| 125 |
<style nonce="${nonce}"> |
| 126 |
body { font: var(--vscode-font-size) var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; display: flex; flex-direction: column; height: 100vh; } |
| 127 |
header { padding: 10px 16px; border-bottom: 1px solid var(--vscode-panel-border); } |
| 128 |
header h1 { font-size: 15px; margin: 0; } header .purpose { color: var(--vscode-descriptionForeground); } |
| 129 |
details.brief { margin-top: 6px; } details.brief summary { cursor: pointer; color: var(--vscode-textLink-foreground); } details.brief pre { white-space: pre-wrap; background: var(--vscode-textBlockQuote-background); padding: 8px; margin: 6px 0; } |
| 130 |
#log { flex: 1; overflow-y: auto; padding: 8px 16px; } |
| 131 |
.msg { padding: 6px 0; border-bottom: 1px solid var(--vscode-panel-border); } |
| 132 |
.msg.mine .meta strong { color: var(--vscode-textLink-foreground); } |
| 133 |
.meta { color: var(--vscode-descriptionForeground); font-size: 12px; } |
| 134 |
.body { margin-top: 2px; line-height: 1.45; } .body pre { background: var(--vscode-textCodeBlock-background); padding: 6px 8px; overflow-x: auto; } |
| 135 |
.at { color: var(--vscode-textLink-foreground); } |
| 136 |
.redacted { color: var(--vscode-disabledForeground); font-style: italic; } |
| 137 |
.pill { border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 0 6px; font-size: 11px; } |
| 138 |
a { color: var(--vscode-textLink-foreground); cursor: pointer; } |
| 139 |
.inline { margin: 6px 0 2px 10px; padding-left: 10px; border-left: 3px solid var(--vscode-textLink-foreground); } |
| 140 |
.inline .msg { border-bottom: 0; padding: 3px 0; } .inline .more, .inline > .replies { font-size: 12px; } |
| 141 |
.thread { margin: 10px 0 4px 16px; padding-left: 10px; border-left: 2px solid var(--vscode-textLink-foreground); } |
| 142 |
.thead { font-weight: 600; margin-bottom: 4px; } |
| 143 |
.empty, .error { color: var(--vscode-descriptionForeground); padding: 12px 0; } .error { color: var(--vscode-errorForeground); } |
| 144 |
form { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); } |
| 145 |
textarea { flex: 1; min-height: 44px; max-height: 200px; resize: vertical; font: inherit; color: var(--vscode-input-foreground); background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); padding: 6px 8px; } |
| 146 |
button { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: 0; padding: 6px 14px; cursor: pointer; align-self: flex-end; } |
| 147 |
button:disabled { opacity: .6; } |
| 148 |
</style></head><body> |
| 149 |
<header><h1>${esc(title)}</h1><div class="purpose" id="purpose"></div><details class="brief" id="brief" hidden><summary>Project brief · <a id="edit">edit</a></summary><pre id="brieftext"></pre></details></header> |
| 150 |
<div id="log"><p class="empty">Loading…</p></div> |
| 151 |
<form id="f"><textarea id="t" placeholder="Write as yourself. @name mentions an agent. Ctrl+Enter sends."></textarea><button id="b" type="submit">Send</button></form> |
| 152 |
<script nonce="${nonce}"> |
| 153 |
const vscode = acquireVsCodeApi(); |
| 154 |
const log = document.getElementById("log"), t = document.getElementById("t"), b = document.getElementById("b"); |
| 155 |
let thread = ""; |
| 156 |
function send(e) { if (e) e.preventDefault(); const body = t.value.trim(); if (!body) return; b.disabled = true; vscode.postMessage({ type: "post", body, thread: thread || undefined }); } |
| 157 |
document.getElementById("f").addEventListener("submit", send); |
| 158 |
t.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) send(e); }); |
| 159 |
document.getElementById("edit").addEventListener("click", (e) => { e.preventDefault(); vscode.postMessage({ type: "brief" }); }); |
| 160 |
log.addEventListener("click", (e) => { const a = e.target.closest("[data-thread]"); if (!a) return; thread = a.getAttribute("data-thread"); t.placeholder = thread ? "Reply in the thread. Ctrl+Enter sends." : "Write as yourself. @name mentions an agent. Ctrl+Enter sends."; vscode.postMessage({ type: "thread", thread }); }); |
| 161 |
window.addEventListener("message", (ev) => { |
| 162 |
const m = ev.data; |
| 163 |
if (m.type === "render") { |
| 164 |
const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 30; |
| 165 |
log.innerHTML = m.html; |
| 166 |
document.getElementById("purpose").textContent = m.purpose || ""; |
| 167 |
const br = document.getElementById("brief"); br.hidden = !m.brief; document.getElementById("brieftext").textContent = m.brief || ""; |
| 168 |
if (atEnd) log.scrollTop = log.scrollHeight; |
| 169 |
} else if (m.type === "posted") { t.value = ""; b.disabled = false; t.focus(); } |
| 170 |
else if (m.type === "failed") { b.disabled = false; } |
| 171 |
else if (m.type === "error") { log.innerHTML = '<p class="error"></p>'; log.firstChild.textContent = m.text; } |
| 172 |
}); |
| 173 |
</script></body></html>`; |
| 174 |
} |