@ai-setting/roy-plugin-task-show 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,30 @@
1
1
  /**
2
- * @fileoverview Home chat panel — client controller.
2
+ * @fileoverview Chat panel — client controller.
3
3
  *
4
- * v2.5.0+ REQ-2: lives on the index page (top of body). Hooks the
5
- * following DOM contract (pinned by `test/home-chat-v25.test.ts`):
4
+ * v2.5.0+ REQ-2 (home) + REQ-3 (task-aware) + REQ-4 (markdown).
5
+ *
6
+ * The module exposes a single `attachChatPanel(root, opts)` factory so
7
+ * the same JS file can drive both the home chat panel and the
8
+ * task-detail chat panel. The legacy `init()` entry-point is preserved
9
+ * for backward compat with any page that wires the home chat via
10
+ * `<script src="chat-panel.js">` alone (no opts needed).
11
+ *
12
+ * DOM contract (pinned by `test/home-chat-v25.test.ts` and
13
+ * `test/task-chat-v25.test.ts`):
6
14
  *
7
15
  * [data-chat-shell] <section> wrapper
16
+ * [data-chat-scope] "home" | "task" (read by factory)
17
+ * [data-task-id] task id (when scope=task) (read by factory)
8
18
  * [data-chat-messages] <div> message log (role="log")
9
19
  * [data-chat-form] <form> submit handler
10
20
  * [data-chat-input] <input> text input
11
21
  * [data-chat-send] <button> send button (type="submit")
12
22
  *
13
- * sessionId is persisted in `localStorage["taskShow.chat.sessionId"]`
14
- * across page loads so the conversation survives reloads. The first
15
- * run (no stored id) POSTs `/api/chat/start` to allocate one.
23
+ * sessionId is persisted in localStorage under a scope-aware key:
24
+ * - scope=home: `taskShow.chat.sessionId` (one-off uuid)
25
+ * - scope=task: `taskShow.chat.task.${taskId}.sid` (long-lived, per task)
26
+ * The task-detail key intentionally differs from the home key so the
27
+ * two scopes don't clobber each other.
16
28
  *
17
29
  * The server speaks SSE for streaming chunks. We use `fetch` + an
18
30
  * `ReadableStream` reader instead of `EventSource` because the chat
@@ -21,240 +33,533 @@
21
33
  *
22
34
  * If the chat feature is disabled server-side (503), the panel hides
23
35
  * itself with a banner instead of throwing.
36
+ *
37
+ * v2.5.x REQ-4: assistant messages are rendered through
38
+ * `markdownRenderer.renderMarkdown()` (vendored `marked` +
39
+ * `DOMPurify`). User messages deliberately stay `textContent` —
40
+ * users must not be able to inject markdown / HTML into their own
41
+ * bubbles.
24
42
  */
25
43
 
26
- (function () {
27
- const STORAGE_KEY = "taskShow.chat.sessionId";
28
- const SHELL_SEL = "[data-chat-shell]";
29
- const MESSAGES_SEL = "[data-chat-messages]";
30
- const FORM_SEL = "[data-chat-form]";
31
- const INPUT_SEL = "[data-chat-input]";
32
- const SEND_SEL = "[data-chat-send]";
44
+ // ---------------------------------------------------------------------------
45
+ // Module dual-mode
46
+ //
47
+ // - In the browser this file is loaded as a classic <script>. The
48
+ // trailing bootstrap calls `init()` and the panel becomes live.
49
+ // - In tests (bun:test) it is imported as an ES module; the bootstrap
50
+ // branch is skipped (no `document`/`window` globals), and the named
51
+ // exports below are what the harness drives.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const HOME_STORAGE_KEY = "taskShow.chat.sessionId";
55
+ const SHELL_SEL = "[data-chat-shell]";
56
+ const MESSAGES_SEL = "[data-chat-messages]";
57
+ const FORM_SEL = "[data-chat-form]";
58
+ const INPUT_SEL = "[data-chat-input]";
59
+ const SEND_SEL = "[data-chat-send]";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Portable DOM access. happy-dom tests give us a Window via
63
+ // `container.ownerDocument`; the browser gives us a global `document`.
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function getDoc(container) {
67
+ if (container && container.ownerDocument) return container.ownerDocument;
68
+ if (typeof document !== "undefined") return document;
69
+ return null;
70
+ }
71
+
72
+ function getWindow(doc) {
73
+ if (doc && doc.defaultView) return doc.defaultView;
74
+ if (typeof window !== "undefined") return window;
75
+ return null;
76
+ }
77
+
78
+ function $(container, sel) {
79
+ const doc = getDoc(container);
80
+ if (doc) return doc.querySelector(sel);
81
+ // Fallback — should never happen for the production code path.
82
+ return typeof document !== "undefined" ? document.querySelector(sel) : null;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Markdown renderer resolution. In production the browser script tag
87
+ // order is:
88
+ // marked.min.js → dompurify.min.js → markdown-renderer.js → chat-panel.js
89
+ // so by the time chat-panel.js runs, `window.markdownRenderer` is set.
90
+ //
91
+ // If a test harness imported chat-panel.js without loading
92
+ // markdown-renderer.js first, we lazily require it (Node-only path).
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function resolveRenderer() {
96
+ if (typeof window !== "undefined" && window.markdownRenderer) {
97
+ return window.markdownRenderer;
98
+ }
99
+ if (typeof globalThis !== "undefined" && globalThis.markdownRenderer) {
100
+ return globalThis.markdownRenderer;
101
+ }
102
+ try {
103
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
104
+ const mod = require("./markdown-renderer.js");
105
+ return { renderMarkdown: mod.renderMarkdown, attachStreamingRenderer: mod.attachStreamingRenderer };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Scope-aware storage key helpers (REQ-3). The home key is the legacy
113
+ // single global; the task key is per-task-id so different tasks don't
114
+ // clobber each other across reloads.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function storageKey(scope, taskId) {
118
+ if (scope === "task" && Number.isInteger(taskId)) {
119
+ return `taskShow.chat.task.${taskId}.sid`;
120
+ }
121
+ return HOME_STORAGE_KEY;
122
+ }
33
123
 
34
- function $(sel) { return document.querySelector(sel); }
124
+ function readScopedSessionId(scope, taskId) {
125
+ try {
126
+ const w = typeof window !== "undefined" ? window : null;
127
+ if (!w || !w.localStorage) return "";
128
+ return w.localStorage.getItem(storageKey(scope, taskId)) || "";
129
+ } catch {
130
+ return "";
131
+ }
132
+ }
35
133
 
36
- function readSessionId() {
37
- try { return window.localStorage.getItem(STORAGE_KEY) || ""; }
38
- catch { return ""; }
134
+ function writeScopedSessionId(scope, taskId, id) {
135
+ try {
136
+ const w = typeof window !== "undefined" ? window : null;
137
+ if (!w || !w.localStorage) return;
138
+ w.localStorage.setItem(storageKey(scope, taskId), id);
139
+ } catch {
140
+ /* ignore */
39
141
  }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Persistence helpers (legacy single-key API; REQ-2 home path uses
146
+ // these directly). REQ-3 task scope goes through readScopedSessionId /
147
+ // writeScopedSessionId above. Guarded try/catch so headless test envs
148
+ // without localStorage don't blow up.
149
+ // ---------------------------------------------------------------------------
40
150
 
41
- function writeSessionId(id) {
42
- try { window.localStorage.setItem(STORAGE_KEY, id); } catch { /* ignore */ }
151
+ export function readSessionId(win) {
152
+ try {
153
+ const w = win || (typeof window !== "undefined" ? window : null);
154
+ if (!w || !w.localStorage) return "";
155
+ return w.localStorage.getItem(HOME_STORAGE_KEY) || "";
156
+ } catch {
157
+ return "";
43
158
  }
159
+ }
44
160
 
45
- function clearMessages(container) {
46
- while (container.firstChild) container.removeChild(container.firstChild);
161
+ export function writeSessionId(id, win) {
162
+ try {
163
+ const w = win || (typeof window !== "undefined" ? window : null);
164
+ if (!w || !w.localStorage) return;
165
+ w.localStorage.setItem(HOME_STORAGE_KEY, id);
166
+ } catch {
167
+ /* ignore */
47
168
  }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // DOM mutation helpers
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export function clearMessages(container) {
176
+ while (container.firstChild) container.removeChild(container.firstChild);
177
+ }
48
178
 
49
- function appendMessage(container, role, text) {
50
- const empty = container.querySelector(".chat-empty");
51
- if (empty) empty.remove();
52
- const el = document.createElement("div");
53
- el.className = "chat-message chat-message--" + role;
54
- el.dataset.role = role;
55
- const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error";
56
- el.innerHTML =
57
- `<div class="chat-message-meta">${label}</div>` +
58
- `<div class="chat-message-text"></div>`;
59
- el.querySelector(".chat-message-text").textContent = text;
60
- container.appendChild(el);
179
+ /**
180
+ * Append a message bubble to the chat log.
181
+ *
182
+ * - `role === "assistant"` → text is rendered via
183
+ * `markdownRenderer.renderMarkdown()` (marked + DOMPurify).
184
+ * - `role === "user"` → text stays `textContent`. Users must not
185
+ * be able to inject HTML / markdown into their own bubble.
186
+ * - `role === "error"` → also `textContent` — error text should
187
+ * be readable as-is, no formatting.
188
+ *
189
+ * Returns the created element so the test harness can inspect it.
190
+ */
191
+ export function appendMessage(container, role, text) {
192
+ const doc = getDoc(container);
193
+ if (!doc) throw new Error("chat-panel.appendMessage: no document available");
194
+ const empty = container.querySelector(".chat-empty");
195
+ if (empty) empty.remove();
196
+ const el = doc.createElement("div");
197
+ el.className = "chat-message chat-message--" + role;
198
+ el.dataset.role = role;
199
+ const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error";
200
+ el.innerHTML =
201
+ `<div class="chat-message-meta">${label}</div>` +
202
+ `<div class="chat-message-text"></div>`;
203
+ const textEl = el.querySelector(".chat-message-text");
204
+ if (role === "assistant") {
205
+ const renderer = resolveRenderer();
206
+ if (renderer) {
207
+ textEl.innerHTML = renderer.renderMarkdown(text);
208
+ } else {
209
+ // Defensive fallback — should never hit in production.
210
+ textEl.textContent = text;
211
+ }
212
+ } else {
213
+ // user / error — textContent literal.
214
+ textEl.textContent = text;
215
+ }
216
+ container.appendChild(el);
217
+ if (container.scrollHeight != null) {
61
218
  container.scrollTop = container.scrollHeight;
62
- return el;
219
+ }
220
+ return el;
221
+ }
222
+
223
+ /**
224
+ * Streaming chunk → debounced markdown render. The first chunk of a
225
+ * turn creates the assistant bubble lazily; subsequent chunks feed
226
+ * the bubble's streaming renderer.
227
+ *
228
+ * Returns the controller (with `flush` / `dispose`) so the caller
229
+ * can force a final render on `event: done`.
230
+ */
231
+ export function appendChunk(container, text) {
232
+ const win = getWindow(getDoc(container));
233
+ let last = container.querySelector(".chat-message--assistant:last-of-type");
234
+ if (!last) last = appendMessage(container, "assistant", "");
235
+ const textEl = last.querySelector(".chat-message-text");
236
+
237
+ // Cache the streaming controller on the element so successive
238
+ // chunks reuse the same buffer + timer.
239
+ let ctrl = last.__markdownStreamCtrl;
240
+ const renderer = resolveRenderer();
241
+ if (!ctrl && renderer) {
242
+ ctrl = renderer.attachStreamingRenderer(textEl, { debounceMs: 50 });
243
+ last.__markdownStreamCtrl = ctrl;
63
244
  }
64
245
 
65
- /**
66
- * Append a streaming chunk to the last assistant message. If no
67
- * assistant message exists yet (first chunk of a turn), create one.
68
- */
69
- function appendChunk(container, text) {
70
- let last = container.querySelector(".chat-message--assistant:last-of-type");
71
- if (!last) last = appendMessage(container, "assistant", "");
72
- const textEl = last.querySelector(".chat-message-text");
246
+ if (ctrl) {
247
+ ctrl.append(text);
248
+ } else {
249
+ // No renderer available — fall back to textContent (textContent
250
+ // of a chunk is the safest path when DOMPurify isn't loaded).
73
251
  textEl.textContent += text;
252
+ }
253
+ if (win && container.scrollHeight != null) {
74
254
  container.scrollTop = container.scrollHeight;
75
255
  }
256
+ }
76
257
 
77
- function setBusy(form, busy) {
78
- const input = form.querySelector(INPUT_SEL);
79
- const send = form.querySelector(SEND_SEL);
80
- if (input) input.disabled = busy;
81
- if (send) {
82
- send.disabled = busy;
83
- send.textContent = busy ? "Sending…" : "Send";
84
- }
258
+ // ---------------------------------------------------------------------------
259
+ // Form / network helpers (kept outside any IIFE so they can be reached
260
+ // in the browser; tests don't exercise these).
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function setBusy(form, busy) {
264
+ const input = form.querySelector(INPUT_SEL);
265
+ const send = form.querySelector(SEND_SEL);
266
+ if (input) input.disabled = busy;
267
+ if (send) {
268
+ send.disabled = busy;
269
+ send.textContent = busy ? "Sending…" : "Send";
85
270
  }
271
+ }
272
+
273
+ function showDisabledBanner(shell, msg) {
274
+ const doc = getDoc(shell);
275
+ if (!doc) return;
276
+ const banner = doc.createElement("p");
277
+ banner.className = "chat-banner";
278
+ banner.dataset.role = "banner";
279
+ banner.textContent = msg;
280
+ shell.appendChild(banner);
281
+ const form = shell.querySelector(FORM_SEL);
282
+ if (form) form.remove();
283
+ }
86
284
 
87
- function showDisabledBanner(shell, msg) {
88
- const banner = document.createElement("p");
89
- banner.className = "chat-banner";
90
- banner.dataset.role = "banner";
91
- banner.textContent = msg;
92
- shell.appendChild(banner);
93
- const form = shell.querySelector(FORM_SEL);
94
- if (form) form.remove();
285
+ /**
286
+ * Resolve a fresh session id for the current scope.
287
+ * - scope=home: POST /api/chat/start → { sessionId }
288
+ * - scope=task: deterministic `task-${taskId}`; we don't need to
289
+ * round-trip the server because the task-detail endpoint keys
290
+ * off the taskId directly. This guarantees multi-turn
291
+ * conversations survive reloads even when the CLI never saw a
292
+ * /start call.
293
+ */
294
+ async function ensureSessionId(scope, taskId) {
295
+ if (scope === "task" && Number.isInteger(taskId)) {
296
+ return `task-${taskId}`;
95
297
  }
298
+ const cached = readScopedSessionId(scope, taskId);
299
+ if (cached) return cached;
300
+ const res = await fetch("/api/chat/start", {
301
+ method: "POST",
302
+ headers: { "Content-Type": "application/json" },
303
+ body: "{}",
304
+ });
305
+ if (!res.ok) throw new Error("chat_start_failed: " + res.status);
306
+ const j = await res.json();
307
+ writeScopedSessionId(scope, taskId, j.sessionId);
308
+ return j.sessionId;
309
+ }
96
310
 
97
- async function ensureSessionId() {
98
- let id = readSessionId();
99
- if (id) return id;
100
- const res = await fetch("/api/chat/start", {
311
+ /**
312
+ * Compute the message-endpoint URL for the current scope.
313
+ * - scope=home: /api/chat/<sessionId>/message
314
+ * - scope=task: /api/task-chat/<taskId>/message (server derives
315
+ * sessionId from taskId, so we never need it on the client side)
316
+ *
317
+ * If `opts.endpoint` is supplied, it's used as the base prefix
318
+ * (mostly for tests / proxies); the resource sub-path is still
319
+ * computed from scope + (taskId|sessionId).
320
+ */
321
+ function endpointFor(opts, sessionId) {
322
+ const base = (opts && opts.endpoint) || "";
323
+ const trimBase = base.replace(/\/+$/, "");
324
+ if (opts.scope === "task" && Number.isInteger(opts.taskId)) {
325
+ const url = `/api/task-chat/${opts.taskId}/message`;
326
+ return trimBase ? `${trimBase}${url}` : url;
327
+ }
328
+ const url = `/api/chat/${encodeURIComponent(sessionId)}/message`;
329
+ return trimBase ? `${trimBase}${url}` : url;
330
+ }
331
+
332
+ /**
333
+ * Public hook for the test harness — only used by the browser init
334
+ * flow in tests that simulate a full submit. Kept as a named export
335
+ * so home-chat-v25's fake ACT test can call into the same code path.
336
+ *
337
+ * Legacy signature: sendMessage(form, container, message) — used by
338
+ * `init()` for the home scope. REQ-3 goes through attachChatPanel()
339
+ * instead, which calls into streamSession() below.
340
+ */
341
+ export async function sendMessage(form, container, message) {
342
+ return streamSession({ scope: "home", endpoint: "" }, form, container, message);
343
+ }
344
+
345
+ /**
346
+ * Stream one user message to the server, render chunks back into the
347
+ * container. Shared by both legacy `sendMessage` (home) and the
348
+ * REQ-3 `attachChatPanel` (home + task).
349
+ */
350
+ async function streamSession(opts, form, container, message) {
351
+ let sessionId;
352
+ try {
353
+ sessionId = await ensureSessionId(opts.scope, opts.taskId);
354
+ } catch (e) {
355
+ appendMessage(container, "error", String(e && e.message || e));
356
+ return;
357
+ }
358
+ appendMessage(container, "user", message);
359
+ setBusy(form, true);
360
+
361
+ const url = endpointFor(opts, sessionId);
362
+ let res;
363
+ try {
364
+ res = await fetch(url, {
101
365
  method: "POST",
102
- headers: { "Content-Type": "application/json" },
103
- body: "{}",
366
+ headers: {
367
+ "Content-Type": "application/json",
368
+ Accept: "text/event-stream",
369
+ },
370
+ body: JSON.stringify({ message }),
104
371
  });
105
- if (!res.ok) throw new Error("chat_start_failed: " + res.status);
106
- const j = await res.json();
107
- id = j.sessionId;
108
- writeSessionId(id);
109
- return id;
372
+ } catch (e) {
373
+ appendMessage(container, "error", "network_error: " + (e && e.message || e));
374
+ setBusy(form, false);
375
+ return;
110
376
  }
111
377
 
112
- async function sendMessage(form, container, message) {
113
- let sessionId;
114
- try {
115
- sessionId = await ensureSessionId();
116
- } catch (e) {
117
- appendMessage(container, "error", String(e && e.message || e));
118
- return;
119
- }
120
- appendMessage(container, "user", message);
121
- setBusy(form, true);
122
-
123
- let res;
124
- try {
125
- res = await fetch(`/api/chat/${encodeURIComponent(sessionId)}/message`, {
126
- method: "POST",
127
- headers: {
128
- "Content-Type": "application/json",
129
- Accept: "text/event-stream",
130
- },
131
- body: JSON.stringify({ message }),
132
- });
133
- } catch (e) {
134
- appendMessage(container, "error", "network_error: " + (e && e.message || e));
135
- setBusy(form, false);
136
- return;
137
- }
378
+ if (!res.ok || !res.body) {
379
+ let detail = "";
380
+ try { detail = await res.text(); } catch { /* ignore */ }
381
+ appendMessage(container, "error", `server_error: ${res.status} ${detail.slice(0, 200)}`);
382
+ setBusy(form, false);
383
+ return;
384
+ }
138
385
 
139
- if (!res.ok || !res.body) {
140
- let detail = "";
141
- try { detail = await res.text(); } catch { /* ignore */ }
142
- appendMessage(container, "error", `server_error: ${res.status} ${detail.slice(0, 200)}`);
143
- setBusy(form, false);
144
- return;
145
- }
386
+ const reader = res.body.getReader();
387
+ const decoder = new TextDecoder("utf-8");
388
+ let buf = "";
389
+ let sawError = false;
146
390
 
147
- const reader = res.body.getReader();
148
- const decoder = new TextDecoder("utf-8");
149
- let buf = "";
150
- let sawError = false;
151
-
152
- try {
153
- while (true) {
154
- const { value, done } = await reader.read();
155
- if (done) break;
156
- buf += decoder.decode(value, { stream: true });
157
- let nl;
158
- while ((nl = buf.indexOf("\n\n")) >= 0) {
159
- const frame = buf.slice(0, nl);
160
- buf = buf.slice(nl + 2);
161
- handleFrame(frame, container, (err) => { sawError = sawError || !!err; });
162
- }
391
+ try {
392
+ while (true) {
393
+ const { value, done } = await reader.read();
394
+ if (done) break;
395
+ buf += decoder.decode(value, { stream: true });
396
+ let nl;
397
+ while ((nl = buf.indexOf("\n\n")) >= 0) {
398
+ const frame = buf.slice(0, nl);
399
+ buf = buf.slice(nl + 2);
400
+ handleFrame(frame, container, (err) => { sawError = sawError || !!err; });
163
401
  }
164
- // Flush trailing frame if any.
165
- if (buf.trim().length > 0) handleFrame(buf, container, () => {});
166
- } catch (e) {
167
- appendMessage(container, "error", "stream_error: " + (e && e.message || e));
168
- } finally {
169
- setBusy(form, false);
170
- // Suppress the final empty-message that may have been left if the
171
- // assistant produced no text. We intentionally leave error messages
172
- // alone so the user can see what went wrong.
173
- if (!sawError) {
174
- const last = container.querySelector(".chat-message--assistant:last-of-type");
175
- if (last && !last.querySelector(".chat-message-text").textContent.trim()) {
176
- last.remove();
177
- }
402
+ }
403
+ // Flush trailing frame if any.
404
+ if (buf.trim().length > 0) handleFrame(buf, container, () => {});
405
+ } catch (e) {
406
+ appendMessage(container, "error", "stream_error: " + (e && e.message || e));
407
+ } finally {
408
+ setBusy(form, false);
409
+ // Final markdown flush so the last debounced chunk renders before
410
+ // the busy spinner goes away.
411
+ const last = container.querySelector(".chat-message--assistant:last-of-type");
412
+ if (last && last.__markdownStreamCtrl && last.__markdownStreamCtrl.flush) {
413
+ last.__markdownStreamCtrl.flush();
414
+ }
415
+ // Suppress the final empty-message that may have been left if the
416
+ // assistant produced no text. We intentionally leave error messages
417
+ // alone so the user can see what went wrong.
418
+ if (!sawError) {
419
+ const last2 = container.querySelector(".chat-message--assistant:last-of-type");
420
+ if (last2 && !last2.querySelector(".chat-message-text").textContent.trim()) {
421
+ last2.remove();
178
422
  }
179
423
  }
180
424
  }
425
+ }
181
426
 
182
- /** Parse one SSE frame (event:/data:/id: lines). Returns the parsed frame or null. */
183
- function handleFrame(raw, container, onError) {
184
- let event = "";
185
- let data = "";
186
- let id = "";
187
- for (const line of raw.split(/\r?\n/)) {
188
- if (line.startsWith(":")) continue;
189
- if (line.startsWith("event:")) event = line.slice(6).trim();
190
- else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
191
- else if (line.startsWith("id:")) id = line.slice(3).trim();
192
- }
193
- if (!event && !data) return null;
194
- let parsed = null;
195
- if (data) {
196
- try { parsed = JSON.parse(data); } catch { parsed = data; }
197
- }
198
- if (event === "chunk" && parsed && typeof parsed === "object") {
199
- if (parsed.type === "text" && typeof parsed.text === "string") {
200
- appendChunk(container, parsed.text);
201
- } else if (parsed.type === "reasoning" && typeof parsed.text === "string") {
202
- // Reasoning is hidden in the chat UI by design; we still log it
203
- // so a developer can inspect via DevTools.
204
- if (window.console && window.console.debug) {
205
- window.console.debug("[chat] reasoning:", parsed.text);
206
- }
207
- } else if (parsed.type === "start") {
208
- // No-op: the assistant bubble is created lazily on first text chunk.
209
- } else if (parsed.type === "error" && parsed.error) {
210
- onError(parsed);
211
- appendMessage(container, "error", parsed.error.message || "unknown error");
427
+ /** Parse one SSE frame (event:/data:/id: lines). Returns the parsed frame or null. */
428
+ function handleFrame(raw, container, onError) {
429
+ let event = "";
430
+ let data = "";
431
+ let id = "";
432
+ for (const line of raw.split(/\r?\n/)) {
433
+ if (line.startsWith(":")) continue;
434
+ if (line.startsWith("event:")) event = line.slice(6).trim();
435
+ else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
436
+ else if (line.startsWith("id:")) id = line.slice(3).trim();
437
+ }
438
+ if (!event && !data) return null;
439
+ let parsed = null;
440
+ if (data) {
441
+ try { parsed = JSON.parse(data); } catch { parsed = data; }
442
+ }
443
+ if (event === "chunk" && parsed && typeof parsed === "object") {
444
+ if (parsed.type === "text" && typeof parsed.text === "string") {
445
+ appendChunk(container, parsed.text);
446
+ } else if (parsed.type === "reasoning" && typeof parsed.text === "string") {
447
+ // Reasoning is hidden in the chat UI by design; we still log it
448
+ // so a developer can inspect via DevTools.
449
+ if (typeof console !== "undefined" && console.debug) {
450
+ console.debug("[chat] reasoning:", parsed.text);
212
451
  }
213
- } else if (event === "done") {
214
- // Terminal marker nothing to render.
215
- } else if (event === "error") {
452
+ } else if (parsed.type === "start") {
453
+ // No-op: the assistant bubble is created lazily on first text chunk.
454
+ } else if (parsed.type === "error" && parsed.error) {
216
455
  onError(parsed);
217
- const msg = parsed && parsed.error && parsed.error.message
218
- ? parsed.error.message
219
- : "stream error";
220
- appendMessage(container, "error", msg);
221
- } else if (event === "ready") {
222
- // SSE handshake — nothing to render.
456
+ appendMessage(container, "error", parsed.error.message || "unknown error");
223
457
  }
224
- return { event, data, id, parsed };
458
+ } else if (event === "done") {
459
+ // Terminal marker — final flush is handled by the caller's finally.
460
+ } else if (event === "error") {
461
+ onError(parsed);
462
+ const msg = parsed && parsed.error && parsed.error.message
463
+ ? parsed.error.message
464
+ : "stream error";
465
+ appendMessage(container, "error", msg);
466
+ } else if (event === "ready") {
467
+ // SSE handshake — nothing to render.
225
468
  }
469
+ return { event, data, id, parsed };
470
+ }
226
471
 
227
- function init() {
228
- const shell = document.querySelector(SHELL_SEL);
229
- if (!shell) return;
230
- const form = shell.querySelector(FORM_SEL);
231
- const container = shell.querySelector(MESSAGES_SEL);
232
- const input = shell.querySelector(INPUT_SEL);
233
- if (!form || !container || !input) return;
234
-
235
- // Probe feature availability with a HEAD request. If 503, hide UI.
236
- fetch("/api/chat/start", { method: "HEAD" }).then((res) => {
237
- if (res.status === 503) {
238
- showDisabledBanner(shell, "Chat disabled (server returned 503 — chatOptions not configured).");
239
- }
240
- }).catch(() => { /* offline — leave UI alone */ });
241
-
242
- form.addEventListener("submit", (e) => {
243
- e.preventDefault();
244
- const message = (input.value || "").trim();
245
- if (!message) return;
246
- input.value = "";
247
- sendMessage(form, container, message);
248
- });
472
+ /**
473
+ * Resolve the chat options from DOM data-attrs when the caller
474
+ * didn't pass them explicitly. Lets the server emit a single
475
+ * `<section data-chat-scope="task" data-task-id="4242">` and have
476
+ * the JS pick up the right endpoint / storage key automatically.
477
+ */
478
+ function readOptsFromRoot(root, opts) {
479
+ const merged = Object.assign({}, opts || {});
480
+ if (!merged.scope) {
481
+ const scopeAttr = root.getAttribute && root.getAttribute("data-chat-scope");
482
+ merged.scope = scopeAttr === "task" ? "task" : "home";
483
+ }
484
+ if (merged.scope === "task" && !Number.isInteger(merged.taskId)) {
485
+ const idAttr = root.getAttribute && root.getAttribute("data-task-id");
486
+ const n = Number(idAttr);
487
+ if (Number.isInteger(n)) merged.taskId = n;
249
488
  }
489
+ return merged;
490
+ }
250
491
 
492
+ /**
493
+ * Factory — wire a chat panel inside `root` using the supplied
494
+ * options. Returns the controller handle (for tests / programmatic
495
+ * sends). Backward-compatible with the legacy `init()` entry point
496
+ * which auto-discovers the home shell.
497
+ *
498
+ * @param {HTMLElement} root the [data-chat-shell] element
499
+ * @param {{
500
+ * endpoint?: string; // optional base prefix (e.g. "" or "https://proxy")
501
+ * scope?: "home"|"task"; // which API endpoint to use (default: from DOM data-chat-scope)
502
+ * taskId?: number; // required when scope === "task"
503
+ * }} [opts]
504
+ */
505
+ export function attachChatPanel(root, opts) {
506
+ if (!root) return null;
507
+ const finalOpts = readOptsFromRoot(root, opts);
508
+ const form = root.querySelector(FORM_SEL);
509
+ const container = root.querySelector(MESSAGES_SEL);
510
+ const input = root.querySelector(INPUT_SEL);
511
+ if (!form || !container || !input) return null;
512
+
513
+ // Probe feature availability with a HEAD request. If 503, hide UI.
514
+ // For task scope we don't have a generic /api/chat/start — but
515
+ // the same server returns 503 on /api/task-chat/0/message when
516
+ // the chat is disabled (any numeric id triggers the chatManager
517
+ // check), so we use that as a feature probe.
518
+ const probeUrl = finalOpts.scope === "task"
519
+ ? `/api/task-chat/0/message`
520
+ : "/api/chat/start";
521
+ fetch(probeUrl, { method: "HEAD" }).then((res) => {
522
+ if (res.status === 503) {
523
+ showDisabledBanner(root, "Chat disabled (server returned 503 — chatOptions not configured).");
524
+ }
525
+ }).catch(() => { /* offline — leave UI alone */ });
526
+
527
+ form.addEventListener("submit", (e) => {
528
+ e.preventDefault();
529
+ const message = (input.value || "").trim();
530
+ if (!message) return;
531
+ input.value = "";
532
+ streamSession(finalOpts, form, container, message);
533
+ });
534
+
535
+ return { root, opts: finalOpts, send: (msg) => streamSession(finalOpts, form, container, msg) };
536
+ }
537
+
538
+ export function init() {
539
+ const shell = $(null, SHELL_SEL);
540
+ if (!shell) return null;
541
+ return attachChatPanel(shell, {});
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Browser-only bootstrap. Tests run this file under bun:test where
546
+ // `document`/`window` are not globals, so the if-branch is skipped and
547
+ // only the named exports are available.
548
+ // ---------------------------------------------------------------------------
549
+
550
+ if (typeof document !== "undefined" && typeof window !== "undefined") {
251
551
  if (document.readyState === "loading") {
252
552
  document.addEventListener("DOMContentLoaded", init);
253
553
  } else {
254
554
  init();
255
555
  }
256
-
257
556
  // Expose a tiny test hook so headless harness can drive the panel
258
557
  // without firing real events.
259
- window.taskShowChatPanel = { init, sendMessage, STORAGE_KEY };
260
- })();
558
+ window.taskShowChatPanel = {
559
+ init,
560
+ attachChatPanel,
561
+ sendMessage,
562
+ storageKey,
563
+ HOME_STORAGE_KEY,
564
+ };
565
+ }