OXYGENHUB
# ff39a5e808da spectra-vscode / src / chat.ts
9.7 KiB · last changed Spectra for VS Code 0.1: set up and chat without the command line (#6) 1 day ago source blame history raw
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
      const brief = this.model.briefs.projects.find((b) => b.project === this.name)?.text;
50
      const purpose = this.model.channels.find((c) => c.name === this.name)?.purpose ?? "";
51
      void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread), brief: brief ?? "", purpose });
52
    } catch (e) {
53
      void this.panel.webview.postMessage({ type: "error", text: (e as Error).message });
54
    }
55
  }
56
57
  private async onMessage(m: { type: string; body?: string; thread?: string }): Promise<void> {
58
    if (m.type === "post" && m.body?.trim()) {
59
      try {
60
        const body = { body: m.body, reply_to: m.thread ?? null };
61
        const p = await this.bridge.request<Posted>("POST", `/api/v1/channels/${encodeURIComponent(this.name)}/messages`, body);
62
        if (p.duplicate) void vscode.window.showInformationMessage("You said exactly that a moment ago.");
63
        void this.panel.webview.postMessage({ type: "posted" });
64
        await this.load();
65
      } catch (e) {
66
        void vscode.window.showErrorMessage(`Spectra: not posted: ${(e as Error).message}`);
67
        void this.panel.webview.postMessage({ type: "failed" });
68
      }
69
    } else if (m.type === "thread") {
70
      this.openThread = m.thread || undefined;
71
      await this.load();
72
    } else if (m.type === "brief") {
73
      void vscode.commands.executeCommand("spectra.editProjectBrief", this.name);
74
    }
75
  }
76
}
77
78
/** Markdown enough for chat: code, bold, italics, links, line breaks. Escaped first. */
79
function md(s: string): string {
80
  let h = esc(s);
81
  h = h.replace(/```([\s\S]*?)```/g, (_m, c) => `<pre>${c.replace(/^\n/, "")}</pre>`);
82
  h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>");
83
  h = h.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
84
  h = h.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>");
85
  h = h.replace(/(^|\s)(@[a-z0-9][a-z0-9-]*)/g, '$1<span class="at">$2</span>');
86
  return h.replace(/\n/g, "<br>");
87
}
88
89
function one(m: Message, inThread: boolean): string {
90
  const who = m.author === "human" ? "you" : m.author_display || m.author;
91
  const replies = !inThread ? `<a class="replies" data-thread="${esc(m.id)}">${m.replies ? `${m.replies} repl${m.replies === 1 ? "y" : "ies"}` : "reply"}</a>` : "";
92
  const body = m.redacted ? `<div class="redacted">${esc(m.body)}</div>` : `<div class="body">${md(m.body)}</div>`;
93
  const kind = m.kind && m.kind !== "chat" ? ` <span class="pill">${esc(m.kind)}</span>` : "";
94
  return `<div class="msg${m.author === "human" ? " mine" : ""}"><div class="meta"><strong>${esc(who)}</strong> · ${esc(when(m.at_ms))}${kind} ${replies}</div>${body}</div>`;
95
}
96
97
function render(items: Message[], thread: Message[] | undefined, open: string | undefined): string {
98
  if (!items.length && !thread) return `<p class="empty">No messages yet. Say hello below.</p>`;
99
  const list = items.map((m) => one(m, false)).join("");
100
  if (!thread || !open) return list;
101
  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>`;
102
}
103
104
function page(webview: vscode.Webview, title: string): string {
105
  const nonce = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
106
  return `<!doctype html><html><head><meta charset="utf-8">
107
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'nonce-${nonce}'; script-src 'nonce-${nonce}';">
108
<style nonce="${nonce}">
109
body { font: var(--vscode-font-size) var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; display: flex; flex-direction: column; height: 100vh; }
110
header { padding: 10px 16px; border-bottom: 1px solid var(--vscode-panel-border); }
111
header h1 { font-size: 15px; margin: 0; } header .purpose { color: var(--vscode-descriptionForeground); }
112
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; }
113
#log { flex: 1; overflow-y: auto; padding: 8px 16px; }
114
.msg { padding: 6px 0; border-bottom: 1px solid var(--vscode-panel-border); }
115
.msg.mine .meta strong { color: var(--vscode-textLink-foreground); }
116
.meta { color: var(--vscode-descriptionForeground); font-size: 12px; }
117
.body { margin-top: 2px; line-height: 1.45; } .body pre { background: var(--vscode-textCodeBlock-background); padding: 6px 8px; overflow-x: auto; }
118
.at { color: var(--vscode-textLink-foreground); }
119
.redacted { color: var(--vscode-disabledForeground); font-style: italic; }
120
.pill { border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 0 6px; font-size: 11px; }
121
a { color: var(--vscode-textLink-foreground); cursor: pointer; }
122
.thread { margin: 10px 0 4px 16px; padding-left: 10px; border-left: 2px solid var(--vscode-textLink-foreground); }
123
.thead { font-weight: 600; margin-bottom: 4px; }
124
.empty, .error { color: var(--vscode-descriptionForeground); padding: 12px 0; } .error { color: var(--vscode-errorForeground); }
125
form { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); }
126
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; }
127
button { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: 0; padding: 6px 14px; cursor: pointer; align-self: flex-end; }
128
button:disabled { opacity: .6; }
129
</style></head><body>
130
<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>
131
<div id="log"><p class="empty">Loading…</p></div>
132
<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>
133
<script nonce="${nonce}">
134
const vscode = acquireVsCodeApi();
135
const log = document.getElementById("log"), t = document.getElementById("t"), b = document.getElementById("b");
136
let thread = "";
137
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 }); }
138
document.getElementById("f").addEventListener("submit", send);
139
t.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) send(e); });
140
document.getElementById("edit").addEventListener("click", (e) => { e.preventDefault(); vscode.postMessage({ type: "brief" }); });
141
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 }); });
142
window.addEventListener("message", (ev) => {
143
  const m = ev.data;
144
  if (m.type === "render") {
145
    const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
146
    log.innerHTML = m.html;
147
    document.getElementById("purpose").textContent = m.purpose || "";
148
    const br = document.getElementById("brief"); br.hidden = !m.brief; document.getElementById("brieftext").textContent = m.brief || "";
149
    if (atEnd) log.scrollTop = log.scrollHeight;
150
  } else if (m.type === "posted") { t.value = ""; b.disabled = false; t.focus(); }
151
  else if (m.type === "failed") { b.disabled = false; }
152
  else if (m.type === "error") { log.innerHTML = '<p class="error"></p>'; log.firstChild.textContent = m.text; }
153
});
154
</script></body></html>`;
155
}