OXYGENHUB
◆ main spectra-vscode / src / chat.ts
14.4 KiB · last changed 0.3.1: @-completion of agent names, @channel, @here and @all in the chat box 1 day ago source blame history raw
ff39a5e8 Andy McCall ↶ 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;
95a01bce Andy McCall ↶ 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
      );
ff39a5e8 Andy McCall ↶ 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 ?? "";
0bfe8876 Andy McCall ↶ 61
      // Names for @-completion: every agent, its project, presence; @channel, @here, @all.
62
      const names = this.model.agents
63
        .filter((a) => !a.disabled)
64
        .map((a) => ({ name: a.name, detail: `${a.kind} · #${a.project} · ${a.presence}` }))
65
        .concat([
66
          { name: "channel", detail: "every agent who belongs in this channel" },
67
          { name: "here", detail: "every agent online in this channel" },
68
          { name: "all", detail: "every agent, everywhere" },
69
        ]);
70
      void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread, replies), brief: brief ?? "", purpose, names });
ff39a5e8 Andy McCall ↶ 71
    } catch (e) {
72
      void this.panel.webview.postMessage({ type: "error", text: (e as Error).message });
73
    }
74
  }
75
76
  private async onMessage(m: { type: string; body?: string; thread?: string }): Promise<void> {
77
    if (m.type === "post" && m.body?.trim()) {
78
      try {
79
        const body = { body: m.body, reply_to: m.thread ?? null };
80
        const p = await this.bridge.request<Posted>("POST", `/api/v1/channels/${encodeURIComponent(this.name)}/messages`, body);
81
        if (p.duplicate) void vscode.window.showInformationMessage("You said exactly that a moment ago.");
82
        void this.panel.webview.postMessage({ type: "posted" });
83
        await this.load();
84
      } catch (e) {
85
        void vscode.window.showErrorMessage(`Spectra: not posted: ${(e as Error).message}`);
86
        void this.panel.webview.postMessage({ type: "failed" });
87
      }
88
    } else if (m.type === "thread") {
89
      this.openThread = m.thread || undefined;
90
      await this.load();
91
    } else if (m.type === "brief") {
92
      void vscode.commands.executeCommand("spectra.editProjectBrief", this.name);
93
    }
94
  }
95
}
96
97
/** Markdown enough for chat: code, bold, italics, links, line breaks. Escaped first. */
98
function md(s: string): string {
99
  let h = esc(s);
100
  h = h.replace(/```([\s\S]*?)```/g, (_m, c) => `<pre>${c.replace(/^\n/, "")}</pre>`);
101
  h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>");
102
  h = h.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
103
  h = h.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>");
104
  h = h.replace(/(^|\s)(@[a-z0-9][a-z0-9-]*)/g, '$1<span class="at">$2</span>');
105
  return h.replace(/\n/g, "<br>");
106
}
107
95a01bce Andy McCall ↶ 108
const INLINE_REPLIES = 3;
109
110
function one(m: Message, inThread: boolean, latest?: Message[]): string {
ff39a5e8 Andy McCall ↶ 111
  const who = m.author === "human" ? "you" : m.author_display || m.author;
95a01bce Andy McCall ↶ 112
  const link = !inThread ? `<a class="replies" data-thread="${esc(m.id)}">${m.replies ? "open thread" : "reply"}</a>` : "";
ff39a5e8 Andy McCall ↶ 113
  const body = m.redacted ? `<div class="redacted">${esc(m.body)}</div>` : `<div class="body">${md(m.body)}</div>`;
114
  const kind = m.kind && m.kind !== "chat" ? ` <span class="pill">${esc(m.kind)}</span>` : "";
95a01bce Andy McCall ↶ 115
  let under = "";
116
  if (!inThread && latest && latest.length) {
117
    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>` : "";
118
    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>`;
119
  }
120
  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>`;
ff39a5e8 Andy McCall ↶ 121
}
122
95a01bce Andy McCall ↶ 123
function render(items: Message[], thread: Message[] | undefined, open: string | undefined, replies: Map<string, Message[]>): string {
ff39a5e8 Andy McCall ↶ 124
  if (!items.length && !thread) return `<p class="empty">No messages yet. Say hello below.</p>`;
95a01bce Andy McCall ↶ 125
  const list = items.map((m) => one(m, false, replies.get(m.id))).join("");
ff39a5e8 Andy McCall ↶ 126
  if (!thread || !open) return list;
127
  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>`;
128
}
129
130
function page(webview: vscode.Webview, title: string): string {
131
  const nonce = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
132
  return `<!doctype html><html><head><meta charset="utf-8">
133
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'nonce-${nonce}'; script-src 'nonce-${nonce}';">
134
<style nonce="${nonce}">
135
body { font: var(--vscode-font-size) var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; display: flex; flex-direction: column; height: 100vh; }
136
header { padding: 10px 16px; border-bottom: 1px solid var(--vscode-panel-border); }
137
header h1 { font-size: 15px; margin: 0; } header .purpose { color: var(--vscode-descriptionForeground); }
138
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; }
139
#log { flex: 1; overflow-y: auto; padding: 8px 16px; }
140
.msg { padding: 6px 0; border-bottom: 1px solid var(--vscode-panel-border); }
141
.msg.mine .meta strong { color: var(--vscode-textLink-foreground); }
142
.meta { color: var(--vscode-descriptionForeground); font-size: 12px; }
143
.body { margin-top: 2px; line-height: 1.45; } .body pre { background: var(--vscode-textCodeBlock-background); padding: 6px 8px; overflow-x: auto; }
144
.at { color: var(--vscode-textLink-foreground); }
145
.redacted { color: var(--vscode-disabledForeground); font-style: italic; }
146
.pill { border: 1px solid var(--vscode-panel-border); border-radius: 8px; padding: 0 6px; font-size: 11px; }
147
a { color: var(--vscode-textLink-foreground); cursor: pointer; }
95a01bce Andy McCall ↶ 148
.inline { margin: 6px 0 2px 10px; padding-left: 10px; border-left: 3px solid var(--vscode-textLink-foreground); }
149
.inline .msg { border-bottom: 0; padding: 3px 0; } .inline .more, .inline > .replies { font-size: 12px; }
ff39a5e8 Andy McCall ↶ 150
.thread { margin: 10px 0 4px 16px; padding-left: 10px; border-left: 2px solid var(--vscode-textLink-foreground); }
151
.thead { font-weight: 600; margin-bottom: 4px; }
152
.empty, .error { color: var(--vscode-descriptionForeground); padding: 12px 0; } .error { color: var(--vscode-errorForeground); }
0bfe8876 Andy McCall ↶ 153
form { position: relative; display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); }
154
#ac { position: absolute; left: 16px; bottom: 100%; margin-bottom: 2px; min-width: 280px; max-height: 220px; overflow-y: auto; background: var(--vscode-editorSuggestWidget-background); border: 1px solid var(--vscode-editorSuggestWidget-border); color: var(--vscode-editorSuggestWidget-foreground); z-index: 10; }
155
#ac[hidden] { display: none; }
156
#ac div { padding: 3px 8px; cursor: pointer; display: flex; gap: 10px; justify-content: space-between; }
157
#ac div.on { background: var(--vscode-editorSuggestWidget-selectedBackground); color: var(--vscode-editorSuggestWidget-selectedForeground, inherit); }
158
#ac .d { color: var(--vscode-descriptionForeground); font-size: 11px; }
ff39a5e8 Andy McCall ↶ 159
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; }
160
button { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: 0; padding: 6px 14px; cursor: pointer; align-self: flex-end; }
161
button:disabled { opacity: .6; }
162
</style></head><body>
163
<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>
164
<div id="log"><p class="empty">Loading…</p></div>
0bfe8876 Andy McCall ↶ 165
<form id="f"><div id="ac" role="listbox" hidden></div><textarea id="t" placeholder="Write as yourself. @name mentions an agent. Ctrl+Enter sends."></textarea><button id="b" type="submit">Send</button></form>
ff39a5e8 Andy McCall ↶ 166
<script nonce="${nonce}">
167
const vscode = acquireVsCodeApi();
168
const log = document.getElementById("log"), t = document.getElementById("t"), b = document.getElementById("b");
169
let thread = "";
0bfe8876 Andy McCall ↶ 170
// @-completion of agent names.
171
const ac = document.getElementById("ac");
172
let names = [], shown = [], sel = 0, at = -1;
173
function token() {
174
  const upto = t.value.slice(0, t.selectionStart);
175
  const m = /(^|[\s(])@([a-z0-9-]*)$/i.exec(upto);
176
  return m ? { start: upto.length - m[2].length - 1, word: m[2].toLowerCase() } : null;
177
}
178
function close() { ac.hidden = true; shown = []; at = -1; }
179
function draw() {
180
  ac.innerHTML = "";
181
  shown.forEach((n, i) => {
182
    const d = document.createElement("div");
183
    d.className = i === sel ? "on" : "";
184
    d.setAttribute("role", "option");
185
    const a = document.createElement("span"); a.textContent = "@" + n.name;
186
    const b = document.createElement("span"); b.className = "d"; b.textContent = n.detail;
187
    d.append(a, b);
188
    d.addEventListener("mousedown", (e) => { e.preventDefault(); pick(i); });
189
    ac.appendChild(d);
190
  });
191
  ac.hidden = !shown.length;
192
  const on = ac.children[sel]; if (on) on.scrollIntoView({ block: "nearest" });
193
}
194
function suggest() {
195
  const tk = token();
196
  if (!tk) return close();
197
  at = tk.start;
198
  const w = tk.word;
199
  const starts = names.filter((n) => n.name.startsWith(w));
200
  const parts = names.filter((n) => !n.name.startsWith(w) && n.name.split("-").some((p) => p.startsWith(w)));
201
  shown = starts.concat(parts).slice(0, 12);
202
  sel = 0;
203
  draw();
204
}
205
function pick(i) {
206
  const n = shown[i]; if (!n || at < 0) return;
207
  const end = t.selectionStart;
208
  t.value = t.value.slice(0, at) + "@" + n.name + " " + t.value.slice(end);
209
  const caret = at + n.name.length + 2;
210
  t.setSelectionRange(caret, caret);
211
  close(); t.focus();
212
}
213
t.addEventListener("input", suggest);
214
t.addEventListener("click", suggest);
215
t.addEventListener("blur", () => setTimeout(close, 100));
216
t.addEventListener("keydown", (e) => {
217
  if (ac.hidden) return;
218
  if (e.key === "ArrowDown") { e.preventDefault(); sel = (sel + 1) % shown.length; draw(); }
219
  else if (e.key === "ArrowUp") { e.preventDefault(); sel = (sel - 1 + shown.length) % shown.length; draw(); }
220
  else if ((e.key === "Enter" && !e.ctrlKey && !e.metaKey) || e.key === "Tab") { e.preventDefault(); e.stopImmediatePropagation(); pick(sel); }
221
  else if (e.key === "Escape") { e.preventDefault(); close(); }
222
}, true);
ff39a5e8 Andy McCall ↶ 223
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 }); }
224
document.getElementById("f").addEventListener("submit", send);
0bfe8876 Andy McCall ↶ 225
t.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { close(); send(e); } });
ff39a5e8 Andy McCall ↶ 226
document.getElementById("edit").addEventListener("click", (e) => { e.preventDefault(); vscode.postMessage({ type: "brief" }); });
227
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 }); });
228
window.addEventListener("message", (ev) => {
229
  const m = ev.data;
230
  if (m.type === "render") {
231
    const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
232
    log.innerHTML = m.html;
233
    document.getElementById("purpose").textContent = m.purpose || "";
0bfe8876 Andy McCall ↶ 234
    if (m.names) names = m.names;
ff39a5e8 Andy McCall ↶ 235
    const br = document.getElementById("brief"); br.hidden = !m.brief; document.getElementById("brieftext").textContent = m.brief || "";
236
    if (atEnd) log.scrollTop = log.scrollHeight;
237
  } else if (m.type === "posted") { t.value = ""; b.disabled = false; t.focus(); }
238
  else if (m.type === "failed") { b.disabled = false; }
239
  else if (m.type === "error") { log.innerHTML = '<p class="error"></p>'; log.firstChild.textContent = m.text; }
240
});
241
</script></body></html>`;
242
}