OXYGENHUB
# 95a01bce821d spectra-vscode / src / chat.ts
10.9 KiB · last changed 0.1.4: replies show under their message in a channel tab 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
      // 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
}