@in-the-loop-labs/pair-review 1.6.2 → 2.0.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.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,2955 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * ChatPanel - AI chat sidebar component
4
+ * Provides a sliding chat panel for conversing with AI about the current review.
5
+ * Works in both PR mode and Local mode.
6
+ */
7
+
8
+ const DISMISS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>`;
9
+
10
+ /** Pixel threshold for considering the user "near the bottom" of the messages container. */
11
+ const NEAR_BOTTOM_THRESHOLD = 80;
12
+
13
+ class ChatPanel {
14
+ constructor(containerId) {
15
+ this.containerId = containerId;
16
+ this.container = document.getElementById(containerId);
17
+ this.currentSessionId = null;
18
+ this.reviewId = null;
19
+ this.isOpen = false;
20
+ this.isStreaming = false;
21
+ this.eventSource = null;
22
+ this._sseReconnectTimer = null;
23
+ this.messages = [];
24
+ this._streamingContent = '';
25
+ this._pendingContext = [];
26
+ this._pendingContextData = [];
27
+ this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
28
+ this._contextItemId = null; // suggestion ID or comment ID from context
29
+ this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
30
+ this._pendingActionContext = null; // { type, itemId } — set by action button handlers, consumed by sendMessage
31
+ this._resizeConfig = { min: 300, default: 400, storageKey: 'chat-panel-width' };
32
+ this._analysisContextRemoved = false;
33
+ this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
+ this._openPromise = null; // concurrency guard for open()
35
+
36
+ this._render();
37
+ this._bindEvents();
38
+ this._initContextTooltip();
39
+ }
40
+
41
+ /**
42
+ * Render the chat panel DOM structure into the container
43
+ */
44
+ _render() {
45
+ if (!this.container) return;
46
+
47
+ this.container.innerHTML = `
48
+ <div id="chat-panel" class="chat-panel chat-panel--closed">
49
+ <div class="chat-panel__resize-handle" title="Drag to resize"></div>
50
+ <div class="chat-panel__header">
51
+ <div class="chat-panel__session-picker">
52
+ <button class="chat-panel__session-picker-btn" title="Switch conversation">
53
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
54
+ <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
55
+ </svg>
56
+ <span class="chat-panel__title-text">Chat &middot; Pi</span>
57
+ <span class="chat-panel__chevron-sep">&middot;</span>
58
+ <svg class="chat-panel__chevron" viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
59
+ <path d="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>
60
+ </svg>
61
+ </button>
62
+ <div class="chat-panel__session-dropdown" style="display: none;"></div>
63
+ </div>
64
+ <div class="chat-panel__actions">
65
+ <button class="chat-panel__new-btn" title="New conversation">
66
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
67
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>
68
+ </svg>
69
+ </button>
70
+ <button class="chat-panel__close-btn" title="Close">
71
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
72
+ <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>
73
+ </svg>
74
+ </button>
75
+ </div>
76
+ </div>
77
+ <div class="chat-panel__messages-wrapper">
78
+ <div class="chat-panel__messages" id="chat-messages">
79
+ <div class="chat-panel__empty">
80
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
81
+ <path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
82
+ </svg>
83
+ <p>Ask questions about this review, or the changes</p>
84
+ </div>
85
+ </div>
86
+ <button class="chat-panel__new-content-pill" style="display:none">\u2193 New content</button>
87
+ </div>
88
+ <div class="chat-panel__action-bar" style="display: none;">
89
+ <button class="chat-panel__action-btn chat-panel__action-btn--adopt" style="display: none;" title="Adopt this suggestion with edits based on the conversation">
90
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
91
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
92
+ </svg>
93
+ Adopt with AI edits
94
+ </button>
95
+ <button class="chat-panel__action-btn chat-panel__action-btn--update" style="display: none;" title="Update the comment based on the conversation">
96
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
97
+ <path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>
98
+ </svg>
99
+ Update comment
100
+ </button>
101
+ <button class="chat-panel__action-btn chat-panel__action-btn--dismiss-suggestion" style="display: none;" title="Dismiss this AI suggestion">
102
+ ${DISMISS_ICON}
103
+ Dismiss suggestion
104
+ </button>
105
+ <button class="chat-panel__action-btn chat-panel__action-btn--dismiss-comment" style="display: none;" title="Dismiss this comment">
106
+ ${DISMISS_ICON}
107
+ Dismiss comment
108
+ </button>
109
+ <button class="chat-panel__action-btn chat-panel__action-btn--create-comment" style="display: none;" title="Create a review comment for this line">
110
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
111
+ <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
112
+ </svg>
113
+ Create comment
114
+ </button>
115
+ <button class="chat-panel__action-bar-dismiss" title="Dismiss shortcuts">
116
+ ${DISMISS_ICON}
117
+ </button>
118
+ </div>
119
+ <div class="chat-panel__input-area">
120
+ <textarea class="chat-panel__input" placeholder="Ask about this review..." rows="1"></textarea>
121
+ <div class="chat-panel__input-footer">
122
+ <span class="chat-panel__input-hint">${typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Enter to send</span>
123
+ <div class="chat-panel__input-actions">
124
+ <button class="chat-panel__send-btn" title="Send" disabled>
125
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
126
+ <path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-4.776L2.296 7.25h5.954a.75.75 0 0 1 0 1.5H2.296l-.704 4.026L13.788 8Z"/>
127
+ </svg>
128
+ </button>
129
+ <button class="chat-panel__stop-btn" title="Stop" style="display: none;">
130
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
131
+ <path d="M4.5 2A2.5 2.5 0 0 0 2 4.5v7A2.5 2.5 0 0 0 4.5 14h7a2.5 2.5 0 0 0 2.5-2.5v-7A2.5 2.5 0 0 0 11.5 2h-7Z"/>
132
+ </svg>
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ `;
139
+
140
+ // Cache element references
141
+ this.panel = this.container.querySelector('#chat-panel');
142
+ this.messagesEl = this.container.querySelector('#chat-messages');
143
+ this.inputEl = this.container.querySelector('.chat-panel__input');
144
+ this.sendBtn = this.container.querySelector('.chat-panel__send-btn');
145
+ this.stopBtn = this.container.querySelector('.chat-panel__stop-btn');
146
+ this.closeBtn = this.container.querySelector('.chat-panel__close-btn');
147
+ this.newBtn = this.container.querySelector('.chat-panel__new-btn');
148
+ this.actionBar = this.container.querySelector('.chat-panel__action-bar');
149
+ this.adoptBtn = this.container.querySelector('.chat-panel__action-btn--adopt');
150
+ this.updateBtn = this.container.querySelector('.chat-panel__action-btn--update');
151
+ this.dismissSuggestionBtn = this.container.querySelector('.chat-panel__action-btn--dismiss-suggestion');
152
+ this.dismissCommentBtn = this.container.querySelector('.chat-panel__action-btn--dismiss-comment');
153
+ this.createCommentBtn = this.container.querySelector('.chat-panel__action-btn--create-comment');
154
+ this.actionBarDismissBtn = this.container.querySelector('.chat-panel__action-bar-dismiss');
155
+ this.sessionPickerEl = this.container.querySelector('.chat-panel__session-picker');
156
+ this.sessionPickerBtn = this.container.querySelector('.chat-panel__session-picker-btn');
157
+ this.sessionDropdown = this.container.querySelector('.chat-panel__session-dropdown');
158
+ this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
159
+ this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
160
+ }
161
+
162
+ /**
163
+ * Bind event listeners
164
+ */
165
+ _bindEvents() {
166
+ if (!this.panel) return;
167
+
168
+ // Close button
169
+ this.closeBtn.addEventListener('click', () => this.close());
170
+
171
+ // New conversation button
172
+ this.newBtn.addEventListener('click', () => this._startNewConversation());
173
+
174
+ // Session picker button
175
+ this.sessionPickerBtn.addEventListener('click', () => this._toggleSessionDropdown());
176
+
177
+ // Send button
178
+ this.sendBtn.addEventListener('click', () => this.sendMessage());
179
+
180
+ // Stop button
181
+ this.stopBtn.addEventListener('click', () => this._stopAgent());
182
+
183
+ // Action buttons
184
+ this.adoptBtn.addEventListener('click', () => this._handleAdoptClick());
185
+ this.updateBtn.addEventListener('click', () => this._handleUpdateClick());
186
+ this.dismissSuggestionBtn.addEventListener('click', () => this._handleDismissSuggestionClick());
187
+ this.dismissCommentBtn.addEventListener('click', () => this._handleDismissCommentClick());
188
+ this.createCommentBtn.addEventListener('click', () => this._handleCreateCommentClick());
189
+ this.actionBarDismissBtn.addEventListener('click', () => this._handleActionBarDismiss());
190
+
191
+ // New-content pill: click to scroll to bottom
192
+ if (this.newContentPill) {
193
+ this.newContentPill.addEventListener('click', () => this.scrollToBottom({ force: true }));
194
+ }
195
+
196
+ // Track scroll direction to disengage/re-engage auto-scroll
197
+ if (this.messagesEl) {
198
+ let lastScrollTop = 0;
199
+ this.messagesEl.addEventListener('scroll', () => {
200
+ const { scrollTop, scrollHeight, clientHeight } = this.messagesEl;
201
+ const distance = scrollHeight - scrollTop - clientHeight;
202
+
203
+ if (scrollTop < lastScrollTop && distance >= NEAR_BOTTOM_THRESHOLD) {
204
+ // Scrolling UP and not near bottom — disengage
205
+ this._userScrolledAway = true;
206
+ this._showNewContentPill();
207
+ } else if (distance < NEAR_BOTTOM_THRESHOLD) {
208
+ // Back near bottom — re-engage
209
+ this._userScrolledAway = false;
210
+ this._hideNewContentPill();
211
+ }
212
+ lastScrollTop = scrollTop;
213
+ }, { passive: true });
214
+ }
215
+
216
+ // Textarea input handling
217
+ this.inputEl.addEventListener('input', () => {
218
+ this._autoResizeTextarea();
219
+ this.sendBtn.disabled = !this.inputEl.value.trim() || this.isStreaming;
220
+ });
221
+
222
+ // Keyboard shortcuts
223
+ this.inputEl.addEventListener('keydown', (e) => {
224
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
225
+ e.preventDefault();
226
+ if (this.inputEl.value.trim() && !this.isStreaming) {
227
+ this.sendMessage();
228
+ }
229
+ }
230
+ });
231
+
232
+ // Escape: close dropdown if open, stop agent if streaming, blur textarea if focused, otherwise close panel
233
+ this._onKeydown = (e) => {
234
+ if (e.key === 'Escape' && this.isOpen) {
235
+ if (this._isSessionDropdownOpen()) {
236
+ this._hideSessionDropdown();
237
+ } else if (this.isStreaming) {
238
+ this._stopAgent();
239
+ } else if (document.activeElement === this.inputEl) {
240
+ this.inputEl.blur();
241
+ } else {
242
+ this.close();
243
+ }
244
+ }
245
+ };
246
+ document.addEventListener('keydown', this._onKeydown);
247
+
248
+ // Chat file link click handler (event delegation)
249
+ this.messagesEl?.addEventListener('click', (e) => {
250
+ const link = e.target.closest('.chat-file-link');
251
+ if (link) {
252
+ e.preventDefault();
253
+ this._handleFileLinkClick(link);
254
+ }
255
+ });
256
+
257
+ this._bindResizeEvents();
258
+ }
259
+
260
+ /**
261
+ * Bind resize drag events on the left edge handle
262
+ */
263
+ _bindResizeEvents() {
264
+ const handle = this.panel.querySelector('.chat-panel__resize-handle');
265
+ if (!handle) return;
266
+
267
+ const { min, storageKey } = this._resizeConfig;
268
+
269
+ let startX = 0;
270
+ let startWidth = 0;
271
+
272
+ const onMouseMove = (e) => {
273
+ // Compute dynamic max: leave room for the sidebar and a minimum content area
274
+ const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10) || 260;
275
+ const dynamicMax = window.innerWidth - sidebarWidth - 100;
276
+
277
+ // Panel is right-anchored, so dragging left (decreasing clientX) should increase width
278
+ const delta = startX - e.clientX;
279
+ const newWidth = Math.max(min, Math.min(dynamicMax, startWidth + delta));
280
+ document.documentElement.style.setProperty('--chat-panel-width', newWidth + 'px');
281
+ };
282
+
283
+ const onMouseUp = () => {
284
+ // Persist the final width
285
+ const finalWidth = this.panel.getBoundingClientRect().width;
286
+ localStorage.setItem(storageKey, Math.round(finalWidth));
287
+
288
+ handle.classList.remove('dragging');
289
+ this.panel.classList.remove('chat-panel--resizing');
290
+ document.body.classList.remove('resizing');
291
+
292
+ document.removeEventListener('mousemove', onMouseMove);
293
+ document.removeEventListener('mouseup', onMouseUp);
294
+
295
+ // Notify PanelGroup so --right-panel-group-width stays in sync
296
+ window.panelGroup?._updateRightPanelGroupWidth();
297
+ };
298
+
299
+ handle.addEventListener('mousedown', (e) => {
300
+ e.preventDefault();
301
+ startX = e.clientX;
302
+ startWidth = this.panel.getBoundingClientRect().width;
303
+
304
+ handle.classList.add('dragging');
305
+ this.panel.classList.add('chat-panel--resizing');
306
+ document.body.classList.add('resizing');
307
+
308
+ document.addEventListener('mousemove', onMouseMove);
309
+ document.addEventListener('mouseup', onMouseUp);
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Auto-resize textarea based on content.
315
+ * Grows with content up to maxHeight, then switches to scrollable overflow.
316
+ * Shrinks back down when content is deleted.
317
+ */
318
+ _autoResizeTextarea() {
319
+ const el = this.inputEl;
320
+ const maxHeight = 120;
321
+
322
+ // Collapse to auto so scrollHeight reflects actual content height
323
+ el.style.height = 'auto';
324
+ el.style.overflowY = 'hidden';
325
+
326
+ const contentHeight = el.scrollHeight;
327
+ if (contentHeight > maxHeight) {
328
+ el.style.height = maxHeight + 'px';
329
+ el.style.overflowY = 'auto';
330
+ } else {
331
+ el.style.height = contentHeight + 'px';
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Disable chat input and send button (e.g. while reviewId is unavailable).
337
+ * Saves the original placeholder so _enableInput() can restore it.
338
+ */
339
+ _disableInput() {
340
+ this._savedPlaceholder = this.inputEl.placeholder;
341
+ this.inputEl.disabled = true;
342
+ this.inputEl.placeholder = 'Connecting to review\u2026';
343
+ this.sendBtn.disabled = true;
344
+ }
345
+
346
+ /**
347
+ * Re-enable chat input and send button after reviewId becomes available.
348
+ * Restores the original placeholder saved by _disableInput().
349
+ */
350
+ _enableInput() {
351
+ this.inputEl.disabled = false;
352
+ this.inputEl.placeholder = this._savedPlaceholder || 'Ask about this review...';
353
+ this._savedPlaceholder = null;
354
+ this.sendBtn.disabled = !this.inputEl.value.trim() || this.isStreaming;
355
+ }
356
+
357
+ /**
358
+ * Update the chat panel title with provider and model info.
359
+ * @param {string} [provider='Pi'] - Provider display name
360
+ * @param {string} [model] - Model ID or display name (e.g. 'default', 'multi-model')
361
+ */
362
+ _updateTitle(provider = 'Pi', model) {
363
+ if (!this.titleTextEl) return;
364
+ const modelDisplay = model
365
+ ? model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
366
+ : null;
367
+ const parts = ['Chat', provider];
368
+ if (modelDisplay) parts.push(modelDisplay);
369
+ this.titleTextEl.textContent = parts.join(' \u00b7 ');
370
+ }
371
+
372
+ /**
373
+ * Open the chat panel
374
+ * @param {Object} options - Optional context
375
+ * @param {number} options.reviewId - Review ID
376
+ * @param {number} options.suggestionId - Suggestion ID to ask about
377
+ * @param {Object} options.suggestionContext - AI suggestion details for context
378
+ * @param {Object} options.commentContext - User comment details for context
379
+ * @param {string} options.commentContext.commentId - Comment ID
380
+ * @param {string} options.commentContext.body - Comment body text
381
+ * @param {string} options.commentContext.file - File path
382
+ * @param {number} options.commentContext.line_start - Start line number
383
+ * @param {number} options.commentContext.line_end - End line number
384
+ * @param {string} options.commentContext.source - 'user' for user comments
385
+ * @param {boolean} options.commentContext.isFileLevel - True if file-level comment
386
+ */
387
+ async open(options = {}) {
388
+ // Concurrency guard: if a previous open() is still loading MRU / messages,
389
+ // wait for it to finish before proceeding. This prevents a race where a
390
+ // second open() call sees `currentSessionId` already set (by the first
391
+ // call's _loadMRUSession midway through) and skips MRU loading, causing
392
+ // _ensureAnalysisContext to run before message history is rendered.
393
+ if (this._openPromise) {
394
+ await this._openPromise;
395
+ }
396
+
397
+ // Wrap the async body in a tracked promise so subsequent callers can wait
398
+ this._openPromise = this._openInner(options);
399
+ try {
400
+ await this._openPromise;
401
+ } finally {
402
+ this._openPromise = null;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Inner implementation of open(), separated so the concurrency guard in
408
+ * open() can track the full async lifecycle.
409
+ * @param {Object} options - Same options as open()
410
+ */
411
+ async _openInner(options) {
412
+ // Resolve reviewId from options or from prManager
413
+ if (options.reviewId) {
414
+ this.reviewId = options.reviewId;
415
+ } else if (window.prManager?.currentPR?.id) {
416
+ this.reviewId = window.prManager.currentPR.id;
417
+ }
418
+
419
+ // Restore persisted width before opening (mirrors AIPanel.expand pattern)
420
+ const { min, default: defaultWidth, storageKey } = this._resizeConfig;
421
+ const savedWidth = localStorage.getItem(storageKey);
422
+ if (savedWidth) {
423
+ const width = parseInt(savedWidth, 10);
424
+ if (width >= min) {
425
+ document.documentElement.style.setProperty('--chat-panel-width', width + 'px');
426
+ } else {
427
+ document.documentElement.style.setProperty('--chat-panel-width', defaultWidth + 'px');
428
+ }
429
+ } else {
430
+ document.documentElement.style.setProperty('--chat-panel-width', defaultWidth + 'px');
431
+ }
432
+
433
+ this.isOpen = true;
434
+ this.panel.classList.remove('chat-panel--closed');
435
+ this.panel.classList.add('chat-panel--open');
436
+
437
+ // Ensure SSE is connected (but don't create a session yet — lazy creation)
438
+ this._ensureGlobalSSE();
439
+
440
+ // Load MRU session with message history (if any previous sessions exist).
441
+ // Skip when opening with explicit context (suggestion/comment/file) — the
442
+ // user wants a *new* conversation about that item, not to resume the last one.
443
+ const hasExplicitContext = !!(options.suggestionContext || options.commentContext || options.fileContext);
444
+ if (!this.currentSessionId && !hasExplicitContext) {
445
+ await this._loadMRUSession();
446
+ }
447
+
448
+ // Ensure analysis context is added on every expand — not just when opening
449
+ // with suggestion/comment context. This detects new analysis runs that
450
+ // completed while the panel was closed and adds them as pending context.
451
+ this._ensureAnalysisContext();
452
+
453
+ // If opening with suggestion context, inject it as a context card
454
+ if (options.suggestionContext) {
455
+ this._sendContextMessage(options.suggestionContext);
456
+ this._contextSource = 'suggestion';
457
+ this._contextItemId = options.suggestionId || null;
458
+ } else if (options.commentContext) {
459
+ // If opening with user comment context, inject it as a context card
460
+ this._sendCommentContextMessage(options.commentContext);
461
+ if (options.commentContext.type === 'line') {
462
+ this._contextSource = 'line';
463
+ this._contextItemId = null;
464
+ this._contextLineMeta = {
465
+ file: options.commentContext.file,
466
+ line_start: options.commentContext.line_start,
467
+ line_end: options.commentContext.line_end,
468
+ };
469
+ } else {
470
+ this._contextSource = 'user';
471
+ this._contextItemId = options.commentContext.commentId || null;
472
+ }
473
+ } else if (options.fileContext) {
474
+ // If opening with file context, inject it as a context card
475
+ this._sendFileContextMessage(options.fileContext);
476
+ this._contextSource = 'file';
477
+ this._contextItemId = null;
478
+ }
479
+
480
+ // Gate input when reviewId is not yet available (PanelGroup auto-restore race)
481
+ if (!this.reviewId) {
482
+ this._disableInput();
483
+ }
484
+
485
+ this._updateActionButtons();
486
+ window.panelGroup?._onChatVisibilityChanged(true);
487
+ if (!options.suppressFocus) {
488
+ this.inputEl.focus();
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Close the chat panel
494
+ */
495
+ close() {
496
+ this._hideSessionDropdown();
497
+ // Reset UI streaming state (buttons) but keep isStreaming and _streamingContent
498
+ // intact so the background SSE handler can continue accumulating events.
499
+ this.sendBtn.style.display = '';
500
+ this.stopBtn.style.display = 'none';
501
+ this.sendBtn.disabled = !this.inputEl?.value?.trim();
502
+
503
+ this.isOpen = false;
504
+ this.panel.classList.remove('chat-panel--open');
505
+ this.panel.classList.add('chat-panel--closed');
506
+ this._pendingContext = [];
507
+ this._pendingContextData = [];
508
+ this._contextSource = null;
509
+ this._contextItemId = null;
510
+ this._contextLineMeta = null;
511
+ // Zero out CSS variable so max-width calcs don't reserve space (mirrors AIPanel.collapse)
512
+ document.documentElement.style.setProperty('--chat-panel-width', '0px');
513
+ // Preserve _analysisContextRemoved and _sessionAnalysisRunId across
514
+ // close/reopen so _ensureAnalysisContext can detect NEW runs on the next
515
+ // expand. These are reset by _startNewConversation() or when a new run
516
+ // is detected in _ensureAnalysisContext().
517
+ window.panelGroup?._onChatVisibilityChanged(false);
518
+ }
519
+
520
+ /**
521
+ * Toggle panel visibility
522
+ */
523
+ toggle() {
524
+ if (this.isOpen) {
525
+ this.close();
526
+ } else {
527
+ this.open();
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Start a new conversation (reset session)
533
+ * Preserves any unsent pending context cards and re-adds them to the new conversation.
534
+ */
535
+ async _startNewConversation() {
536
+ this._hideSessionDropdown();
537
+ // 1. Snapshot pending context before clearing (these are unsent context cards)
538
+ const savedContext = this._pendingContext.slice();
539
+ const savedContextData = this._pendingContextData.slice();
540
+ const savedContextSource = this._contextSource;
541
+ const savedContextItemId = this._contextItemId;
542
+ const savedContextLineMeta = this._contextLineMeta;
543
+
544
+ // 2. Clear everything as normal
545
+ this._finalizeStreaming();
546
+ this.currentSessionId = null;
547
+ this.messages = [];
548
+ this._streamingContent = '';
549
+ this._pendingContext = [];
550
+ this._pendingContextData = [];
551
+ this._contextSource = null;
552
+ this._contextItemId = null;
553
+ this._contextLineMeta = null;
554
+ this._analysisContextRemoved = false;
555
+ this._sessionAnalysisRunId = null;
556
+ this._clearMessages();
557
+ this._updateActionButtons();
558
+ this._updateTitle(); // Reset title for new conversation
559
+ // SSE stays connected — it's multiplexed and will filter by sessionId
560
+
561
+ // 3. Re-add analysis context (appears first, handled separately from pending arrays)
562
+ this._ensureAnalysisContext();
563
+
564
+ // 4. Re-add saved pending context cards (if any were unsent)
565
+ if (savedContext.length > 0) {
566
+ // Remove empty state since we're about to add context cards
567
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
568
+ if (emptyState) emptyState.remove();
569
+
570
+ // Restore context metadata
571
+ this._contextSource = savedContextSource;
572
+ this._contextItemId = savedContextItemId;
573
+ this._contextLineMeta = savedContextLineMeta;
574
+
575
+ for (let i = 0; i < savedContextData.length; i++) {
576
+ const ctxData = savedContextData[i];
577
+ this._pendingContext.push(savedContext[i]);
578
+ this._pendingContextData.push(ctxData);
579
+
580
+ // Render the appropriate card type based on the context data
581
+ if (ctxData.type === 'file') {
582
+ this._addFileContextCard(ctxData, { removable: true });
583
+ } else if (ctxData.type === 'line') {
584
+ this._addLineContextCard(ctxData, { removable: true });
585
+ } else if (ctxData.type === 'comment') {
586
+ this._addCommentContextCard(ctxData, { removable: true });
587
+ } else if (ctxData.type === 'analysis-run') {
588
+ this._addAnalysisRunContextCard(ctxData, { removable: true });
589
+ } else {
590
+ this._addContextCard(ctxData, { removable: true });
591
+ }
592
+ }
593
+
594
+ this._updateActionButtons();
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Fetch sessions for the current review.
600
+ * Extracted from _loadMRUSession for reuse by the session picker dropdown.
601
+ * @returns {Promise<Array>} Array of session objects with message_count and first_message
602
+ */
603
+ async _fetchSessions() {
604
+ if (!this.reviewId) return [];
605
+ try {
606
+ const response = await fetch(`/api/review/${this.reviewId}/chat/sessions`);
607
+ if (!response.ok) return [];
608
+ const result = await response.json();
609
+ return result.data?.sessions || [];
610
+ } catch (err) {
611
+ console.warn('[ChatPanel] Failed to fetch sessions:', err);
612
+ return [];
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Load the most recently used session for the current review.
618
+ * Picks the first session (MRU) and loads its message history.
619
+ */
620
+ async _loadMRUSession() {
621
+ if (!this.reviewId) return;
622
+
623
+ try {
624
+ const sessions = await this._fetchSessions();
625
+ if (sessions.length === 0) return;
626
+
627
+ const mru = sessions[0];
628
+ this.currentSessionId = mru.id;
629
+ console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
630
+
631
+ if (mru.provider) {
632
+ const providerName = mru.provider.charAt(0).toUpperCase() + mru.provider.slice(1);
633
+ this._updateTitle(providerName, mru.model);
634
+ }
635
+
636
+ if (mru.message_count > 0) {
637
+ await this._loadMessageHistory(mru.id);
638
+ }
639
+ } catch (err) {
640
+ console.warn('[ChatPanel] Failed to load MRU session:', err);
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Load and render message history for a session.
646
+ * Fetches messages from the API and renders context cards and message bubbles.
647
+ * @param {number} sessionId
648
+ */
649
+ async _loadMessageHistory(sessionId) {
650
+ try {
651
+ const response = await fetch(`/api/chat/session/${sessionId}/messages`);
652
+ if (!response.ok) return;
653
+
654
+ const result = await response.json();
655
+ const messages = result.data?.messages || [];
656
+ if (messages.length === 0) return;
657
+
658
+ // Remove empty state
659
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
660
+ if (emptyState) emptyState.remove();
661
+
662
+ for (const msg of messages) {
663
+ if (msg.type === 'context') {
664
+ // Render context card from stored context data
665
+ try {
666
+ const ctxData = JSON.parse(msg.content);
667
+ if (ctxData.type === 'analysis') {
668
+ this._addAnalysisContextCard(ctxData);
669
+ } else if (ctxData.type === 'file') {
670
+ this._addFileContextCard(ctxData);
671
+ } else if (ctxData.type === 'line') {
672
+ this._addLineContextCard(ctxData);
673
+ } else if (ctxData.type === 'comment') {
674
+ this._addCommentContextCard(ctxData);
675
+ } else {
676
+ this._addContextCard(ctxData);
677
+ }
678
+ } catch {
679
+ // Not JSON — skip malformed context
680
+ }
681
+ } else if (msg.type === 'message') {
682
+ this.addMessage(msg.role, msg.content, msg.id);
683
+ }
684
+ }
685
+ } catch (err) {
686
+ console.warn('[ChatPanel] Failed to load message history:', err);
687
+ }
688
+ }
689
+
690
+ // ── Session picker dropdown ────────────────────────────────────────────
691
+
692
+ _isSessionDropdownOpen() {
693
+ return this.sessionDropdown && this.sessionDropdown.style.display !== 'none';
694
+ }
695
+
696
+ _toggleSessionDropdown() {
697
+ if (this._isSessionDropdownOpen()) {
698
+ this._hideSessionDropdown();
699
+ } else {
700
+ this._showSessionDropdown();
701
+ }
702
+ }
703
+
704
+ async _showSessionDropdown() {
705
+ if (!this.sessionDropdown) return;
706
+
707
+ const sessions = await this._fetchSessions();
708
+ this._renderSessionDropdown(sessions);
709
+ this.sessionDropdown.style.display = '';
710
+ this.sessionPickerBtn.classList.add('chat-panel__session-picker-btn--open');
711
+
712
+ // Bind outside-click-to-close (one-shot)
713
+ this._outsideClickHandler = (e) => {
714
+ if (!this.sessionPickerEl.contains(e.target)) {
715
+ this._hideSessionDropdown();
716
+ }
717
+ };
718
+ // Use setTimeout so the current click event doesn't immediately trigger close
719
+ setTimeout(() => {
720
+ document.addEventListener('click', this._outsideClickHandler);
721
+ }, 0);
722
+ }
723
+
724
+ _hideSessionDropdown() {
725
+ if (!this.sessionDropdown) return;
726
+ this.sessionDropdown.style.display = 'none';
727
+ this.sessionPickerBtn.classList.remove('chat-panel__session-picker-btn--open');
728
+ if (this._outsideClickHandler) {
729
+ document.removeEventListener('click', this._outsideClickHandler);
730
+ this._outsideClickHandler = null;
731
+ }
732
+ }
733
+
734
+ _renderSessionDropdown(sessions) {
735
+ if (!this.sessionDropdown) return;
736
+
737
+ if (sessions.length === 0) {
738
+ this.sessionDropdown.innerHTML = `
739
+ <div class="chat-panel__session-empty">No conversations yet</div>
740
+ `;
741
+ return;
742
+ }
743
+
744
+ const items = sessions.map(s => {
745
+ const isActive = s.id === this.currentSessionId;
746
+ const preview = s.first_message
747
+ ? this._truncate(s.first_message, 60)
748
+ : 'New conversation';
749
+ const timeAgo = this._formatRelativeTime(s.updated_at);
750
+
751
+ return `
752
+ <button class="chat-panel__session-item${isActive ? ' chat-panel__session-item--active' : ''}"
753
+ data-session-id="${s.id}">
754
+ <span class="chat-panel__session-preview">${this._escapeHtml(preview)}</span>
755
+ <span class="chat-panel__session-meta">${this._escapeHtml(timeAgo)}</span>
756
+ </button>
757
+ `;
758
+ }).join('');
759
+
760
+ this.sessionDropdown.innerHTML = items;
761
+
762
+ // Bind click handlers on each item
763
+ this.sessionDropdown.querySelectorAll('.chat-panel__session-item').forEach(btn => {
764
+ btn.addEventListener('click', () => {
765
+ const sessionId = parseInt(btn.dataset.sessionId, 10);
766
+ const sessionData = sessions.find(s => s.id === sessionId);
767
+ if (sessionData) {
768
+ this._switchToSession(sessionId, sessionData);
769
+ }
770
+ this._hideSessionDropdown();
771
+ });
772
+ });
773
+ }
774
+
775
+ /**
776
+ * Switch to a different chat session.
777
+ * Tears down current state and loads the target session.
778
+ * @param {number} sessionId - The session ID to switch to
779
+ * @param {Object} sessionData - Session metadata (provider, model, message_count, etc.)
780
+ */
781
+ async _switchToSession(sessionId, sessionData) {
782
+ if (sessionId === this.currentSessionId) return;
783
+
784
+ // 1. Finalize any active stream
785
+ this._finalizeStreaming();
786
+
787
+ // 2. Reset state
788
+ this.currentSessionId = sessionId;
789
+ this.messages = [];
790
+ this._streamingContent = '';
791
+ this._pendingContext = [];
792
+ this._pendingContextData = [];
793
+ this._contextSource = null;
794
+ this._contextItemId = null;
795
+ this._contextLineMeta = null;
796
+ this._pendingActionContext = null;
797
+ this._analysisContextRemoved = false;
798
+ this._sessionAnalysisRunId = null;
799
+
800
+ // 3. Clear UI
801
+ this._clearMessages();
802
+ this._updateActionButtons();
803
+
804
+ // 4. Update title
805
+ if (sessionData.provider) {
806
+ const providerName = sessionData.provider.charAt(0).toUpperCase() + sessionData.provider.slice(1);
807
+ this._updateTitle(providerName, sessionData.model);
808
+ } else {
809
+ this._updateTitle();
810
+ }
811
+
812
+ // 5. Load message history
813
+ if (sessionData.message_count > 0) {
814
+ await this._loadMessageHistory(sessionId);
815
+ }
816
+
817
+ // 6. Ensure analysis context for the new session
818
+ this._ensureAnalysisContext();
819
+ }
820
+
821
+ /**
822
+ * Format a timestamp as relative time (same logic as AnalysisHistoryManager.formatRelativeTime).
823
+ * @param {string} timestamp - ISO or SQLite timestamp
824
+ * @returns {string} Relative time string
825
+ */
826
+ _formatRelativeTime(timestamp) {
827
+ if (!timestamp) return 'Unknown';
828
+
829
+ const now = new Date();
830
+ const date = window.parseTimestamp ? window.parseTimestamp(timestamp) : new Date(timestamp);
831
+ const diffMs = now - date;
832
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
833
+ const diffDays = Math.floor(diffHours / 24);
834
+
835
+ if (diffHours < 1) {
836
+ return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
837
+ } else if (diffHours < 24) {
838
+ return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
839
+ } else if (diffDays < 7) {
840
+ return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
841
+ } else {
842
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Truncate text to maxLen characters with ellipsis.
848
+ * @param {string} text
849
+ * @param {number} maxLen
850
+ * @returns {string}
851
+ */
852
+ _truncate(text, maxLen) {
853
+ if (!text || text.length <= maxLen) return text || '';
854
+ return text.substring(0, maxLen) + '\u2026';
855
+ }
856
+
857
+ /**
858
+ * Clear all messages from the display and show empty state
859
+ */
860
+ _clearMessages() {
861
+ this.messagesEl.innerHTML = `
862
+ <div class="chat-panel__empty">
863
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
864
+ <path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
865
+ </svg>
866
+ <p>Ask questions about this review, or click "Ask about this" on any suggestion.</p>
867
+ </div>
868
+ `;
869
+ }
870
+
871
+ /**
872
+ * Ensure the global SSE connection is active.
873
+ * No longer creates sessions — that happens lazily on first message.
874
+ * @returns {{sessionData: null}}
875
+ */
876
+ _ensureConnected() {
877
+ this._ensureGlobalSSE();
878
+ return { sessionData: null };
879
+ }
880
+
881
+ /**
882
+ * Late-bind a reviewId after the panel has already been opened.
883
+ * Called by PRManager._initReviewEventListeners() (or equivalent in local.js)
884
+ * to handle the race condition where PanelGroup auto-restores an open chat
885
+ * panel during DOMContentLoaded, before prManager has loaded the review.
886
+ * If the panel is open and has no reviewId yet, this sets it and loads
887
+ * the MRU session so the user sees their previous conversation.
888
+ * @param {number} reviewId - The review ID from prManager
889
+ */
890
+ async _lateBindReview(reviewId) {
891
+ if (!reviewId) return;
892
+ if (this.reviewId) return; // already bound
893
+ this.reviewId = reviewId;
894
+ console.debug('[ChatPanel] Late-bound reviewId:', reviewId);
895
+
896
+ // Re-enable input now that reviewId is available
897
+ if (this.inputEl.disabled) {
898
+ this._enableInput();
899
+ }
900
+
901
+ // If the panel is already open, load the MRU session now
902
+ if (this.isOpen && !this.currentSessionId) {
903
+ await this._loadMRUSession();
904
+ this._ensureAnalysisContext();
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Create a new chat session via API
910
+ * @param {number} contextCommentId - Optional AI suggestion ID for context
911
+ * @returns {Object|null} Session data ({ id, status, context? }) or null on failure
912
+ */
913
+ async createSession(contextCommentId) {
914
+ if (!this.reviewId) {
915
+ console.warn('[ChatPanel] No reviewId available');
916
+ return null;
917
+ }
918
+
919
+ try {
920
+ const body = {
921
+ provider: 'pi',
922
+ reviewId: this.reviewId
923
+ };
924
+ if (contextCommentId) {
925
+ body.contextCommentId = contextCommentId;
926
+ }
927
+ if (this._analysisContextRemoved) {
928
+ body.skipAnalysisContext = true;
929
+ }
930
+
931
+ console.debug('[ChatPanel] Creating session for review', this.reviewId);
932
+ const response = await fetch('/api/chat/session', {
933
+ method: 'POST',
934
+ headers: { 'Content-Type': 'application/json' },
935
+ body: JSON.stringify(body)
936
+ });
937
+
938
+ if (!response.ok) {
939
+ const err = await response.json().catch(() => ({}));
940
+ throw new Error(err.error || 'Failed to create chat session');
941
+ }
942
+
943
+ const result = await response.json();
944
+ this.currentSessionId = result.data.id;
945
+ console.debug('[ChatPanel] Session created:', this.currentSessionId);
946
+ return result.data;
947
+ } catch (error) {
948
+ console.error('[ChatPanel] Error creating session:', error);
949
+ this._showError('Failed to start chat session. ' + error.message);
950
+ return null;
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Send the current input text as a message
956
+ */
957
+ async sendMessage() {
958
+ const content = this.inputEl.value.trim();
959
+ if (!content || this.isStreaming) return;
960
+
961
+ // Save message text before clearing (for error recovery)
962
+ const messageText = content;
963
+
964
+ // Clear input
965
+ this.inputEl.value = '';
966
+ this._autoResizeTextarea();
967
+ this.sendBtn.disabled = true;
968
+
969
+ // Remove empty state if present
970
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
971
+ if (emptyState) emptyState.remove();
972
+
973
+ // Display user message (just the user's actual text)
974
+ const msgElRef = this.addMessage('user', content);
975
+
976
+ // Lazy session creation: create on first message, not on panel open
977
+ if (!this.currentSessionId) {
978
+ this._ensureGlobalSSE();
979
+ const sessionData = await this.createSession();
980
+ if (!sessionData) {
981
+ // Restore the user's message text into the input
982
+ this.inputEl.value = messageText;
983
+ this._autoResizeTextarea();
984
+ this.sendBtn.disabled = false;
985
+ // Remove the phantom message bubble
986
+ if (msgElRef) msgElRef.remove();
987
+ this.messages.pop();
988
+ // Show error
989
+ this._showError('Unable to start chat session. Please try again.');
990
+ return;
991
+ }
992
+ this._showAnalysisContextIfPresent(sessionData);
993
+ }
994
+ // If currentSessionId is set (from MRU), just send — server auto-resumes
995
+
996
+ // Prepare streaming UI
997
+ this.isStreaming = true;
998
+ this.sendBtn.disabled = true;
999
+ this.sendBtn.style.display = 'none';
1000
+ this.stopBtn.style.display = '';
1001
+ this._updateActionButtons();
1002
+ this._streamingContent = '';
1003
+ this._addStreamingPlaceholder();
1004
+
1005
+ // Build the API payload — may include pending context from "Ask about this"
1006
+ const payload = { content };
1007
+ const savedContext = this._pendingContext;
1008
+ const savedContextData = this._pendingContextData;
1009
+ if (this._pendingContext.length > 0) {
1010
+ payload.context = this._pendingContext.join('\n\n');
1011
+ payload.contextData = this._pendingContextData;
1012
+ this._pendingContext = [];
1013
+ this._pendingContextData = [];
1014
+
1015
+ // Lock context cards — remove close buttons and index attributes
1016
+ const removableCards = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
1017
+ removableCards.forEach((card) => {
1018
+ const btn = card.querySelector('.chat-panel__context-remove');
1019
+ if (btn) btn.remove();
1020
+ delete card.dataset.contextIndex;
1021
+ });
1022
+ }
1023
+
1024
+ // Lock analysis context card (not indexed, handled separately from pending context)
1025
+ const analysisRemoveBtn = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis] .chat-panel__context-remove');
1026
+ if (analysisRemoveBtn) analysisRemoveBtn.remove();
1027
+
1028
+ // Attach action context (set by action button handlers — adopt, update, dismiss)
1029
+ if (this._pendingActionContext) {
1030
+ payload.actionContext = this._pendingActionContext;
1031
+ this._pendingActionContext = null;
1032
+ }
1033
+
1034
+ // Send to API
1035
+ try {
1036
+ console.debug('[ChatPanel] Sending message to session', this.currentSessionId);
1037
+ let response = await fetch(`/api/chat/session/${this.currentSessionId}/message`, {
1038
+ method: 'POST',
1039
+ headers: { 'Content-Type': 'application/json' },
1040
+ body: JSON.stringify(payload)
1041
+ });
1042
+
1043
+ // Handle 410 Gone: session is not resumable — transparently create a new one and retry once
1044
+ if (response.status === 410) {
1045
+ console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
1046
+ this.currentSessionId = null;
1047
+ this._ensureGlobalSSE();
1048
+ const sessionData = await this.createSession();
1049
+ if (!sessionData) {
1050
+ throw new Error('Failed to create replacement session');
1051
+ }
1052
+
1053
+ response = await fetch(`/api/chat/session/${this.currentSessionId}/message`, {
1054
+ method: 'POST',
1055
+ headers: { 'Content-Type': 'application/json' },
1056
+ body: JSON.stringify(payload)
1057
+ });
1058
+ }
1059
+
1060
+ if (!response.ok) {
1061
+ const err = await response.json().catch(() => ({}));
1062
+ throw new Error(err.error || 'Failed to send message');
1063
+ }
1064
+ console.debug('[ChatPanel] Message accepted, waiting for SSE events');
1065
+ } catch (error) {
1066
+ // Restore pending context so it's not lost
1067
+ this._pendingContext = savedContext;
1068
+ this._pendingContextData = savedContextData;
1069
+ // Restore removability on context cards that were locked before the failed send
1070
+ this._restoreRemovableCards();
1071
+ console.error('[ChatPanel] Error sending message:', error);
1072
+ this._showError('Failed to send message. ' + error.message);
1073
+ this._finalizeStreaming();
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Store pending context and render a compact context card in the UI.
1079
+ * Called when the user clicks "Ask about this" on a suggestion.
1080
+ * The context is NOT sent to the agent immediately — it is prepended
1081
+ * to the next user message so the agent receives question + context together.
1082
+ * @param {Object} ctx - Suggestion context {title, type, file, line_start, line_end, body}
1083
+ */
1084
+ _sendContextMessage(ctx) {
1085
+ // Remove empty state if present
1086
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
1087
+ if (emptyState) emptyState.remove();
1088
+
1089
+ // Store structured context data for DB persistence (session resumption)
1090
+ const contextData = {
1091
+ type: ctx.type || 'general',
1092
+ title: ctx.title || 'Untitled',
1093
+ file: ctx.file || null,
1094
+ line_start: ctx.line_start || null,
1095
+ line_end: ctx.line_end || null,
1096
+ side: ctx.side || null,
1097
+ body: ctx.body || null
1098
+ };
1099
+ this._pendingContextData.push(contextData);
1100
+
1101
+ // Build the plain text context for the agent (will be prepended to next message)
1102
+ const lines = ['The user wants to discuss this specific suggestion:'];
1103
+ lines.push(`- Type: ${contextData.type}`);
1104
+ lines.push(`- Title: ${contextData.title}`);
1105
+ if (contextData.file) {
1106
+ let fileLine = `- File: ${contextData.file}`;
1107
+ if (contextData.line_start) {
1108
+ fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
1109
+ }
1110
+ lines.push(fileLine);
1111
+ }
1112
+ if (contextData.body) {
1113
+ lines.push(`- Details: ${contextData.body}`);
1114
+ }
1115
+
1116
+ // Enrich with diff hunk if available
1117
+ const patch = window.prManager?.filePatches?.get(contextData.file);
1118
+ if (patch && window.DiffContext) {
1119
+ if (contextData.line_start) {
1120
+ const hunk = window.DiffContext.extractHunkForLines(
1121
+ patch, contextData.line_start, contextData.line_end || contextData.line_start, contextData.side
1122
+ );
1123
+ if (hunk) {
1124
+ lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
1125
+ }
1126
+ } else {
1127
+ const ranges = window.DiffContext.extractHunkRangesForFile(patch);
1128
+ if (ranges.length) {
1129
+ lines.push(`- Diff hunk ranges: ${JSON.stringify(ranges)}`);
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ this._pendingContext.push(lines.join('\n'));
1135
+
1136
+ // Render the compact context card in the UI
1137
+ this._addContextCard(ctx, { removable: true });
1138
+ }
1139
+
1140
+ /**
1141
+ * Store pending context and render a compact context card for a user comment or line reference.
1142
+ * Called when the user clicks "Ask about this" on a user comment, or clicks
1143
+ * the gutter chat button (line reference with no comment body).
1144
+ * The context is NOT sent to the agent immediately -- it is prepended
1145
+ * to the next user message so the agent receives question + context together.
1146
+ * @param {Object} ctx - Comment context {commentId, type, body, file, line_start, line_end, source, isFileLevel}
1147
+ */
1148
+ _sendCommentContextMessage(ctx) {
1149
+ // Remove empty state if present
1150
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
1151
+ if (emptyState) emptyState.remove();
1152
+
1153
+ const isLine = ctx.type === 'line';
1154
+
1155
+ // Store structured context data for DB persistence
1156
+ const lineLabel = !ctx.line_start
1157
+ ? (ctx.file || 'File').split('/').pop()
1158
+ : (ctx.line_end && ctx.line_end !== ctx.line_start ? `Lines ${ctx.line_start}-${ctx.line_end}` : `Line ${ctx.line_start}`);
1159
+ const contextData = {
1160
+ type: isLine ? 'line' : 'comment',
1161
+ title: isLine
1162
+ ? lineLabel
1163
+ : (ctx.isFileLevel ? 'File comment' : `Comment on line ${ctx.line_start || '?'}`),
1164
+ file: ctx.file || null,
1165
+ line_start: ctx.line_start || null,
1166
+ line_end: ctx.line_end || null,
1167
+ body: ctx.body || null,
1168
+ source: 'user'
1169
+ };
1170
+ this._pendingContextData.push(contextData);
1171
+
1172
+ // Build the plain text context for the agent
1173
+ const lines = isLine
1174
+ ? [ctx.line_start
1175
+ ? `The user wants to discuss code at ${lineLabel} in ${contextData.file || 'unknown file'}:`
1176
+ : `The user wants to discuss the file ${contextData.file || 'unknown file'}:`]
1177
+ : ['The user wants to discuss a review comment:'];
1178
+ if (contextData.file) {
1179
+ let fileLine = `- File: ${contextData.file}`;
1180
+ if (contextData.line_start) {
1181
+ fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
1182
+ }
1183
+ lines.push(fileLine);
1184
+ }
1185
+ if (ctx.isFileLevel) {
1186
+ lines.push('- Scope: File-level comment');
1187
+ }
1188
+ if (ctx.parentId) {
1189
+ lines.push('- Origin: adopted from AI suggestion');
1190
+ }
1191
+ if (contextData.body) {
1192
+ lines.push(`- Comment: ${contextData.body}`);
1193
+ }
1194
+
1195
+ // Enrich with diff hunk if available
1196
+ const patch = window.prManager?.filePatches?.get(contextData.file);
1197
+ if (patch && window.DiffContext) {
1198
+ if (contextData.line_start && !ctx.isFileLevel) {
1199
+ const hunk = window.DiffContext.extractHunkForLines(
1200
+ patch, contextData.line_start, contextData.line_end || contextData.line_start
1201
+ );
1202
+ if (hunk) {
1203
+ lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
1204
+ }
1205
+ } else {
1206
+ const ranges = window.DiffContext.extractHunkRangesForFile(patch);
1207
+ if (ranges.length) {
1208
+ lines.push(`- Diff hunk ranges: ${JSON.stringify(ranges)}`);
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ this._pendingContext.push(lines.join('\n'));
1214
+
1215
+ // Render the compact context card in the UI
1216
+ if (isLine) {
1217
+ this._addLineContextCard(ctx, { removable: true });
1218
+ } else {
1219
+ this._addCommentContextCard(ctx, { removable: true });
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Send a file context message to the chat panel.
1225
+ * Called when the user clicks "Chat about file" on a file header.
1226
+ * @param {Object} fileContext - File context data
1227
+ * @param {string} fileContext.file - File path
1228
+ */
1229
+ _sendFileContextMessage(fileContext) {
1230
+ let contextText = `The user wants to discuss ${fileContext.file}`;
1231
+
1232
+ // Check for duplicate context (use startsWith because contextText may
1233
+ // get enriched with diff hunk ranges after this check)
1234
+ const isDuplicate = this._pendingContext.some(c => c === contextText || c.startsWith(contextText)) ||
1235
+ this.messages.some(m => m.role === 'context' && (m.content === contextText || m.content.startsWith(contextText)));
1236
+ if (isDuplicate) return;
1237
+
1238
+ // Remove empty state if present
1239
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
1240
+ if (emptyState) emptyState.remove();
1241
+
1242
+ // Store structured context data for DB persistence
1243
+ const contextData = {
1244
+ type: 'file',
1245
+ title: fileContext.file,
1246
+ file: fileContext.file,
1247
+ line_start: null,
1248
+ line_end: null,
1249
+ body: null
1250
+ };
1251
+ this._pendingContextData.push(contextData);
1252
+
1253
+ // Enrich with diff hunk ranges if available
1254
+ const patch = window.prManager?.filePatches?.get(fileContext.file);
1255
+ if (patch && window.DiffContext) {
1256
+ const ranges = window.DiffContext.extractHunkRangesForFile(patch);
1257
+ if (ranges.length) {
1258
+ contextText += `\n- Diff hunk ranges: ${JSON.stringify(ranges)}`;
1259
+ }
1260
+ }
1261
+
1262
+ this._pendingContext.push(contextText);
1263
+
1264
+ // Render the compact context card in the UI
1265
+ this._addFileContextCard(contextData, { removable: true });
1266
+ }
1267
+
1268
+ /**
1269
+ * Add an analysis run as context for the chat conversation.
1270
+ * Fetches run metadata from the backend and creates a removable context card
1271
+ * that participates in the pending context arrays (data-context-index path).
1272
+ * Unlike the auto-added analysis card (data-analysis="true"), this is a
1273
+ * manually-added card that goes through the standard pending context flow.
1274
+ * @param {string} runId - The analysis run ID to add as context
1275
+ */
1276
+ async addAnalysisRunContext(runId) {
1277
+ // 1. Check for duplicate - look for any card with this run ID (both auto-added and manually-added)
1278
+ const existingCard = this.messagesEl?.querySelector(`[data-analysis-run-id="${runId}"]`);
1279
+ if (existingCard) {
1280
+ this._showToast('Analysis run already added');
1281
+ return;
1282
+ }
1283
+
1284
+ // 2. Open panel if closed
1285
+ await this.open({ suppressFocus: true });
1286
+
1287
+ // Re-check: open() may have auto-added a card for this run via _ensureAnalysisContext
1288
+ const existingCardPostOpen = this.messagesEl?.querySelector(
1289
+ `[data-analysis-run-id="${runId}"]`
1290
+ );
1291
+ if (existingCardPostOpen) {
1292
+ this._showToast('Analysis run already added');
1293
+ return;
1294
+ }
1295
+
1296
+ // 3. Fetch context from backend
1297
+ const response = await fetch(`/api/chat/analysis-context/${runId}?reviewId=${this.reviewId}`);
1298
+ if (!response.ok) {
1299
+ console.error('[ChatPanel] Failed to fetch analysis context:', response.statusText);
1300
+ return;
1301
+ }
1302
+ const result = await response.json();
1303
+ const data = result.data;
1304
+
1305
+ // 4. Push to pending context arrays
1306
+ this._pendingContext.push(data.text);
1307
+ const contextData = {
1308
+ type: 'analysis-run',
1309
+ aiRunId: runId,
1310
+ provider: data.run.provider,
1311
+ model: data.run.model,
1312
+ summary: data.run.summary,
1313
+ suggestionCount: data.suggestionCount,
1314
+ configType: data.run.configType,
1315
+ completedAt: data.run.completedAt
1316
+ };
1317
+ this._pendingContextData.push(contextData);
1318
+
1319
+ // 5. Remove empty state if present
1320
+ const emptyState = this.messagesEl?.querySelector('.chat-panel__empty');
1321
+ if (emptyState) emptyState.remove();
1322
+
1323
+ // 6. Create the card and append
1324
+ this._addAnalysisRunContextCard(contextData, { removable: true });
1325
+
1326
+ // 7. Focus input
1327
+ if (this.inputEl) this.inputEl.focus();
1328
+ }
1329
+
1330
+ /**
1331
+ * Make a context card removable by adding a data-context-index and a remove button.
1332
+ * Shared helper used by _addContextCard, _addCommentContextCard, and _addFileContextCard.
1333
+ * @param {HTMLElement} card - The context card element
1334
+ */
1335
+ _makeCardRemovable(card) {
1336
+ const idx = this._pendingContextData.length - 1;
1337
+ card.dataset.contextIndex = idx;
1338
+ const removeBtn = document.createElement('button');
1339
+ removeBtn.className = 'chat-panel__context-remove';
1340
+ removeBtn.title = 'Remove context';
1341
+ removeBtn.innerHTML = '\u00d7';
1342
+ removeBtn.addEventListener('click', (e) => {
1343
+ e.stopPropagation();
1344
+ this._removeContextCard(card);
1345
+ });
1346
+ card.appendChild(removeBtn);
1347
+ }
1348
+
1349
+ /**
1350
+ * Add a compact file context card to the messages area.
1351
+ * @param {Object} ctx - File context data { file, title }
1352
+ * @param {Object} [options] - Options
1353
+ * @param {boolean} [options.removable=false] - Whether the card should have a remove button
1354
+ */
1355
+ _addFileContextCard(ctx, { removable = false } = {}) {
1356
+ const card = document.createElement('div');
1357
+ card.className = 'chat-panel__context-card';
1358
+
1359
+ const filePath = ctx.file || ctx.title || '';
1360
+
1361
+ card.innerHTML = `
1362
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
1363
+ <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/>
1364
+ </svg>
1365
+ <span class="chat-panel__context-label"><strong>FILE</strong></span>
1366
+ <span class="chat-panel__context-title">${this._escapeHtml(filePath)}</span>
1367
+ `;
1368
+
1369
+ if (removable) this._makeCardRemovable(card);
1370
+
1371
+ this.messagesEl.appendChild(card);
1372
+ requestAnimationFrame(() => this.scrollToBottom({ force: true }));
1373
+ }
1374
+
1375
+ /**
1376
+ * Ensure the latest AI analysis context is added as the first context item.
1377
+ * Called on every panel expand (not just when opening with specific context).
1378
+ * Detects new analysis runs by comparing the latest completed run ID
1379
+ * against the one already loaded in the session. Only adds if suggestions exist.
1380
+ */
1381
+ _ensureAnalysisContext() {
1382
+ // Determine the latest completed run ID from the analysis history manager or prManager
1383
+ const currentRunId = this._getLatestCompletedRunId();
1384
+
1385
+ // Detect whether a NEW analysis run has appeared since we last loaded context.
1386
+ // If the run ID changed, we need to replace the old card with a new one.
1387
+ // This handles the case where _sessionAnalysisRunId was explicitly set.
1388
+ const isNewRunVsSession = currentRunId && this._sessionAnalysisRunId &&
1389
+ String(currentRunId) !== String(this._sessionAnalysisRunId);
1390
+
1391
+ if (isNewRunVsSession) {
1392
+ console.debug('[ChatPanel] _ensureAnalysisContext: new run detected:', currentRunId, '(was:', this._sessionAnalysisRunId + ')');
1393
+ // Remove the old analysis card from the DOM (if present)
1394
+ const oldCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
1395
+ if (oldCard) oldCard.remove();
1396
+ // Reset flags — the user removed the OLD run's context, but this is a different run
1397
+ this._analysisContextRemoved = false;
1398
+ this._sessionAnalysisRunId = null;
1399
+ }
1400
+
1401
+ // Check for an existing card in the DOM (e.g., loaded from MRU session history).
1402
+ // If _sessionAnalysisRunId is not set, this card may be stale — compare its
1403
+ // stamped run ID against the latest completed run to detect new analyses that
1404
+ // completed while the panel was closed.
1405
+ const existingCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
1406
+ if (existingCard) {
1407
+ if (!this._sessionAnalysisRunId && currentRunId) {
1408
+ const cardRunId = existingCard.dataset.analysisRunId || null;
1409
+ if (cardRunId && String(cardRunId) === String(currentRunId)) {
1410
+ // Card matches the latest run — adopt its run ID so future opens can detect changes
1411
+ console.debug('[ChatPanel] _ensureAnalysisContext: adopting existing card runId:', cardRunId);
1412
+ this._sessionAnalysisRunId = String(currentRunId);
1413
+ return;
1414
+ }
1415
+ // Card has no run ID stamp or a different run ID — it's stale.
1416
+ // Remove it so a fresh card for the current run is added below.
1417
+ console.debug('[ChatPanel] _ensureAnalysisContext: replacing stale DOM card (card:', cardRunId, 'latest:', currentRunId + ')');
1418
+ existingCard.remove();
1419
+ this._analysisContextRemoved = false;
1420
+ } else {
1421
+ console.debug('[ChatPanel] _ensureAnalysisContext: skipped — card already in DOM');
1422
+ return;
1423
+ }
1424
+ }
1425
+
1426
+ // Skip if the current session already has analysis context loaded (by run ID)
1427
+ // and no new run was detected (handled above)
1428
+ if (this._sessionAnalysisRunId) {
1429
+ console.debug('[ChatPanel] _ensureAnalysisContext: skipped — runId already set:', this._sessionAnalysisRunId);
1430
+ return;
1431
+ }
1432
+
1433
+ // Skip if analysis context was explicitly removed in this conversation
1434
+ if (this._analysisContextRemoved) {
1435
+ console.debug('[ChatPanel] _ensureAnalysisContext: skipped — explicitly removed');
1436
+ return;
1437
+ }
1438
+
1439
+ // Count suggestions from the DOM (from the latest analysis run)
1440
+ const suggestionEls = typeof document !== 'undefined' && document.querySelectorAll
1441
+ ? document.querySelectorAll('.ai-suggestion[data-suggestion-id]')
1442
+ : [];
1443
+ const count = suggestionEls.length;
1444
+ if (count === 0) {
1445
+ console.debug('[ChatPanel] _ensureAnalysisContext: skipped — no suggestions in DOM');
1446
+ return;
1447
+ }
1448
+ console.debug('[ChatPanel] _ensureAnalysisContext: adding card with', count, 'suggestions');
1449
+
1450
+ // Remove empty state
1451
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
1452
+ if (emptyState) emptyState.remove();
1453
+
1454
+ // Render the analysis context card (removable).
1455
+ // Prepend only when the messages area is empty (fresh conversation) so the card
1456
+ // appears first. When re-opening an existing chat that already has messages,
1457
+ // append instead so the card lands at the bottom where the user can see it
1458
+ // (prepending + scrollToBottom would hide it above the fold).
1459
+ // Note: analysis card is NOT added to _pendingContext/_pendingContextData —
1460
+ // the backend includes full suggestion data via initialContext at session creation.
1461
+ // The card is a visual indicator that controls whether the backend includes it.
1462
+ const hasExistingMessages = this.messagesEl.querySelectorAll('.chat-panel__message').length > 0;
1463
+ const contextData = this._buildAnalysisContextData(currentRunId, count);
1464
+ this._addAnalysisContextCard(contextData, { removable: true, prepend: !hasExistingMessages });
1465
+
1466
+ // Persist to DB so the card is restored on session reload
1467
+ this._persistAnalysisContext(contextData);
1468
+
1469
+ // Mark that analysis context is loaded for this session.
1470
+ // Use the actual run ID if available, otherwise fall back to 'dom'.
1471
+ this._sessionAnalysisRunId = currentRunId || 'dom';
1472
+ }
1473
+
1474
+ /**
1475
+ * Build enriched analysis context data for the card.
1476
+ * Pulls metadata (provider, model, summary, configType) from the
1477
+ * cached runs in analysisHistoryManager so the card's tooltip
1478
+ * shows rich info even before a session is created.
1479
+ * @param {string|null} runId - The run ID to look up metadata for
1480
+ * @param {number} count - Number of suggestions in the DOM
1481
+ * @returns {Object} Context data with type, suggestionCount, and optional metadata
1482
+ */
1483
+ _buildAnalysisContextData(runId, count) {
1484
+ const contextData = { type: 'analysis', suggestionCount: count };
1485
+
1486
+ if (!runId) return contextData;
1487
+
1488
+ // Look up the run in the cached analysisHistoryManager.runs array
1489
+ const mgr = window.prManager;
1490
+ const historyMgr = mgr?.analysisHistoryManager;
1491
+ if (!historyMgr?.runs?.length) return contextData;
1492
+
1493
+ const run = historyMgr.runs.find(r => String(r.id) === String(runId));
1494
+ if (!run) return contextData;
1495
+
1496
+ // Enrich with available metadata
1497
+ if (run.provider) contextData.provider = run.provider;
1498
+ if (run.model) contextData.model = run.model;
1499
+ if (run.summary) contextData.summary = run.summary;
1500
+ if (run.config_type) contextData.configType = run.config_type;
1501
+ if (run.completed_at) contextData.completedAt = run.completed_at;
1502
+ contextData.aiRunId = String(run.id);
1503
+
1504
+ return contextData;
1505
+ }
1506
+
1507
+ /**
1508
+ * Get the ID of the latest completed (successful) analysis run.
1509
+ * Looks at the cached runs in analysisHistoryManager (sorted by date DESC)
1510
+ * and returns the first one with status 'completed'.
1511
+ * Falls back to the selected run ID or prManager.selectedRunId for
1512
+ * backward compatibility when the runs array is not available.
1513
+ * @returns {string|null}
1514
+ */
1515
+ _getLatestCompletedRunId() {
1516
+ const mgr = window.prManager;
1517
+ if (!mgr) return null;
1518
+
1519
+ // Prefer the analysisHistoryManager's runs array — find latest completed run
1520
+ const historyMgr = mgr.analysisHistoryManager;
1521
+ if (historyMgr?.runs?.length > 0) {
1522
+ // Runs are sorted by date DESC; find the first completed one
1523
+ const completedRun = historyMgr.runs.find(r => r.status === 'completed');
1524
+ if (completedRun) return String(completedRun.id);
1525
+ }
1526
+
1527
+ // Fall back to selectedRunId on the history manager (for cases where
1528
+ // runs array is empty but a selection exists)
1529
+ if (historyMgr?.getSelectedRunId) {
1530
+ const id = historyMgr.getSelectedRunId();
1531
+ if (id) return String(id);
1532
+ }
1533
+
1534
+ // Fall back to prManager.selectedRunId
1535
+ if (mgr.selectedRunId) return String(mgr.selectedRunId);
1536
+ return null;
1537
+ }
1538
+
1539
+ /**
1540
+ * Remove the analysis context card and mark it as explicitly removed.
1541
+ * When removed, the backend will skip including analysis suggestions in the session.
1542
+ * @param {HTMLElement} cardEl - The analysis context card element
1543
+ */
1544
+ _removeAnalysisContextCard(cardEl) {
1545
+ this._analysisContextRemoved = true;
1546
+ cardEl.remove();
1547
+
1548
+ // If no pending context, no messages, and no other context cards, restore empty state
1549
+ if (this._pendingContext.length === 0 && this.messages.length === 0 &&
1550
+ !this.messagesEl.querySelector('.chat-panel__context-card')) {
1551
+ this._clearMessages();
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Add a compact context card for a user comment to the messages area.
1557
+ * @param {Object} ctx - Comment context {commentId, body, file, line_start, line_end, isFileLevel}
1558
+ */
1559
+ _addCommentContextCard(ctx, { removable = false } = {}) {
1560
+ const card = document.createElement('div');
1561
+ card.className = 'chat-panel__context-card';
1562
+
1563
+ const label = ctx.isFileLevel ? 'file comment' : 'comment';
1564
+ const bodyPreview = ctx.body ? (ctx.body.length > 60 ? ctx.body.substring(0, 60) + '...' : ctx.body) : 'Comment';
1565
+ const fileInfo = ctx.file
1566
+ ? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}`
1567
+ : '';
1568
+
1569
+ card.innerHTML = `
1570
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
1571
+ <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
1572
+ </svg>
1573
+ <span class="chat-panel__context-label">${this._escapeHtml(label)}</span>
1574
+ <span class="chat-panel__context-title">${this._renderInlineMarkdown(bodyPreview)}</span>
1575
+ ${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
1576
+ `;
1577
+
1578
+ // Store tooltip data for rich hover preview
1579
+ if (ctx.body) card.dataset.tooltipBody = ctx.body;
1580
+
1581
+ if (removable) this._makeCardRemovable(card);
1582
+
1583
+ this.messagesEl.appendChild(card);
1584
+ requestAnimationFrame(() => this.scrollToBottom({ force: true }));
1585
+ }
1586
+
1587
+ /**
1588
+ * Add a compact context card for a line reference (optionally with body text).
1589
+ * @param {Object} ctx - Line context {file, line_start, line_end, body}
1590
+ */
1591
+ _addLineContextCard(ctx, { removable = false } = {}) {
1592
+ const card = document.createElement('div');
1593
+ card.className = 'chat-panel__context-card';
1594
+
1595
+ const lineLabel = !ctx.line_start
1596
+ ? (ctx.file || 'File').split('/').pop()
1597
+ : (ctx.line_end && ctx.line_end !== ctx.line_start ? `Lines ${ctx.line_start}-${ctx.line_end}` : `Line ${ctx.line_start}`);
1598
+ const fileInfo = ctx.file
1599
+ ? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}`
1600
+ : '';
1601
+
1602
+ // When body text is provided (e.g. unsaved comment text), show it as the title
1603
+ const titleText = ctx.body
1604
+ ? (ctx.body.length > 60 ? ctx.body.substring(0, 60) + '...' : ctx.body)
1605
+ : lineLabel;
1606
+
1607
+ const label = !ctx.line_start ? 'FILE' : (ctx.line_end && ctx.line_end !== ctx.line_start ? 'LINES' : 'LINE');
1608
+
1609
+ // Code icon (octicon code-square)
1610
+ card.innerHTML = `
1611
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
1612
+ <path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>
1613
+ </svg>
1614
+ <span class="chat-panel__context-label"><strong>${label}</strong></span>
1615
+ <span class="chat-panel__context-title">${this._escapeHtml(titleText)}</span>
1616
+ ${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
1617
+ `;
1618
+
1619
+ // Store tooltip data for rich hover preview when body text is present
1620
+ if (ctx.body) card.dataset.tooltipBody = ctx.body;
1621
+
1622
+ if (removable) this._makeCardRemovable(card);
1623
+
1624
+ this.messagesEl.appendChild(card);
1625
+ requestAnimationFrame(() => this.scrollToBottom());
1626
+ }
1627
+
1628
+ /**
1629
+ * Add a compact context card to the messages area.
1630
+ * Visually indicates which suggestion the user is asking about,
1631
+ * without taking up space as a full message bubble.
1632
+ * @param {Object} ctx - Suggestion context {title, type, file, line_start, line_end, body}
1633
+ */
1634
+ _addContextCard(ctx, { removable = false } = {}) {
1635
+ const card = document.createElement('div');
1636
+ card.className = 'chat-panel__context-card';
1637
+
1638
+ const typeLabel = ctx.type || 'suggestion';
1639
+ const fileInfo = ctx.file ? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}` : '';
1640
+
1641
+ card.innerHTML = `
1642
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
1643
+ <path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>
1644
+ </svg>
1645
+ <span class="chat-panel__context-label">${this._renderInlineMarkdown(typeLabel)}</span>
1646
+ <span class="chat-panel__context-title">${this._renderInlineMarkdown(ctx.title || 'Untitled')}</span>
1647
+ ${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
1648
+ `;
1649
+
1650
+ // Store tooltip data for rich hover preview
1651
+ if (ctx.body) card.dataset.tooltipBody = ctx.body;
1652
+ if (ctx.type) card.dataset.tooltipType = ctx.type;
1653
+ if (ctx.title) card.dataset.tooltipTitle = ctx.title;
1654
+
1655
+ if (removable) this._makeCardRemovable(card);
1656
+
1657
+ this.messagesEl.appendChild(card);
1658
+ requestAnimationFrame(() => this.scrollToBottom({ force: true }));
1659
+ }
1660
+
1661
+ /**
1662
+ * Restore remove buttons and data-context-index on all pending context cards.
1663
+ * Called after a failed send to unlock cards that were locked prematurely.
1664
+ */
1665
+ _restoreRemovableCards() {
1666
+ // Restore analysis context card if it was locked
1667
+ const analysisCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
1668
+ if (analysisCard && !analysisCard.querySelector('.chat-panel__context-remove')) {
1669
+ const removeBtn = document.createElement('button');
1670
+ removeBtn.className = 'chat-panel__context-remove';
1671
+ removeBtn.title = 'Remove context';
1672
+ removeBtn.innerHTML = '\u00d7';
1673
+ removeBtn.addEventListener('click', (e) => {
1674
+ e.stopPropagation();
1675
+ this._removeAnalysisContextCard(analysisCard);
1676
+ });
1677
+ analysisCard.appendChild(removeBtn);
1678
+ }
1679
+
1680
+ const cards = this.messagesEl.querySelectorAll('.chat-panel__context-card:not([data-analysis])');
1681
+ let idx = 0;
1682
+ cards.forEach((card) => {
1683
+ // Only restore cards that don't already have a remove button
1684
+ if (!card.querySelector('.chat-panel__context-remove')) {
1685
+ card.dataset.contextIndex = idx;
1686
+ const removeBtn = document.createElement('button');
1687
+ removeBtn.className = 'chat-panel__context-remove';
1688
+ removeBtn.title = 'Remove context';
1689
+ removeBtn.innerHTML = '\u00d7';
1690
+ removeBtn.addEventListener('click', (e) => {
1691
+ e.stopPropagation();
1692
+ this._removeContextCard(card);
1693
+ });
1694
+ card.appendChild(removeBtn);
1695
+ } else {
1696
+ card.dataset.contextIndex = idx;
1697
+ }
1698
+ idx++;
1699
+ });
1700
+ }
1701
+
1702
+ /**
1703
+ * Remove a pending context card from the UI and data arrays.
1704
+ * Re-indexes remaining cards so data-context-index stays in sync.
1705
+ * @param {HTMLElement} cardEl - The context card element to remove
1706
+ */
1707
+ _removeContextCard(cardEl) {
1708
+ const idx = parseInt(cardEl.dataset.contextIndex, 10);
1709
+ if (!isNaN(idx) && idx >= 0 && idx < this._pendingContext.length) {
1710
+ this._pendingContext.splice(idx, 1);
1711
+ this._pendingContextData.splice(idx, 1);
1712
+ }
1713
+ // Hide context tooltip – mouseleave won't fire on a removed element
1714
+ clearTimeout(this._ctxTooltipTimer);
1715
+ if (this._ctxTooltipEl) this._ctxTooltipEl.style.display = 'none';
1716
+
1717
+ cardEl.remove();
1718
+
1719
+ // Re-index remaining removable context cards
1720
+ const remainingCards = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
1721
+ remainingCards.forEach((card, i) => {
1722
+ card.dataset.contextIndex = i;
1723
+ });
1724
+
1725
+ // If no pending context, no messages, and no other context cards, restore empty state
1726
+ if (this._pendingContext.length === 0 && this.messages.length === 0 &&
1727
+ !this.messagesEl.querySelector('.chat-panel__context-card')) {
1728
+ this._clearMessages();
1729
+ }
1730
+ }
1731
+
1732
+ /**
1733
+ * Show analysis context card if the session response includes context metadata.
1734
+ * Removes the empty state first so the card appears as the first element.
1735
+ * @param {Object} sessionData - Response data from createSession ({ id, status, context? })
1736
+ */
1737
+ _showAnalysisContextIfPresent(sessionData) {
1738
+ if (sessionData.context && sessionData.context.suggestionCount > 0) {
1739
+ const existingCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
1740
+ if (existingCard) {
1741
+ // Upgrade a bare-bones card (no metadata) with richer data from the backend.
1742
+ // Update IN-PLACE to preserve the card's DOM position (avoids jumping below user message).
1743
+ const hasRicherContext = !existingCard.dataset.hasMetadata &&
1744
+ (sessionData.context.provider || sessionData.context.model || sessionData.context.summary);
1745
+ if (!hasRicherContext) return;
1746
+ this._updateAnalysisCardContent(existingCard, sessionData.context);
1747
+ } else {
1748
+ const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
1749
+ if (emptyState) emptyState.remove();
1750
+ this._addAnalysisContextCard(sessionData.context);
1751
+ }
1752
+
1753
+ // Persist richer analysis context to DB (includes provider, model, summary, etc.)
1754
+ const contextData = { type: 'analysis', ...sessionData.context };
1755
+ this._persistAnalysisContext(contextData);
1756
+
1757
+ // Track which run's context is loaded so _ensureAnalysisContext can skip if already present
1758
+ this._sessionAnalysisRunId = sessionData.context.aiRunId || 'session';
1759
+ }
1760
+ }
1761
+
1762
+ /**
1763
+ * Build the inner HTML string for an analysis context card.
1764
+ * Shared by _addAnalysisContextCard (new card) and _updateAnalysisCardContent (in-place upgrade).
1765
+ * @param {Object} context - Context metadata { suggestionCount, provider, model, summary, configType }
1766
+ * @returns {string} HTML string for the card's content (SVG icon + label + title span)
1767
+ */
1768
+ _buildAnalysisCardInnerHTML(context) {
1769
+ const count = context.suggestionCount;
1770
+ const title = count === 1 ? '1 suggestion loaded' : `${count} suggestions loaded`;
1771
+
1772
+ // Build metadata details string (model/provider info)
1773
+ const metaParts = [];
1774
+ if (context.provider) metaParts.push(this._escapeHtml(context.provider));
1775
+ if (context.model) metaParts.push(this._escapeHtml(context.model));
1776
+ const metaStr = metaParts.length > 0 ? ` (${metaParts.join(' / ')})` : '';
1777
+
1778
+ // Build tooltip with provider, model, config, and summary (no title, no completedAt here since it's formatted for display)
1779
+ const tooltipParts = [];
1780
+ if (context.provider || context.model) {
1781
+ tooltipParts.push(`Provider: ${context.provider || 'unknown'}, Model: ${context.model || 'unknown'}`);
1782
+ }
1783
+ if (context.configType) tooltipParts.push(`Config: ${context.configType}`);
1784
+ if (context.completedAt) {
1785
+ const completedDate = window.parseTimestamp(context.completedAt);
1786
+ tooltipParts.push(`Completed: ${completedDate.toLocaleString()}`);
1787
+ }
1788
+ if (context.summary) tooltipParts.push(`Summary: ${context.summary}`);
1789
+ const tooltip = tooltipParts.join('\n');
1790
+
1791
+ return `
1792
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
1793
+ <path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
1794
+ </svg>
1795
+ <span class="chat-panel__context-label">analysis run</span>
1796
+ <span class="chat-panel__context-title" title="${window.escapeHtmlAttribute(tooltip)}">${this._escapeHtml(title)}${metaStr}</span>
1797
+ `;
1798
+ }
1799
+
1800
+ /**
1801
+ * Update an existing analysis context card's content and dataset in-place.
1802
+ * Preserves the card's DOM position (avoids remove+recreate which causes ordering bugs).
1803
+ * @param {HTMLElement} card - The existing analysis context card element
1804
+ * @param {Object} context - Richer context metadata from the backend
1805
+ */
1806
+ _updateAnalysisCardContent(card, context) {
1807
+ // Preserve existing remove button (if card is removable) before replacing innerHTML
1808
+ const removeBtn = card.querySelector('.chat-panel__context-remove');
1809
+
1810
+ // Rebuild card innerHTML with richer metadata
1811
+ card.innerHTML = this._buildAnalysisCardInnerHTML(context);
1812
+
1813
+ // Re-append the remove button if it existed
1814
+ if (removeBtn) {
1815
+ card.appendChild(removeBtn);
1816
+ }
1817
+
1818
+ // Update dataset attributes
1819
+ if (context.aiRunId) {
1820
+ card.dataset.analysisRunId = context.aiRunId;
1821
+ }
1822
+ if (context.provider || context.model || context.summary) {
1823
+ card.dataset.hasMetadata = 'true';
1824
+ }
1825
+ }
1826
+
1827
+ /**
1828
+ * Add a compact analysis-run context card to the messages area.
1829
+ * Used for manually-added analysis run cards that participate in the pending
1830
+ * context arrays (data-context-index path). Unlike _addAnalysisContextCard
1831
+ * (auto-added, data-analysis="true"), these cards use _removeContextCard.
1832
+ * @param {Object} ctxData - Context data { type, aiRunId, provider, model, summary, suggestionCount, configType }
1833
+ * @param {Object} [opts] - Options
1834
+ * @param {boolean} [opts.removable=false] - Whether the card should have a remove button
1835
+ */
1836
+ _addAnalysisRunContextCard(ctxData, { removable = false } = {}) {
1837
+ const card = document.createElement('div');
1838
+ card.className = 'chat-panel__context-card';
1839
+ card.dataset.contextIndex = this._pendingContext.length - 1;
1840
+ card.dataset.analysisRunId = ctxData.aiRunId;
1841
+ card.innerHTML = this._buildAnalysisCardInnerHTML(ctxData);
1842
+
1843
+ if (removable) this._makeCardRemovable(card);
1844
+
1845
+ this.messagesEl.appendChild(card);
1846
+ requestAnimationFrame(() => this.scrollToBottom({ force: true }));
1847
+ }
1848
+
1849
+ /**
1850
+ * Add a compact analysis context card to the messages area.
1851
+ * Visually indicates that the agent has analysis suggestions loaded as context.
1852
+ * Displays run metadata (model, provider, summary) when available.
1853
+ * @param {Object} context - Context metadata { suggestionCount, aiRunId, provider, model, summary, completedAt, configType, parentRunId }
1854
+ */
1855
+ _addAnalysisContextCard(context, { removable = false, prepend = false } = {}) {
1856
+ const card = document.createElement('div');
1857
+ card.className = 'chat-panel__context-card';
1858
+ card.dataset.analysis = 'true';
1859
+ if (context.aiRunId) {
1860
+ card.dataset.analysisRunId = context.aiRunId;
1861
+ }
1862
+ if (context.provider || context.model || context.summary) {
1863
+ card.dataset.hasMetadata = 'true';
1864
+ }
1865
+
1866
+ card.innerHTML = this._buildAnalysisCardInnerHTML(context);
1867
+
1868
+ if (removable) {
1869
+ const removeBtn = document.createElement('button');
1870
+ removeBtn.className = 'chat-panel__context-remove';
1871
+ removeBtn.title = 'Remove context';
1872
+ removeBtn.innerHTML = '\u00d7';
1873
+ removeBtn.addEventListener('click', (e) => {
1874
+ e.stopPropagation();
1875
+ this._removeAnalysisContextCard(card);
1876
+ });
1877
+ card.appendChild(removeBtn);
1878
+ }
1879
+
1880
+ if (prepend) {
1881
+ const firstChild = this.messagesEl.firstChild;
1882
+ if (firstChild) {
1883
+ this.messagesEl.insertBefore(card, firstChild);
1884
+ } else {
1885
+ this.messagesEl.appendChild(card);
1886
+ }
1887
+ } else {
1888
+ this.messagesEl.appendChild(card);
1889
+ }
1890
+ requestAnimationFrame(() => this.scrollToBottom({ force: true }));
1891
+ }
1892
+
1893
+ /**
1894
+ * Persist an analysis context card to the backend as a 'context' message.
1895
+ * Called immediately when an analysis context card is added, so it appears
1896
+ * in the conversation history on reload.
1897
+ * @param {Object} contextData - Analysis context metadata (type, suggestionCount, etc.)
1898
+ */
1899
+ async _persistAnalysisContext(contextData) {
1900
+ if (!this.currentSessionId) return;
1901
+
1902
+ try {
1903
+ const response = await fetch(`/api/chat/session/${this.currentSessionId}/context`, {
1904
+ method: 'POST',
1905
+ headers: { 'Content-Type': 'application/json' },
1906
+ body: JSON.stringify({ contextData })
1907
+ });
1908
+ if (!response.ok) {
1909
+ console.warn('[ChatPanel] Failed to persist analysis context:', response.status);
1910
+ }
1911
+ } catch (err) {
1912
+ console.warn('[ChatPanel] Failed to persist analysis context:', err);
1913
+ }
1914
+ }
1915
+
1916
+ /**
1917
+ * Ensure the global multiplexed SSE connection is established.
1918
+ * Creates the EventSource once; subsequent calls are no-ops if already connected.
1919
+ * Events are filtered by sessionId to dispatch only to the active session.
1920
+ */
1921
+ _ensureGlobalSSE() {
1922
+ // Already connected or connecting — nothing to do
1923
+ if (this.eventSource &&
1924
+ this.eventSource.readyState !== EventSource.CLOSED) {
1925
+ return;
1926
+ }
1927
+
1928
+ // Clear any pending reconnect timer
1929
+ clearTimeout(this._sseReconnectTimer);
1930
+ this._sseReconnectTimer = null;
1931
+
1932
+ const url = '/api/chat/stream';
1933
+ console.debug('[ChatPanel] Connecting multiplexed SSE:', url);
1934
+ this.eventSource = new EventSource(url);
1935
+
1936
+ this.eventSource.onmessage = (event) => {
1937
+ try {
1938
+ const data = JSON.parse(event.data);
1939
+
1940
+ // Initial connection acknowledgement — no sessionId, just log
1941
+ if (data.type === 'connected' && !data.sessionId) {
1942
+ console.debug('[ChatPanel] Multiplexed SSE connected');
1943
+ return;
1944
+ }
1945
+
1946
+ // Route review-scoped events to document as CustomEvents
1947
+ if (data.reviewId && data.type?.startsWith('review:')) {
1948
+ document.dispatchEvent(new CustomEvent(data.type, {
1949
+ detail: { ...data }
1950
+ }));
1951
+ return;
1952
+ }
1953
+
1954
+ // Filter: only process events for the active session
1955
+ if (data.sessionId !== this.currentSessionId) return;
1956
+
1957
+ if (data.type !== 'delta') {
1958
+ console.debug('[ChatPanel] SSE event:', data.type, 'session:', data.sessionId);
1959
+ }
1960
+
1961
+ // When the panel is closed, still accumulate internal state
1962
+ // so messages are available when the panel reopens.
1963
+ if (!this.isOpen) {
1964
+ switch (data.type) {
1965
+ case 'delta':
1966
+ this._streamingContent += data.text;
1967
+ break;
1968
+ case 'complete':
1969
+ if (this._streamingContent) {
1970
+ this.messages.push({ role: 'assistant', content: this._streamingContent, id: data.messageId });
1971
+ }
1972
+ this._streamingContent = '';
1973
+ this.isStreaming = false;
1974
+ break;
1975
+ case 'error':
1976
+ this._streamingContent = '';
1977
+ this.isStreaming = false;
1978
+ break;
1979
+ // tool_use, status: purely visual, skip when closed
1980
+ }
1981
+ return;
1982
+ }
1983
+
1984
+ switch (data.type) {
1985
+ case 'delta':
1986
+ this._hideThinkingIndicator();
1987
+ this._streamingContent += data.text;
1988
+ this.updateStreamingMessage(this._streamingContent);
1989
+ break;
1990
+
1991
+ case 'tool_use':
1992
+ this._showToolUse(data.toolName, data.status, data.toolInput);
1993
+ break;
1994
+
1995
+ case 'status':
1996
+ this._handleAgentStatus(data.status);
1997
+ break;
1998
+
1999
+ case 'complete':
2000
+ this.finalizeStreamingMessage(data.messageId);
2001
+ break;
2002
+
2003
+ case 'error':
2004
+ this._showError(data.message || 'An error occurred');
2005
+ this._finalizeStreaming();
2006
+ break;
2007
+ }
2008
+ } catch (e) {
2009
+ console.error('[ChatPanel] SSE parse error:', e);
2010
+ }
2011
+ };
2012
+
2013
+ this.eventSource.onerror = () => {
2014
+ if (this.eventSource?.readyState === EventSource.CLOSED) {
2015
+ console.warn('[ChatPanel] Multiplexed SSE connection closed, reconnecting in 2s');
2016
+ this.eventSource = null;
2017
+ this._sseReconnectTimer = setTimeout(() => {
2018
+ this._ensureGlobalSSE();
2019
+ }, 2000);
2020
+ }
2021
+ };
2022
+ }
2023
+
2024
+ /**
2025
+ * Close the global SSE connection and cancel any reconnect timer.
2026
+ */
2027
+ _closeGlobalSSE() {
2028
+ clearTimeout(this._sseReconnectTimer);
2029
+ this._sseReconnectTimer = null;
2030
+ if (this.eventSource) {
2031
+ this.eventSource.close();
2032
+ this.eventSource = null;
2033
+ }
2034
+ }
2035
+
2036
+ /**
2037
+ * Add a message to the display
2038
+ * @param {string} role - 'user' or 'assistant'
2039
+ * @param {string} content - Message text
2040
+ * @param {number} id - Optional message ID
2041
+ * @returns {HTMLElement} The message element that was appended
2042
+ */
2043
+ addMessage(role, content, id) {
2044
+ const msg = { role, content, id };
2045
+ this.messages.push(msg);
2046
+
2047
+ const msgEl = document.createElement('div');
2048
+ msgEl.className = `chat-panel__message chat-panel__message--${role}`;
2049
+ if (id) msgEl.dataset.messageId = id;
2050
+
2051
+ const bubble = document.createElement('div');
2052
+ bubble.className = 'chat-panel__bubble';
2053
+
2054
+ if (role === 'assistant') {
2055
+ bubble.innerHTML = this.renderMarkdown(content);
2056
+ this._linkifyFileReferences(bubble);
2057
+ bubble.appendChild(this._createCopyButton(content));
2058
+ } else {
2059
+ bubble.textContent = content;
2060
+ }
2061
+
2062
+ msgEl.appendChild(bubble);
2063
+ this.messagesEl.appendChild(msgEl);
2064
+ this.scrollToBottom({ force: true });
2065
+ return msgEl;
2066
+ }
2067
+
2068
+ /**
2069
+ * Create a copy-to-clipboard button for an assistant message bubble.
2070
+ * @param {string} rawContent - Raw markdown to copy
2071
+ * @returns {HTMLButtonElement}
2072
+ */
2073
+ _createCopyButton(rawContent) {
2074
+ const btn = document.createElement('button');
2075
+ btn.className = 'chat-panel__copy-btn';
2076
+ btn.title = 'Copy message';
2077
+ const clipboardIcon = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
2078
+ <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
2079
+ <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
2080
+ </svg>`;
2081
+ const checkIcon = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
2082
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
2083
+ </svg>`;
2084
+ btn.innerHTML = clipboardIcon;
2085
+
2086
+ btn.addEventListener('click', async (e) => {
2087
+ e.stopPropagation();
2088
+ try {
2089
+ await navigator.clipboard.writeText(rawContent);
2090
+ btn.innerHTML = checkIcon;
2091
+ btn.classList.add('chat-panel__copy-btn--success');
2092
+ setTimeout(() => {
2093
+ btn.innerHTML = clipboardIcon;
2094
+ btn.classList.remove('chat-panel__copy-btn--success');
2095
+ }, 2000);
2096
+ } catch (err) {
2097
+ console.error('[ChatPanel] Copy failed:', err);
2098
+ btn.title = 'Copy failed';
2099
+ setTimeout(() => { btn.title = 'Copy message'; }, 2000);
2100
+ }
2101
+ });
2102
+
2103
+ return btn;
2104
+ }
2105
+
2106
+ /**
2107
+ * Add a streaming placeholder for the assistant's response
2108
+ */
2109
+ _addStreamingPlaceholder() {
2110
+ const msgEl = document.createElement('div');
2111
+ msgEl.className = 'chat-panel__message chat-panel__message--assistant chat-panel__message--streaming';
2112
+ msgEl.id = 'chat-streaming-msg';
2113
+
2114
+ const bubble = document.createElement('div');
2115
+ bubble.className = 'chat-panel__bubble';
2116
+ bubble.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
2117
+
2118
+ msgEl.appendChild(bubble);
2119
+ this.messagesEl.appendChild(msgEl);
2120
+ this.scrollToBottom({ force: true });
2121
+ }
2122
+
2123
+ /**
2124
+ * Update the currently streaming message
2125
+ * @param {string} text - Full accumulated text so far
2126
+ */
2127
+ updateStreamingMessage(text) {
2128
+ const streamingMsg = document.getElementById('chat-streaming-msg');
2129
+ if (!streamingMsg) return;
2130
+
2131
+ // Remove transient tool badge when real text arrives
2132
+ const transient = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
2133
+ if (transient) transient.remove();
2134
+
2135
+ const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2136
+ if (bubble) {
2137
+ bubble.innerHTML = this.renderMarkdown(text) + '<span class="chat-panel__cursor"></span>';
2138
+ this._linkifyFileReferences(bubble);
2139
+ }
2140
+ this.scrollToBottom();
2141
+ }
2142
+
2143
+ /**
2144
+ * Finalize the streaming message with final ID
2145
+ * @param {number} messageId - Database message ID
2146
+ */
2147
+ finalizeStreamingMessage(messageId) {
2148
+ const streamingMsg = document.getElementById('chat-streaming-msg');
2149
+ if (streamingMsg) {
2150
+ streamingMsg.classList.remove('chat-panel__message--streaming');
2151
+ streamingMsg.id = '';
2152
+ if (messageId) streamingMsg.dataset.messageId = messageId;
2153
+
2154
+ // Remove cursor and thinking indicator
2155
+ const cursor = streamingMsg.querySelector('.chat-panel__cursor');
2156
+ if (cursor) cursor.remove();
2157
+ const thinking = streamingMsg.querySelector('.chat-panel__thinking');
2158
+ if (thinking) thinking.remove();
2159
+
2160
+ // Remove transient tool badge
2161
+ const transientBadge = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
2162
+ if (transientBadge) transientBadge.remove();
2163
+
2164
+ // Remove any active tool spinners (e.g. abort mid-tool-execution)
2165
+ const spinners = streamingMsg.querySelectorAll('.chat-panel__tool-spinner');
2166
+ spinners.forEach(s => s.remove());
2167
+
2168
+ // Final render
2169
+ const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2170
+ if (bubble) {
2171
+ if (this._streamingContent) {
2172
+ bubble.innerHTML = this.renderMarkdown(this._streamingContent);
2173
+ this._linkifyFileReferences(bubble);
2174
+ bubble.appendChild(this._createCopyButton(this._streamingContent));
2175
+ } else {
2176
+ // Empty response - show a subtle message
2177
+ bubble.innerHTML = '<em class="chat-panel__empty-response">No response generated.</em>';
2178
+ }
2179
+ }
2180
+ }
2181
+
2182
+ // Store in messages array
2183
+ if (this._streamingContent) {
2184
+ this.messages.push({ role: 'assistant', content: this._streamingContent, id: messageId });
2185
+ }
2186
+
2187
+ this._finalizeStreaming();
2188
+ }
2189
+
2190
+ /**
2191
+ * Abort the current agent turn
2192
+ */
2193
+ async _stopAgent() {
2194
+ if (!this.isStreaming || !this.currentSessionId) return;
2195
+
2196
+ try {
2197
+ await fetch(`/api/chat/session/${this.currentSessionId}/abort`, {
2198
+ method: 'POST'
2199
+ });
2200
+ } catch (error) {
2201
+ console.error('[ChatPanel] Error aborting:', error);
2202
+ }
2203
+
2204
+ // Finalize the streaming message with whatever content we have so far
2205
+ this.finalizeStreamingMessage(null);
2206
+ }
2207
+
2208
+ /**
2209
+ * Clean up streaming state
2210
+ */
2211
+ _finalizeStreaming() {
2212
+ this.isStreaming = false;
2213
+ this._streamingContent = '';
2214
+ this.sendBtn.style.display = '';
2215
+ this.stopBtn.style.display = 'none';
2216
+ this.sendBtn.disabled = !this.inputEl?.value?.trim();
2217
+ this._updateActionButtons();
2218
+ this.inputEl?.focus();
2219
+ }
2220
+
2221
+ /**
2222
+ * Show a tool use indicator in the streaming message
2223
+ * @param {string} toolName - Name of the tool being used
2224
+ * @param {string} status - 'start' or 'end'
2225
+ * @param {Object} [toolInput] - Tool input/arguments (optional)
2226
+ */
2227
+ _showToolUse(toolName, status, toolInput) {
2228
+ const streamingMsg = document.getElementById('chat-streaming-msg');
2229
+ if (!streamingMsg) return;
2230
+
2231
+ const isTask = toolName.toLowerCase() === 'task';
2232
+
2233
+ if (status === 'start') {
2234
+ this._hideThinkingIndicator();
2235
+ const argSummary = this._summarizeToolInput(toolName, toolInput);
2236
+
2237
+ const badgeHTML = `
2238
+ <svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10">
2239
+ <path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
2240
+ </svg>
2241
+ <span>${this._escapeHtml(toolName)}</span>${argSummary ? `<span class="chat-panel__tool-args" title="${window.escapeHtmlAttribute(argSummary)}">${this._escapeHtml(argSummary)}</span>` : ''}
2242
+ <span class="chat-panel__tool-spinner"></span>
2243
+ `;
2244
+
2245
+ if (isTask) {
2246
+ // Task tools get persistent badges (meaningful delegated work)
2247
+ const badge = document.createElement('div');
2248
+ badge.className = 'chat-panel__tool-badge';
2249
+ badge.dataset.tool = toolName;
2250
+ badge.innerHTML = badgeHTML;
2251
+ const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2252
+ streamingMsg.insertBefore(badge, bubble);
2253
+ } else {
2254
+ // Non-Task tools reuse a single transient badge
2255
+ let badge = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
2256
+ if (!badge) {
2257
+ badge = document.createElement('div');
2258
+ badge.className = 'chat-panel__tool-badge chat-panel__tool-badge--transient';
2259
+ const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2260
+ streamingMsg.insertBefore(badge, bubble);
2261
+ }
2262
+ badge.dataset.tool = toolName;
2263
+ badge.innerHTML = badgeHTML;
2264
+ }
2265
+ } else {
2266
+ if (isTask) {
2267
+ // Remove spinner from completed Task badge
2268
+ const badges = streamingMsg.querySelectorAll('.chat-panel__tool-badge[data-tool="Task"]:not(.chat-panel__tool-badge--transient)');
2269
+ badges.forEach(b => {
2270
+ const spinner = b.querySelector('.chat-panel__tool-spinner');
2271
+ if (spinner) spinner.remove();
2272
+ });
2273
+ } else {
2274
+ // Remove spinner from transient badge (badge stays until text arrives or next tool starts)
2275
+ const transient = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
2276
+ if (transient) {
2277
+ const spinner = transient.querySelector('.chat-panel__tool-spinner');
2278
+ if (spinner) spinner.remove();
2279
+ }
2280
+ }
2281
+ this._showThinkingIndicator();
2282
+ }
2283
+ }
2284
+
2285
+ /**
2286
+ * Extract a compact summary string from tool input for display.
2287
+ * @param {string} toolName - Name of the tool
2288
+ * @param {Object} [input] - Tool input/arguments
2289
+ * @returns {string} Compact summary or empty string
2290
+ */
2291
+ _summarizeToolInput(toolName, input) {
2292
+ if (!input || typeof input !== 'object') return '';
2293
+
2294
+ const name = toolName.toLowerCase();
2295
+ switch (name) {
2296
+ case 'bash': {
2297
+ let cmd = input.command || '';
2298
+ // Strip "cd <path> && " prefix — the actual command is more interesting
2299
+ cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, '');
2300
+ return cmd;
2301
+ }
2302
+ case 'read':
2303
+ return input.file_path || input.path || '';
2304
+ case 'grep':
2305
+ return input.pattern || '';
2306
+ case 'glob':
2307
+ return input.pattern || '';
2308
+ case 'find':
2309
+ case 'ls':
2310
+ return input.path || '';
2311
+ case 'write':
2312
+ case 'edit':
2313
+ return input.file_path || input.path || '';
2314
+ default: {
2315
+ // For unknown tools, show the first string-valued argument
2316
+ const vals = Object.values(input);
2317
+ for (const v of vals) {
2318
+ if (typeof v === 'string' && v.length > 0) return v;
2319
+ }
2320
+ return '';
2321
+ }
2322
+ }
2323
+ }
2324
+
2325
+ /**
2326
+ * Handle agent status events from the backend.
2327
+ * @param {string} status - 'working' or 'turn_complete'
2328
+ */
2329
+ _handleAgentStatus(status) {
2330
+ if (status === 'working') {
2331
+ this._showThinkingIndicator();
2332
+ }
2333
+ // 'turn_complete' is informational; the agent may start another turn
2334
+ }
2335
+
2336
+ /**
2337
+ * Show the pulsing thinking indicator in/below the streaming message.
2338
+ * If there's already content, append it after the content. If no content, it's the typing dots.
2339
+ */
2340
+ _showThinkingIndicator() {
2341
+ const streamingMsg = document.getElementById('chat-streaming-msg');
2342
+ if (!streamingMsg) return;
2343
+
2344
+ // Don't add duplicate
2345
+ if (streamingMsg.querySelector('.chat-panel__thinking')) return;
2346
+
2347
+ // Don't add if the bubble still has its initial typing indicator (no content yet).
2348
+ // The bubble's own dots are sufficient — adding a second set would show two pulsing indicators.
2349
+ const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2350
+ if (bubble && bubble.querySelector('.chat-panel__typing-indicator')) return;
2351
+
2352
+ // Remove the cursor — the thinking indicator replaces it as the "working" signal.
2353
+ // When new text arrives, updateStreamingMessage() will re-add the cursor naturally.
2354
+ const cursor = bubble?.querySelector('.chat-panel__cursor');
2355
+ if (cursor) cursor.remove();
2356
+
2357
+ const indicator = document.createElement('div');
2358
+ indicator.className = 'chat-panel__thinking';
2359
+ indicator.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
2360
+ streamingMsg.appendChild(indicator);
2361
+ this.scrollToBottom();
2362
+ }
2363
+
2364
+ /**
2365
+ * Hide the thinking indicator from the streaming message.
2366
+ */
2367
+ _hideThinkingIndicator() {
2368
+ const streamingMsg = document.getElementById('chat-streaming-msg');
2369
+ if (!streamingMsg) return;
2370
+ const thinking = streamingMsg.querySelector('.chat-panel__thinking');
2371
+ if (thinking) thinking.remove();
2372
+ }
2373
+
2374
+ /**
2375
+ * Show an error message in the chat
2376
+ * @param {string} message - Error text
2377
+ */
2378
+ _showError(message) {
2379
+ const errorEl = document.createElement('div');
2380
+ errorEl.className = 'chat-panel__message chat-panel__message--error';
2381
+ errorEl.innerHTML = `
2382
+ <div class="chat-panel__error-bubble">
2383
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
2384
+ <path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"/>
2385
+ </svg>
2386
+ ${this._escapeHtml(message)}
2387
+ </div>
2388
+ `;
2389
+ this.messagesEl.appendChild(errorEl);
2390
+ this.scrollToBottom({ force: true });
2391
+ }
2392
+
2393
+ /**
2394
+ * Show an auto-dismissing toast notification overlaid at the border between
2395
+ * the header and messages area. Appended to the outer .chat-panel container
2396
+ * (which has position:relative) so it stays visible regardless of scroll
2397
+ * position in the messages area.
2398
+ * @param {string} message - Text to display
2399
+ */
2400
+ _showToast(message) {
2401
+ // Remove any existing toast
2402
+ const existing = this.panel?.querySelector('.chat-panel__toast');
2403
+ if (existing) existing.remove();
2404
+
2405
+ const toast = document.createElement('div');
2406
+ toast.className = 'chat-panel__toast';
2407
+ toast.textContent = message;
2408
+
2409
+ // Append to the outer chat-panel container so the toast is positioned
2410
+ // relative to it (not inside the scrollable messages area).
2411
+ if (this.panel) {
2412
+ this.panel.appendChild(toast);
2413
+ }
2414
+
2415
+ // Auto-dismiss after 2.5 seconds
2416
+ setTimeout(() => {
2417
+ toast.classList.add('chat-panel__toast--dismissing');
2418
+ toast.addEventListener('animationend', () => toast.remove());
2419
+ }, 2500);
2420
+ }
2421
+
2422
+ /**
2423
+ * Post-process a container element to convert [[file:...]] tokens into
2424
+ * clickable links that scroll to the file in the diff.
2425
+ *
2426
+ * Supported formats:
2427
+ * [[file:path/to/file.ext]] -> file only
2428
+ * [[file:path/to/file.ext:42]] -> file + line
2429
+ * [[file:path/to/file.ext:42-78]] -> file + line range
2430
+ *
2431
+ * Tokens inside <pre> blocks are left untouched.
2432
+ *
2433
+ * @param {HTMLElement} container - Element whose text nodes to scan
2434
+ */
2435
+ _linkifyFileReferences(container) {
2436
+ if (!container || typeof document.createTreeWalker !== 'function') return;
2437
+
2438
+ const FILE_TOKEN = /\[\[file:([^\]]+?)(?::(\d+)(?:-(\d+))?)?\]\]/g;
2439
+
2440
+ // Collect text nodes that contain tokens (avoid mutating during traversal)
2441
+ // NodeFilter.SHOW_TEXT === 4; use literal for environments without NodeFilter global
2442
+ const SHOW_TEXT = typeof NodeFilter !== 'undefined' ? NodeFilter.SHOW_TEXT : 4;
2443
+ const walker = document.createTreeWalker(container, SHOW_TEXT);
2444
+ const candidates = [];
2445
+ while (walker.nextNode()) {
2446
+ const node = walker.currentNode;
2447
+ // Skip anything inside a <pre> (code block)
2448
+ if (node.parentElement?.closest('pre')) continue;
2449
+ FILE_TOKEN.lastIndex = 0;
2450
+ if (FILE_TOKEN.test(node.textContent)) {
2451
+ candidates.push(node);
2452
+ }
2453
+ }
2454
+
2455
+ for (const node of candidates) {
2456
+ const fragment = document.createDocumentFragment();
2457
+ const text = node.textContent;
2458
+ let lastIndex = 0;
2459
+
2460
+ FILE_TOKEN.lastIndex = 0;
2461
+ let match;
2462
+ while ((match = FILE_TOKEN.exec(text)) !== null) {
2463
+ // Add any text before this match
2464
+ if (match.index > lastIndex) {
2465
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
2466
+ }
2467
+
2468
+ const filePath = match[1];
2469
+ const lineStart = match[2] || null;
2470
+ const lineEnd = match[3] || null;
2471
+
2472
+ // Build the clickable link
2473
+ const link = document.createElement('a');
2474
+ link.className = 'chat-file-link';
2475
+ link.dataset.file = filePath;
2476
+ if (lineStart) link.dataset.lineStart = lineStart;
2477
+ if (lineEnd) link.dataset.lineEnd = lineEnd;
2478
+ link.title = 'View in diff';
2479
+
2480
+ // File icon SVG
2481
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2482
+ icon.setAttribute('viewBox', '0 0 16 16');
2483
+ icon.setAttribute('fill', 'currentColor');
2484
+ icon.setAttribute('width', '12');
2485
+ icon.setAttribute('height', '12');
2486
+ icon.classList.add('chat-file-link__icon');
2487
+ const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2488
+ pathEl.setAttribute('d', 'M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z');
2489
+ icon.appendChild(pathEl);
2490
+
2491
+ // Display text: show the file reference naturally
2492
+ let displayText = filePath;
2493
+ if (lineStart && lineEnd) {
2494
+ displayText += `:${lineStart}-${lineEnd}`;
2495
+ } else if (lineStart) {
2496
+ displayText += `:${lineStart}`;
2497
+ }
2498
+
2499
+ link.appendChild(icon);
2500
+ link.appendChild(document.createTextNode(' ' + displayText));
2501
+
2502
+ fragment.appendChild(link);
2503
+ lastIndex = match.index + match[0].length;
2504
+ }
2505
+
2506
+ // Add any remaining text after the last match
2507
+ if (lastIndex < text.length) {
2508
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
2509
+ }
2510
+
2511
+ node.parentNode.replaceChild(fragment, node);
2512
+ }
2513
+ }
2514
+
2515
+ /**
2516
+ * Handle click on a chat file link. Scrolls to the referenced file and line
2517
+ * in the diff view.
2518
+ * @param {HTMLElement} linkEl - The clicked .chat-file-link element
2519
+ */
2520
+ async _handleFileLinkClick(linkEl) {
2521
+ const file = linkEl.dataset.file;
2522
+ const lineStart = linkEl.dataset.lineStart ? parseInt(linkEl.dataset.lineStart, 10) : null;
2523
+ const lineEnd = linkEl.dataset.lineEnd ? parseInt(linkEl.dataset.lineEnd, 10) : null;
2524
+
2525
+ // Check if file wrapper exists in DOM
2526
+ const wrapper = document.querySelector(`[data-file-name="${CSS.escape(file)}"]`);
2527
+
2528
+ if (wrapper) {
2529
+ // File is already rendered — scroll to it
2530
+ const contextEl = wrapper.closest('.context-file');
2531
+ if (contextEl) {
2532
+ // Context file — find the right chunk by line number or use first chunk
2533
+ let contextFileId = contextEl.dataset?.contextId; // legacy: on wrapper itself
2534
+ let lineFoundInChunk = !!contextFileId; // legacy mode assumes line is present
2535
+ if (!contextFileId && lineStart) {
2536
+ // Merged wrapper: find chunk tbody containing this line
2537
+ const chunks = [...contextEl.querySelectorAll('tbody.context-chunk[data-context-id]')];
2538
+ for (const chunk of chunks) {
2539
+ const row = chunk.querySelector(`tr[data-line-number="${lineStart}"]`);
2540
+ if (row) {
2541
+ contextFileId = chunk.dataset.contextId;
2542
+ lineFoundInChunk = true;
2543
+ break;
2544
+ }
2545
+ }
2546
+ }
2547
+
2548
+ if (lineFoundInChunk || !lineStart) {
2549
+ if (!contextFileId) {
2550
+ const firstChunk = contextEl.querySelector('tbody.context-chunk[data-context-id]');
2551
+ if (firstChunk) contextFileId = firstChunk.dataset.contextId;
2552
+ }
2553
+ if (window.prManager?.scrollToContextFile) {
2554
+ window.prManager.scrollToContextFile(file, lineStart, contextFileId);
2555
+ }
2556
+ return;
2557
+ }
2558
+ // Line not found in any existing chunk — fall through to add new range
2559
+ } else {
2560
+ // Diff file
2561
+ if (lineStart) {
2562
+ await this._scrollToLine(file, lineStart, lineEnd);
2563
+ } else if (window.prManager?.scrollToFile) {
2564
+ window.prManager.scrollToFile(file);
2565
+ }
2566
+ return;
2567
+ }
2568
+ }
2569
+
2570
+ // File not in DOM — try to add as context file
2571
+ if (!window.prManager?.ensureContextFile) return;
2572
+
2573
+ linkEl.classList.add('chat-file-link--loading');
2574
+ try {
2575
+ const result = await window.prManager.ensureContextFile(file, lineStart, lineEnd);
2576
+
2577
+ if (!result) {
2578
+ this._showToast('Could not load file');
2579
+ return;
2580
+ }
2581
+
2582
+ if (result.type === 'diff') {
2583
+ if (lineStart) {
2584
+ await this._scrollToLine(file, lineStart, lineEnd);
2585
+ } else if (window.prManager?.scrollToFile) {
2586
+ window.prManager.scrollToFile(file);
2587
+ }
2588
+ } else if (result.type === 'context') {
2589
+ // Brief delay for DOM to settle after loadContextFiles
2590
+ await new Promise(resolve => setTimeout(resolve, 100));
2591
+ if (window.prManager.scrollToContextFile) {
2592
+ window.prManager.scrollToContextFile(file, lineStart, result.contextFile?.id);
2593
+ }
2594
+ }
2595
+ } catch (err) {
2596
+ console.error('[ChatPanel] Error handling file link click:', err);
2597
+ this._showToast('Could not load file');
2598
+ } finally {
2599
+ linkEl.classList.remove('chat-file-link--loading');
2600
+ }
2601
+ }
2602
+
2603
+ /**
2604
+ * Scroll to a specific line within a file wrapper, applying a bold
2605
+ * left-border + background highlight that fades over ~3.5s.
2606
+ * Supports line ranges: if lineEnd is provided, all rows from
2607
+ * lineStart to lineEnd are highlighted. If the line is in a collapsed
2608
+ * diff chunk, expands the chunk first via ensureLinesVisible().
2609
+ * @param {string} file - File path
2610
+ * @param {number} lineStart - Target line number (start of range)
2611
+ * @param {number|null} [lineEnd] - End of target line range (used for expansion)
2612
+ * @param {HTMLElement} [fileWrapper] - Pre-resolved file wrapper element
2613
+ */
2614
+ async _scrollToLine(file, lineStart, lineEnd, fileWrapper) {
2615
+ if (!fileWrapper) {
2616
+ const escaped = CSS.escape(file);
2617
+ fileWrapper = document.querySelector(`[data-file-name="${escaped}"]`) ||
2618
+ document.querySelector(`[data-file-name$="/${escaped}"]`);
2619
+ }
2620
+ if (!fileWrapper) return;
2621
+
2622
+ // Collect all target rows (single line or range)
2623
+ const end = lineEnd || lineStart;
2624
+ let targetRows = this._findLineRows(fileWrapper, lineStart, end);
2625
+
2626
+ // If not found, try expanding the collapsed diff context
2627
+ if (targetRows.length === 0 && window.prManager?.ensureLinesVisible) {
2628
+ await window.prManager.ensureLinesVisible([
2629
+ { file, line_start: lineStart, line_end: end, side: 'RIGHT' }
2630
+ ]);
2631
+ targetRows = this._findLineRows(fileWrapper, lineStart, end);
2632
+ }
2633
+ if (targetRows.length === 0) return;
2634
+
2635
+ const primaryRow = targetRows[0];
2636
+
2637
+ // Check if the primary target row is already visible in the viewport
2638
+ const rect = primaryRow.getBoundingClientRect();
2639
+ const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
2640
+
2641
+ if (!isVisible) {
2642
+ primaryRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
2643
+ }
2644
+
2645
+ // Apply the highlight to all target rows
2646
+ for (const row of targetRows) {
2647
+ // Remove any existing highlight first (in case of rapid re-clicks)
2648
+ row.classList.remove('chat-line-highlight');
2649
+ // Force reflow so re-adding the class restarts the animation
2650
+ void row.offsetWidth;
2651
+ row.classList.add('chat-line-highlight');
2652
+ row.addEventListener('animationend', () => {
2653
+ row.classList.remove('chat-line-highlight');
2654
+ }, { once: true });
2655
+ }
2656
+ }
2657
+
2658
+ /**
2659
+ * Find all table rows matching a line range within a file wrapper.
2660
+ * @param {HTMLElement} fileWrapper - The file wrapper element
2661
+ * @param {number} lineStart - Start line number
2662
+ * @param {number} lineEnd - End line number (inclusive)
2663
+ * @returns {HTMLElement[]} Matching rows
2664
+ */
2665
+ _findLineRows(fileWrapper, lineStart, lineEnd) {
2666
+ const rows = [];
2667
+ const lineNums = fileWrapper.querySelectorAll('.line-num2');
2668
+ for (const ln of lineNums) {
2669
+ const num = parseInt(ln.textContent.trim(), 10);
2670
+ if (!isNaN(num) && num >= lineStart && num <= lineEnd) {
2671
+ const row = ln.closest('tr');
2672
+ if (row) rows.push(row);
2673
+ }
2674
+ }
2675
+ return rows;
2676
+ }
2677
+
2678
+ /**
2679
+ * Render markdown text to HTML
2680
+ * @param {string} text - Markdown text
2681
+ * @returns {string} HTML string
2682
+ */
2683
+ renderMarkdown(text) {
2684
+ if (!text) return '';
2685
+ // Use the global renderMarkdown if available (from markdown.js utility)
2686
+ if (window.renderMarkdown) {
2687
+ return window.renderMarkdown(text);
2688
+ }
2689
+ // Basic fallback: escape and convert newlines
2690
+ return this._escapeHtml(text).replace(/\n/g, '<br>');
2691
+ }
2692
+
2693
+ /**
2694
+ * Render markdown to inline HTML (strips outer <p> wrapper).
2695
+ * Useful for context card labels/titles where block-level wrapping is unwanted.
2696
+ * @param {string} text - Markdown text
2697
+ * @returns {string} Inline HTML string
2698
+ */
2699
+ _renderInlineMarkdown(text) {
2700
+ if (!text) return '';
2701
+ const html = this.renderMarkdown(text);
2702
+ return html.replace(/^<p>([\s\S]*?)<\/p>\s*$/, '$1');
2703
+ }
2704
+
2705
+ /**
2706
+ * Escape HTML special characters
2707
+ * @param {string} text - Raw text
2708
+ * @returns {string} Escaped text
2709
+ */
2710
+ _escapeHtml(text) {
2711
+ if (!text) return '';
2712
+ const div = document.createElement('div');
2713
+ div.textContent = text;
2714
+ return div.innerHTML;
2715
+ }
2716
+
2717
+ /**
2718
+ * Auto-scroll messages to bottom.
2719
+ * When force is true (user-initiated actions), always scrolls.
2720
+ * When force is false (streaming content), only scrolls if already near the bottom.
2721
+ * @param {{ force?: boolean }} options
2722
+ */
2723
+ scrollToBottom({ force = false } = {}) {
2724
+ if (!this.messagesEl) return;
2725
+ if (!force && this._userScrolledAway) {
2726
+ this._showNewContentPill();
2727
+ return; // instant bail, no threshold fight
2728
+ }
2729
+ const { scrollTop, scrollHeight, clientHeight } = this.messagesEl;
2730
+ const nearBottom = scrollHeight - scrollTop - clientHeight < NEAR_BOTTOM_THRESHOLD;
2731
+ if (force || nearBottom) {
2732
+ this.messagesEl.scrollTop = scrollHeight;
2733
+ this._userScrolledAway = false;
2734
+ this._hideNewContentPill();
2735
+ } else {
2736
+ this._userScrolledAway = true;
2737
+ this._showNewContentPill();
2738
+ }
2739
+ }
2740
+
2741
+ /**
2742
+ * Show the "new content" pill indicator at the bottom of the messages area.
2743
+ */
2744
+ _showNewContentPill() {
2745
+ if (this.newContentPill) {
2746
+ this.newContentPill.style.display = '';
2747
+ }
2748
+ }
2749
+
2750
+ /**
2751
+ * Hide the "new content" pill indicator.
2752
+ */
2753
+ _hideNewContentPill() {
2754
+ if (this.newContentPill) {
2755
+ this.newContentPill.style.display = 'none';
2756
+ }
2757
+ }
2758
+
2759
+ /**
2760
+ * Update visibility and disabled state of action buttons based on context and streaming state.
2761
+ */
2762
+ _updateActionButtons() {
2763
+ // Check if shortcuts are disabled via config
2764
+ if (document.documentElement.getAttribute('data-chat-shortcuts') === 'disabled') {
2765
+ this.actionBar.style.display = 'none';
2766
+ return;
2767
+ }
2768
+
2769
+ const hasSuggestion = this._contextSource === 'suggestion' && this._contextItemId;
2770
+ const hasComment = this._contextSource === 'user' && this._contextItemId;
2771
+ const hasLine = this._contextSource === 'line';
2772
+
2773
+ // Show the bar only if at least one button is relevant
2774
+ const showBar = hasSuggestion || hasComment || hasLine;
2775
+ this.actionBar.style.display = showBar ? '' : 'none';
2776
+ this.adoptBtn.style.display = hasSuggestion ? '' : 'none';
2777
+ this.dismissSuggestionBtn.style.display = hasSuggestion ? '' : 'none';
2778
+ this.updateBtn.style.display = hasComment ? '' : 'none';
2779
+ this.dismissCommentBtn.style.display = hasComment ? '' : 'none';
2780
+ this.createCommentBtn.style.display = hasLine ? '' : 'none';
2781
+ this.createCommentBtn.disabled = this.isStreaming;
2782
+
2783
+ // Disable while streaming
2784
+ this.adoptBtn.disabled = this.isStreaming;
2785
+ this.updateBtn.disabled = this.isStreaming;
2786
+ this.dismissSuggestionBtn.disabled = this.isStreaming;
2787
+ this.dismissCommentBtn.disabled = this.isStreaming;
2788
+ }
2789
+
2790
+ /**
2791
+ * Handle click on "Adopt with AI edits" button.
2792
+ * Sends a message asking the agent to refine and adopt the suggestion.
2793
+ */
2794
+ _handleAdoptClick() {
2795
+ if (this.isStreaming || !this._contextItemId) return;
2796
+ this._pendingActionContext = { type: 'adopt', itemId: this._contextItemId };
2797
+ this.inputEl.value = 'Based on our conversation, please refine and adopt this AI suggestion.';
2798
+ this.sendMessage();
2799
+ }
2800
+
2801
+ /**
2802
+ * Handle click on "Update comment" button.
2803
+ * Sends a message asking the agent to update the user's comment.
2804
+ */
2805
+ _handleUpdateClick() {
2806
+ if (this.isStreaming || !this._contextItemId) return;
2807
+ this._pendingActionContext = { type: 'update', itemId: this._contextItemId };
2808
+ this.inputEl.value = 'Based on our conversation, please update my comment.';
2809
+ this.sendMessage();
2810
+ }
2811
+
2812
+ /**
2813
+ * Handle click on "Dismiss suggestion" button.
2814
+ * Sends a message asking the agent to dismiss the AI suggestion.
2815
+ */
2816
+ _handleDismissSuggestionClick() {
2817
+ if (this.isStreaming || !this._contextItemId) return;
2818
+ this._pendingActionContext = { type: 'dismiss-suggestion', itemId: this._contextItemId };
2819
+ this.inputEl.value = 'Please dismiss this AI suggestion.';
2820
+ this.sendMessage();
2821
+ }
2822
+
2823
+ /**
2824
+ * Handle click on "Dismiss comment" button.
2825
+ * Sends a message asking the agent to dismiss the user comment.
2826
+ */
2827
+ _handleDismissCommentClick() {
2828
+ if (this.isStreaming || !this._contextItemId) return;
2829
+ this._pendingActionContext = { type: 'dismiss-comment', itemId: this._contextItemId };
2830
+ this.inputEl.value = 'Please delete this comment.';
2831
+ this.sendMessage();
2832
+ }
2833
+
2834
+ /**
2835
+ * Handle click on action bar dismiss button.
2836
+ * Hides the action bar for this conversation by clearing context source.
2837
+ */
2838
+ _handleActionBarDismiss() {
2839
+ this._contextSource = null;
2840
+ this._contextItemId = null;
2841
+ this._contextLineMeta = null;
2842
+ this._updateActionButtons();
2843
+ }
2844
+
2845
+ /**
2846
+ * Handle click on "Create comment" button.
2847
+ * Sends a message asking the agent to create a review comment for the referenced lines.
2848
+ */
2849
+ _handleCreateCommentClick() {
2850
+ if (this.isStreaming) return;
2851
+ this._pendingActionContext = {
2852
+ type: 'create-comment',
2853
+ file: this._contextLineMeta?.file,
2854
+ line_start: this._contextLineMeta?.line_start,
2855
+ line_end: this._contextLineMeta?.line_end,
2856
+ };
2857
+ this.inputEl.value = 'Based on our conversation, please create a review comment for this code.';
2858
+ this.sendMessage();
2859
+ }
2860
+
2861
+ /**
2862
+ * Initialize the shared context tooltip with event delegation on the messages area.
2863
+ * Uses mouseenter/mouseleave with a short delay to avoid flickering.
2864
+ */
2865
+ _initContextTooltip() {
2866
+ this._ctxTooltipEl = document.createElement('div');
2867
+ this._ctxTooltipEl.className = 'chat-panel__ctx-tooltip';
2868
+ this._ctxTooltipEl.style.display = 'none';
2869
+ document.body.appendChild(this._ctxTooltipEl);
2870
+ this._ctxTooltipTimer = null;
2871
+
2872
+ if (!this.messagesEl) return;
2873
+
2874
+ this._onCtxCardEnter = (e) => {
2875
+ const card = e.target.closest('.chat-panel__context-card[data-tooltip-body]');
2876
+ if (!card) return;
2877
+ clearTimeout(this._ctxTooltipTimer);
2878
+ this._ctxTooltipTimer = setTimeout(() => this._showContextTooltip(card), 200);
2879
+ };
2880
+
2881
+ this._onCtxCardLeave = (e) => {
2882
+ const card = e.target.closest('.chat-panel__context-card[data-tooltip-body]');
2883
+ if (!card) return;
2884
+ clearTimeout(this._ctxTooltipTimer);
2885
+ this._ctxTooltipEl.style.display = 'none';
2886
+ };
2887
+
2888
+ this.messagesEl.addEventListener('mouseenter', this._onCtxCardEnter, true);
2889
+ this.messagesEl.addEventListener('mouseleave', this._onCtxCardLeave, true);
2890
+ }
2891
+
2892
+ /**
2893
+ * Show the context tooltip positioned relative to a context card element.
2894
+ * @param {HTMLElement} card - The context card to show tooltip for
2895
+ */
2896
+ _showContextTooltip(card) {
2897
+ const body = card.dataset.tooltipBody;
2898
+ if (!body) return;
2899
+
2900
+ const type = card.dataset.tooltipType;
2901
+ const title = card.dataset.tooltipTitle;
2902
+
2903
+ let headerHTML = '';
2904
+ if (type || title) {
2905
+ headerHTML = `<div class="chat-panel__ctx-tooltip-header">${type ? `<span class="chat-panel__ctx-tooltip-type">${this._renderInlineMarkdown(type)}</span>` : ''}${title ? `<span class="chat-panel__ctx-tooltip-title">${this._renderInlineMarkdown(title)}</span>` : ''}</div>`;
2906
+ }
2907
+
2908
+ this._ctxTooltipEl.innerHTML = `${headerHTML}<div class="chat-panel__ctx-tooltip-body">${this.renderMarkdown(body)}</div>`;
2909
+
2910
+ const rect = card.getBoundingClientRect();
2911
+ const tooltipHeight = 300; // max-height
2912
+ const spaceBelow = window.innerHeight - rect.bottom;
2913
+
2914
+ this._ctxTooltipEl.style.display = '';
2915
+ this._ctxTooltipEl.style.left = `${rect.left}px`;
2916
+
2917
+ if (spaceBelow >= tooltipHeight || spaceBelow >= rect.top) {
2918
+ // Show below
2919
+ this._ctxTooltipEl.style.top = `${rect.bottom + 4}px`;
2920
+ this._ctxTooltipEl.style.bottom = '';
2921
+ } else {
2922
+ // Flip above
2923
+ this._ctxTooltipEl.style.top = '';
2924
+ this._ctxTooltipEl.style.bottom = `${window.innerHeight - rect.top + 4}px`;
2925
+ }
2926
+ }
2927
+
2928
+ /**
2929
+ * Clean up on page unload
2930
+ */
2931
+ destroy() {
2932
+ document.removeEventListener('keydown', this._onKeydown);
2933
+ this._closeGlobalSSE();
2934
+ this.messages = [];
2935
+
2936
+ // Clean up context tooltip
2937
+ clearTimeout(this._ctxTooltipTimer);
2938
+ if (this._ctxTooltipEl && this._ctxTooltipEl.parentNode) {
2939
+ this._ctxTooltipEl.parentNode.removeChild(this._ctxTooltipEl);
2940
+ }
2941
+ this._ctxTooltipEl = null;
2942
+
2943
+ if (this.container) {
2944
+ this.container.innerHTML = '';
2945
+ }
2946
+ }
2947
+ }
2948
+
2949
+ // Make ChatPanel available globally
2950
+ window.ChatPanel = ChatPanel;
2951
+
2952
+ // Export for CommonJS testing environments
2953
+ if (typeof module !== 'undefined' && module.exports) {
2954
+ module.exports = { ChatPanel, NEAR_BOTTOM_THRESHOLD };
2955
+ }