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

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @fileoverview Home chat panel — client controller.
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`):
6
+ *
7
+ * [data-chat-shell] <section> wrapper
8
+ * [data-chat-messages] <div> message log (role="log")
9
+ * [data-chat-form] <form> submit handler
10
+ * [data-chat-input] <input> text input
11
+ * [data-chat-send] <button> send button (type="submit")
12
+ *
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.
16
+ *
17
+ * The server speaks SSE for streaming chunks. We use `fetch` + an
18
+ * `ReadableStream` reader instead of `EventSource` because the chat
19
+ * endpoint is POST (EventSource can't POST) and we need to send the
20
+ * `message` in the request body.
21
+ *
22
+ * If the chat feature is disabled server-side (503), the panel hides
23
+ * itself with a banner instead of throwing.
24
+ */
25
+
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]";
33
+
34
+ function $(sel) { return document.querySelector(sel); }
35
+
36
+ function readSessionId() {
37
+ try { return window.localStorage.getItem(STORAGE_KEY) || ""; }
38
+ catch { return ""; }
39
+ }
40
+
41
+ function writeSessionId(id) {
42
+ try { window.localStorage.setItem(STORAGE_KEY, id); } catch { /* ignore */ }
43
+ }
44
+
45
+ function clearMessages(container) {
46
+ while (container.firstChild) container.removeChild(container.firstChild);
47
+ }
48
+
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);
61
+ container.scrollTop = container.scrollHeight;
62
+ return el;
63
+ }
64
+
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");
73
+ textEl.textContent += text;
74
+ container.scrollTop = container.scrollHeight;
75
+ }
76
+
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
+ }
85
+ }
86
+
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();
95
+ }
96
+
97
+ async function ensureSessionId() {
98
+ let id = readSessionId();
99
+ if (id) return id;
100
+ const res = await fetch("/api/chat/start", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: "{}",
104
+ });
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;
110
+ }
111
+
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
+ }
138
+
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
+ }
146
+
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
+ }
163
+ }
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
+ }
178
+ }
179
+ }
180
+ }
181
+
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");
212
+ }
213
+ } else if (event === "done") {
214
+ // Terminal marker — nothing to render.
215
+ } else if (event === "error") {
216
+ 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.
223
+ }
224
+ return { event, data, id, parsed };
225
+ }
226
+
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
+ });
249
+ }
250
+
251
+ if (document.readyState === "loading") {
252
+ document.addEventListener("DOMContentLoaded", init);
253
+ } else {
254
+ init();
255
+ }
256
+
257
+ // Expose a tiny test hook so headless harness can drive the panel
258
+ // without firing real events.
259
+ window.taskShowChatPanel = { init, sendMessage, STORAGE_KEY };
260
+ })();
@@ -293,10 +293,17 @@
293
293
  if (cw === 0 && ch === 0 && sw === 0 && sh === 0) {
294
294
  return { tx: safeTx, ty: safeTy };
295
295
  }
296
+ // v2.4.2: bidirectional pan — when the diagram is wider/taller
297
+ // than the container, allow tx/ty in [-(dim-stage-dim-container),
298
+ // +(dim-stage-dim-container)] so the user can drag in any
299
+ // direction and see any part of the diagram. When the diagram
300
+ // fits, pin to 0 on that axis.
296
301
  const minTx = sw > cw ? -(sw - cw) : 0;
302
+ const maxTx = sw > cw ? (sw - cw) : 0;
297
303
  const minTy = sh > ch ? -(sh - ch) : 0;
298
- const nextTx = Math.max(minTx, Math.min(0, safeTx));
299
- const nextTy = Math.max(minTy, Math.min(0, safeTy));
304
+ const maxTy = sh > ch ? (sh - ch) : 0;
305
+ const nextTx = Math.max(minTx, Math.min(maxTx, safeTx));
306
+ const nextTy = Math.max(minTy, Math.min(maxTy, safeTy));
300
307
  return { tx: nextTx, ty: nextTy };
301
308
  }
302
309
 
@@ -477,9 +484,23 @@
477
484
  return { tx: clamped.tx, ty: clamped.ty };
478
485
  }
479
486
 
480
- // v2.4.1: pointer-driven drag handlers. We wire them on the
481
- // stage (not the container) so they don't interfere with
482
- // text selection inside the toolbar.
487
+ // v2.4.2: pointer-driven drag handlers. The pointerdown
488
+ // listener is wired on the CONTAINER (not the stage) because
489
+ // real mermaid SVGs include child elements (mermaid's
490
+ // `bindFunctions` injects click handlers on `<g>` and
491
+ // `<path>`) that may call `event.stopPropagation()`. If the
492
+ // listener lived on the stage, those child handlers would
493
+ // silently eat the pointerdown and the pan would never start
494
+ // — but the CSS-driven `cursor: grab` on the stage would
495
+ // still show, fooling the user into thinking drag was wired
496
+ // up. Listening on the container (a parent of the stage)
497
+ // bypasses any stopPropagation that originates inside the
498
+ // SVG and still lets us reject clicks on the zoom toolbar
499
+ // (the toolbar is a sibling of the stage, not a descendant).
500
+ //
501
+ // pointermove / pointerup / pointercancel still attach to
502
+ // the stage so `setPointerCapture` keeps the events flowing
503
+ // to the stage even when the pointer leaves the SVG bounds.
483
504
  function attachPanHandlers(container) {
484
505
  if (!container || !container.ownerDocument) return;
485
506
  // Already wired — don't double-bind.
@@ -492,9 +513,25 @@
492
513
  // the current pan offsets so subsequent pointermove events
493
514
  // can compute the delta.
494
515
  function onPointerDown(ev) {
495
- // Only respond to the primary pointer button (left click
496
- // or primary touch). Ignore right-click and middle-click.
497
- if (ev && typeof ev.button === "number" && ev.button !== 0) return;
516
+ // Only respond to the primary pointer button. We accept
517
+ // button === 0 (left click / primary touch) AND
518
+ // button === -1 (Wacom / iPad Pencil hover events that
519
+ // report `button: -1` in PointerEvent spec). We reject
520
+ // button > 0 (right / middle / x1 / x2). The previous
521
+ // `!== 0` check rejected -1 and broke touch/pen entirely.
522
+ if (ev && typeof ev.button === "number" && ev.button > 0) return;
523
+ // Ignore secondary pointers: if a drag is already in
524
+ // progress (e.g. multi-touch), a second pointerdown
525
+ // should NOT overwrite the captured drag state. Without
526
+ // this guard the primary drag's startX/startTx are
527
+ // silently lost and the next pointermove from the
528
+ // primary pointer is filtered out by the pointerId
529
+ // mismatch check.
530
+ if (dragState.get(container)) return;
531
+ // The container holds both the stage (pan target) and
532
+ // the zoom toolbar (which we must NOT pan). Only start
533
+ // a pan when the pointerdown target is inside the stage.
534
+ if (ev && ev.target && typeof stage.contains === "function" && !stage.contains(ev.target)) return;
498
535
  // Allow the user to drag using touch / pen too.
499
536
  const pid = ev && typeof ev.pointerId === "number" ? ev.pointerId : 1;
500
537
  const cur = getPan(container);
@@ -509,7 +546,9 @@
509
546
  try {
510
547
  stage.setPointerCapture && stage.setPointerCapture(pid);
511
548
  } catch (_) {
512
- // ignore — setPointerCapture can throw in jsdom
549
+ // ignore — setPointerCapture can throw when the
550
+ // element is detached, has zero size, or the pointer
551
+ // is already captured by another element.
513
552
  }
514
553
  if (ev && typeof ev.preventDefault === "function") {
515
554
  try { ev.preventDefault(); } catch (_) { /* ignore */ }
@@ -544,18 +583,28 @@
544
583
  }
545
584
  }
546
585
 
547
- // pointermove + pointerup + pointercancel all attach to the
548
- // stage itself so the drag survives the pointer leaving
549
- // the stage element. setPointerCapture keeps the events
550
- // routed to the stage even if the cursor drifts off the
551
- // diagram. Attaching to the stage (rather than window)
552
- // also keeps the implementation deterministic in test
553
- // environments where `window` and `doc.defaultView` can
554
- // be distinct objects.
555
- stage.addEventListener("pointerdown", onPointerDown);
556
- stage.addEventListener("pointermove", onPointerMove);
557
- stage.addEventListener("pointerup", endDrag);
558
- stage.addEventListener("pointercancel", endDrag);
586
+ // pointerdown attaches to the CONTAINER in CAPTURE phase
587
+ // so that any stopPropagation inside the SVG (mermaid's
588
+ // bindFunctions, custom `<path>` handlers, etc.) cannot
589
+ // block the pan handler. Capture-phase listeners fire on
590
+ // the way DOWN to the target, BEFORE the target's bubble
591
+ // phase handlers run so even if a `<g>` child calls
592
+ // `e.stopPropagation()`, our container listener has
593
+ // already fired. The `stage.contains(target)` check above
594
+ // still rejects clicks on the toolbar (a sibling of the
595
+ // stage).
596
+ container.addEventListener("pointerdown", onPointerDown, true);
597
+ // v2.4.2: pointermove + pointerup + pointercancel also
598
+ // attach to the CONTAINER in capture phase for the same
599
+ // reason. Real mermaid SVGs may call `stopPropagation` on
600
+ // pointermove (e.g. when binding drag-to-pan on a node),
601
+ // which would otherwise silently drop every move event
602
+ // after the drag started. We still call setPointerCapture
603
+ // on the stage below so the drag survives the pointer
604
+ // leaving the container entirely.
605
+ container.addEventListener("pointermove", onPointerMove, true);
606
+ container.addEventListener("pointerup", endDrag, true);
607
+ container.addEventListener("pointercancel", endDrag, true);
559
608
  // Also attach to document/window as a safety net — some
560
609
  // browsers route pointercancel through the document even
561
610
  // when capture is set. We swallow duplicates in endDrag.
@@ -565,7 +614,7 @@
565
614
  }
566
615
  // We need a way to remove these listeners on destroy();
567
616
  // stash a teardown closure on the container.
568
- container.__panHandlers = { onPointerDown, onPointerMove, endDrag, doc, stage };
617
+ container.__panHandlers = { onPointerDown, onPointerMove, endDrag, doc, stage, container };
569
618
  container.__panHandlersAttached = true;
570
619
  }
571
620
 
@@ -573,10 +622,16 @@
573
622
  if (!container || !container.__panHandlers) return;
574
623
  const h = container.__panHandlers;
575
624
  try {
576
- h.stage && h.stage.removeEventListener && h.stage.removeEventListener("pointerdown", h.onPointerDown);
577
- h.stage && h.stage.removeEventListener && h.stage.removeEventListener("pointermove", h.onPointerMove);
578
- h.stage && h.stage.removeEventListener && h.stage.removeEventListener("pointerup", h.endDrag);
579
- h.stage && h.stage.removeEventListener && h.stage.removeEventListener("pointercancel", h.endDrag);
625
+ // v2.4.2: pointerdown was moved to the container (capture
626
+ // phase) so it can survive stopPropagation in SVG
627
+ // children. pointermove/up/cancel also moved. Mirror
628
+ // those changes here so destroy() actually unbinds.
629
+ if (h.container && h.container.removeEventListener) {
630
+ h.container.removeEventListener("pointerdown", h.onPointerDown, true);
631
+ h.container.removeEventListener("pointermove", h.onPointerMove, true);
632
+ h.container.removeEventListener("pointerup", h.endDrag, true);
633
+ h.container.removeEventListener("pointercancel", h.endDrag, true);
634
+ }
580
635
  if (h.doc && h.doc.removeEventListener) {
581
636
  h.doc.removeEventListener("pointerup", h.endDrag);
582
637
  h.doc.removeEventListener("pointercancel", h.endDrag);
package/public/style.css CHANGED
@@ -1987,3 +1987,144 @@ details summary {
1987
1987
  font-size: 11px;
1988
1988
  color: var(--fg-muted, #6b7280);
1989
1989
  }
1990
+
1991
+ /* ============================================================
1992
+ * v2.5.0+ REQ-2: home-page chat panel
1993
+ * ============================================================ */
1994
+
1995
+ .chat-panel {
1996
+ display: flex;
1997
+ flex-direction: column;
1998
+ gap: 12px;
1999
+ margin-top: 12px;
2000
+ }
2001
+
2002
+ .chat-panel-header h2 {
2003
+ margin: 0 0 4px;
2004
+ font-size: 18px;
2005
+ }
2006
+
2007
+ .chat-panel-meta {
2008
+ margin: 0;
2009
+ font-size: 12px;
2010
+ color: var(--fg-muted, #6b7280);
2011
+ }
2012
+
2013
+ .chat-messages {
2014
+ display: flex;
2015
+ flex-direction: column;
2016
+ gap: 8px;
2017
+ min-height: 80px;
2018
+ max-height: 360px;
2019
+ overflow-y: auto;
2020
+ padding: 12px;
2021
+ border: 1px solid var(--border, #e5e7eb);
2022
+ border-radius: 8px;
2023
+ background: #fff;
2024
+ font-size: 14px;
2025
+ }
2026
+
2027
+ .chat-empty {
2028
+ margin: 0;
2029
+ color: var(--fg-muted, #9ca3af);
2030
+ font-style: italic;
2031
+ }
2032
+
2033
+ .chat-message {
2034
+ padding: 8px 10px;
2035
+ border-radius: 6px;
2036
+ border: 1px solid transparent;
2037
+ }
2038
+
2039
+ .chat-message-meta {
2040
+ font-size: 11px;
2041
+ color: var(--fg-muted, #6b7280);
2042
+ margin-bottom: 2px;
2043
+ }
2044
+
2045
+ .chat-message-text {
2046
+ white-space: pre-wrap;
2047
+ word-break: break-word;
2048
+ line-height: 1.4;
2049
+ }
2050
+
2051
+ .chat-message--user {
2052
+ align-self: flex-end;
2053
+ background: #dbeafe;
2054
+ border-color: #93c5fd;
2055
+ max-width: 80%;
2056
+ }
2057
+
2058
+ .chat-message--assistant {
2059
+ align-self: flex-start;
2060
+ background: #f3f4f6;
2061
+ border-color: #d1d5db;
2062
+ max-width: 80%;
2063
+ }
2064
+
2065
+ .chat-message--error {
2066
+ align-self: stretch;
2067
+ background: #fee2e2;
2068
+ border-color: #fca5a5;
2069
+ color: #991b1b;
2070
+ }
2071
+
2072
+ .chat-form {
2073
+ display: flex;
2074
+ gap: 8px;
2075
+ }
2076
+
2077
+ .chat-input {
2078
+ flex: 1 1 auto;
2079
+ padding: 8px 10px;
2080
+ border: 1px solid var(--border, #d1d5db);
2081
+ border-radius: 6px;
2082
+ font-size: 14px;
2083
+ font-family: inherit;
2084
+ }
2085
+
2086
+ .chat-input:focus {
2087
+ outline: 2px solid #3b82f6;
2088
+ outline-offset: -1px;
2089
+ border-color: #3b82f6;
2090
+ }
2091
+
2092
+ .chat-send {
2093
+ padding: 8px 16px;
2094
+ background: #2563eb;
2095
+ color: #fff;
2096
+ border: 1px solid #1d4ed8;
2097
+ border-radius: 6px;
2098
+ cursor: pointer;
2099
+ font-weight: 500;
2100
+ }
2101
+
2102
+ .chat-send:hover:not(:disabled) {
2103
+ background: #1d4ed8;
2104
+ }
2105
+
2106
+ .chat-send:disabled {
2107
+ opacity: 0.6;
2108
+ cursor: not-allowed;
2109
+ }
2110
+
2111
+ .chat-banner {
2112
+ padding: 8px 12px;
2113
+ margin: 0;
2114
+ background: #fef3c7;
2115
+ border: 1px solid #fcd34d;
2116
+ border-radius: 6px;
2117
+ color: #78350f;
2118
+ font-size: 13px;
2119
+ }
2120
+
2121
+ .visually-hidden {
2122
+ position: absolute;
2123
+ width: 1px;
2124
+ height: 1px;
2125
+ padding: 0;
2126
+ margin: -1px;
2127
+ overflow: hidden;
2128
+ clip: rect(0, 0, 0, 0);
2129
+ border: 0;
2130
+ }