@in-the-loop-labs/pair-review 3.4.1 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +2 -1
- package/src/database.js +566 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/github/client.js +77 -1
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/server.js +9 -0
|
@@ -17,41 +17,757 @@ function getChatSpinnerHTML() {
|
|
|
17
17
|
return window.__pairReview?.chatSpinner === 'loop' ? LOOP_SPINNER_HTML : DOTS_SPINNER_HTML;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ChatTab
|
|
22
|
+
* @property {number|null} sessionId - Backend session ID (null for an unsaved new tab)
|
|
23
|
+
* @property {string} title - Tab title shown in the strip
|
|
24
|
+
* @property {'idle'|'streaming'|'error'} status - Drives the status dot color
|
|
25
|
+
* @property {string|null} errorMessage - Last error, cleared on next successful send
|
|
26
|
+
* @property {Array<{role: string, content: string, id?: number}>} messages
|
|
27
|
+
* @property {boolean} isStreaming
|
|
28
|
+
* @property {string} streamingContent - Accumulated stream text (delta concatenation)
|
|
29
|
+
* @property {boolean} sessionWarm - True once the session has been used this page load (ACP resume flag)
|
|
30
|
+
* @property {string} provider - Provider id assigned to this tab when its session was created
|
|
31
|
+
* @property {string|null} model - Model id for header label
|
|
32
|
+
* @property {string|null} contextSource - 'suggestion' | 'user' | 'line' | 'file' | null
|
|
33
|
+
* @property {(string|number)|null} contextItemId
|
|
34
|
+
* @property {Object|null} contextLineMeta - { file, line_start, line_end }
|
|
35
|
+
* @property {Object|null} pendingActionContext - Set by action buttons, consumed in sendMessage
|
|
36
|
+
* @property {string[]} pendingContext - Unsent context text blocks for next send
|
|
37
|
+
* @property {Object[]} pendingContextData - Structured context data parallel to pendingContext
|
|
38
|
+
* @property {string|null} latestDiffState - Latest diff snapshot (idempotent, overwrites)
|
|
39
|
+
* @property {string[]} pendingUserActionHints - Ordered log of UI actions, drained on send
|
|
40
|
+
* @property {boolean} analysisContextRemoved
|
|
41
|
+
* @property {string|null} sessionAnalysisRunId
|
|
42
|
+
* @property {HTMLElement} messagesEl - Per-tab scrollable messages container
|
|
43
|
+
* @property {HTMLElement|null} streamingMsgEl - Reference to the in-progress assistant bubble
|
|
44
|
+
* @property {boolean} userScrolledAway - Auto-scroll engagement flag
|
|
45
|
+
* @property {Function|null} wsUnsub - Unsubscribe handle for the per-session WS topic
|
|
46
|
+
* @property {Promise<void>|null} historyLoadPromise - Set while history is loading
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
let _newTabCounter = 0;
|
|
50
|
+
function _nextNewTabTitle() {
|
|
51
|
+
_newTabCounter += 1;
|
|
52
|
+
return _newTabCounter === 1 ? 'New Chat' : `New Chat ${_newTabCounter}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
class ChatPanel {
|
|
21
56
|
constructor(containerId) {
|
|
22
57
|
this.containerId = containerId;
|
|
23
58
|
this.container = document.getElementById(containerId);
|
|
24
|
-
this.currentSessionId = null;
|
|
25
59
|
this.reviewId = null;
|
|
26
60
|
this.isOpen = false;
|
|
27
|
-
this.isStreaming = false;
|
|
28
|
-
this._chatUnsub = null;
|
|
29
61
|
this._reviewUnsub = null;
|
|
30
|
-
this.messages = [];
|
|
31
|
-
this._streamingContent = '';
|
|
32
|
-
this._pendingContext = [];
|
|
33
|
-
this._pendingContextData = [];
|
|
34
|
-
this._pendingDiffStateNotifications = [];
|
|
35
|
-
this._pendingUserActionHints = [];
|
|
36
|
-
this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
|
|
37
|
-
this._contextItemId = null; // suggestion ID or comment ID from context
|
|
38
|
-
this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
|
|
39
|
-
this._pendingActionContext = null; // { type, itemId } — set by action button handlers, consumed by sendMessage
|
|
40
62
|
this._resizeConfig = ChatPanel.RESIZE_CONFIG;
|
|
41
|
-
this.
|
|
42
|
-
this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
|
|
43
|
-
this._openPromise = null; // concurrency guard for open()
|
|
44
|
-
this._sessionWarm = false; // true once the session has been used in this page load
|
|
63
|
+
this._openPromise = null;
|
|
45
64
|
this._activeProvider = window.__pairReview?.chatProvider || 'pi';
|
|
46
65
|
this._chatProviders = window.__pairReview?.chatProviders || [];
|
|
47
66
|
this._enterToSend = window.__pairReview?.chatEnterToSend ?? true;
|
|
48
67
|
|
|
68
|
+
/** @type {ChatTab[]} Open tabs, in display order (left to right). */
|
|
69
|
+
this.tabs = [];
|
|
70
|
+
/** @type {number|null} Sentinel ID of the active tab; matches sessionId once saved. */
|
|
71
|
+
this.activeTabKey = null;
|
|
72
|
+
/** Counter used as a sentinel key for tabs that haven't been assigned a sessionId yet. */
|
|
73
|
+
this._tabKeyCounter = -1;
|
|
74
|
+
|
|
49
75
|
this._render();
|
|
50
76
|
this._bindEvents();
|
|
51
77
|
this._initContextTooltip();
|
|
52
78
|
this._updateTitle();
|
|
53
79
|
}
|
|
54
80
|
|
|
81
|
+
// ── Tab helpers ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
_getActiveTab() {
|
|
84
|
+
if (this.activeTabKey == null) return null;
|
|
85
|
+
return this.tabs.find(t => this._tabKey(t) === this.activeTabKey) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_tabKey(tab) {
|
|
89
|
+
return tab.sessionId != null ? tab.sessionId : tab._localKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_findTabBySessionId(sessionId) {
|
|
93
|
+
if (sessionId == null) return null;
|
|
94
|
+
return this.tabs.find(t => t.sessionId === sessionId) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Allocate a per-tab descriptor with sensible defaults. Caller is responsible
|
|
99
|
+
* for appending it to this.tabs and creating its messagesEl.
|
|
100
|
+
* @param {Object} [init]
|
|
101
|
+
* @returns {ChatTab}
|
|
102
|
+
*/
|
|
103
|
+
_createTab(init = {}) {
|
|
104
|
+
const tab = {
|
|
105
|
+
sessionId: init.sessionId ?? null,
|
|
106
|
+
_localKey: this._tabKeyCounter--,
|
|
107
|
+
title: init.title || _nextNewTabTitle(),
|
|
108
|
+
// 'pending' = fresh tab, no messages exchanged yet (gray dot).
|
|
109
|
+
// Transitions to 'idle'/'streaming'/'error' once the conversation
|
|
110
|
+
// is underway. See _updateTabStatus for the demote rule.
|
|
111
|
+
status: 'pending',
|
|
112
|
+
errorMessage: null,
|
|
113
|
+
messages: [],
|
|
114
|
+
isStreaming: false,
|
|
115
|
+
streamingContent: '',
|
|
116
|
+
sessionWarm: !!init.sessionWarm,
|
|
117
|
+
provider: init.provider || this._activeProvider,
|
|
118
|
+
model: init.model || null,
|
|
119
|
+
contextSource: null,
|
|
120
|
+
contextItemId: null,
|
|
121
|
+
contextLineMeta: null,
|
|
122
|
+
pendingActionContext: null,
|
|
123
|
+
pendingContext: [],
|
|
124
|
+
pendingContextData: [],
|
|
125
|
+
latestDiffState: init.latestDiffState !== undefined ? init.latestDiffState : (this._initialDiffState || null),
|
|
126
|
+
pendingUserActionHints: [],
|
|
127
|
+
analysisContextRemoved: false,
|
|
128
|
+
sessionAnalysisRunId: null,
|
|
129
|
+
messagesEl: null,
|
|
130
|
+
streamingMsgEl: null,
|
|
131
|
+
userScrolledAway: false,
|
|
132
|
+
wsUnsub: null,
|
|
133
|
+
titleFromUser: false,
|
|
134
|
+
historyLoadPromise: null,
|
|
135
|
+
};
|
|
136
|
+
return tab;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Per-tab state delegation (SYNC ONLY) ────────────────────────────────
|
|
140
|
+
//
|
|
141
|
+
// These getters/setters forward to the active tab so synchronous helpers
|
|
142
|
+
// (context-card builders, sync handler callers) don't need a tab parameter.
|
|
143
|
+
// SYNC ONLY — do NOT read across awaits. Async code paths (`sendMessage`,
|
|
144
|
+
// `_handleChatMessageForTab`, WS reconnect) capture `tab` at function entry
|
|
145
|
+
// and write through `tab.*` directly.
|
|
146
|
+
//
|
|
147
|
+
// Array-valued state (`tab.messages`, `tab.pendingContext`,
|
|
148
|
+
// `tab.pendingContextData`, `tab.pendingActionContext`,
|
|
149
|
+
// `tab.pendingUserActionHints`) and per-tab snapshot state
|
|
150
|
+
// (`tab.latestDiffState`) are deliberately NOT exposed via shims —
|
|
151
|
+
// callers must use the explicit tab reference to avoid silent cross-tab
|
|
152
|
+
// bleed across awaits.
|
|
153
|
+
|
|
154
|
+
get currentSessionId() {
|
|
155
|
+
return this._getActiveTab()?.sessionId ?? null;
|
|
156
|
+
}
|
|
157
|
+
set currentSessionId(v) {
|
|
158
|
+
const tab = this._getActiveTab();
|
|
159
|
+
if (!tab) return;
|
|
160
|
+
const prev = tab.sessionId;
|
|
161
|
+
tab.sessionId = v;
|
|
162
|
+
if (prev !== v) {
|
|
163
|
+
this.activeTabKey = this._tabKey(tab);
|
|
164
|
+
this._renderTabStrip();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
get isStreaming() {
|
|
168
|
+
return this._getActiveTab()?.isStreaming ?? false;
|
|
169
|
+
}
|
|
170
|
+
set isStreaming(v) {
|
|
171
|
+
const tab = this._getActiveTab();
|
|
172
|
+
if (!tab) return;
|
|
173
|
+
tab.isStreaming = v;
|
|
174
|
+
this._updateTabStatus(tab, v ? 'streaming' : (tab.errorMessage ? 'error' : 'idle'));
|
|
175
|
+
}
|
|
176
|
+
get _streamingContent() {
|
|
177
|
+
return this._getActiveTab()?.streamingContent ?? '';
|
|
178
|
+
}
|
|
179
|
+
set _streamingContent(v) {
|
|
180
|
+
const tab = this._getActiveTab();
|
|
181
|
+
if (tab) tab.streamingContent = v;
|
|
182
|
+
}
|
|
183
|
+
get _sessionWarm() {
|
|
184
|
+
return this._getActiveTab()?.sessionWarm ?? false;
|
|
185
|
+
}
|
|
186
|
+
set _sessionWarm(v) {
|
|
187
|
+
const tab = this._getActiveTab();
|
|
188
|
+
if (tab) tab.sessionWarm = v;
|
|
189
|
+
}
|
|
190
|
+
get _contextSource() {
|
|
191
|
+
return this._getActiveTab()?.contextSource ?? null;
|
|
192
|
+
}
|
|
193
|
+
set _contextSource(v) {
|
|
194
|
+
const tab = this._getActiveTab();
|
|
195
|
+
if (tab) tab.contextSource = v;
|
|
196
|
+
}
|
|
197
|
+
get _contextItemId() {
|
|
198
|
+
return this._getActiveTab()?.contextItemId ?? null;
|
|
199
|
+
}
|
|
200
|
+
set _contextItemId(v) {
|
|
201
|
+
const tab = this._getActiveTab();
|
|
202
|
+
if (tab) tab.contextItemId = v;
|
|
203
|
+
}
|
|
204
|
+
get _contextLineMeta() {
|
|
205
|
+
return this._getActiveTab()?.contextLineMeta ?? null;
|
|
206
|
+
}
|
|
207
|
+
set _contextLineMeta(v) {
|
|
208
|
+
const tab = this._getActiveTab();
|
|
209
|
+
if (tab) tab.contextLineMeta = v;
|
|
210
|
+
}
|
|
211
|
+
get _analysisContextRemoved() {
|
|
212
|
+
return this._getActiveTab()?.analysisContextRemoved ?? false;
|
|
213
|
+
}
|
|
214
|
+
set _analysisContextRemoved(v) {
|
|
215
|
+
const tab = this._getActiveTab();
|
|
216
|
+
if (tab) tab.analysisContextRemoved = v;
|
|
217
|
+
}
|
|
218
|
+
get _sessionAnalysisRunId() {
|
|
219
|
+
return this._getActiveTab()?.sessionAnalysisRunId ?? null;
|
|
220
|
+
}
|
|
221
|
+
set _sessionAnalysisRunId(v) {
|
|
222
|
+
const tab = this._getActiveTab();
|
|
223
|
+
if (tab) tab.sessionAnalysisRunId = v;
|
|
224
|
+
}
|
|
225
|
+
get _userScrolledAway() {
|
|
226
|
+
return this._getActiveTab()?.userScrolledAway ?? false;
|
|
227
|
+
}
|
|
228
|
+
set _userScrolledAway(v) {
|
|
229
|
+
const tab = this._getActiveTab();
|
|
230
|
+
if (tab) tab.userScrolledAway = v;
|
|
231
|
+
}
|
|
232
|
+
get messagesEl() {
|
|
233
|
+
return this._getActiveTab()?.messagesEl || null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Allocate a new <div class="chat-panel__messages"> for a tab and append it
|
|
238
|
+
* into the stack. Hidden by default; _switchToTab toggles the .--active class.
|
|
239
|
+
*/
|
|
240
|
+
_createTabMessagesEl(tab) {
|
|
241
|
+
const el = document.createElement('div');
|
|
242
|
+
el.className = 'chat-panel__messages';
|
|
243
|
+
el.dataset.tabKey = String(this._tabKey(tab));
|
|
244
|
+
el.style.display = 'none';
|
|
245
|
+
el.innerHTML = `
|
|
246
|
+
<div class="chat-panel__empty">
|
|
247
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
|
|
248
|
+
<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"/>
|
|
249
|
+
</svg>
|
|
250
|
+
<p>Ask questions about this review, or the changes</p>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
253
|
+
let lastScrollTop = 0;
|
|
254
|
+
el.addEventListener('scroll', () => {
|
|
255
|
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
256
|
+
const distance = scrollHeight - scrollTop - clientHeight;
|
|
257
|
+
if (scrollTop < lastScrollTop && distance >= NEAR_BOTTOM_THRESHOLD) {
|
|
258
|
+
tab.userScrolledAway = true;
|
|
259
|
+
if (this._getActiveTab() === tab) this._showNewContentPill();
|
|
260
|
+
} else if (distance < NEAR_BOTTOM_THRESHOLD) {
|
|
261
|
+
tab.userScrolledAway = false;
|
|
262
|
+
if (this._getActiveTab() === tab) this._hideNewContentPill();
|
|
263
|
+
}
|
|
264
|
+
lastScrollTop = scrollTop;
|
|
265
|
+
}, { passive: true });
|
|
266
|
+
tab.messagesEl = el;
|
|
267
|
+
return el;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Insert a new tab into the strip and the messages stack.
|
|
272
|
+
* @param {ChatTab} tab
|
|
273
|
+
* @param {{focus?: boolean, position?: number}} [opts]
|
|
274
|
+
*/
|
|
275
|
+
_appendTab(tab, { focus = true, position } = {}) {
|
|
276
|
+
const el = this._createTabMessagesEl(tab);
|
|
277
|
+
if (typeof position === 'number' && position < this.tabs.length) {
|
|
278
|
+
this.tabs.splice(position, 0, tab);
|
|
279
|
+
} else {
|
|
280
|
+
this.tabs.push(tab);
|
|
281
|
+
}
|
|
282
|
+
this.messagesStackEl.appendChild(el);
|
|
283
|
+
if (focus) {
|
|
284
|
+
this._switchToTab(this._tabKey(tab));
|
|
285
|
+
} else {
|
|
286
|
+
this._renderTabStrip();
|
|
287
|
+
}
|
|
288
|
+
this._updateNoTabsEmptyState();
|
|
289
|
+
// Persist if the tab already has a sessionId (history-picker restore path).
|
|
290
|
+
// For new tabs that get a session async, persistence fires from the place
|
|
291
|
+
// that assigns sessionId.
|
|
292
|
+
if (tab.sessionId != null) this._persistOpenTabs();
|
|
293
|
+
return tab;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Activate a tab by key. Hides other tabs' message containers without
|
|
298
|
+
* tearing down their state or subscriptions.
|
|
299
|
+
* @param {number} key
|
|
300
|
+
*/
|
|
301
|
+
_switchToTab(key) {
|
|
302
|
+
const tab = this.tabs.find(t => this._tabKey(t) === key);
|
|
303
|
+
if (!tab) return;
|
|
304
|
+
if (this.activeTabKey === key && tab.messagesEl?.style.display !== 'none') {
|
|
305
|
+
this._renderTabStrip();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.activeTabKey = key;
|
|
309
|
+
this._persistOpenTabs();
|
|
310
|
+
// Toggle visibility of message containers
|
|
311
|
+
for (const t of this.tabs) {
|
|
312
|
+
if (!t.messagesEl) continue;
|
|
313
|
+
t.messagesEl.style.display = (t === tab) ? '' : 'none';
|
|
314
|
+
}
|
|
315
|
+
// Header should reflect the focused tab's provider/model
|
|
316
|
+
if (tab.provider) {
|
|
317
|
+
this._activeProvider = tab.provider;
|
|
318
|
+
this._updateTitle(tab.provider, tab.model);
|
|
319
|
+
} else {
|
|
320
|
+
this._updateTitle();
|
|
321
|
+
}
|
|
322
|
+
this._renderTabStrip();
|
|
323
|
+
this._updateActionButtons();
|
|
324
|
+
// Adjust send/stop buttons to reflect the new active tab's streaming state
|
|
325
|
+
this.sendBtn.style.display = tab.isStreaming ? 'none' : '';
|
|
326
|
+
this.stopBtn.style.display = tab.isStreaming ? '' : 'none';
|
|
327
|
+
this.sendBtn.disabled = tab.isStreaming || !this.inputEl?.value?.trim();
|
|
328
|
+
// Re-anchor scroll
|
|
329
|
+
if (!tab.userScrolledAway) this.scrollToBottom({ force: true });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Close a tab: kills its bridge, removes its DOM, unsubscribes from its
|
|
334
|
+
* WebSocket topic. If it was active, focuses the rightmost remaining tab
|
|
335
|
+
* (or shows the empty state if none).
|
|
336
|
+
* @param {number} key
|
|
337
|
+
*/
|
|
338
|
+
async _closeTab(key) {
|
|
339
|
+
const idx = this.tabs.findIndex(t => this._tabKey(t) === key);
|
|
340
|
+
if (idx === -1) return;
|
|
341
|
+
const tab = this.tabs[idx];
|
|
342
|
+
this._removeTabFromDom(tab);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Remove a tab from the strip and the DOM without contacting the backend.
|
|
347
|
+
* Shared by _closeTab (which additionally fires the DELETE) and the 404
|
|
348
|
+
* branch in _loadMessageHistory (which must NOT, since the session is
|
|
349
|
+
* already gone). Idempotent: a no-op for tabs that have already been
|
|
350
|
+
* removed.
|
|
351
|
+
*
|
|
352
|
+
* Always preserves the "no tabs left → reset Send/Stop" behavior so the
|
|
353
|
+
* input chrome doesn't get stranded in a streaming state.
|
|
354
|
+
*
|
|
355
|
+
* @param {ChatTab} tab
|
|
356
|
+
* @param {{ skipDelete?: boolean }} [opts]
|
|
357
|
+
*/
|
|
358
|
+
_removeTabFromDom(tab, { skipDelete = false } = {}) {
|
|
359
|
+
if (!tab) return;
|
|
360
|
+
const idx = this.tabs.indexOf(tab);
|
|
361
|
+
if (idx === -1) return; // already removed
|
|
362
|
+
|
|
363
|
+
const key = this._tabKey(tab);
|
|
364
|
+
|
|
365
|
+
// Unsubscribe immediately so we stop receiving events for this session
|
|
366
|
+
if (tab.wsUnsub) { try { tab.wsUnsub(); } catch { /* noop */ } tab.wsUnsub = null; }
|
|
367
|
+
|
|
368
|
+
// Fire-and-forget DELETE — server-side closeSession is idempotent
|
|
369
|
+
if (!skipDelete && tab.sessionId != null) {
|
|
370
|
+
fetch(`/api/chat/session/${tab.sessionId}`, { method: 'DELETE' })
|
|
371
|
+
.catch(err => console.warn('[ChatPanel] Failed to close session:', err));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Remove the DOM container
|
|
375
|
+
if (tab.messagesEl?.parentNode) {
|
|
376
|
+
tab.messagesEl.parentNode.removeChild(tab.messagesEl);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Remove from the array
|
|
380
|
+
this.tabs.splice(idx, 1);
|
|
381
|
+
this._persistOpenTabs();
|
|
382
|
+
|
|
383
|
+
if (this.activeTabKey === key) {
|
|
384
|
+
// Focus the tab to the right (or last) if any remain
|
|
385
|
+
const next = this.tabs[idx] || this.tabs[idx - 1] || this.tabs[this.tabs.length - 1] || null;
|
|
386
|
+
if (next) {
|
|
387
|
+
this._switchToTab(this._tabKey(next));
|
|
388
|
+
} else {
|
|
389
|
+
this.activeTabKey = null;
|
|
390
|
+
this._renderTabStrip();
|
|
391
|
+
this._updateNoTabsEmptyState();
|
|
392
|
+
this._updateTitle();
|
|
393
|
+
this._updateActionButtons();
|
|
394
|
+
this.sendBtn.style.display = '';
|
|
395
|
+
this.stopBtn.style.display = 'none';
|
|
396
|
+
this.sendBtn.disabled = true;
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
this._renderTabStrip();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Render or re-render the tab strip from this.tabs.
|
|
405
|
+
*/
|
|
406
|
+
_renderTabStrip() {
|
|
407
|
+
if (!this.tabStripItemsEl) return;
|
|
408
|
+
if (this.tabs.length === 0) {
|
|
409
|
+
this.tabStripItemsEl.innerHTML = '';
|
|
410
|
+
this.tabStripEl.classList.add('chat-panel__tab-strip--empty');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
this.tabStripEl.classList.remove('chat-panel__tab-strip--empty');
|
|
414
|
+
const items = this.tabs.map((tab) => {
|
|
415
|
+
const key = this._tabKey(tab);
|
|
416
|
+
const isActive = key === this.activeTabKey;
|
|
417
|
+
const dotClass = `chat-panel__tab-dot chat-panel__tab-dot--${tab.status || 'idle'}`;
|
|
418
|
+
const tooltip = tab.errorMessage
|
|
419
|
+
? `${this._escapeAttr(tab.title)} (error: ${this._escapeAttr(tab.errorMessage)})`
|
|
420
|
+
: this._escapeAttr(tab.title);
|
|
421
|
+
const sessionIdAttr = tab.sessionId != null ? ` data-session-id="${tab.sessionId}"` : '';
|
|
422
|
+
return `
|
|
423
|
+
<div class="chat-panel__tab${isActive ? ' chat-panel__tab--active' : ''}"
|
|
424
|
+
role="tab"
|
|
425
|
+
data-tab-key="${key}"${sessionIdAttr}
|
|
426
|
+
title="${tooltip}">
|
|
427
|
+
<span class="${dotClass}" aria-hidden="true"></span>
|
|
428
|
+
<span class="chat-panel__tab-title">${this._escapeHtml(tab.title)}</span>
|
|
429
|
+
<button class="chat-panel__tab-close" title="Close conversation" data-tab-key="${key}" aria-label="Close conversation">
|
|
430
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10"><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>
|
|
431
|
+
</button>
|
|
432
|
+
</div>
|
|
433
|
+
`;
|
|
434
|
+
}).join('');
|
|
435
|
+
this.tabStripItemsEl.innerHTML = items;
|
|
436
|
+
// Bind click handlers
|
|
437
|
+
this.tabStripItemsEl.querySelectorAll('.chat-panel__tab').forEach((el) => {
|
|
438
|
+
const key = parseInt(el.dataset.tabKey, 10);
|
|
439
|
+
el.addEventListener('click', (e) => {
|
|
440
|
+
if (e.target.closest('.chat-panel__tab-close')) return;
|
|
441
|
+
this._switchToTab(key);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
this.tabStripItemsEl.querySelectorAll('.chat-panel__tab-close').forEach((btn) => {
|
|
445
|
+
const key = parseInt(btn.dataset.tabKey, 10);
|
|
446
|
+
btn.addEventListener('click', (e) => {
|
|
447
|
+
e.stopPropagation();
|
|
448
|
+
this._closeTab(key);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Update a tab's status (and its dot in the strip).
|
|
455
|
+
* @param {ChatTab} tab
|
|
456
|
+
* @param {'pending'|'idle'|'streaming'|'error'} status
|
|
457
|
+
*/
|
|
458
|
+
_updateTabStatus(tab, status) {
|
|
459
|
+
if (!tab) return;
|
|
460
|
+
if (status === 'idle') tab.errorMessage = null;
|
|
461
|
+
// A tab with no exchanged messages stays in the 'pending' (gray) state
|
|
462
|
+
// even when callers request 'idle' — the conversation hasn't started yet,
|
|
463
|
+
// so the active-affordance blue would over-signal. 'streaming' and
|
|
464
|
+
// 'error' always win.
|
|
465
|
+
if (status === 'idle' && (tab.messages?.length ?? 0) === 0) {
|
|
466
|
+
status = 'pending';
|
|
467
|
+
}
|
|
468
|
+
tab.status = status;
|
|
469
|
+
if (this.tabStripItemsEl) {
|
|
470
|
+
const dot = this.tabStripItemsEl.querySelector(`.chat-panel__tab[data-tab-key="${this._tabKey(tab)}"] .chat-panel__tab-dot`);
|
|
471
|
+
if (dot) {
|
|
472
|
+
dot.className = `chat-panel__tab-dot chat-panel__tab-dot--${status}`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Update a tab's title (and re-render the strip).
|
|
479
|
+
* @param {ChatTab} tab
|
|
480
|
+
* @param {string} title
|
|
481
|
+
*/
|
|
482
|
+
_setTabTitle(tab, title) {
|
|
483
|
+
if (!tab || !title) return;
|
|
484
|
+
tab.title = title;
|
|
485
|
+
if (this.tabStripItemsEl) {
|
|
486
|
+
const titleEl = this.tabStripItemsEl.querySelector(`.chat-panel__tab[data-tab-key="${this._tabKey(tab)}"] .chat-panel__tab-title`);
|
|
487
|
+
if (titleEl) titleEl.textContent = title;
|
|
488
|
+
const tabEl = this.tabStripItemsEl.querySelector(`.chat-panel__tab[data-tab-key="${this._tabKey(tab)}"]`);
|
|
489
|
+
if (tabEl) tabEl.title = this._escapeAttr(title);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Show or hide the "no tabs open" empty state.
|
|
495
|
+
*/
|
|
496
|
+
_updateNoTabsEmptyState() {
|
|
497
|
+
const noTabsEmpty = this.messagesStackEl?.querySelector('.chat-panel__empty--no-tabs');
|
|
498
|
+
if (!noTabsEmpty) return;
|
|
499
|
+
noTabsEmpty.style.display = this.tabs.length === 0 ? '' : 'none';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Persistence (open tabs in localStorage) ────────────────────────────
|
|
503
|
+
//
|
|
504
|
+
// Per-review key holds an ordered list of session IDs plus the active one.
|
|
505
|
+
// Format: { version: 1, tabs: [sessionId, ...], activeSessionId: number|null }
|
|
506
|
+
// Writes are debounced (100ms trailing) to coalesce bursts during open/restore.
|
|
507
|
+
// Reads/writes are wrapped in try/catch — localStorage failures are soft.
|
|
508
|
+
|
|
509
|
+
_chatTabsStorageKey() {
|
|
510
|
+
if (!this.reviewId) return null;
|
|
511
|
+
return `pair-review:chat-tabs:${this.reviewId}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Serialize open tabs and schedule a write. Only tabs with a saved sessionId
|
|
516
|
+
* are persisted — unsaved (just-opened, pre-session) tabs are ignored.
|
|
517
|
+
*/
|
|
518
|
+
_persistOpenTabs() {
|
|
519
|
+
const key = this._chatTabsStorageKey();
|
|
520
|
+
if (!key) return;
|
|
521
|
+
if (this._persistTimer) {
|
|
522
|
+
clearTimeout(this._persistTimer);
|
|
523
|
+
}
|
|
524
|
+
this._persistTimer = setTimeout(() => {
|
|
525
|
+
this._persistTimer = null;
|
|
526
|
+
try {
|
|
527
|
+
const ids = this.tabs.map(t => t.sessionId).filter(id => id != null);
|
|
528
|
+
if (ids.length === 0) {
|
|
529
|
+
window.localStorage.removeItem(key);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const active = this._getActiveTab();
|
|
533
|
+
const activeSessionId = active?.sessionId ?? null;
|
|
534
|
+
const payload = { version: 1, tabs: ids, activeSessionId };
|
|
535
|
+
window.localStorage.setItem(key, JSON.stringify(payload));
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.warn('[ChatPanel] Failed to persist open tabs:', err);
|
|
538
|
+
}
|
|
539
|
+
}, 100);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Read the persisted tab list for the current reviewId. Returns null when
|
|
544
|
+
* absent, malformed, or the wrong shape.
|
|
545
|
+
* @returns {{ tabs: number[], activeSessionId: number|null }|null}
|
|
546
|
+
*/
|
|
547
|
+
_loadPersistedTabs() {
|
|
548
|
+
const key = this._chatTabsStorageKey();
|
|
549
|
+
if (!key) return null;
|
|
550
|
+
try {
|
|
551
|
+
const raw = window.localStorage.getItem(key);
|
|
552
|
+
if (!raw) return null;
|
|
553
|
+
const parsed = JSON.parse(raw);
|
|
554
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tabs)) return null;
|
|
555
|
+
const tabs = parsed.tabs
|
|
556
|
+
.map(n => parseInt(n, 10))
|
|
557
|
+
.filter(n => Number.isFinite(n));
|
|
558
|
+
if (tabs.length === 0) return null;
|
|
559
|
+
const active = Number.isFinite(parsed.activeSessionId)
|
|
560
|
+
? parseInt(parsed.activeSessionId, 10)
|
|
561
|
+
: null;
|
|
562
|
+
return { tabs, activeSessionId: active };
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.warn('[ChatPanel] Failed to read persisted tabs:', err);
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Restore tabs from a persisted descriptor. Fetches session metadata from
|
|
571
|
+
* the review's sessions list (for first-message titles, provider, model)
|
|
572
|
+
* and renders each tab. Stale session IDs (404 from messages endpoint) are
|
|
573
|
+
* silently dropped and the storage entry is rewritten without them.
|
|
574
|
+
*
|
|
575
|
+
* Returns true if at least one tab was restored.
|
|
576
|
+
*
|
|
577
|
+
* @param {{ tabs: number[], activeSessionId: number|null }} saved
|
|
578
|
+
* @returns {Promise<boolean>}
|
|
579
|
+
*/
|
|
580
|
+
async _restoreTabs(saved) {
|
|
581
|
+
if (!saved || !this.reviewId) return false;
|
|
582
|
+
|
|
583
|
+
// Fetch all sessions for this review once for metadata lookup
|
|
584
|
+
const sessions = await this._fetchSessions();
|
|
585
|
+
const sessionById = new Map(sessions.map(s => [s.id, s]));
|
|
586
|
+
|
|
587
|
+
const surviving = [];
|
|
588
|
+
for (const sid of saved.tabs) {
|
|
589
|
+
const meta = sessionById.get(sid);
|
|
590
|
+
// If the session doesn't appear in the sessions list, it's been deleted
|
|
591
|
+
// out-of-band. Skip it — it'll be pruned from storage by the write at
|
|
592
|
+
// the end of this function.
|
|
593
|
+
if (!meta) continue;
|
|
594
|
+
|
|
595
|
+
const tab = this._createTab({
|
|
596
|
+
sessionId: sid,
|
|
597
|
+
provider: meta.provider || this._activeProvider,
|
|
598
|
+
model: meta.model || null,
|
|
599
|
+
sessionWarm: false,
|
|
600
|
+
});
|
|
601
|
+
if (meta.first_message) {
|
|
602
|
+
tab.title = this._truncate(meta.first_message, 28);
|
|
603
|
+
tab.titleFromUser = true;
|
|
604
|
+
}
|
|
605
|
+
// Append without focus — we focus the chosen tab in one go below
|
|
606
|
+
this._appendTab(tab, { focus: false });
|
|
607
|
+
this._subscribeTab(tab);
|
|
608
|
+
|
|
609
|
+
// Load history without blocking other tabs — race-guarded load knows how
|
|
610
|
+
// to discard responses that arrive after the tab has been closed/swapped.
|
|
611
|
+
// Track the in-flight promise on the tab so sendMessage can await before
|
|
612
|
+
// mutating its message list.
|
|
613
|
+
if (meta.message_count > 0) {
|
|
614
|
+
const p = this._loadMessageHistory(sid, tab)
|
|
615
|
+
.catch(err => console.warn('[ChatPanel] Restore: history load failed for', sid, err))
|
|
616
|
+
.finally(() => {
|
|
617
|
+
if (tab.historyLoadPromise === p) tab.historyLoadPromise = null;
|
|
618
|
+
});
|
|
619
|
+
tab.historyLoadPromise = p;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
surviving.push({ tab, meta });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (surviving.length === 0) return false;
|
|
626
|
+
|
|
627
|
+
// Choose which tab gets focus: prefer the saved activeSessionId, else
|
|
628
|
+
// the rightmost restored tab.
|
|
629
|
+
let focusTab = null;
|
|
630
|
+
if (saved.activeSessionId != null) {
|
|
631
|
+
const found = surviving.find(s => s.meta.id === saved.activeSessionId);
|
|
632
|
+
if (found) focusTab = found.tab;
|
|
633
|
+
}
|
|
634
|
+
if (!focusTab) focusTab = surviving[surviving.length - 1].tab;
|
|
635
|
+
this._switchToTab(this._tabKey(focusTab));
|
|
636
|
+
|
|
637
|
+
// Re-persist to prune any stale IDs that were dropped above
|
|
638
|
+
this._persistOpenTabs();
|
|
639
|
+
|
|
640
|
+
// Await the focused tab's history before returning so the panel doesn't
|
|
641
|
+
// expose an empty conversation to the user mid-load.
|
|
642
|
+
if (focusTab.historyLoadPromise) {
|
|
643
|
+
try { await focusTab.historyLoadPromise; } catch { /* swallow */ }
|
|
644
|
+
}
|
|
645
|
+
// A 404 in _loadMessageHistory silently closes the tab via the in-line
|
|
646
|
+
// removal path. If the focused tab was the casualty (or every tab was),
|
|
647
|
+
// signal failure so the caller falls through to _loadMRUSession.
|
|
648
|
+
if (this.tabs.length === 0 || !this.tabs.includes(focusTab)) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Escape a string for safe use in an HTML attribute value.
|
|
656
|
+
*/
|
|
657
|
+
_escapeAttr(text) {
|
|
658
|
+
if (!text) return '';
|
|
659
|
+
return String(text)
|
|
660
|
+
.replace(/&/g, '&')
|
|
661
|
+
.replace(/"/g, '"')
|
|
662
|
+
.replace(/'/g, ''')
|
|
663
|
+
.replace(/</g, '<')
|
|
664
|
+
.replace(/>/g, '>');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Open a fresh tab and focus it. Does NOT eagerly POST to the backend —
|
|
669
|
+
* the session is lazily created on first send by sendMessage. Side effects:
|
|
670
|
+
* - Allocates a tab descriptor with sessionId=null.
|
|
671
|
+
* - Appends it to the strip and focuses it.
|
|
672
|
+
* - Surfaces analysis context (when one is available locally) so the user
|
|
673
|
+
* sees the card before they ever type a message.
|
|
674
|
+
* @returns {Promise<void>}
|
|
675
|
+
*/
|
|
676
|
+
async _openNewTab() {
|
|
677
|
+
if (!this.reviewId) {
|
|
678
|
+
console.warn('[ChatPanel] _openNewTab: no reviewId yet');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const tab = this._createTab({ provider: this._activeProvider });
|
|
682
|
+
this._appendTab(tab, { focus: true });
|
|
683
|
+
// No session yet, so _showAnalysisContextIfPresent gets a null sessionData.
|
|
684
|
+
// We still want the auto-detected analysis card surfaced on the fresh tab
|
|
685
|
+
// — that's what _ensureAnalysisContext handles. Route it through the
|
|
686
|
+
// captured tab so a focus change can't bleed the card elsewhere.
|
|
687
|
+
this._ensureAnalysisContext(tab);
|
|
688
|
+
if (this.isOpen) this.inputEl?.focus();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Create a backend chat session bound to a specific tab. Subscribes the tab
|
|
693
|
+
* to its session's WebSocket topic and updates the tab's sessionId.
|
|
694
|
+
* @param {ChatTab} tab
|
|
695
|
+
* @returns {Promise<Object|null>}
|
|
696
|
+
*/
|
|
697
|
+
async _createSessionForTab(tab) {
|
|
698
|
+
if (!this.reviewId) return null;
|
|
699
|
+
if (!tab) return null;
|
|
700
|
+
// Capture the provider at entry. If the user swaps providers on this tab
|
|
701
|
+
// while the POST is in flight, the response describes a session for the
|
|
702
|
+
// wrong provider — we must abandon it and let the next send (with the new
|
|
703
|
+
// provider) start fresh.
|
|
704
|
+
const capturedProvider = tab.provider;
|
|
705
|
+
const isAcp = this._getProviderType(capturedProvider) === 'acp';
|
|
706
|
+
if (isAcp) this._showStatusFlash('Starting Agent Client Protocol');
|
|
707
|
+
try {
|
|
708
|
+
const body = { provider: capturedProvider, reviewId: this.reviewId };
|
|
709
|
+
if (tab.analysisContextRemoved) body.skipAnalysisContext = true;
|
|
710
|
+
const response = await fetch('/api/chat/session', {
|
|
711
|
+
method: 'POST',
|
|
712
|
+
headers: { 'Content-Type': 'application/json' },
|
|
713
|
+
body: JSON.stringify(body)
|
|
714
|
+
});
|
|
715
|
+
if (isAcp) this._hideStatusFlash();
|
|
716
|
+
if (!response.ok) {
|
|
717
|
+
const err = await response.json().catch(() => ({}));
|
|
718
|
+
throw new Error(err.error || 'Failed to create chat session');
|
|
719
|
+
}
|
|
720
|
+
const result = await response.json();
|
|
721
|
+
// The tab may have been closed while the POST was in flight. Hand the
|
|
722
|
+
// server-side session back so it can be cleaned up, and bail.
|
|
723
|
+
if (!this.tabs.includes(tab)) {
|
|
724
|
+
fetch(`/api/chat/session/${result.data.id}`, { method: 'DELETE' }).catch(() => {});
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
// Provider was swapped between our captured snapshot and the response.
|
|
728
|
+
// The session belongs to capturedProvider — let it be cleaned up and
|
|
729
|
+
// signal failure so the caller's lazy-create path can retry under the
|
|
730
|
+
// new provider.
|
|
731
|
+
if (tab.provider !== capturedProvider) {
|
|
732
|
+
fetch(`/api/chat/session/${result.data.id}`, { method: 'DELETE' }).catch(() => {});
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
tab.sessionId = result.data.id;
|
|
736
|
+
tab.sessionWarm = true;
|
|
737
|
+
if (tab.messagesEl) tab.messagesEl.dataset.tabKey = String(tab.sessionId);
|
|
738
|
+
// Re-key the active marker if this is the focused tab (so getters work)
|
|
739
|
+
if (this.activeTabKey === tab._localKey) this.activeTabKey = tab.sessionId;
|
|
740
|
+
this._subscribeTab(tab);
|
|
741
|
+
this._renderTabStrip();
|
|
742
|
+
this._persistOpenTabs();
|
|
743
|
+
return result.data;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (isAcp) this._hideStatusFlash();
|
|
746
|
+
console.error('[ChatPanel] Error creating session:', error);
|
|
747
|
+
this._showError('Failed to start chat session. ' + error.message, tab);
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Subscribe a tab to its session's WebSocket topic. Idempotent: if a
|
|
754
|
+
* subscription handle already exists, it is left in place.
|
|
755
|
+
* @param {ChatTab} tab
|
|
756
|
+
*/
|
|
757
|
+
_subscribeTab(tab) {
|
|
758
|
+
if (!tab || tab.sessionId == null) return;
|
|
759
|
+
if (tab.wsUnsub) return;
|
|
760
|
+
window.wsClient.connect();
|
|
761
|
+
tab.wsUnsub = window.wsClient.subscribe('chat:' + tab.sessionId, (msg) => {
|
|
762
|
+
this._handleChatMessageForTab(tab, msg);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
_getProviderType(providerId) {
|
|
767
|
+
const entry = this._chatProviders.find(p => p.id === providerId);
|
|
768
|
+
return entry?.type;
|
|
769
|
+
}
|
|
770
|
+
|
|
55
771
|
/**
|
|
56
772
|
* Render the chat panel DOM structure into the container
|
|
57
773
|
*/
|
|
@@ -83,11 +799,6 @@ class ChatPanel {
|
|
|
83
799
|
<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"/>
|
|
84
800
|
</svg>
|
|
85
801
|
</button>
|
|
86
|
-
<button class="chat-panel__new-btn" title="New conversation">
|
|
87
|
-
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
88
|
-
<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"/>
|
|
89
|
-
</svg>
|
|
90
|
-
</button>
|
|
91
802
|
<button class="chat-panel__close-btn" title="Close">
|
|
92
803
|
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
93
804
|
<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"/>
|
|
@@ -95,16 +806,25 @@ class ChatPanel {
|
|
|
95
806
|
</button>
|
|
96
807
|
</div>
|
|
97
808
|
</div>
|
|
809
|
+
<div class="chat-panel__tab-strip" role="tablist">
|
|
810
|
+
<div class="chat-panel__tab-strip-items"></div>
|
|
811
|
+
<button class="chat-panel__tab-new-btn" title="New conversation">
|
|
812
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
813
|
+
<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"/>
|
|
814
|
+
</svg>
|
|
815
|
+
</button>
|
|
816
|
+
</div>
|
|
98
817
|
<div class="chat-panel__status-flash" style="display:none">
|
|
99
818
|
<span class="chat-panel__status-flash-text">Starting Agent Client Protocol</span>
|
|
100
819
|
</div>
|
|
101
820
|
<div class="chat-panel__messages-wrapper">
|
|
102
|
-
<div class="chat-panel__messages" id="chat-messages">
|
|
103
|
-
<div class="chat-panel__empty">
|
|
821
|
+
<div class="chat-panel__messages-stack" id="chat-messages-stack">
|
|
822
|
+
<div class="chat-panel__empty chat-panel__empty--no-tabs">
|
|
104
823
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
|
|
105
824
|
<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"/>
|
|
106
825
|
</svg>
|
|
107
|
-
<p>
|
|
826
|
+
<p>No conversation open.</p>
|
|
827
|
+
<button class="chat-panel__empty-new-btn">Start a new chat</button>
|
|
108
828
|
</div>
|
|
109
829
|
</div>
|
|
110
830
|
<button class="chat-panel__new-content-pill" style="display:none">\u2193 New content</button>
|
|
@@ -163,12 +883,15 @@ class ChatPanel {
|
|
|
163
883
|
|
|
164
884
|
// Cache element references
|
|
165
885
|
this.panel = this.container.querySelector('#chat-panel');
|
|
166
|
-
this.
|
|
886
|
+
this.messagesStackEl = this.container.querySelector('#chat-messages-stack');
|
|
887
|
+
this.tabStripEl = this.container.querySelector('.chat-panel__tab-strip');
|
|
888
|
+
this.tabStripItemsEl = this.container.querySelector('.chat-panel__tab-strip-items');
|
|
889
|
+
this.tabNewBtn = this.container.querySelector('.chat-panel__tab-new-btn');
|
|
167
890
|
this.inputEl = this.container.querySelector('.chat-panel__input');
|
|
168
891
|
this.sendBtn = this.container.querySelector('.chat-panel__send-btn');
|
|
169
892
|
this.stopBtn = this.container.querySelector('.chat-panel__stop-btn');
|
|
170
893
|
this.closeBtn = this.container.querySelector('.chat-panel__close-btn');
|
|
171
|
-
this.
|
|
894
|
+
this.emptyNewBtn = this.container.querySelector('.chat-panel__empty-new-btn');
|
|
172
895
|
this.actionBar = this.container.querySelector('.chat-panel__action-bar');
|
|
173
896
|
this.adoptBtn = this.container.querySelector('.chat-panel__action-btn--adopt');
|
|
174
897
|
this.updateBtn = this.container.querySelector('.chat-panel__action-btn--update');
|
|
@@ -196,8 +919,9 @@ class ChatPanel {
|
|
|
196
919
|
// Close button
|
|
197
920
|
this.closeBtn.addEventListener('click', () => this.close());
|
|
198
921
|
|
|
199
|
-
// New
|
|
200
|
-
this.
|
|
922
|
+
// New tab button (in tab strip)
|
|
923
|
+
this.tabNewBtn?.addEventListener('click', () => this._openNewTab());
|
|
924
|
+
this.emptyNewBtn?.addEventListener('click', () => this._openNewTab());
|
|
201
925
|
|
|
202
926
|
// Provider picker button
|
|
203
927
|
this.providerPickerBtn.addEventListener('click', () => this._toggleProviderDropdown());
|
|
@@ -224,26 +948,6 @@ class ChatPanel {
|
|
|
224
948
|
this.newContentPill.addEventListener('click', () => this.scrollToBottom({ force: true }));
|
|
225
949
|
}
|
|
226
950
|
|
|
227
|
-
// Track scroll direction to disengage/re-engage auto-scroll
|
|
228
|
-
if (this.messagesEl) {
|
|
229
|
-
let lastScrollTop = 0;
|
|
230
|
-
this.messagesEl.addEventListener('scroll', () => {
|
|
231
|
-
const { scrollTop, scrollHeight, clientHeight } = this.messagesEl;
|
|
232
|
-
const distance = scrollHeight - scrollTop - clientHeight;
|
|
233
|
-
|
|
234
|
-
if (scrollTop < lastScrollTop && distance >= NEAR_BOTTOM_THRESHOLD) {
|
|
235
|
-
// Scrolling UP and not near bottom — disengage
|
|
236
|
-
this._userScrolledAway = true;
|
|
237
|
-
this._showNewContentPill();
|
|
238
|
-
} else if (distance < NEAR_BOTTOM_THRESHOLD) {
|
|
239
|
-
// Back near bottom — re-engage
|
|
240
|
-
this._userScrolledAway = false;
|
|
241
|
-
this._hideNewContentPill();
|
|
242
|
-
}
|
|
243
|
-
lastScrollTop = scrollTop;
|
|
244
|
-
}, { passive: true });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
951
|
// Textarea input handling
|
|
248
952
|
this.inputEl.addEventListener('input', () => {
|
|
249
953
|
this._autoResizeTextarea();
|
|
@@ -294,8 +998,8 @@ class ChatPanel {
|
|
|
294
998
|
};
|
|
295
999
|
document.addEventListener('keydown', this._onKeydown);
|
|
296
1000
|
|
|
297
|
-
// Chat file link click handler (event delegation)
|
|
298
|
-
this.
|
|
1001
|
+
// Chat file link click handler (event delegation on the per-tab stack)
|
|
1002
|
+
this.messagesStackEl?.addEventListener('click', (e) => {
|
|
299
1003
|
const link = e.target.closest('.chat-file-link');
|
|
300
1004
|
if (link) {
|
|
301
1005
|
e.preventDefault();
|
|
@@ -491,14 +1195,31 @@ class ChatPanel {
|
|
|
491
1195
|
* @param {number} options.reviewId - Review ID
|
|
492
1196
|
* @param {number} options.suggestionId - Suggestion ID to ask about
|
|
493
1197
|
* @param {Object} options.suggestionContext - AI suggestion details for context
|
|
494
|
-
* @param {Object} options.commentContext -
|
|
1198
|
+
* @param {Object} options.commentContext - Comment details for context
|
|
495
1199
|
* @param {string} options.commentContext.commentId - Comment ID
|
|
496
1200
|
* @param {string} options.commentContext.body - Comment body text
|
|
497
1201
|
* @param {string} options.commentContext.file - File path
|
|
498
1202
|
* @param {number} options.commentContext.line_start - Start line number
|
|
499
1203
|
* @param {number} options.commentContext.line_end - End line number
|
|
500
|
-
* @param {string} options.commentContext.source - 'user' for user comments
|
|
1204
|
+
* @param {string} options.commentContext.source - 'user' for user comments, 'external' for external systems (e.g. GitHub)
|
|
1205
|
+
* @param {string} [options.commentContext.externalSource] - When source === 'external', the external system id (e.g. 'github'). Drives theming.
|
|
1206
|
+
* @param {string} [options.commentContext.externalUrl] - Permalink to the comment in the external system.
|
|
1207
|
+
* @param {boolean} [options.commentContext.isOutdated] - Whether the external comment is anchored to an outdated diff position.
|
|
1208
|
+
* @param {string} [options.commentContext.author] - External author/username.
|
|
501
1209
|
* @param {boolean} options.commentContext.isFileLevel - True if file-level comment
|
|
1210
|
+
* @param {Object} options.threadContext - Multi-comment thread context (external systems only)
|
|
1211
|
+
* @param {number|string} options.threadContext.rootId - Local id of the thread root
|
|
1212
|
+
* @param {string} options.threadContext.source - Always 'external' for now
|
|
1213
|
+
* @param {string} options.threadContext.externalSource - e.g. 'github'; drives theming + label
|
|
1214
|
+
* @param {string} options.threadContext.file - File path
|
|
1215
|
+
* @param {number|null} options.threadContext.line_start - Start line (null if outdated)
|
|
1216
|
+
* @param {number|null} options.threadContext.line_end - End line (null if outdated)
|
|
1217
|
+
* @param {Array<Object>} options.threadContext.comments - Ordered comments in the thread
|
|
1218
|
+
* @param {string|null} options.threadContext.comments[].author - Author name
|
|
1219
|
+
* @param {string} options.threadContext.comments[].body - Comment markdown
|
|
1220
|
+
* @param {boolean} options.threadContext.comments[].isOutdated - Whether this comment is outdated
|
|
1221
|
+
* @param {string|null} options.threadContext.comments[].externalUrl - Permalink in the source system
|
|
1222
|
+
* @param {string|null} options.threadContext.comments[].externalCreatedAt - ISO timestamp of creation
|
|
502
1223
|
*/
|
|
503
1224
|
async open(options = {}) {
|
|
504
1225
|
// Concurrency guard: if a previous open() is still loading MRU / messages,
|
|
@@ -550,15 +1271,49 @@ class ChatPanel {
|
|
|
550
1271
|
this.panel.classList.remove('chat-panel--closed');
|
|
551
1272
|
this.panel.classList.add('chat-panel--open');
|
|
552
1273
|
|
|
553
|
-
// Ensure
|
|
1274
|
+
// Ensure review-scope subscription is active. Per-tab subscriptions are
|
|
1275
|
+
// attached as tabs are created/restored.
|
|
554
1276
|
this._ensureSubscriptions();
|
|
555
1277
|
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1278
|
+
// Recognise thread context (external systems) alongside suggestion / user
|
|
1279
|
+
// comment / file when deciding whether to open with explicit context.
|
|
1280
|
+
const hasExplicitContext = !!(
|
|
1281
|
+
options.suggestionContext ||
|
|
1282
|
+
options.commentContext ||
|
|
1283
|
+
options.threadContext ||
|
|
1284
|
+
options.fileContext
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
// First-time open behaviour:
|
|
1288
|
+
// 1. If localStorage has a tab list for this review, restore those tabs.
|
|
1289
|
+
// 2. Else, fall back to the legacy single-tab + MRU-load path.
|
|
1290
|
+
// Explicit context (Ask about this / Chat about this file) lands in the
|
|
1291
|
+
// currently-active tab — the user is augmenting the conversation they're
|
|
1292
|
+
// already in. Only spin up a fresh tab when there isn't one yet.
|
|
1293
|
+
if (this.tabs.length === 0) {
|
|
1294
|
+
let restored = false;
|
|
1295
|
+
if (this.reviewId && !hasExplicitContext) {
|
|
1296
|
+
const saved = this._loadPersistedTabs();
|
|
1297
|
+
if (saved) {
|
|
1298
|
+
restored = await this._restoreTabs(saved);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (!restored) {
|
|
1302
|
+
const tab = this._createTab({ provider: this._activeProvider });
|
|
1303
|
+
this._appendTab(tab, { focus: true });
|
|
1304
|
+
if (!hasExplicitContext) {
|
|
1305
|
+
await this._loadMRUSession();
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Resync the Send/Stop controls from the active tab's streaming state on
|
|
1311
|
+
// every open so a reopen mid-stream shows the Stop button.
|
|
1312
|
+
const activeOnOpen = this._getActiveTab();
|
|
1313
|
+
if (activeOnOpen) {
|
|
1314
|
+
this.sendBtn.style.display = activeOnOpen.isStreaming ? 'none' : '';
|
|
1315
|
+
this.stopBtn.style.display = activeOnOpen.isStreaming ? '' : 'none';
|
|
1316
|
+
this.sendBtn.disabled = activeOnOpen.isStreaming || !this.inputEl?.value?.trim();
|
|
562
1317
|
}
|
|
563
1318
|
|
|
564
1319
|
// Ensure analysis context is added on every expand — not just when opening
|
|
@@ -582,10 +1337,19 @@ class ChatPanel {
|
|
|
582
1337
|
line_start: options.commentContext.line_start,
|
|
583
1338
|
line_end: options.commentContext.line_end,
|
|
584
1339
|
};
|
|
1340
|
+
} else if (options.commentContext.source === 'external') {
|
|
1341
|
+
// External comments are read-only — no adopt/update/dismiss actions
|
|
1342
|
+
this._contextSource = 'external-comment';
|
|
1343
|
+
this._contextItemId = options.commentContext.commentId || null;
|
|
585
1344
|
} else {
|
|
586
1345
|
this._contextSource = 'user';
|
|
587
1346
|
this._contextItemId = options.commentContext.commentId || null;
|
|
588
1347
|
}
|
|
1348
|
+
} else if (options.threadContext) {
|
|
1349
|
+
// If opening with thread context (external systems only), inject as a card
|
|
1350
|
+
this._sendThreadContextMessage(options.threadContext);
|
|
1351
|
+
this._contextSource = 'external-thread';
|
|
1352
|
+
this._contextItemId = options.threadContext.rootId || null;
|
|
589
1353
|
} else if (options.fileContext) {
|
|
590
1354
|
// If opening with file context, inject it as a context card
|
|
591
1355
|
this._sendFileContextMessage(options.fileContext);
|
|
@@ -620,11 +1384,14 @@ class ChatPanel {
|
|
|
620
1384
|
this.isOpen = false;
|
|
621
1385
|
this.panel.classList.remove('chat-panel--open');
|
|
622
1386
|
this.panel.classList.add('chat-panel--closed');
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1387
|
+
const closeTab = this._getActiveTab();
|
|
1388
|
+
if (closeTab) {
|
|
1389
|
+
closeTab.pendingContext = [];
|
|
1390
|
+
closeTab.pendingContextData = [];
|
|
1391
|
+
closeTab.contextSource = null;
|
|
1392
|
+
closeTab.contextItemId = null;
|
|
1393
|
+
closeTab.contextLineMeta = null;
|
|
1394
|
+
}
|
|
628
1395
|
// Zero out CSS variable so max-width calcs don't reserve space (mirrors AIPanel.collapse)
|
|
629
1396
|
document.documentElement.style.setProperty('--chat-panel-width', '0px');
|
|
630
1397
|
// Preserve _analysisContextRemoved and _sessionAnalysisRunId across
|
|
@@ -646,73 +1413,20 @@ class ChatPanel {
|
|
|
646
1413
|
}
|
|
647
1414
|
|
|
648
1415
|
/**
|
|
649
|
-
* Start a new conversation
|
|
650
|
-
*
|
|
1416
|
+
* Start a new conversation by opening a fresh tab.
|
|
1417
|
+
* Unlike the legacy single-tab implementation this no longer destroys the
|
|
1418
|
+
* current conversation — it simply adds a new tab and focuses it. Any
|
|
1419
|
+
* unsent pending context is left on the previous tab.
|
|
651
1420
|
*/
|
|
652
1421
|
async _startNewConversation() {
|
|
653
1422
|
this._hideProviderDropdown();
|
|
654
1423
|
this._hideSessionDropdown();
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
// 2. Clear everything as normal
|
|
663
|
-
this._finalizeStreaming();
|
|
664
|
-
this.currentSessionId = null;
|
|
665
|
-
this._resubscribeChat(); // Unsubscribe old chat topic
|
|
666
|
-
this.messages = [];
|
|
667
|
-
this._streamingContent = '';
|
|
668
|
-
this._pendingContext = [];
|
|
669
|
-
this._pendingContextData = [];
|
|
670
|
-
this._pendingDiffStateNotifications = [];
|
|
671
|
-
this._pendingUserActionHints = [];
|
|
672
|
-
this._contextSource = null;
|
|
673
|
-
this._contextItemId = null;
|
|
674
|
-
this._contextLineMeta = null;
|
|
675
|
-
this._analysisContextRemoved = false;
|
|
676
|
-
this._sessionAnalysisRunId = null;
|
|
677
|
-
this._clearMessages();
|
|
678
|
-
this._updateActionButtons();
|
|
679
|
-
this._updateTitle(); // Reset title for new conversation
|
|
680
|
-
|
|
681
|
-
// 3. Re-add analysis context (appears first, handled separately from pending arrays)
|
|
682
|
-
this._ensureAnalysisContext();
|
|
683
|
-
|
|
684
|
-
// 4. Re-add saved pending context cards (if any were unsent)
|
|
685
|
-
if (savedContext.length > 0) {
|
|
686
|
-
// Remove empty state since we're about to add context cards
|
|
687
|
-
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
688
|
-
if (emptyState) emptyState.remove();
|
|
689
|
-
|
|
690
|
-
// Restore context metadata
|
|
691
|
-
this._contextSource = savedContextSource;
|
|
692
|
-
this._contextItemId = savedContextItemId;
|
|
693
|
-
this._contextLineMeta = savedContextLineMeta;
|
|
694
|
-
|
|
695
|
-
for (let i = 0; i < savedContextData.length; i++) {
|
|
696
|
-
const ctxData = savedContextData[i];
|
|
697
|
-
this._pendingContext.push(savedContext[i]);
|
|
698
|
-
this._pendingContextData.push(ctxData);
|
|
699
|
-
|
|
700
|
-
// Render the appropriate card type based on the context data
|
|
701
|
-
if (ctxData.type === 'file') {
|
|
702
|
-
this._addFileContextCard(ctxData, { removable: true });
|
|
703
|
-
} else if (ctxData.type === 'line') {
|
|
704
|
-
this._addLineContextCard(ctxData, { removable: true });
|
|
705
|
-
} else if (ctxData.type === 'comment') {
|
|
706
|
-
this._addCommentContextCard(ctxData, { removable: true });
|
|
707
|
-
} else if (ctxData.type === 'analysis-run') {
|
|
708
|
-
this._addAnalysisRunContextCard(ctxData, { removable: true });
|
|
709
|
-
} else {
|
|
710
|
-
this._addContextCard(ctxData, { removable: true });
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
this._updateActionButtons();
|
|
715
|
-
}
|
|
1424
|
+
// Multi-chat replaces the legacy in-place reset with a fresh tab; the
|
|
1425
|
+
// per-tab restore path inside _appendTab handles context cards
|
|
1426
|
+
// (including thread cards introduced by external-comments) so the
|
|
1427
|
+
// savedContext / ctxData.type === 'thread' branch is no longer needed
|
|
1428
|
+
// at this entry point.
|
|
1429
|
+
await this._openNewTab();
|
|
716
1430
|
}
|
|
717
1431
|
|
|
718
1432
|
/**
|
|
@@ -734,29 +1448,87 @@ class ChatPanel {
|
|
|
734
1448
|
}
|
|
735
1449
|
|
|
736
1450
|
/**
|
|
737
|
-
* Load the most recently used session
|
|
738
|
-
* Picks the first session (MRU) and loads its message history.
|
|
1451
|
+
* Load the most recently used session into the active tab.
|
|
1452
|
+
* Picks the first session (MRU) and loads its message history. Assumes the
|
|
1453
|
+
* caller has already created an active tab via _appendTab().
|
|
739
1454
|
*/
|
|
740
1455
|
async _loadMRUSession() {
|
|
741
|
-
|
|
1456
|
+
const tab = this._getActiveTab();
|
|
1457
|
+
if (!tab || !this.reviewId) return;
|
|
1458
|
+
// Capture the messagesEl reference for the race-guard after the fetch.
|
|
1459
|
+
const capturedEl = tab.messagesEl;
|
|
742
1460
|
|
|
743
1461
|
try {
|
|
744
1462
|
const sessions = await this._fetchSessions();
|
|
745
1463
|
if (sessions.length === 0) return;
|
|
746
1464
|
|
|
747
|
-
|
|
748
|
-
this.
|
|
749
|
-
|
|
750
|
-
this.
|
|
1465
|
+
// Race guard: by the time _fetchSessions resolved, the user may have
|
|
1466
|
+
// closed this tab or swapped its session. If anything looks stale,
|
|
1467
|
+
// abandon the load.
|
|
1468
|
+
if (!this.tabs.includes(tab)) return;
|
|
1469
|
+
if (tab.sessionId != null) return; // tab was assigned a session by another path
|
|
1470
|
+
if (tab.messagesEl !== capturedEl) return;
|
|
1471
|
+
if (!capturedEl?.isConnected) return;
|
|
1472
|
+
|
|
1473
|
+
// Skip MRU candidates already open in another tab — picking one would
|
|
1474
|
+
// create a duplicate. Walk down the list (most-recent first) until we
|
|
1475
|
+
// find one that no surviving tab is bound to.
|
|
1476
|
+
let mru = null;
|
|
1477
|
+
for (const candidate of sessions) {
|
|
1478
|
+
if (!this._findTabBySessionId(candidate.id)) {
|
|
1479
|
+
mru = candidate;
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (!mru) {
|
|
1484
|
+
// Every recent session is already open — focus the most recent one
|
|
1485
|
+
// and drop the empty placeholder so we don't litter the strip.
|
|
1486
|
+
const firstOpen = this._findTabBySessionId(sessions[0].id);
|
|
1487
|
+
if (firstOpen && firstOpen !== tab) {
|
|
1488
|
+
this._switchToTab(this._tabKey(firstOpen));
|
|
1489
|
+
// The placeholder may be the originating `tab`. Remove it so the
|
|
1490
|
+
// user doesn't see an empty extra tab alongside the focused one.
|
|
1491
|
+
if (this.tabs.includes(tab) && tab.sessionId == null) {
|
|
1492
|
+
this._removeTabFromDom(tab, { skipDelete: true });
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
tab.sessionId = mru.id;
|
|
1499
|
+
// Re-key the active marker only if this tab is still the active one
|
|
1500
|
+
// (don't yank focus from a tab the user switched to during the fetch).
|
|
1501
|
+
const wasActive = this.activeTabKey === tab._localKey;
|
|
1502
|
+
if (wasActive) this.activeTabKey = mru.id;
|
|
1503
|
+
tab.sessionWarm = false;
|
|
1504
|
+
if (tab.messagesEl) tab.messagesEl.dataset.tabKey = String(tab.sessionId);
|
|
1505
|
+
this._subscribeTab(tab);
|
|
1506
|
+
this._persistOpenTabs();
|
|
751
1507
|
console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
|
|
752
1508
|
|
|
753
|
-
if (mru.provider) {
|
|
754
|
-
|
|
755
|
-
|
|
1509
|
+
if (mru.provider) {
|
|
1510
|
+
tab.provider = mru.provider;
|
|
1511
|
+
tab.model = mru.model;
|
|
1512
|
+
// Only update the global header/active provider when this tab is in the
|
|
1513
|
+
// foreground; otherwise a stale MRU load would yank the header out from
|
|
1514
|
+
// under the user's currently focused tab.
|
|
1515
|
+
if (this._getActiveTab() === tab) {
|
|
1516
|
+
this._activeProvider = mru.provider;
|
|
1517
|
+
this._updateTitle(mru.provider, mru.model);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Title heuristic: prefer first user message preview if available
|
|
1522
|
+
if (mru.first_message) {
|
|
1523
|
+
tab.titleFromUser = true;
|
|
1524
|
+
this._setTabTitle(tab, this._truncate(mru.first_message, 28));
|
|
1525
|
+
} else {
|
|
1526
|
+
this._setTabTitle(tab, tab.title);
|
|
756
1527
|
}
|
|
1528
|
+
this._renderTabStrip();
|
|
757
1529
|
|
|
758
1530
|
if (mru.message_count > 0) {
|
|
759
|
-
await this._loadMessageHistory(mru.id);
|
|
1531
|
+
await this._loadMessageHistory(mru.id, tab);
|
|
760
1532
|
}
|
|
761
1533
|
} catch (err) {
|
|
762
1534
|
console.warn('[ChatPanel] Failed to load MRU session:', err);
|
|
@@ -766,21 +1538,79 @@ class ChatPanel {
|
|
|
766
1538
|
/**
|
|
767
1539
|
* Load and render message history for a session.
|
|
768
1540
|
* Fetches messages from the API and renders context cards and message bubbles.
|
|
769
|
-
*
|
|
1541
|
+
*
|
|
1542
|
+
* Race-safe: captures the target tab at call time. If the response arrives
|
|
1543
|
+
* after the user switched tabs, closed this tab, or swapped its session via
|
|
1544
|
+
* the history picker, the write is abandoned and the response is discarded.
|
|
1545
|
+
*
|
|
1546
|
+
* Stale-tolerant: a 404 (session deleted out-of-band) prunes the tab from
|
|
1547
|
+
* the persisted list and silently closes it. Other errors are warned.
|
|
1548
|
+
*
|
|
1549
|
+
* @param {number} sessionId - Session ID to fetch messages for
|
|
1550
|
+
* @param {ChatTab} [targetTab] - Tab to render into. Defaults to the active
|
|
1551
|
+
* tab at call time. Tests + the restore path pass this explicitly so the
|
|
1552
|
+
* tab is not derived from the active-tab getter.
|
|
770
1553
|
*/
|
|
771
|
-
async _loadMessageHistory(sessionId) {
|
|
1554
|
+
async _loadMessageHistory(sessionId, targetTab) {
|
|
1555
|
+
const tab = targetTab || this._getActiveTab();
|
|
1556
|
+
if (!tab) return;
|
|
1557
|
+
// Capture the messagesEl reference at call time. If the tab is later
|
|
1558
|
+
// closed, messagesEl is detached from the DOM — we check via isConnected
|
|
1559
|
+
// before writing.
|
|
1560
|
+
const capturedEl = tab.messagesEl;
|
|
1561
|
+
if (!capturedEl) return;
|
|
1562
|
+
|
|
1563
|
+
let response;
|
|
772
1564
|
try {
|
|
773
|
-
|
|
774
|
-
|
|
1565
|
+
response = await fetch(`/api/chat/session/${sessionId}/messages`);
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
console.warn('[ChatPanel] Failed to load message history:', err);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
775
1570
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1571
|
+
// Stale-session tolerance: the session was deleted out-of-band.
|
|
1572
|
+
if (response.status === 404) {
|
|
1573
|
+
// Apply the same captured-tab guards as the success branch below — the
|
|
1574
|
+
// tab may have been closed, repointed to a different session, or had
|
|
1575
|
+
// its messagesEl re-created while the request was in flight.
|
|
1576
|
+
if (!this.tabs.includes(tab)) return;
|
|
1577
|
+
if (tab.sessionId !== sessionId) return;
|
|
1578
|
+
if (tab.messagesEl !== capturedEl) return;
|
|
1579
|
+
if (!capturedEl.isConnected) return;
|
|
1580
|
+
console.debug('[ChatPanel] Session', sessionId, 'returned 404, removing tab');
|
|
1581
|
+
this._removeTabFromDom(tab, { skipDelete: true });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (!response.ok) return;
|
|
1585
|
+
|
|
1586
|
+
let result;
|
|
1587
|
+
try {
|
|
1588
|
+
result = await response.json();
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
console.warn('[ChatPanel] Failed to parse message history:', err);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const messages = result.data?.messages || [];
|
|
1594
|
+
if (messages.length === 0) return;
|
|
1595
|
+
|
|
1596
|
+
// Race guard: by the time the fetch resolved, the tab may have been
|
|
1597
|
+
// closed, had its session swapped via the history picker, or had its
|
|
1598
|
+
// messagesEl re-created. Bail in any of those cases.
|
|
1599
|
+
if (!this.tabs.includes(tab)) return;
|
|
1600
|
+
if (tab.sessionId !== sessionId) return;
|
|
1601
|
+
if (tab.messagesEl !== capturedEl) return;
|
|
1602
|
+
if (!capturedEl.isConnected) return;
|
|
779
1603
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1604
|
+
// Remove empty state
|
|
1605
|
+
const emptyState = capturedEl.querySelector('.chat-panel__empty');
|
|
1606
|
+
if (emptyState) emptyState.remove();
|
|
783
1607
|
|
|
1608
|
+
// Render into the target tab. The helper methods (_addContextCard,
|
|
1609
|
+
// addMessage, etc.) read `this.messagesEl` via the active-tab getter, so
|
|
1610
|
+
// we temporarily redirect the active marker for the synchronous render
|
|
1611
|
+
// loop. _switchToTab toggles visibility — we use a lower-level swap
|
|
1612
|
+
// (_renderInTab) so the user's actual focused tab stays focused.
|
|
1613
|
+
this._renderInTab(tab, () => {
|
|
784
1614
|
for (const msg of messages) {
|
|
785
1615
|
if (msg.type === 'context') {
|
|
786
1616
|
// Render context card from stored context data
|
|
@@ -794,6 +1624,8 @@ class ChatPanel {
|
|
|
794
1624
|
this._addLineContextCard(ctxData);
|
|
795
1625
|
} else if (ctxData.type === 'comment') {
|
|
796
1626
|
this._addCommentContextCard(ctxData);
|
|
1627
|
+
} else if (ctxData.type === 'thread') {
|
|
1628
|
+
this._addThreadContextCard(ctxData);
|
|
797
1629
|
} else {
|
|
798
1630
|
this._addContextCard(ctxData);
|
|
799
1631
|
}
|
|
@@ -804,8 +1636,42 @@ class ChatPanel {
|
|
|
804
1636
|
this.addMessage(msg.role, msg.content, msg.id);
|
|
805
1637
|
}
|
|
806
1638
|
}
|
|
807
|
-
}
|
|
808
|
-
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// The tab was initialized as 'pending' (gray dot) before history loaded.
|
|
1642
|
+
// Now that messages exist, promote to 'idle' (blue dot) — but don't
|
|
1643
|
+
// override a streaming/error state that may have started up in parallel.
|
|
1644
|
+
if (tab.status === 'pending' && tab.messages.length > 0) {
|
|
1645
|
+
this._updateTabStatus(tab, 'idle');
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Synchronously run a render block as if `tab` were the active tab so the
|
|
1651
|
+
* shared render helpers (which read `this.messagesEl` via the active-tab
|
|
1652
|
+
* getter) write into the right per-tab container. Restores the original
|
|
1653
|
+
* active marker after the block.
|
|
1654
|
+
*
|
|
1655
|
+
* Strictly synchronous: do not pass an async function here. The visible
|
|
1656
|
+
* focused tab is preserved because we touch only `activeTabKey`, not the
|
|
1657
|
+
* DOM visibility toggles in `_switchToTab`.
|
|
1658
|
+
*
|
|
1659
|
+
* @param {ChatTab} tab - The tab to render into
|
|
1660
|
+
* @param {Function} fn - Synchronous render callback
|
|
1661
|
+
*/
|
|
1662
|
+
_renderInTab(tab, fn) {
|
|
1663
|
+
if (!tab) return;
|
|
1664
|
+
const prev = this.activeTabKey;
|
|
1665
|
+
const key = this._tabKey(tab);
|
|
1666
|
+
if (prev === key) {
|
|
1667
|
+
fn();
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
this.activeTabKey = key;
|
|
1671
|
+
try {
|
|
1672
|
+
fn();
|
|
1673
|
+
} finally {
|
|
1674
|
+
this.activeTabKey = prev;
|
|
809
1675
|
}
|
|
810
1676
|
}
|
|
811
1677
|
|
|
@@ -901,14 +1767,46 @@ class ChatPanel {
|
|
|
901
1767
|
}
|
|
902
1768
|
|
|
903
1769
|
/**
|
|
904
|
-
* Select a provider.
|
|
1770
|
+
* Select a provider. Reuses the currently active tab when it is "fresh"
|
|
1771
|
+
* (no messages, no streaming, no user-renamed title) — that's the common
|
|
1772
|
+
* just-opened-a-tab case where spawning a sibling would just litter the
|
|
1773
|
+
* strip. Otherwise opens a new tab so the prior conversation isn't lost.
|
|
1774
|
+
*
|
|
1775
|
+
* Note: with lazy session creation, a fresh tab typically has sessionId
|
|
1776
|
+
* null and we simply swap `tab.provider`. If the tab already got a session
|
|
1777
|
+
* (e.g. a previous lazy send just resolved), DELETE the old one so the
|
|
1778
|
+
* next send creates a fresh session under the new provider.
|
|
1779
|
+
*
|
|
905
1780
|
* @param {string} id - Provider ID to activate
|
|
906
1781
|
*/
|
|
907
|
-
_selectProvider(id) {
|
|
1782
|
+
async _selectProvider(id) {
|
|
908
1783
|
if (id === this._activeProvider) return;
|
|
909
1784
|
this._activeProvider = id;
|
|
910
1785
|
this._updateTitle();
|
|
911
|
-
|
|
1786
|
+
|
|
1787
|
+
const tab = this._getActiveTab();
|
|
1788
|
+
const isFresh = tab
|
|
1789
|
+
&& tab.messages.length === 0
|
|
1790
|
+
&& !tab.isStreaming
|
|
1791
|
+
&& !tab.streamingContent
|
|
1792
|
+
&& !tab.titleFromUser;
|
|
1793
|
+
|
|
1794
|
+
if (!isFresh) {
|
|
1795
|
+
await this._openNewTab();
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
tab.provider = id;
|
|
1800
|
+
if (tab.wsUnsub) { try { tab.wsUnsub(); } catch { /* noop */ } tab.wsUnsub = null; }
|
|
1801
|
+
if (tab.sessionId != null) {
|
|
1802
|
+
const staleId = tab.sessionId;
|
|
1803
|
+
tab.sessionId = null;
|
|
1804
|
+
tab.sessionWarm = false;
|
|
1805
|
+
// Restore the active marker so getter delegation still finds this tab.
|
|
1806
|
+
if (this.activeTabKey === staleId) this.activeTabKey = tab._localKey;
|
|
1807
|
+
fetch(`/api/chat/session/${staleId}`, { method: 'DELETE' }).catch(() => {});
|
|
1808
|
+
}
|
|
1809
|
+
this._renderTabStrip();
|
|
912
1810
|
}
|
|
913
1811
|
|
|
914
1812
|
// ── Session picker dropdown ────────────────────────────────────────────
|
|
@@ -977,8 +1875,17 @@ class ChatPanel {
|
|
|
977
1875
|
return;
|
|
978
1876
|
}
|
|
979
1877
|
|
|
1878
|
+
// Build a set of sessionIds currently open in any tab so the dropdown can
|
|
1879
|
+
// mark them as "(open)" — clicking one focuses the existing tab rather
|
|
1880
|
+
// than duplicating it into the active tab via _switchToSession.
|
|
1881
|
+
const openSessionIds = new Set(
|
|
1882
|
+
this.tabs.map(t => t.sessionId).filter(id => id != null)
|
|
1883
|
+
);
|
|
1884
|
+
const activeSessionId = this.currentSessionId;
|
|
1885
|
+
|
|
980
1886
|
const items = sessions.map(s => {
|
|
981
|
-
const isActive = s.id ===
|
|
1887
|
+
const isActive = s.id === activeSessionId;
|
|
1888
|
+
const isOpenElsewhere = openSessionIds.has(s.id) && !isActive;
|
|
982
1889
|
const preview = s.first_message
|
|
983
1890
|
? this._truncate(s.first_message, 60)
|
|
984
1891
|
: 'New conversation';
|
|
@@ -986,11 +1893,18 @@ class ChatPanel {
|
|
|
986
1893
|
const providerLabel = s.provider
|
|
987
1894
|
? `<span class="chat-panel__session-provider">${this._escapeHtml(this._getProviderDisplayName(s.provider))}</span>`
|
|
988
1895
|
: '';
|
|
1896
|
+
const openTag = (isActive || isOpenElsewhere)
|
|
1897
|
+
? '<span class="chat-panel__session-open-tag">open</span>'
|
|
1898
|
+
: '';
|
|
1899
|
+
|
|
1900
|
+
const classes = ['chat-panel__session-item'];
|
|
1901
|
+
if (isActive) classes.push('chat-panel__session-item--active');
|
|
1902
|
+
if (isOpenElsewhere) classes.push('chat-panel__session-item--open');
|
|
989
1903
|
|
|
990
1904
|
return `
|
|
991
|
-
<button class="
|
|
1905
|
+
<button class="${classes.join(' ')}"
|
|
992
1906
|
data-session-id="${s.id}">
|
|
993
|
-
<span class="chat-panel__session-preview">${this._escapeHtml(preview)}</span>
|
|
1907
|
+
<span class="chat-panel__session-preview">${this._escapeHtml(preview)}${openTag}</span>
|
|
994
1908
|
<span class="chat-panel__session-meta">${providerLabel}${this._escapeHtml(timeAgo)}</span>
|
|
995
1909
|
</button>
|
|
996
1910
|
`;
|
|
@@ -1003,62 +1917,105 @@ class ChatPanel {
|
|
|
1003
1917
|
btn.addEventListener('click', () => {
|
|
1004
1918
|
const sessionId = parseInt(btn.dataset.sessionId, 10);
|
|
1005
1919
|
const sessionData = sessions.find(s => s.id === sessionId);
|
|
1006
|
-
if (sessionData) {
|
|
1920
|
+
if (!sessionData) {
|
|
1921
|
+
this._hideSessionDropdown();
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
// If this session is already open in another tab, focus that tab
|
|
1925
|
+
// instead of swapping the active tab's session (avoid duplicates).
|
|
1926
|
+
const existingTab = this._findTabBySessionId(sessionId);
|
|
1927
|
+
if (existingTab && this._tabKey(existingTab) !== this.activeTabKey) {
|
|
1928
|
+
this._switchToTab(this._tabKey(existingTab));
|
|
1929
|
+
} else if (!existingTab) {
|
|
1930
|
+
// Not currently open — swap the active tab to it (legacy behavior)
|
|
1007
1931
|
this._switchToSession(sessionId, sessionData);
|
|
1008
1932
|
}
|
|
1933
|
+
// If it's already the active tab, clicking is a no-op (re-focuses, but no work needed)
|
|
1009
1934
|
this._hideSessionDropdown();
|
|
1010
1935
|
});
|
|
1011
1936
|
});
|
|
1012
1937
|
}
|
|
1013
1938
|
|
|
1014
1939
|
/**
|
|
1015
|
-
*
|
|
1016
|
-
* Tears down current state and loads the target
|
|
1940
|
+
* Swap the active tab's session for a different one (e.g. via the history
|
|
1941
|
+
* picker). Tears down current state on this tab and loads the target
|
|
1942
|
+
* session's messages. Other tabs are untouched.
|
|
1017
1943
|
* @param {number} sessionId - The session ID to switch to
|
|
1018
1944
|
* @param {Object} sessionData - Session metadata (provider, model, message_count, etc.)
|
|
1019
1945
|
*/
|
|
1020
1946
|
async _switchToSession(sessionId, sessionData) {
|
|
1021
|
-
|
|
1947
|
+
const tab = this._getActiveTab();
|
|
1948
|
+
if (!tab) return;
|
|
1949
|
+
if (sessionId === tab.sessionId) return;
|
|
1022
1950
|
|
|
1023
|
-
// 1. Finalize any active stream
|
|
1951
|
+
// 1. Finalize any active stream on this tab
|
|
1024
1952
|
this._finalizeStreaming();
|
|
1025
1953
|
|
|
1026
|
-
// 2.
|
|
1027
|
-
this
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
this.
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1954
|
+
// 2. Tear down the existing subscription so events for the old session
|
|
1955
|
+
// don't keep flowing into this tab
|
|
1956
|
+
if (tab.wsUnsub) { try { tab.wsUnsub(); } catch { /* noop */ } tab.wsUnsub = null; }
|
|
1957
|
+
|
|
1958
|
+
// 3. Reset per-tab state
|
|
1959
|
+
tab.sessionId = sessionId;
|
|
1960
|
+
this.activeTabKey = sessionId;
|
|
1961
|
+
if (tab.messagesEl) tab.messagesEl.dataset.tabKey = String(sessionId);
|
|
1962
|
+
tab.sessionWarm = false;
|
|
1963
|
+
tab.messages = [];
|
|
1964
|
+
tab.streamingContent = '';
|
|
1965
|
+
tab.streamingMsgEl = null;
|
|
1966
|
+
tab.pendingContext = [];
|
|
1967
|
+
tab.pendingContextData = [];
|
|
1968
|
+
tab.latestDiffState = null;
|
|
1969
|
+
tab.pendingUserActionHints = [];
|
|
1970
|
+
tab.contextSource = null;
|
|
1971
|
+
tab.contextItemId = null;
|
|
1972
|
+
tab.contextLineMeta = null;
|
|
1973
|
+
tab.pendingActionContext = null;
|
|
1974
|
+
tab.analysisContextRemoved = false;
|
|
1975
|
+
tab.sessionAnalysisRunId = null;
|
|
1976
|
+
tab.errorMessage = null;
|
|
1977
|
+
tab.isStreaming = false;
|
|
1978
|
+
tab.titleFromUser = !!sessionData.first_message;
|
|
1979
|
+
this._updateTabStatus(tab, 'idle');
|
|
1980
|
+
|
|
1981
|
+
// 4. Subscribe to the new session
|
|
1982
|
+
this._subscribeTab(tab);
|
|
1983
|
+
this._persistOpenTabs();
|
|
1984
|
+
|
|
1985
|
+
// 5. Clear UI and update title
|
|
1044
1986
|
this._clearMessages();
|
|
1045
1987
|
this._updateActionButtons();
|
|
1046
|
-
|
|
1047
|
-
// 4. Update title
|
|
1048
1988
|
if (sessionData.provider) {
|
|
1049
|
-
|
|
1989
|
+
tab.provider = sessionData.provider;
|
|
1990
|
+
tab.model = sessionData.model;
|
|
1050
1991
|
this._activeProvider = sessionData.provider;
|
|
1992
|
+
this._updateTitle(sessionData.provider, sessionData.model);
|
|
1051
1993
|
} else {
|
|
1052
1994
|
this._updateTitle();
|
|
1053
1995
|
}
|
|
1054
1996
|
|
|
1055
|
-
//
|
|
1997
|
+
// 6. Update tab title from the session's first user message
|
|
1998
|
+
if (sessionData.first_message) {
|
|
1999
|
+
this._setTabTitle(tab, this._truncate(sessionData.first_message, 28));
|
|
2000
|
+
} else {
|
|
2001
|
+
this._setTabTitle(tab, _nextNewTabTitle());
|
|
2002
|
+
tab.titleFromUser = false;
|
|
2003
|
+
}
|
|
2004
|
+
this._renderTabStrip();
|
|
2005
|
+
|
|
2006
|
+
// 7. Load message history (race-guarded — passes explicit tab so a
|
|
2007
|
+
// subsequent tab switch can't reroute the render)
|
|
1056
2008
|
if (sessionData.message_count > 0) {
|
|
1057
|
-
await this._loadMessageHistory(sessionId);
|
|
2009
|
+
await this._loadMessageHistory(sessionId, tab);
|
|
1058
2010
|
}
|
|
1059
2011
|
|
|
1060
|
-
//
|
|
1061
|
-
|
|
2012
|
+
// Re-check after the await: the user may have closed the tab or swapped
|
|
2013
|
+
// it again. Route _ensureAnalysisContext through the captured tab so the
|
|
2014
|
+
// analysis card lands on the right messages container.
|
|
2015
|
+
if (!this.tabs.includes(tab) || tab.sessionId !== sessionId) return;
|
|
2016
|
+
|
|
2017
|
+
// 8. Ensure analysis context for the new session
|
|
2018
|
+
this._ensureAnalysisContext(tab);
|
|
1062
2019
|
}
|
|
1063
2020
|
|
|
1064
2021
|
/**
|
|
@@ -1098,10 +2055,12 @@ class ChatPanel {
|
|
|
1098
2055
|
}
|
|
1099
2056
|
|
|
1100
2057
|
/**
|
|
1101
|
-
* Clear all messages from the display and show empty state
|
|
2058
|
+
* Clear all messages from the active tab's display and show empty state.
|
|
1102
2059
|
*/
|
|
1103
2060
|
_clearMessages() {
|
|
1104
|
-
|
|
2061
|
+
const tab = this._getActiveTab();
|
|
2062
|
+
if (!tab?.messagesEl) return;
|
|
2063
|
+
tab.messagesEl.innerHTML = `
|
|
1105
2064
|
<div class="chat-panel__empty">
|
|
1106
2065
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
|
|
1107
2066
|
<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"/>
|
|
@@ -1109,6 +2068,7 @@ class ChatPanel {
|
|
|
1109
2068
|
<p>Ask questions about this review, or click "Ask about this" on any suggestion.</p>
|
|
1110
2069
|
</div>
|
|
1111
2070
|
`;
|
|
2071
|
+
tab.streamingMsgEl = null;
|
|
1112
2072
|
}
|
|
1113
2073
|
|
|
1114
2074
|
/**
|
|
@@ -1147,40 +2107,65 @@ class ChatPanel {
|
|
|
1147
2107
|
this._enableInput();
|
|
1148
2108
|
}
|
|
1149
2109
|
|
|
1150
|
-
// If the panel is already open,
|
|
2110
|
+
// If the panel is already open, restore tabs (or fall back to MRU load).
|
|
2111
|
+
// open() may have ran before reviewId was bound; tabs in that case are
|
|
2112
|
+
// empty or contain a single empty tab.
|
|
1151
2113
|
if (this.isOpen && !this.currentSessionId) {
|
|
1152
|
-
|
|
2114
|
+
let restored = false;
|
|
2115
|
+
const saved = this._loadPersistedTabs();
|
|
2116
|
+
if (saved) {
|
|
2117
|
+
// If a placeholder tab was created by open() with no sessionId, drop
|
|
2118
|
+
// it so restored tabs don't appear alongside an unused "New Chat".
|
|
2119
|
+
if (this.tabs.length === 1 && this.tabs[0].sessionId == null) {
|
|
2120
|
+
const placeholder = this.tabs[0];
|
|
2121
|
+
if (placeholder.messagesEl?.parentNode) {
|
|
2122
|
+
placeholder.messagesEl.parentNode.removeChild(placeholder.messagesEl);
|
|
2123
|
+
}
|
|
2124
|
+
this.tabs = [];
|
|
2125
|
+
this.activeTabKey = null;
|
|
2126
|
+
}
|
|
2127
|
+
restored = await this._restoreTabs(saved);
|
|
2128
|
+
}
|
|
2129
|
+
if (!restored) {
|
|
2130
|
+
if (this.tabs.length === 0) {
|
|
2131
|
+
const tab = this._createTab({ provider: this._activeProvider });
|
|
2132
|
+
this._appendTab(tab, { focus: true });
|
|
2133
|
+
}
|
|
2134
|
+
await this._loadMRUSession();
|
|
2135
|
+
}
|
|
1153
2136
|
this._ensureAnalysisContext();
|
|
1154
2137
|
}
|
|
1155
2138
|
}
|
|
1156
2139
|
|
|
1157
2140
|
/**
|
|
1158
|
-
* Create a new chat session via API
|
|
2141
|
+
* Create a new chat session via API for the active tab.
|
|
1159
2142
|
* @param {number} contextCommentId - Optional AI suggestion ID for context
|
|
1160
2143
|
* @returns {Object|null} Session data ({ id, status, context? }) or null on failure
|
|
1161
2144
|
*/
|
|
1162
2145
|
async createSession(contextCommentId) {
|
|
2146
|
+
// Ensure there is an active tab to bind the new session to. This happens
|
|
2147
|
+
// for lazy-creation paths (first sendMessage on an empty panel).
|
|
2148
|
+
if (!this._getActiveTab()) {
|
|
2149
|
+
const tab = this._createTab({ provider: this._activeProvider });
|
|
2150
|
+
this._appendTab(tab, { focus: true });
|
|
2151
|
+
}
|
|
1163
2152
|
if (!this.reviewId) {
|
|
1164
2153
|
console.warn('[ChatPanel] No reviewId available');
|
|
1165
2154
|
return null;
|
|
1166
2155
|
}
|
|
2156
|
+
const tab = this._getActiveTab();
|
|
2157
|
+
if (!tab) return null;
|
|
1167
2158
|
|
|
1168
2159
|
const isAcp = this._isAcpProvider();
|
|
1169
|
-
if (isAcp)
|
|
1170
|
-
this._showStatusFlash('Starting Agent Client Protocol');
|
|
1171
|
-
}
|
|
2160
|
+
if (isAcp) this._showStatusFlash('Starting Agent Client Protocol');
|
|
1172
2161
|
|
|
1173
2162
|
try {
|
|
1174
2163
|
const body = {
|
|
1175
|
-
provider: this._activeProvider,
|
|
2164
|
+
provider: tab.provider || this._activeProvider,
|
|
1176
2165
|
reviewId: this.reviewId
|
|
1177
2166
|
};
|
|
1178
|
-
if (contextCommentId)
|
|
1179
|
-
|
|
1180
|
-
}
|
|
1181
|
-
if (this._analysisContextRemoved) {
|
|
1182
|
-
body.skipAnalysisContext = true;
|
|
1183
|
-
}
|
|
2167
|
+
if (contextCommentId) body.contextCommentId = contextCommentId;
|
|
2168
|
+
if (tab.analysisContextRemoved) body.skipAnalysisContext = true;
|
|
1184
2169
|
|
|
1185
2170
|
console.debug('[ChatPanel] Creating session for review', this.reviewId);
|
|
1186
2171
|
const response = await fetch('/api/chat/session', {
|
|
@@ -1197,87 +2182,129 @@ class ChatPanel {
|
|
|
1197
2182
|
}
|
|
1198
2183
|
|
|
1199
2184
|
const result = await response.json();
|
|
1200
|
-
this.
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
2185
|
+
if (!this.tabs.includes(tab)) {
|
|
2186
|
+
fetch(`/api/chat/session/${result.data.id}`, { method: 'DELETE' }).catch(() => {});
|
|
2187
|
+
return null;
|
|
2188
|
+
}
|
|
2189
|
+
tab.sessionId = result.data.id;
|
|
2190
|
+
this.activeTabKey = result.data.id;
|
|
2191
|
+
tab.sessionWarm = true;
|
|
2192
|
+
if (tab.messagesEl) tab.messagesEl.dataset.tabKey = String(tab.sessionId);
|
|
2193
|
+
this._subscribeTab(tab);
|
|
2194
|
+
this._renderTabStrip();
|
|
2195
|
+
this._persistOpenTabs();
|
|
2196
|
+
console.debug('[ChatPanel] Session created:', tab.sessionId);
|
|
1204
2197
|
return result.data;
|
|
1205
2198
|
} catch (error) {
|
|
1206
2199
|
if (isAcp) this._hideStatusFlash();
|
|
1207
2200
|
console.error('[ChatPanel] Error creating session:', error);
|
|
1208
|
-
this._showError('Failed to start chat session. ' + error.message);
|
|
2201
|
+
this._showError('Failed to start chat session. ' + error.message, tab);
|
|
1209
2202
|
return null;
|
|
1210
2203
|
}
|
|
1211
2204
|
}
|
|
1212
2205
|
|
|
1213
2206
|
/**
|
|
1214
|
-
* Send the current input text as a message
|
|
2207
|
+
* Send the current input text as a message.
|
|
2208
|
+
*
|
|
2209
|
+
* Captures the originating tab once at entry. All subsequent state reads
|
|
2210
|
+
* and writes go through that explicit reference so awaits cannot reroute
|
|
2211
|
+
* the send to whichever tab is active when the promise resolves.
|
|
1215
2212
|
*/
|
|
1216
2213
|
async sendMessage() {
|
|
1217
2214
|
const content = this.inputEl.value.trim();
|
|
1218
|
-
if (!content
|
|
2215
|
+
if (!content) return;
|
|
2216
|
+
|
|
2217
|
+
// Capture the originating tab BEFORE any awaits. Bail if no tab.
|
|
2218
|
+
let tab = this._getActiveTab();
|
|
2219
|
+
if (!tab) {
|
|
2220
|
+
tab = this._createTab({ provider: this._activeProvider });
|
|
2221
|
+
this._appendTab(tab, { focus: true });
|
|
2222
|
+
}
|
|
2223
|
+
if (tab.isStreaming) return;
|
|
1219
2224
|
|
|
1220
2225
|
// Save message text before clearing (for error recovery)
|
|
1221
2226
|
const messageText = content;
|
|
1222
2227
|
|
|
1223
|
-
// Clear input
|
|
2228
|
+
// Clear input UP FRONT — BEFORE the historyLoadPromise await — so a tab
|
|
2229
|
+
// switch during the wait doesn't leave the typed content sitting in the
|
|
2230
|
+
// shared textarea on someone else's tab. We only restore on error if the
|
|
2231
|
+
// user hasn't moved on.
|
|
1224
2232
|
this.inputEl.value = '';
|
|
1225
2233
|
this._autoResizeTextarea();
|
|
1226
2234
|
this.sendBtn.disabled = true;
|
|
1227
2235
|
|
|
2236
|
+
// If history is still loading for this tab, wait for it to finish so we
|
|
2237
|
+
// don't race with the renderer.
|
|
2238
|
+
if (tab.historyLoadPromise) {
|
|
2239
|
+
try { await tab.historyLoadPromise; } catch { /* swallow */ }
|
|
2240
|
+
if (!this.tabs.includes(tab)) return;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1228
2243
|
// Remove empty state if present
|
|
1229
|
-
const emptyState =
|
|
2244
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
1230
2245
|
if (emptyState) emptyState.remove();
|
|
1231
2246
|
|
|
1232
2247
|
// Display user message (just the user's actual text)
|
|
1233
|
-
const msgElRef = this.addMessage('user', content);
|
|
2248
|
+
const msgElRef = this.addMessage('user', content, undefined, tab);
|
|
1234
2249
|
|
|
1235
2250
|
// Lazy session creation: create on first message, not on panel open
|
|
1236
|
-
if (
|
|
2251
|
+
if (tab.sessionId == null) {
|
|
1237
2252
|
this._ensureSubscriptions();
|
|
1238
|
-
const sessionData = await this.
|
|
2253
|
+
const sessionData = await this._createSessionForTab(tab);
|
|
2254
|
+
if (!this.tabs.includes(tab)) return;
|
|
1239
2255
|
if (!sessionData) {
|
|
1240
|
-
// Restore the user's message text into the input
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
this.
|
|
2256
|
+
// Restore the user's message text into the input — but ONLY if the
|
|
2257
|
+
// user is still focused on the originating tab. Otherwise they may
|
|
2258
|
+
// have moved on and typed something on a sibling; we mustn't clobber.
|
|
2259
|
+
if (this._getActiveTab() === tab && !this.inputEl.value) {
|
|
2260
|
+
this.inputEl.value = messageText;
|
|
2261
|
+
this._autoResizeTextarea();
|
|
2262
|
+
this.sendBtn.disabled = false;
|
|
2263
|
+
}
|
|
1244
2264
|
// Remove the phantom message bubble
|
|
1245
2265
|
if (msgElRef) msgElRef.remove();
|
|
1246
|
-
|
|
2266
|
+
tab.messages.pop();
|
|
1247
2267
|
// Show error
|
|
1248
|
-
this._showError('Unable to start chat session. Please try again.');
|
|
2268
|
+
this._showError('Unable to start chat session. Please try again.', tab);
|
|
1249
2269
|
return;
|
|
1250
2270
|
}
|
|
1251
|
-
|
|
2271
|
+
// Route through the captured tab so a focus change between the POST
|
|
2272
|
+
// and the response can't bleed the analysis card into a sibling tab.
|
|
2273
|
+
this._showAnalysisContextIfPresent(sessionData, tab);
|
|
1252
2274
|
}
|
|
1253
|
-
// If
|
|
1254
|
-
|
|
1255
|
-
// Prepare streaming UI
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
this.
|
|
1259
|
-
this.
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
2275
|
+
// If sessionId is set (from MRU), just send — server auto-resumes
|
|
2276
|
+
|
|
2277
|
+
// Prepare streaming UI. Clear any previous error on this tab.
|
|
2278
|
+
tab.errorMessage = null;
|
|
2279
|
+
tab.isStreaming = true;
|
|
2280
|
+
this._updateTabStatus(tab, 'streaming');
|
|
2281
|
+
if (this._getActiveTab() === tab) {
|
|
2282
|
+
this.sendBtn.disabled = true;
|
|
2283
|
+
this.sendBtn.style.display = 'none';
|
|
2284
|
+
this.stopBtn.style.display = '';
|
|
2285
|
+
this._updateActionButtons();
|
|
2286
|
+
}
|
|
2287
|
+
tab.streamingContent = '';
|
|
2288
|
+
this._addStreamingPlaceholder(tab);
|
|
1263
2289
|
|
|
1264
2290
|
// Build the API payload — may include pending context from "Ask about this"
|
|
1265
2291
|
const payload = { content };
|
|
1266
2292
|
|
|
1267
|
-
// Snapshot diff-state
|
|
1268
|
-
|
|
2293
|
+
// Snapshot diff-state for error recovery (invisible to user, no UI cards).
|
|
2294
|
+
// Diff state is a snapshot — one latest value per tab. Drain via copy-and-clear.
|
|
2295
|
+
const savedDiffState = tab.latestDiffState;
|
|
1269
2296
|
let diffStatePrefix = '';
|
|
1270
|
-
if (
|
|
1271
|
-
diffStatePrefix = '[Diff State Update]\n' +
|
|
1272
|
-
|
|
2297
|
+
if (savedDiffState) {
|
|
2298
|
+
diffStatePrefix = '[Diff State Update]\n' + savedDiffState;
|
|
2299
|
+
tab.latestDiffState = null;
|
|
1273
2300
|
}
|
|
1274
2301
|
|
|
1275
|
-
// Snapshot user-action-hints queue for error recovery (
|
|
1276
|
-
const savedUserActionHints =
|
|
2302
|
+
// Snapshot user-action-hints queue for error recovery (ordered, per-tab)
|
|
2303
|
+
const savedUserActionHints = tab.pendingUserActionHints.slice();
|
|
1277
2304
|
let userActionPrefix = '';
|
|
1278
|
-
if (
|
|
1279
|
-
userActionPrefix = '[User Action Hints]\n' +
|
|
1280
|
-
|
|
2305
|
+
if (tab.pendingUserActionHints.length > 0) {
|
|
2306
|
+
userActionPrefix = '[User Action Hints]\n' + tab.pendingUserActionHints.join('\n');
|
|
2307
|
+
tab.pendingUserActionHints = [];
|
|
1281
2308
|
}
|
|
1282
2309
|
|
|
1283
2310
|
// Combine invisible prefixes (diff state + user action hints)
|
|
@@ -1288,19 +2315,19 @@ class ChatPanel {
|
|
|
1288
2315
|
invisiblePrefix = diffStatePrefix || userActionPrefix;
|
|
1289
2316
|
}
|
|
1290
2317
|
|
|
1291
|
-
const savedContext =
|
|
1292
|
-
const savedContextData =
|
|
1293
|
-
if (
|
|
1294
|
-
const userContext =
|
|
2318
|
+
const savedContext = tab.pendingContext.slice();
|
|
2319
|
+
const savedContextData = tab.pendingContextData.slice();
|
|
2320
|
+
if (tab.pendingContext.length > 0) {
|
|
2321
|
+
const userContext = tab.pendingContext.join('\n\n');
|
|
1295
2322
|
payload.context = invisiblePrefix
|
|
1296
2323
|
? invisiblePrefix + '\n\n' + userContext
|
|
1297
2324
|
: userContext;
|
|
1298
|
-
payload.contextData =
|
|
1299
|
-
|
|
1300
|
-
|
|
2325
|
+
payload.contextData = tab.pendingContextData;
|
|
2326
|
+
tab.pendingContext = [];
|
|
2327
|
+
tab.pendingContextData = [];
|
|
1301
2328
|
|
|
1302
2329
|
// Lock context cards — remove close buttons and index attributes
|
|
1303
|
-
const removableCards =
|
|
2330
|
+
const removableCards = tab.messagesEl?.querySelectorAll('.chat-panel__context-card[data-context-index]') || [];
|
|
1304
2331
|
removableCards.forEach((card) => {
|
|
1305
2332
|
const btn = card.querySelector('.chat-panel__context-remove');
|
|
1306
2333
|
if (btn) btn.remove();
|
|
@@ -1311,54 +2338,60 @@ class ChatPanel {
|
|
|
1311
2338
|
}
|
|
1312
2339
|
|
|
1313
2340
|
// Lock analysis context card (not indexed, handled separately from pending context)
|
|
1314
|
-
const analysisRemoveBtn =
|
|
2341
|
+
const analysisRemoveBtn = tab.messagesEl?.querySelector('.chat-panel__context-card[data-analysis] .chat-panel__context-remove');
|
|
1315
2342
|
if (analysisRemoveBtn) analysisRemoveBtn.remove();
|
|
1316
2343
|
|
|
1317
2344
|
// Attach action context (set by action button handlers — adopt, update, dismiss)
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
2345
|
+
const savedActionContext = tab.pendingActionContext;
|
|
2346
|
+
if (tab.pendingActionContext) {
|
|
2347
|
+
payload.actionContext = tab.pendingActionContext;
|
|
2348
|
+
tab.pendingActionContext = null;
|
|
1321
2349
|
}
|
|
1322
2350
|
|
|
1323
2351
|
// Show ACP resume flash when the session may need server-side auto-resume
|
|
1324
|
-
const acpResuming = this._isAcpProvider() && !
|
|
2352
|
+
const acpResuming = this._isAcpProvider() && !tab.sessionWarm;
|
|
1325
2353
|
if (acpResuming) {
|
|
1326
2354
|
this._showStatusFlash('Resuming Agent Client Protocol');
|
|
1327
2355
|
}
|
|
1328
2356
|
|
|
1329
2357
|
// Send to API
|
|
1330
2358
|
try {
|
|
1331
|
-
console.debug('[ChatPanel] Sending message to session',
|
|
1332
|
-
let response = await fetch(`/api/chat/session/${
|
|
2359
|
+
console.debug('[ChatPanel] Sending message to session', tab.sessionId);
|
|
2360
|
+
let response = await fetch(`/api/chat/session/${tab.sessionId}/message`, {
|
|
1333
2361
|
method: 'POST',
|
|
1334
2362
|
headers: { 'Content-Type': 'application/json' },
|
|
1335
2363
|
body: JSON.stringify(payload)
|
|
1336
2364
|
});
|
|
2365
|
+
if (!this.tabs.includes(tab)) return;
|
|
1337
2366
|
|
|
1338
2367
|
if (acpResuming) {
|
|
1339
2368
|
this._hideStatusFlash();
|
|
1340
|
-
|
|
2369
|
+
tab.sessionWarm = true;
|
|
1341
2370
|
}
|
|
1342
2371
|
|
|
1343
|
-
// Handle 410 Gone: session is not resumable — transparently create a new
|
|
1344
|
-
//
|
|
1345
|
-
// _showStatusFlash() which overwrites the pill text directly, avoiding a
|
|
1346
|
-
// visible hide/show flicker during the transparent retry.
|
|
2372
|
+
// Handle 410 Gone: session is not resumable — transparently create a new
|
|
2373
|
+
// one bound to the SAME tab and retry once.
|
|
1347
2374
|
if (response.status === 410) {
|
|
1348
2375
|
console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
2376
|
+
if (tab.wsUnsub) { try { tab.wsUnsub(); } catch { /* noop */ } tab.wsUnsub = null; }
|
|
2377
|
+
// Re-anchor the active marker to the tab's local key BEFORE nulling
|
|
2378
|
+
// sessionId so _createSessionForTab can re-bind the SAME tab via the
|
|
2379
|
+
// _localKey check (rather than orphaning this tab and spawning a new).
|
|
2380
|
+
const wasActive = this._getActiveTab() === tab;
|
|
2381
|
+
tab.sessionId = null;
|
|
2382
|
+
if (wasActive) this.activeTabKey = tab._localKey;
|
|
2383
|
+
const sessionData = await this._createSessionForTab(tab);
|
|
2384
|
+
if (!this.tabs.includes(tab)) return;
|
|
1353
2385
|
if (!sessionData) {
|
|
1354
2386
|
throw new Error('Failed to create replacement session');
|
|
1355
2387
|
}
|
|
1356
2388
|
|
|
1357
|
-
response = await fetch(`/api/chat/session/${
|
|
2389
|
+
response = await fetch(`/api/chat/session/${tab.sessionId}/message`, {
|
|
1358
2390
|
method: 'POST',
|
|
1359
2391
|
headers: { 'Content-Type': 'application/json' },
|
|
1360
2392
|
body: JSON.stringify(payload)
|
|
1361
2393
|
});
|
|
2394
|
+
if (!this.tabs.includes(tab)) return;
|
|
1362
2395
|
}
|
|
1363
2396
|
|
|
1364
2397
|
if (!response.ok) {
|
|
@@ -1367,38 +2400,55 @@ class ChatPanel {
|
|
|
1367
2400
|
}
|
|
1368
2401
|
console.debug('[ChatPanel] Message accepted, waiting for WebSocket events');
|
|
1369
2402
|
} catch (error) {
|
|
2403
|
+
if (!this.tabs.includes(tab)) return;
|
|
1370
2404
|
if (acpResuming) this._hideStatusFlash();
|
|
1371
|
-
// Restore pending
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
2405
|
+
// Restore pending state on the originating tab so it's not lost.
|
|
2406
|
+
tab.pendingContext = savedContext;
|
|
2407
|
+
tab.pendingContextData = savedContextData;
|
|
2408
|
+
// Only restore the snapshot if no newer one arrived during the send.
|
|
2409
|
+
// Diff state is a snapshot — a freshly queued value supersedes the old.
|
|
2410
|
+
if (savedDiffState && !tab.latestDiffState) tab.latestDiffState = savedDiffState;
|
|
2411
|
+
tab.pendingUserActionHints = [...savedUserActionHints, ...tab.pendingUserActionHints];
|
|
2412
|
+
if (savedActionContext && !tab.pendingActionContext) tab.pendingActionContext = savedActionContext;
|
|
1376
2413
|
// Restore removability on context cards that were locked before the failed send
|
|
1377
|
-
this._restoreRemovableCards();
|
|
2414
|
+
this._restoreRemovableCards(tab);
|
|
1378
2415
|
console.error('[ChatPanel] Error sending message:', error);
|
|
1379
|
-
this._showError('Failed to send message. ' + error.message);
|
|
1380
|
-
this._finalizeStreaming();
|
|
2416
|
+
this._showError('Failed to send message. ' + error.message, tab);
|
|
2417
|
+
this._finalizeStreaming(tab);
|
|
1381
2418
|
}
|
|
1382
2419
|
}
|
|
1383
2420
|
|
|
1384
2421
|
/**
|
|
1385
2422
|
* Queue an invisible diff-state notification for the chat agent.
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
2423
|
+
*
|
|
2424
|
+
* Diff state is a SNAPSHOT — every tab gets the same latest value, and a
|
|
2425
|
+
* subsequent call overwrites the prior value rather than appending. Drained
|
|
2426
|
+
* from the originating tab on its next sendMessage(). If no tabs are open
|
|
2427
|
+
* yet, the value is stashed on the panel so the next tab created inherits
|
|
2428
|
+
* it.
|
|
1388
2429
|
* @param {string} message - Description of the diff state change
|
|
1389
2430
|
*/
|
|
1390
2431
|
queueDiffStateNotification(message) {
|
|
1391
|
-
|
|
2432
|
+
// Always cache the latest snapshot so tabs created later inherit it.
|
|
2433
|
+
// Without this, a tab opened between the first notification and the first
|
|
2434
|
+
// send would never see the snapshot.
|
|
2435
|
+
this._initialDiffState = message;
|
|
2436
|
+
if (this.tabs.length === 0) return;
|
|
2437
|
+
for (const tab of this.tabs) tab.latestDiffState = message;
|
|
1392
2438
|
}
|
|
1393
2439
|
|
|
1394
2440
|
/**
|
|
1395
2441
|
* Queue an invisible user-action hint for the chat agent.
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
2442
|
+
*
|
|
2443
|
+
* Hints are ordered events and attribution matters: they belong to the tab
|
|
2444
|
+
* that was active when the action happened. Background tabs never
|
|
2445
|
+
* accumulate. Silent no-op when no active tab.
|
|
2446
|
+
* @param {string} message - Description of the user action
|
|
1399
2447
|
*/
|
|
1400
2448
|
queueUserActionHint(message) {
|
|
1401
|
-
this.
|
|
2449
|
+
const active = this._getActiveTab();
|
|
2450
|
+
if (!active) return;
|
|
2451
|
+
active.pendingUserActionHints.push(message);
|
|
1402
2452
|
}
|
|
1403
2453
|
|
|
1404
2454
|
/**
|
|
@@ -1409,8 +2459,10 @@ class ChatPanel {
|
|
|
1409
2459
|
* @param {Object} ctx - Suggestion context {title, type, file, line_start, line_end, body}
|
|
1410
2460
|
*/
|
|
1411
2461
|
_sendContextMessage(ctx) {
|
|
2462
|
+
const tab = this._getActiveTab();
|
|
2463
|
+
if (!tab) return;
|
|
1412
2464
|
// Remove empty state if present
|
|
1413
|
-
const emptyState =
|
|
2465
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
1414
2466
|
if (emptyState) emptyState.remove();
|
|
1415
2467
|
|
|
1416
2468
|
// Store structured context data for DB persistence (session resumption)
|
|
@@ -1423,7 +2475,7 @@ class ChatPanel {
|
|
|
1423
2475
|
side: ctx.side || null,
|
|
1424
2476
|
body: ctx.body || null
|
|
1425
2477
|
};
|
|
1426
|
-
|
|
2478
|
+
tab.pendingContextData.push(contextData);
|
|
1427
2479
|
|
|
1428
2480
|
// Build the plain text context for the agent (will be prepended to next message)
|
|
1429
2481
|
const lines = ['The user wants to discuss this specific suggestion:'];
|
|
@@ -1458,26 +2510,30 @@ class ChatPanel {
|
|
|
1458
2510
|
}
|
|
1459
2511
|
}
|
|
1460
2512
|
|
|
1461
|
-
|
|
2513
|
+
tab.pendingContext.push(lines.join('\n'));
|
|
1462
2514
|
|
|
1463
2515
|
// Render the compact context card in the UI
|
|
1464
2516
|
this._addContextCard(ctx, { removable: true });
|
|
1465
2517
|
}
|
|
1466
2518
|
|
|
1467
2519
|
/**
|
|
1468
|
-
* Store pending context and render a compact context card for a user comment or line reference.
|
|
1469
|
-
* Called when the user clicks "Ask about this" on a user comment,
|
|
1470
|
-
* the gutter chat button (line reference with no comment body).
|
|
2520
|
+
* Store pending context and render a compact context card for a user/external comment or line reference.
|
|
2521
|
+
* Called when the user clicks "Ask about this" on a user comment, an external (e.g. GitHub) comment,
|
|
2522
|
+
* or clicks the gutter chat button (line reference with no comment body).
|
|
1471
2523
|
* The context is NOT sent to the agent immediately -- it is prepended
|
|
1472
2524
|
* to the next user message so the agent receives question + context together.
|
|
1473
|
-
* @param {Object} ctx - Comment context
|
|
2525
|
+
* @param {Object} ctx - Comment context. When `source === 'external'`, additional fields are honored:
|
|
2526
|
+
* `externalSource` (string, e.g. 'github'), `externalUrl` (permalink), `isOutdated` (boolean), `author` (string).
|
|
1474
2527
|
*/
|
|
1475
2528
|
_sendCommentContextMessage(ctx) {
|
|
2529
|
+
const tab = this._getActiveTab();
|
|
2530
|
+
if (!tab) return;
|
|
1476
2531
|
// Remove empty state if present
|
|
1477
|
-
const emptyState =
|
|
2532
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
1478
2533
|
if (emptyState) emptyState.remove();
|
|
1479
2534
|
|
|
1480
2535
|
const isLine = ctx.type === 'line';
|
|
2536
|
+
const isExternal = ctx.source === 'external';
|
|
1481
2537
|
|
|
1482
2538
|
// Store structured context data for DB persistence
|
|
1483
2539
|
const lineLabel = !ctx.line_start
|
|
@@ -1489,19 +2545,33 @@ class ChatPanel {
|
|
|
1489
2545
|
? lineLabel
|
|
1490
2546
|
: (ctx.isFileLevel ? 'File comment' : `Comment on line ${ctx.line_start || '?'}`),
|
|
1491
2547
|
file: ctx.file || null,
|
|
2548
|
+
side: ctx.side || null,
|
|
1492
2549
|
line_start: ctx.line_start || null,
|
|
1493
2550
|
line_end: ctx.line_end || null,
|
|
1494
2551
|
body: ctx.body || null,
|
|
1495
|
-
source: 'user'
|
|
2552
|
+
source: isExternal ? 'external' : 'user'
|
|
1496
2553
|
};
|
|
1497
|
-
|
|
2554
|
+
if (isExternal) {
|
|
2555
|
+
contextData.externalSource = ctx.externalSource || null;
|
|
2556
|
+
contextData.externalUrl = ctx.externalUrl || null;
|
|
2557
|
+
contextData.isOutdated = !!ctx.isOutdated;
|
|
2558
|
+
contextData.author = ctx.author || null;
|
|
2559
|
+
}
|
|
2560
|
+
tab.pendingContextData.push(contextData);
|
|
1498
2561
|
|
|
1499
2562
|
// Build the plain text context for the agent
|
|
2563
|
+
const sourceLabel = isExternal
|
|
2564
|
+
? (ctx.externalSource ? this._formatExternalSourceLabel(ctx.externalSource) : 'an external system')
|
|
2565
|
+
: null;
|
|
1500
2566
|
const lines = isLine
|
|
1501
2567
|
? [ctx.line_start
|
|
1502
2568
|
? `The user wants to discuss code at ${lineLabel} in ${contextData.file || 'unknown file'}:`
|
|
1503
2569
|
: `The user wants to discuss the file ${contextData.file || 'unknown file'}:`]
|
|
1504
|
-
:
|
|
2570
|
+
: isExternal
|
|
2571
|
+
? [ctx.author
|
|
2572
|
+
? `The user wants to discuss a review comment posted on ${sourceLabel} by ${ctx.author}:`
|
|
2573
|
+
: `The user wants to discuss a review comment posted on ${sourceLabel}:`]
|
|
2574
|
+
: ['The user wants to discuss a review comment:'];
|
|
1505
2575
|
if (contextData.file) {
|
|
1506
2576
|
let fileLine = `- File: ${contextData.file}`;
|
|
1507
2577
|
if (contextData.line_start) {
|
|
@@ -1512,9 +2582,20 @@ class ChatPanel {
|
|
|
1512
2582
|
if (ctx.isFileLevel) {
|
|
1513
2583
|
lines.push('- Scope: File-level comment');
|
|
1514
2584
|
}
|
|
1515
|
-
if (ctx.parentId) {
|
|
2585
|
+
if (ctx.parentId && !isExternal) {
|
|
1516
2586
|
lines.push('- Origin: adopted from AI suggestion');
|
|
1517
2587
|
}
|
|
2588
|
+
if (isExternal) {
|
|
2589
|
+
if (ctx.author) {
|
|
2590
|
+
lines.push(`- Author: ${ctx.author}`);
|
|
2591
|
+
}
|
|
2592
|
+
if (ctx.isOutdated) {
|
|
2593
|
+
lines.push('- Status: outdated (the diff position no longer exists in the current PR head)');
|
|
2594
|
+
}
|
|
2595
|
+
if (ctx.externalUrl) {
|
|
2596
|
+
lines.push(`- Link: ${ctx.externalUrl}`);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
1518
2599
|
if (contextData.body) {
|
|
1519
2600
|
lines.push(`- Comment: ${contextData.body}`);
|
|
1520
2601
|
}
|
|
@@ -1524,7 +2605,7 @@ class ChatPanel {
|
|
|
1524
2605
|
if (patch && window.DiffContext) {
|
|
1525
2606
|
if (contextData.line_start && !ctx.isFileLevel) {
|
|
1526
2607
|
const hunk = window.DiffContext.extractHunkForLines(
|
|
1527
|
-
patch, contextData.line_start, contextData.line_end || contextData.line_start
|
|
2608
|
+
patch, contextData.line_start, contextData.line_end || contextData.line_start, contextData.side
|
|
1528
2609
|
);
|
|
1529
2610
|
if (hunk) {
|
|
1530
2611
|
lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
|
|
@@ -1537,7 +2618,7 @@ class ChatPanel {
|
|
|
1537
2618
|
}
|
|
1538
2619
|
}
|
|
1539
2620
|
|
|
1540
|
-
|
|
2621
|
+
tab.pendingContext.push(lines.join('\n'));
|
|
1541
2622
|
|
|
1542
2623
|
// Render the compact context card in the UI
|
|
1543
2624
|
if (isLine) {
|
|
@@ -1547,6 +2628,120 @@ class ChatPanel {
|
|
|
1547
2628
|
}
|
|
1548
2629
|
}
|
|
1549
2630
|
|
|
2631
|
+
/**
|
|
2632
|
+
* Format an externalSource identifier into a human-readable label.
|
|
2633
|
+
* @param {string} externalSource - e.g. 'github', 'gitlab'
|
|
2634
|
+
* @returns {string}
|
|
2635
|
+
*/
|
|
2636
|
+
_formatExternalSourceLabel(externalSource) {
|
|
2637
|
+
if (!externalSource) return 'an external system';
|
|
2638
|
+
switch (externalSource) {
|
|
2639
|
+
case 'github': return 'GitHub';
|
|
2640
|
+
case 'gitlab': return 'GitLab';
|
|
2641
|
+
case 'linear': return 'Linear';
|
|
2642
|
+
default:
|
|
2643
|
+
return externalSource.charAt(0).toUpperCase() + externalSource.slice(1);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
/**
|
|
2648
|
+
* Store pending context and render a compact context card for a comment thread.
|
|
2649
|
+
* Called when the user clicks "chat about this thread" on an external thread (e.g. GitHub).
|
|
2650
|
+
* Threads are external systems only -- internal user comments don't have a thread shape.
|
|
2651
|
+
* @param {Object} threadContext - See JSDoc on open() for full shape.
|
|
2652
|
+
*/
|
|
2653
|
+
_sendThreadContextMessage(threadContext) {
|
|
2654
|
+
const tab = this._getActiveTab();
|
|
2655
|
+
if (!tab) return;
|
|
2656
|
+
// Remove empty state if present
|
|
2657
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
2658
|
+
if (emptyState) emptyState.remove();
|
|
2659
|
+
|
|
2660
|
+
const comments = Array.isArray(threadContext.comments) ? threadContext.comments : [];
|
|
2661
|
+
const externalSource = threadContext.externalSource || null;
|
|
2662
|
+
const sourceLabel = this._formatExternalSourceLabel(externalSource);
|
|
2663
|
+
|
|
2664
|
+
// Store structured context data for DB persistence
|
|
2665
|
+
const contextData = {
|
|
2666
|
+
type: 'thread',
|
|
2667
|
+
title: `${sourceLabel} thread`,
|
|
2668
|
+
file: threadContext.file || null,
|
|
2669
|
+
side: threadContext.side || null,
|
|
2670
|
+
line_start: threadContext.line_start || null,
|
|
2671
|
+
line_end: threadContext.line_end || null,
|
|
2672
|
+
body: null,
|
|
2673
|
+
source: 'external',
|
|
2674
|
+
externalSource,
|
|
2675
|
+
rootId: threadContext.rootId || null,
|
|
2676
|
+
comments: comments.map((c) => ({
|
|
2677
|
+
author: c.author || null,
|
|
2678
|
+
body: c.body || '',
|
|
2679
|
+
isOutdated: !!c.isOutdated,
|
|
2680
|
+
externalUrl: c.externalUrl || null,
|
|
2681
|
+
externalCreatedAt: c.externalCreatedAt || null,
|
|
2682
|
+
})),
|
|
2683
|
+
};
|
|
2684
|
+
tab.pendingContextData.push(contextData);
|
|
2685
|
+
|
|
2686
|
+
// Build the plain text context for the agent
|
|
2687
|
+
const fileLabel = contextData.file || 'unknown file';
|
|
2688
|
+
let anchor = fileLabel;
|
|
2689
|
+
if (contextData.line_start) {
|
|
2690
|
+
anchor += `:${contextData.line_start}`;
|
|
2691
|
+
if (contextData.line_end && contextData.line_end !== contextData.line_start) {
|
|
2692
|
+
anchor += `-${contextData.line_end}`;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
const lines = [
|
|
2696
|
+
`The user wants to discuss a thread of ${comments.length} comment${comments.length === 1 ? '' : 's'} from ${sourceLabel} on ${anchor}:`,
|
|
2697
|
+
];
|
|
2698
|
+
if (contextData.file) {
|
|
2699
|
+
let fileLine = `- File: ${contextData.file}`;
|
|
2700
|
+
if (contextData.line_start) {
|
|
2701
|
+
fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
|
|
2702
|
+
}
|
|
2703
|
+
lines.push(fileLine);
|
|
2704
|
+
}
|
|
2705
|
+
lines.push(`- Source: ${sourceLabel}`);
|
|
2706
|
+
lines.push(`- Comment count: ${comments.length}`);
|
|
2707
|
+
|
|
2708
|
+
comments.forEach((c, idx) => {
|
|
2709
|
+
const author = c.author || 'unknown';
|
|
2710
|
+
const parts = [`Comment ${idx + 1} by ${author}`];
|
|
2711
|
+
if (c.externalCreatedAt) parts.push(`at ${c.externalCreatedAt}`);
|
|
2712
|
+
if (c.isOutdated) parts.push('(outdated)');
|
|
2713
|
+
if (c.externalUrl) parts.push(`(${c.externalUrl})`);
|
|
2714
|
+
lines.push(`- ${parts.join(' ')}:`);
|
|
2715
|
+
const body = (c.body || '').trim() || '(no body)';
|
|
2716
|
+
// Indent body so it renders as a quote block
|
|
2717
|
+
const indented = body.split('\n').map((ln) => ` > ${ln}`).join('\n');
|
|
2718
|
+
lines.push(indented);
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
// Enrich with diff hunk if available
|
|
2722
|
+
const patch = window.prManager?.filePatches?.get(contextData.file);
|
|
2723
|
+
if (patch && window.DiffContext) {
|
|
2724
|
+
if (contextData.line_start) {
|
|
2725
|
+
const hunk = window.DiffContext.extractHunkForLines(
|
|
2726
|
+
patch, contextData.line_start, contextData.line_end || contextData.line_start, contextData.side
|
|
2727
|
+
);
|
|
2728
|
+
if (hunk) {
|
|
2729
|
+
lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
|
|
2730
|
+
}
|
|
2731
|
+
} else {
|
|
2732
|
+
const ranges = window.DiffContext.extractHunkRangesForFile(patch);
|
|
2733
|
+
if (ranges.length) {
|
|
2734
|
+
lines.push(`- Diff hunk ranges: ${JSON.stringify(ranges)}`);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
tab.pendingContext.push(lines.join('\n'));
|
|
2740
|
+
|
|
2741
|
+
// Render the compact context card in the UI
|
|
2742
|
+
this._addThreadContextCard(contextData, { removable: true });
|
|
2743
|
+
}
|
|
2744
|
+
|
|
1550
2745
|
/**
|
|
1551
2746
|
* Send a file context message to the chat panel.
|
|
1552
2747
|
* Called when the user clicks "Chat about file" on a file header.
|
|
@@ -1554,16 +2749,18 @@ class ChatPanel {
|
|
|
1554
2749
|
* @param {string} fileContext.file - File path
|
|
1555
2750
|
*/
|
|
1556
2751
|
_sendFileContextMessage(fileContext) {
|
|
2752
|
+
const tab = this._getActiveTab();
|
|
2753
|
+
if (!tab) return;
|
|
1557
2754
|
let contextText = `The user wants to discuss ${fileContext.file}`;
|
|
1558
2755
|
|
|
1559
2756
|
// Check for duplicate context (use startsWith because contextText may
|
|
1560
2757
|
// get enriched with diff hunk ranges after this check)
|
|
1561
|
-
const isDuplicate =
|
|
1562
|
-
|
|
2758
|
+
const isDuplicate = tab.pendingContext.some(c => c === contextText || c.startsWith(contextText)) ||
|
|
2759
|
+
tab.messages.some(m => m.role === 'context' && (m.content === contextText || m.content.startsWith(contextText)));
|
|
1563
2760
|
if (isDuplicate) return;
|
|
1564
2761
|
|
|
1565
2762
|
// Remove empty state if present
|
|
1566
|
-
const emptyState =
|
|
2763
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
1567
2764
|
if (emptyState) emptyState.remove();
|
|
1568
2765
|
|
|
1569
2766
|
// Store structured context data for DB persistence
|
|
@@ -1575,7 +2772,7 @@ class ChatPanel {
|
|
|
1575
2772
|
line_end: null,
|
|
1576
2773
|
body: null
|
|
1577
2774
|
};
|
|
1578
|
-
|
|
2775
|
+
tab.pendingContextData.push(contextData);
|
|
1579
2776
|
|
|
1580
2777
|
// Enrich with diff hunk ranges if available
|
|
1581
2778
|
const patch = window.prManager?.filePatches?.get(fileContext.file);
|
|
@@ -1586,7 +2783,7 @@ class ChatPanel {
|
|
|
1586
2783
|
}
|
|
1587
2784
|
}
|
|
1588
2785
|
|
|
1589
|
-
|
|
2786
|
+
tab.pendingContext.push(contextText);
|
|
1590
2787
|
|
|
1591
2788
|
// Render the compact context card in the UI
|
|
1592
2789
|
this._addFileContextCard(contextData, { removable: true });
|
|
@@ -1611,8 +2808,12 @@ class ChatPanel {
|
|
|
1611
2808
|
// 2. Open panel if closed
|
|
1612
2809
|
await this.open({ suppressFocus: true });
|
|
1613
2810
|
|
|
2811
|
+
// Capture the tab AFTER open() so any restoration/new-tab work is done.
|
|
2812
|
+
const tab = this._getActiveTab();
|
|
2813
|
+
if (!tab) return;
|
|
2814
|
+
|
|
1614
2815
|
// Re-check: open() may have auto-added a card for this run via _ensureAnalysisContext
|
|
1615
|
-
const existingCardPostOpen =
|
|
2816
|
+
const existingCardPostOpen = tab.messagesEl?.querySelector(
|
|
1616
2817
|
`[data-analysis-run-id="${runId}"]`
|
|
1617
2818
|
);
|
|
1618
2819
|
if (existingCardPostOpen) {
|
|
@@ -1622,15 +2823,17 @@ class ChatPanel {
|
|
|
1622
2823
|
|
|
1623
2824
|
// 3. Fetch context from backend
|
|
1624
2825
|
const response = await fetch(`/api/chat/analysis-context/${runId}?reviewId=${this.reviewId}`);
|
|
2826
|
+
if (!this.tabs.includes(tab)) return;
|
|
1625
2827
|
if (!response.ok) {
|
|
1626
2828
|
console.error('[ChatPanel] Failed to fetch analysis context:', response.statusText);
|
|
1627
2829
|
return;
|
|
1628
2830
|
}
|
|
1629
2831
|
const result = await response.json();
|
|
2832
|
+
if (!this.tabs.includes(tab)) return;
|
|
1630
2833
|
const data = result.data;
|
|
1631
2834
|
|
|
1632
2835
|
// 4. Push to pending context arrays
|
|
1633
|
-
|
|
2836
|
+
tab.pendingContext.push(data.text);
|
|
1634
2837
|
const contextData = {
|
|
1635
2838
|
type: 'analysis-run',
|
|
1636
2839
|
aiRunId: runId,
|
|
@@ -1641,14 +2844,15 @@ class ChatPanel {
|
|
|
1641
2844
|
configType: data.run.configType,
|
|
1642
2845
|
completedAt: data.run.completedAt
|
|
1643
2846
|
};
|
|
1644
|
-
|
|
2847
|
+
tab.pendingContextData.push(contextData);
|
|
1645
2848
|
|
|
1646
2849
|
// 5. Remove empty state if present
|
|
1647
|
-
const emptyState =
|
|
2850
|
+
const emptyState = tab.messagesEl?.querySelector('.chat-panel__empty');
|
|
1648
2851
|
if (emptyState) emptyState.remove();
|
|
1649
2852
|
|
|
1650
|
-
// 6. Create the card and append
|
|
1651
|
-
|
|
2853
|
+
// 6. Create the card and append — pass captured tab so a focus change
|
|
2854
|
+
// during the fetch doesn't write the card into a sibling.
|
|
2855
|
+
this._addAnalysisRunContextCard(contextData, { removable: true }, tab);
|
|
1652
2856
|
|
|
1653
2857
|
// 7. Focus input
|
|
1654
2858
|
if (this.inputEl) this.inputEl.focus();
|
|
@@ -1660,7 +2864,8 @@ class ChatPanel {
|
|
|
1660
2864
|
* @param {HTMLElement} card - The context card element
|
|
1661
2865
|
*/
|
|
1662
2866
|
_makeCardRemovable(card) {
|
|
1663
|
-
const
|
|
2867
|
+
const tab = this._getActiveTab();
|
|
2868
|
+
const idx = (tab?.pendingContextData.length ?? 0) - 1;
|
|
1664
2869
|
card.dataset.contextIndex = idx;
|
|
1665
2870
|
const removeBtn = document.createElement('button');
|
|
1666
2871
|
removeBtn.className = 'chat-panel__context-remove';
|
|
@@ -1705,45 +2910,48 @@ class ChatPanel {
|
|
|
1705
2910
|
* Detects new analysis runs by comparing the latest completed run ID
|
|
1706
2911
|
* against the one already loaded in the session. Only adds if suggestions exist.
|
|
1707
2912
|
*/
|
|
1708
|
-
_ensureAnalysisContext() {
|
|
2913
|
+
_ensureAnalysisContext(targetTab) {
|
|
2914
|
+
const tab = targetTab || this._getActiveTab();
|
|
2915
|
+
if (!tab || !tab.messagesEl) return;
|
|
2916
|
+
|
|
1709
2917
|
// Determine the latest completed run ID from the analysis history manager or prManager
|
|
1710
2918
|
const currentRunId = this._getLatestCompletedRunId();
|
|
1711
2919
|
|
|
1712
2920
|
// Detect whether a NEW analysis run has appeared since we last loaded context.
|
|
1713
2921
|
// If the run ID changed, we need to replace the old card with a new one.
|
|
1714
|
-
// This handles the case where
|
|
1715
|
-
const isNewRunVsSession = currentRunId &&
|
|
1716
|
-
String(currentRunId) !== String(
|
|
2922
|
+
// This handles the case where sessionAnalysisRunId was explicitly set.
|
|
2923
|
+
const isNewRunVsSession = currentRunId && tab.sessionAnalysisRunId &&
|
|
2924
|
+
String(currentRunId) !== String(tab.sessionAnalysisRunId);
|
|
1717
2925
|
|
|
1718
2926
|
if (isNewRunVsSession) {
|
|
1719
|
-
console.debug('[ChatPanel] _ensureAnalysisContext: new run detected:', currentRunId, '(was:',
|
|
2927
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: new run detected:', currentRunId, '(was:', tab.sessionAnalysisRunId + ')');
|
|
1720
2928
|
// Remove the old analysis card from the DOM (if present)
|
|
1721
|
-
const oldCard =
|
|
2929
|
+
const oldCard = tab.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1722
2930
|
if (oldCard) oldCard.remove();
|
|
1723
2931
|
// Reset flags — the user removed the OLD run's context, but this is a different run
|
|
1724
|
-
|
|
1725
|
-
|
|
2932
|
+
tab.analysisContextRemoved = false;
|
|
2933
|
+
tab.sessionAnalysisRunId = null;
|
|
1726
2934
|
}
|
|
1727
2935
|
|
|
1728
2936
|
// Check for an existing card in the DOM (e.g., loaded from MRU session history).
|
|
1729
|
-
// If
|
|
2937
|
+
// If sessionAnalysisRunId is not set, this card may be stale — compare its
|
|
1730
2938
|
// stamped run ID against the latest completed run to detect new analyses that
|
|
1731
2939
|
// completed while the panel was closed.
|
|
1732
|
-
const existingCard =
|
|
2940
|
+
const existingCard = tab.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1733
2941
|
if (existingCard) {
|
|
1734
|
-
if (!
|
|
2942
|
+
if (!tab.sessionAnalysisRunId && currentRunId) {
|
|
1735
2943
|
const cardRunId = existingCard.dataset.analysisRunId || null;
|
|
1736
2944
|
if (cardRunId && String(cardRunId) === String(currentRunId)) {
|
|
1737
2945
|
// Card matches the latest run — adopt its run ID so future opens can detect changes
|
|
1738
2946
|
console.debug('[ChatPanel] _ensureAnalysisContext: adopting existing card runId:', cardRunId);
|
|
1739
|
-
|
|
2947
|
+
tab.sessionAnalysisRunId = String(currentRunId);
|
|
1740
2948
|
return;
|
|
1741
2949
|
}
|
|
1742
2950
|
// Card has no run ID stamp or a different run ID — it's stale.
|
|
1743
2951
|
// Remove it so a fresh card for the current run is added below.
|
|
1744
2952
|
console.debug('[ChatPanel] _ensureAnalysisContext: replacing stale DOM card (card:', cardRunId, 'latest:', currentRunId + ')');
|
|
1745
2953
|
existingCard.remove();
|
|
1746
|
-
|
|
2954
|
+
tab.analysisContextRemoved = false;
|
|
1747
2955
|
} else {
|
|
1748
2956
|
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — card already in DOM');
|
|
1749
2957
|
return;
|
|
@@ -1752,13 +2960,13 @@ class ChatPanel {
|
|
|
1752
2960
|
|
|
1753
2961
|
// Skip if the current session already has analysis context loaded (by run ID)
|
|
1754
2962
|
// and no new run was detected (handled above)
|
|
1755
|
-
if (
|
|
1756
|
-
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — runId already set:',
|
|
2963
|
+
if (tab.sessionAnalysisRunId) {
|
|
2964
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — runId already set:', tab.sessionAnalysisRunId);
|
|
1757
2965
|
return;
|
|
1758
2966
|
}
|
|
1759
2967
|
|
|
1760
2968
|
// Skip if analysis context was explicitly removed in this conversation
|
|
1761
|
-
if (
|
|
2969
|
+
if (tab.analysisContextRemoved) {
|
|
1762
2970
|
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — explicitly removed');
|
|
1763
2971
|
return;
|
|
1764
2972
|
}
|
|
@@ -1775,7 +2983,7 @@ class ChatPanel {
|
|
|
1775
2983
|
console.debug('[ChatPanel] _ensureAnalysisContext: adding card with', count, 'suggestions');
|
|
1776
2984
|
|
|
1777
2985
|
// Remove empty state
|
|
1778
|
-
const emptyState =
|
|
2986
|
+
const emptyState = tab.messagesEl.querySelector('.chat-panel__empty');
|
|
1779
2987
|
if (emptyState) emptyState.remove();
|
|
1780
2988
|
|
|
1781
2989
|
// Render the analysis context card (removable).
|
|
@@ -1786,16 +2994,22 @@ class ChatPanel {
|
|
|
1786
2994
|
// Note: analysis card is NOT added to _pendingContext/_pendingContextData —
|
|
1787
2995
|
// the backend includes full suggestion data via initialContext at session creation.
|
|
1788
2996
|
// The card is a visual indicator that controls whether the backend includes it.
|
|
1789
|
-
const hasExistingMessages =
|
|
2997
|
+
const hasExistingMessages = tab.messagesEl.querySelectorAll('.chat-panel__message').length > 0;
|
|
1790
2998
|
const contextData = this._buildAnalysisContextData(currentRunId, count);
|
|
1791
|
-
this.
|
|
2999
|
+
this._renderInTab(tab, () => {
|
|
3000
|
+
this._addAnalysisContextCard(contextData, { removable: true, prepend: !hasExistingMessages });
|
|
3001
|
+
});
|
|
1792
3002
|
|
|
1793
|
-
// Persist to DB so the card is restored on session reload
|
|
1794
|
-
|
|
3003
|
+
// Persist to DB so the card is restored on session reload. Defer when the
|
|
3004
|
+
// tab has no sessionId yet (lazy-create path) — the sendMessage flow will
|
|
3005
|
+
// re-run _ensureAnalysisContext after the session is born.
|
|
3006
|
+
if (tab.sessionId != null) {
|
|
3007
|
+
this._persistAnalysisContext(contextData, tab.sessionId);
|
|
3008
|
+
}
|
|
1795
3009
|
|
|
1796
3010
|
// Mark that analysis context is loaded for this session.
|
|
1797
3011
|
// Use the actual run ID if available, otherwise fall back to 'dom'.
|
|
1798
|
-
|
|
3012
|
+
tab.sessionAnalysisRunId = currentRunId || 'dom';
|
|
1799
3013
|
}
|
|
1800
3014
|
|
|
1801
3015
|
/**
|
|
@@ -1873,31 +3087,71 @@ class ChatPanel {
|
|
|
1873
3087
|
cardEl.remove();
|
|
1874
3088
|
|
|
1875
3089
|
// If no pending context, no messages, and no other context cards, restore empty state
|
|
1876
|
-
|
|
1877
|
-
|
|
3090
|
+
const tab = this._getActiveTab();
|
|
3091
|
+
if (tab && tab.pendingContext.length === 0 && tab.messages.length === 0 &&
|
|
3092
|
+
!tab.messagesEl?.querySelector('.chat-panel__context-card')) {
|
|
1878
3093
|
this._clearMessages();
|
|
1879
3094
|
}
|
|
1880
3095
|
}
|
|
1881
3096
|
|
|
1882
3097
|
/**
|
|
1883
|
-
* Add a compact context card for a user comment to the messages area.
|
|
1884
|
-
*
|
|
3098
|
+
* Add a compact context card for a user or external comment to the messages area.
|
|
3099
|
+
* When `ctx.source === 'external'`, adds external-comment-context classes
|
|
3100
|
+
* (and `source-<externalSource>`) so per-source theming variables apply,
|
|
3101
|
+
* renders the author label (linked to externalUrl when present), and shows
|
|
3102
|
+
* an "outdated" badge when `ctx.isOutdated`.
|
|
3103
|
+
* @param {Object} ctx - Comment context {commentId, body, file, line_start, line_end, isFileLevel, source, externalSource, externalUrl, isOutdated, author}
|
|
1885
3104
|
*/
|
|
1886
3105
|
_addCommentContextCard(ctx, { removable = false } = {}) {
|
|
1887
3106
|
const card = document.createElement('div');
|
|
1888
|
-
|
|
3107
|
+
const isExternal = ctx.source === 'external';
|
|
3108
|
+
const classes = ['chat-panel__context-card'];
|
|
3109
|
+
if (isExternal) {
|
|
3110
|
+
classes.push('external-comment-context');
|
|
3111
|
+
if (ctx.externalSource) classes.push(`source-${ctx.externalSource}`);
|
|
3112
|
+
if (ctx.isOutdated) classes.push('is-outdated');
|
|
3113
|
+
}
|
|
3114
|
+
card.className = classes.join(' ');
|
|
1889
3115
|
|
|
1890
|
-
const
|
|
3116
|
+
const sourceLabel = isExternal
|
|
3117
|
+
? this._formatExternalSourceLabel(ctx.externalSource)
|
|
3118
|
+
: null;
|
|
3119
|
+
const label = isExternal
|
|
3120
|
+
? (ctx.isFileLevel ? `${sourceLabel} file comment` : `${sourceLabel} comment`)
|
|
3121
|
+
: (ctx.isFileLevel ? 'file comment' : 'comment');
|
|
1891
3122
|
const bodyPreview = ctx.body ? (ctx.body.length > 60 ? ctx.body.substring(0, 60) + '...' : ctx.body) : 'Comment';
|
|
1892
3123
|
const fileInfo = ctx.file
|
|
1893
3124
|
? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}`
|
|
1894
3125
|
: '';
|
|
1895
3126
|
|
|
3127
|
+
// Author rendering — linked to externalUrl only when the URL passes
|
|
3128
|
+
// the scheme allowlist. Mirrors the external-comment-manager so
|
|
3129
|
+
// `javascript:` / `data:` URLs from a malicious upstream can't smuggle
|
|
3130
|
+
// a live `<a href>` into the DOM.
|
|
3131
|
+
let authorHTML = '';
|
|
3132
|
+
if (isExternal && ctx.author) {
|
|
3133
|
+
const escapedAuthor = this._escapeHtml(ctx.author);
|
|
3134
|
+
if (ctx.externalUrl && this._isSafeUrl(ctx.externalUrl)) {
|
|
3135
|
+
const escapedUrl = window.escapeHtmlAttribute
|
|
3136
|
+
? window.escapeHtmlAttribute(ctx.externalUrl)
|
|
3137
|
+
: this._escapeHtml(ctx.externalUrl);
|
|
3138
|
+
authorHTML = `<a class="chat-panel__context-author" href="${escapedUrl}" target="_blank" rel="noopener noreferrer">${escapedAuthor}</a>`;
|
|
3139
|
+
} else {
|
|
3140
|
+
authorHTML = `<span class="chat-panel__context-author">${escapedAuthor}</span>`;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
const outdatedHTML = isExternal && ctx.isOutdated
|
|
3145
|
+
? '<span class="chat-panel__context-badge chat-panel__context-badge--outdated">outdated</span>'
|
|
3146
|
+
: '';
|
|
3147
|
+
|
|
1896
3148
|
card.innerHTML = `
|
|
1897
3149
|
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
1898
3150
|
<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"/>
|
|
1899
3151
|
</svg>
|
|
1900
3152
|
<span class="chat-panel__context-label">${this._escapeHtml(label)}</span>
|
|
3153
|
+
${authorHTML}
|
|
3154
|
+
${outdatedHTML}
|
|
1901
3155
|
<span class="chat-panel__context-title">${this._renderInlineMarkdown(bodyPreview)}</span>
|
|
1902
3156
|
${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
|
|
1903
3157
|
`;
|
|
@@ -1911,6 +3165,94 @@ class ChatPanel {
|
|
|
1911
3165
|
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1912
3166
|
}
|
|
1913
3167
|
|
|
3168
|
+
/**
|
|
3169
|
+
* Add a compact context card for an external comment thread to the messages area.
|
|
3170
|
+
* Renders the thread header (source + file:line) and a list of comments,
|
|
3171
|
+
* each with author (linked to externalUrl when present), timestamp, body
|
|
3172
|
+
* (rendered as markdown), and an "outdated" badge when applicable.
|
|
3173
|
+
* @param {Object} ctx - Thread context data persisted by _sendThreadContextMessage.
|
|
3174
|
+
* @param {Object} [options] - Options
|
|
3175
|
+
* @param {boolean} [options.removable=false] - Whether the card should have a remove button
|
|
3176
|
+
*/
|
|
3177
|
+
_addThreadContextCard(ctx, { removable = false } = {}) {
|
|
3178
|
+
const card = document.createElement('div');
|
|
3179
|
+
const externalSource = ctx.externalSource || null;
|
|
3180
|
+
// No --thread modifier: thread cards now use the same compact single-line
|
|
3181
|
+
// layout as line/file cards. The full thread content is exposed via the
|
|
3182
|
+
// card's title attribute (hover tooltip).
|
|
3183
|
+
const classes = ['chat-panel__context-card', 'external-comment-context'];
|
|
3184
|
+
if (externalSource) classes.push(`source-${externalSource}`);
|
|
3185
|
+
card.className = classes.join(' ');
|
|
3186
|
+
|
|
3187
|
+
const sourceLabel = this._formatExternalSourceLabel(externalSource);
|
|
3188
|
+
const fileLabel = ctx.file || 'unknown file';
|
|
3189
|
+
let anchor = fileLabel;
|
|
3190
|
+
if (ctx.line_start) {
|
|
3191
|
+
anchor += `:${ctx.line_start}`;
|
|
3192
|
+
if (ctx.line_end && ctx.line_end !== ctx.line_start) {
|
|
3193
|
+
anchor += `-${ctx.line_end}`;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
const comments = Array.isArray(ctx.comments) ? ctx.comments : [];
|
|
3197
|
+
|
|
3198
|
+
// Snippet from the first comment for the visible title slot. Stripped of
|
|
3199
|
+
// markdown syntax so the line reads cleanly when truncated by ellipsis.
|
|
3200
|
+
const firstBody = comments[0]?.body || '';
|
|
3201
|
+
const stripped = this._stripMarkdownForSnippet(firstBody);
|
|
3202
|
+
const snippet = stripped.length > 80 ? stripped.substring(0, 80) + '…' : stripped;
|
|
3203
|
+
|
|
3204
|
+
// Full thread content for the hover tooltip — plain text, one comment per
|
|
3205
|
+
// block so the native title attribute (which respects newlines on most
|
|
3206
|
+
// platforms) gives the reviewer the full conversation on hover.
|
|
3207
|
+
const tooltip = comments.map((c, i) => {
|
|
3208
|
+
const author = c.author || 'unknown';
|
|
3209
|
+
const ts = c.externalCreatedAt ? ` · ${c.externalCreatedAt}` : '';
|
|
3210
|
+
const out = c.isOutdated ? ' (outdated)' : '';
|
|
3211
|
+
const body = (c.body || '(no body)').trim();
|
|
3212
|
+
return `${i + 1}. ${author}${ts}${out}\n${body}`;
|
|
3213
|
+
}).join('\n\n');
|
|
3214
|
+
|
|
3215
|
+
card.setAttribute('title', tooltip);
|
|
3216
|
+
|
|
3217
|
+
const countText = `${comments.length} comment${comments.length === 1 ? '' : 's'}`;
|
|
3218
|
+
|
|
3219
|
+
card.innerHTML = `
|
|
3220
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
3221
|
+
<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.25Z"/>
|
|
3222
|
+
</svg>
|
|
3223
|
+
<span class="chat-panel__context-label"><strong>${this._escapeHtml(sourceLabel.toUpperCase())} THREAD</strong></span>
|
|
3224
|
+
<span class="chat-panel__context-title">${snippet ? this._escapeHtml(snippet) : '<em>(empty)</em>'}</span>
|
|
3225
|
+
<span class="chat-panel__context-file">${this._escapeHtml(anchor)}</span>
|
|
3226
|
+
<span class="chat-panel__context-count">${countText}</span>
|
|
3227
|
+
`;
|
|
3228
|
+
|
|
3229
|
+
if (removable) this._makeCardRemovable(card);
|
|
3230
|
+
|
|
3231
|
+
this.messagesEl.appendChild(card);
|
|
3232
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
/**
|
|
3236
|
+
* Strip a small set of common markdown syntax so a snippet reads cleanly
|
|
3237
|
+
* when truncated. Not a full parser — just enough to drop heading/list
|
|
3238
|
+
* markers, inline code backticks, and bold/italic emphasis.
|
|
3239
|
+
* @private
|
|
3240
|
+
*/
|
|
3241
|
+
_stripMarkdownForSnippet(text) {
|
|
3242
|
+
if (!text) return '';
|
|
3243
|
+
return String(text)
|
|
3244
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
3245
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
3246
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
3247
|
+
.replace(/^\s*[-*+]\s+/gm, '')
|
|
3248
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
3249
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
3250
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
3251
|
+
.replace(/_([^_]+)_/g, '$1')
|
|
3252
|
+
.replace(/\s+/g, ' ')
|
|
3253
|
+
.trim();
|
|
3254
|
+
}
|
|
3255
|
+
|
|
1914
3256
|
/**
|
|
1915
3257
|
* Add a compact context card for a line reference (optionally with body text).
|
|
1916
3258
|
* @param {Object} ctx - Line context {file, line_start, line_end, body}
|
|
@@ -1988,10 +3330,14 @@ class ChatPanel {
|
|
|
1988
3330
|
/**
|
|
1989
3331
|
* Restore remove buttons and data-context-index on all pending context cards.
|
|
1990
3332
|
* Called after a failed send to unlock cards that were locked prematurely.
|
|
3333
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
1991
3334
|
*/
|
|
1992
|
-
_restoreRemovableCards() {
|
|
3335
|
+
_restoreRemovableCards(targetTab) {
|
|
3336
|
+
const tab = targetTab || this._getActiveTab();
|
|
3337
|
+
const messagesEl = tab?.messagesEl;
|
|
3338
|
+
if (!messagesEl) return;
|
|
1993
3339
|
// Restore analysis context card if it was locked
|
|
1994
|
-
const analysisCard =
|
|
3340
|
+
const analysisCard = messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1995
3341
|
if (analysisCard && !analysisCard.querySelector('.chat-panel__context-remove')) {
|
|
1996
3342
|
const removeBtn = document.createElement('button');
|
|
1997
3343
|
removeBtn.className = 'chat-panel__context-remove';
|
|
@@ -2004,7 +3350,7 @@ class ChatPanel {
|
|
|
2004
3350
|
analysisCard.appendChild(removeBtn);
|
|
2005
3351
|
}
|
|
2006
3352
|
|
|
2007
|
-
const cards =
|
|
3353
|
+
const cards = messagesEl.querySelectorAll('.chat-panel__context-card:not([data-analysis])');
|
|
2008
3354
|
let idx = 0;
|
|
2009
3355
|
cards.forEach((card) => {
|
|
2010
3356
|
// Only restore cards that don't already have a remove button
|
|
@@ -2032,10 +3378,11 @@ class ChatPanel {
|
|
|
2032
3378
|
* @param {HTMLElement} cardEl - The context card element to remove
|
|
2033
3379
|
*/
|
|
2034
3380
|
_removeContextCard(cardEl) {
|
|
3381
|
+
const tab = this._getActiveTab();
|
|
2035
3382
|
const idx = parseInt(cardEl.dataset.contextIndex, 10);
|
|
2036
|
-
if (!isNaN(idx) && idx >= 0 && idx <
|
|
2037
|
-
|
|
2038
|
-
|
|
3383
|
+
if (tab && !isNaN(idx) && idx >= 0 && idx < tab.pendingContext.length) {
|
|
3384
|
+
tab.pendingContext.splice(idx, 1);
|
|
3385
|
+
tab.pendingContextData.splice(idx, 1);
|
|
2039
3386
|
}
|
|
2040
3387
|
// Hide context tooltip – mouseleave won't fire on a removed element
|
|
2041
3388
|
clearTimeout(this._ctxTooltipTimer);
|
|
@@ -2044,14 +3391,17 @@ class ChatPanel {
|
|
|
2044
3391
|
cardEl.remove();
|
|
2045
3392
|
|
|
2046
3393
|
// Re-index remaining removable context cards
|
|
2047
|
-
const
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
3394
|
+
const messagesEl = tab?.messagesEl;
|
|
3395
|
+
if (messagesEl) {
|
|
3396
|
+
const remainingCards = messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
|
|
3397
|
+
remainingCards.forEach((card, i) => {
|
|
3398
|
+
card.dataset.contextIndex = i;
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
2051
3401
|
|
|
2052
3402
|
// If no pending context, no messages, and no other context cards, restore empty state
|
|
2053
|
-
if (
|
|
2054
|
-
!
|
|
3403
|
+
if (tab && tab.pendingContext.length === 0 && tab.messages.length === 0 &&
|
|
3404
|
+
!tab.messagesEl?.querySelector('.chat-panel__context-card')) {
|
|
2055
3405
|
this._clearMessages();
|
|
2056
3406
|
}
|
|
2057
3407
|
}
|
|
@@ -2059,11 +3409,20 @@ class ChatPanel {
|
|
|
2059
3409
|
/**
|
|
2060
3410
|
* Show analysis context card if the session response includes context metadata.
|
|
2061
3411
|
* Removes the empty state first so the card appears as the first element.
|
|
2062
|
-
*
|
|
3412
|
+
* Accepts an explicit `tab` so the card lands in the originating tab even if
|
|
3413
|
+
* the user switched focus while the session POST was in flight.
|
|
3414
|
+
* @param {Object|null} sessionData - Response data from createSession
|
|
3415
|
+
* ({ id, status, context? }) — pass null when called before a session is
|
|
3416
|
+
* created (no-op in that case).
|
|
3417
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2063
3418
|
*/
|
|
2064
|
-
_showAnalysisContextIfPresent(sessionData) {
|
|
2065
|
-
if (sessionData.context
|
|
2066
|
-
|
|
3419
|
+
_showAnalysisContextIfPresent(sessionData, targetTab) {
|
|
3420
|
+
if (!sessionData || !sessionData.context || !(sessionData.context.suggestionCount > 0)) return;
|
|
3421
|
+
const tab = targetTab || this._getActiveTab();
|
|
3422
|
+
if (!tab || !tab.messagesEl) return;
|
|
3423
|
+
|
|
3424
|
+
const existingCard = tab.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
3425
|
+
this._renderInTab(tab, () => {
|
|
2067
3426
|
if (existingCard) {
|
|
2068
3427
|
// Upgrade a bare-bones card (no metadata) with richer data from the backend.
|
|
2069
3428
|
// Update IN-PLACE to preserve the card's DOM position (avoids jumping below user message).
|
|
@@ -2072,18 +3431,20 @@ class ChatPanel {
|
|
|
2072
3431
|
if (!hasRicherContext) return;
|
|
2073
3432
|
this._updateAnalysisCardContent(existingCard, sessionData.context);
|
|
2074
3433
|
} else {
|
|
2075
|
-
const emptyState =
|
|
3434
|
+
const emptyState = tab.messagesEl.querySelector('.chat-panel__empty');
|
|
2076
3435
|
if (emptyState) emptyState.remove();
|
|
2077
3436
|
this._addAnalysisContextCard(sessionData.context);
|
|
2078
3437
|
}
|
|
3438
|
+
});
|
|
2079
3439
|
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
// Track which run's context is loaded so _ensureAnalysisContext can skip if already present
|
|
2085
|
-
this._sessionAnalysisRunId = sessionData.context.aiRunId || 'session';
|
|
3440
|
+
// Persist richer analysis context to DB (includes provider, model, summary, etc.)
|
|
3441
|
+
const contextData = { type: 'analysis', ...sessionData.context };
|
|
3442
|
+
if (tab.sessionId != null) {
|
|
3443
|
+
this._persistAnalysisContext(contextData, tab.sessionId);
|
|
2086
3444
|
}
|
|
3445
|
+
|
|
3446
|
+
// Track which run's context is loaded so _ensureAnalysisContext can skip if already present
|
|
3447
|
+
tab.sessionAnalysisRunId = sessionData.context.aiRunId || 'session';
|
|
2087
3448
|
}
|
|
2088
3449
|
|
|
2089
3450
|
/**
|
|
@@ -2160,16 +3521,22 @@ class ChatPanel {
|
|
|
2160
3521
|
* @param {Object} [opts] - Options
|
|
2161
3522
|
* @param {boolean} [opts.removable=false] - Whether the card should have a remove button
|
|
2162
3523
|
*/
|
|
2163
|
-
_addAnalysisRunContextCard(ctxData, { removable = false } = {}) {
|
|
3524
|
+
_addAnalysisRunContextCard(ctxData, { removable = false } = {}, targetTab) {
|
|
3525
|
+
const tab = targetTab || this._getActiveTab();
|
|
3526
|
+
if (!tab?.messagesEl) return;
|
|
2164
3527
|
const card = document.createElement('div');
|
|
2165
3528
|
card.className = 'chat-panel__context-card';
|
|
2166
|
-
card.dataset.contextIndex =
|
|
3529
|
+
card.dataset.contextIndex = tab.pendingContext.length - 1;
|
|
2167
3530
|
card.dataset.analysisRunId = ctxData.aiRunId;
|
|
2168
3531
|
card.innerHTML = this._buildAnalysisCardInnerHTML(ctxData);
|
|
2169
3532
|
|
|
2170
|
-
|
|
3533
|
+
// _makeCardRemovable reads `tab.pendingContextData.length` via the active
|
|
3534
|
+
// tab getter, so re-route the active marker temporarily.
|
|
3535
|
+
if (removable) {
|
|
3536
|
+
this._renderInTab(tab, () => this._makeCardRemovable(card));
|
|
3537
|
+
}
|
|
2171
3538
|
|
|
2172
|
-
|
|
3539
|
+
tab.messagesEl.appendChild(card);
|
|
2173
3540
|
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
2174
3541
|
}
|
|
2175
3542
|
|
|
@@ -2221,13 +3588,19 @@ class ChatPanel {
|
|
|
2221
3588
|
* Persist an analysis context card to the backend as a 'context' message.
|
|
2222
3589
|
* Called immediately when an analysis context card is added, so it appears
|
|
2223
3590
|
* in the conversation history on reload.
|
|
3591
|
+
*
|
|
3592
|
+
* Tab-aware callers pass an explicit sessionId so the persist write isn't
|
|
3593
|
+
* routed to the active tab's session when focus has shifted between the
|
|
3594
|
+
* card being added and the network call landing.
|
|
3595
|
+
*
|
|
2224
3596
|
* @param {Object} contextData - Analysis context metadata (type, suggestionCount, etc.)
|
|
3597
|
+
* @param {number} [explicitSessionId] - Defaults to this.currentSessionId.
|
|
2225
3598
|
*/
|
|
2226
|
-
async _persistAnalysisContext(contextData) {
|
|
2227
|
-
|
|
2228
|
-
|
|
3599
|
+
async _persistAnalysisContext(contextData, explicitSessionId) {
|
|
3600
|
+
const sessionId = explicitSessionId != null ? explicitSessionId : this.currentSessionId;
|
|
3601
|
+
if (!sessionId) return;
|
|
2229
3602
|
try {
|
|
2230
|
-
const response = await fetch(`/api/chat/session/${
|
|
3603
|
+
const response = await fetch(`/api/chat/session/${sessionId}/context`, {
|
|
2231
3604
|
method: 'POST',
|
|
2232
3605
|
headers: { 'Content-Type': 'application/json' },
|
|
2233
3606
|
body: JSON.stringify({ contextData })
|
|
@@ -2241,14 +3614,13 @@ class ChatPanel {
|
|
|
2241
3614
|
}
|
|
2242
3615
|
|
|
2243
3616
|
/**
|
|
2244
|
-
* Ensure WebSocket
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
3617
|
+
* Ensure the review-scoped WebSocket subscription is established. Per-tab
|
|
3618
|
+
* chat subscriptions are managed independently via _subscribeTab() as tabs
|
|
3619
|
+
* are created.
|
|
2247
3620
|
*/
|
|
2248
3621
|
_ensureSubscriptions() {
|
|
2249
3622
|
window.wsClient.connect();
|
|
2250
3623
|
|
|
2251
|
-
// Subscribe to review events (stable for page lifetime)
|
|
2252
3624
|
if (this.reviewId && !this._reviewUnsub) {
|
|
2253
3625
|
this._reviewUnsub = window.wsClient.subscribe('review:' + this.reviewId, (msg) => {
|
|
2254
3626
|
if (msg.type?.startsWith('review:')) {
|
|
@@ -2259,15 +3631,14 @@ class ChatPanel {
|
|
|
2259
3631
|
});
|
|
2260
3632
|
}
|
|
2261
3633
|
|
|
2262
|
-
//
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
3634
|
+
// Resubscribe any open tabs that may have lost their handles (e.g. after
|
|
3635
|
+
// late-binding a reviewId).
|
|
3636
|
+
for (const tab of this.tabs) {
|
|
3637
|
+
if (tab.sessionId != null && !tab.wsUnsub) {
|
|
3638
|
+
this._subscribeTab(tab);
|
|
3639
|
+
}
|
|
2267
3640
|
}
|
|
2268
3641
|
|
|
2269
|
-
// Listen for WebSocket reconnects — any deltas broadcast during the
|
|
2270
|
-
// reconnect gap are lost, so we re-fetch via HTTP to recover the stream.
|
|
2271
3642
|
if (!this._onReconnect) {
|
|
2272
3643
|
this._onReconnect = () => { this._recoverAfterReconnect(); };
|
|
2273
3644
|
window.addEventListener('wsReconnected', this._onReconnect);
|
|
@@ -2275,23 +3646,24 @@ class ChatPanel {
|
|
|
2275
3646
|
}
|
|
2276
3647
|
|
|
2277
3648
|
/**
|
|
2278
|
-
* Recover streaming state after a WebSocket reconnect.
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
* replace the partial `_streamingContent` with the complete last assistant
|
|
2282
|
-
* message. When not streaming, no action is needed.
|
|
3649
|
+
* Recover streaming state for every open tab after a WebSocket reconnect.
|
|
3650
|
+
* Each tab independently re-fetches its latest assistant message if it was
|
|
3651
|
+
* mid-stream when the connection dropped.
|
|
2283
3652
|
*/
|
|
2284
3653
|
async _recoverAfterReconnect() {
|
|
2285
|
-
|
|
3654
|
+
await Promise.all(this.tabs.map((tab) => this._recoverTabAfterReconnect(tab)));
|
|
3655
|
+
}
|
|
2286
3656
|
|
|
3657
|
+
async _recoverTabAfterReconnect(tab) {
|
|
3658
|
+
if (!tab?.isStreaming || tab.sessionId == null) return;
|
|
3659
|
+
const capturedSessionId = tab.sessionId;
|
|
2287
3660
|
try {
|
|
2288
|
-
const response = await fetch(`/api/chat/session/${
|
|
3661
|
+
const response = await fetch(`/api/chat/session/${tab.sessionId}/messages`);
|
|
3662
|
+
if (!this.tabs.includes(tab) || tab.sessionId !== capturedSessionId) return;
|
|
2289
3663
|
if (!response.ok) return;
|
|
2290
|
-
|
|
2291
3664
|
const result = await response.json();
|
|
3665
|
+
if (!this.tabs.includes(tab) || tab.sessionId !== capturedSessionId) return;
|
|
2292
3666
|
const messages = result.data?.messages || [];
|
|
2293
|
-
|
|
2294
|
-
// Find the last assistant message — this is the one being streamed
|
|
2295
3667
|
let lastAssistant = null;
|
|
2296
3668
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2297
3669
|
if (messages[i].type === 'message' && messages[i].role === 'assistant') {
|
|
@@ -2299,17 +3671,15 @@ class ChatPanel {
|
|
|
2299
3671
|
break;
|
|
2300
3672
|
}
|
|
2301
3673
|
}
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
this.
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
if (this.isOpen) {
|
|
2309
|
-
this.finalizeStreamingMessage(lastAssistant.id);
|
|
3674
|
+
if (lastAssistant?.content) {
|
|
3675
|
+
tab.streamingContent = lastAssistant.content;
|
|
3676
|
+
this._finalizeTabStream(tab, lastAssistant.id);
|
|
3677
|
+
tab.streamingContent = '';
|
|
3678
|
+
if (this.isOpen && this._getActiveTab() === tab) {
|
|
3679
|
+
this._finalizeStreaming(tab);
|
|
2310
3680
|
} else {
|
|
2311
|
-
|
|
2312
|
-
this.
|
|
3681
|
+
tab.isStreaming = false;
|
|
3682
|
+
this._updateTabStatus(tab, 'idle');
|
|
2313
3683
|
}
|
|
2314
3684
|
}
|
|
2315
3685
|
} catch (err) {
|
|
@@ -2318,80 +3688,80 @@ class ChatPanel {
|
|
|
2318
3688
|
}
|
|
2319
3689
|
|
|
2320
3690
|
/**
|
|
2321
|
-
*
|
|
2322
|
-
*
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
if (this.currentSessionId) {
|
|
2327
|
-
this._chatUnsub = window.wsClient.subscribe('chat:' + this.currentSessionId, (msg) => {
|
|
2328
|
-
this._handleChatMessage(msg);
|
|
2329
|
-
});
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
|
-
/**
|
|
2334
|
-
* Handles incoming WebSocket messages for the active chat session.
|
|
2335
|
-
* @param {Object} data - Parsed message object
|
|
3691
|
+
* Handle a WebSocket event for a specific tab. Foreground tabs render
|
|
3692
|
+
* directly to the DOM; background tabs accumulate state silently so it
|
|
3693
|
+
* is visible when the user clicks back.
|
|
3694
|
+
* @param {ChatTab} tab
|
|
3695
|
+
* @param {Object} data - Parsed WS message
|
|
2336
3696
|
*/
|
|
2337
|
-
|
|
3697
|
+
_handleChatMessageForTab(tab, data) {
|
|
2338
3698
|
try {
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
if (data.sessionId !== this.currentSessionId) {
|
|
2342
|
-
console.warn(`[ChatPanel] Unexpected sessionId mismatch: got ${data.sessionId}, expected ${this.currentSessionId}`);
|
|
3699
|
+
if (data.sessionId !== tab.sessionId) {
|
|
3700
|
+
console.warn(`[ChatPanel] sessionId mismatch on tab ${tab.sessionId}: got ${data.sessionId}`);
|
|
2343
3701
|
return;
|
|
2344
3702
|
}
|
|
2345
3703
|
|
|
2346
3704
|
if (data.type !== 'delta') {
|
|
2347
|
-
console.debug('[ChatPanel] WS event:', data.type, '
|
|
3705
|
+
console.debug('[ChatPanel] WS event:', data.type, 'tab:', tab.sessionId);
|
|
2348
3706
|
}
|
|
2349
3707
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
3708
|
+
const isActive = this.isOpen && this._getActiveTab() === tab;
|
|
3709
|
+
|
|
3710
|
+
// Background tab (or panel closed): drive the per-tab DOM via tab-aware
|
|
3711
|
+
// helpers so a tab switch reveals the same content the user would have
|
|
3712
|
+
// seen had it been in the foreground all along.
|
|
3713
|
+
if (!isActive) {
|
|
2353
3714
|
switch (data.type) {
|
|
2354
3715
|
case 'delta':
|
|
2355
|
-
|
|
3716
|
+
if (!tab.streamingMsgEl) this._addStreamingPlaceholder(tab);
|
|
3717
|
+
tab.streamingContent += data.text;
|
|
3718
|
+
this.updateStreamingMessage(tab.streamingContent, tab);
|
|
3719
|
+
this._markStreaming(tab);
|
|
3720
|
+
break;
|
|
3721
|
+
case 'status':
|
|
3722
|
+
if (data.status === 'working') this._markStreaming(tab);
|
|
2356
3723
|
break;
|
|
2357
3724
|
case 'complete':
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
this.
|
|
3725
|
+
this._finalizeTabStream(tab, data.messageId);
|
|
3726
|
+
tab.streamingContent = '';
|
|
3727
|
+
tab.isStreaming = false;
|
|
3728
|
+
tab.errorMessage = null;
|
|
3729
|
+
this._updateTabStatus(tab, 'idle');
|
|
2363
3730
|
break;
|
|
2364
3731
|
case 'error':
|
|
2365
|
-
|
|
2366
|
-
this.
|
|
3732
|
+
// Render the error inline so the user sees what happened when
|
|
3733
|
+
// they switch back to this tab.
|
|
3734
|
+
this._showError(data.message || 'An error occurred', tab);
|
|
3735
|
+
this._finalizeTabStream(tab, null);
|
|
3736
|
+
tab.streamingContent = '';
|
|
3737
|
+
tab.isStreaming = false;
|
|
2367
3738
|
break;
|
|
2368
|
-
// tool_use, status: purely visual, skip when closed
|
|
2369
3739
|
}
|
|
2370
3740
|
return;
|
|
2371
3741
|
}
|
|
2372
3742
|
|
|
3743
|
+
// Foreground path — drive the DOM directly
|
|
2373
3744
|
switch (data.type) {
|
|
2374
3745
|
case 'delta':
|
|
2375
|
-
this._hideThinkingIndicator();
|
|
2376
|
-
|
|
2377
|
-
this.updateStreamingMessage(
|
|
3746
|
+
this._hideThinkingIndicator(tab);
|
|
3747
|
+
tab.streamingContent += data.text;
|
|
3748
|
+
this.updateStreamingMessage(tab.streamingContent, tab);
|
|
2378
3749
|
break;
|
|
2379
|
-
|
|
2380
3750
|
case 'tool_use':
|
|
2381
|
-
this._showToolUse(data.toolName, data.status, data.toolInput);
|
|
3751
|
+
this._showToolUse(data.toolName, data.status, data.toolInput, tab);
|
|
2382
3752
|
break;
|
|
2383
|
-
|
|
2384
3753
|
case 'status':
|
|
2385
|
-
this._handleAgentStatus(data.status);
|
|
3754
|
+
this._handleAgentStatus(data.status, tab);
|
|
2386
3755
|
break;
|
|
2387
|
-
|
|
2388
3756
|
case 'complete':
|
|
2389
|
-
|
|
3757
|
+
tab.errorMessage = null;
|
|
3758
|
+
this.finalizeStreamingMessage(data.messageId, tab);
|
|
2390
3759
|
break;
|
|
2391
|
-
|
|
2392
3760
|
case 'error':
|
|
2393
|
-
|
|
2394
|
-
this.
|
|
3761
|
+
tab.errorMessage = data.message || 'An error occurred';
|
|
3762
|
+
this._updateTabStatus(tab, 'error');
|
|
3763
|
+
this._showError(tab.errorMessage, tab);
|
|
3764
|
+
this._finalizeStreaming(tab);
|
|
2395
3765
|
break;
|
|
2396
3766
|
}
|
|
2397
3767
|
} catch (e) {
|
|
@@ -2400,10 +3770,12 @@ class ChatPanel {
|
|
|
2400
3770
|
}
|
|
2401
3771
|
|
|
2402
3772
|
/**
|
|
2403
|
-
* Close
|
|
3773
|
+
* Close the review-scope subscription and every tab's chat subscription.
|
|
2404
3774
|
*/
|
|
2405
3775
|
_closeSubscriptions() {
|
|
2406
|
-
|
|
3776
|
+
for (const tab of this.tabs) {
|
|
3777
|
+
if (tab.wsUnsub) { try { tab.wsUnsub(); } catch { /* noop */ } tab.wsUnsub = null; }
|
|
3778
|
+
}
|
|
2407
3779
|
if (this._reviewUnsub) { this._reviewUnsub(); this._reviewUnsub = null; }
|
|
2408
3780
|
if (this._onReconnect) {
|
|
2409
3781
|
window.removeEventListener('wsReconnected', this._onReconnect);
|
|
@@ -2412,15 +3784,19 @@ class ChatPanel {
|
|
|
2412
3784
|
}
|
|
2413
3785
|
|
|
2414
3786
|
/**
|
|
2415
|
-
* Add a message to the display
|
|
3787
|
+
* Add a message to the display.
|
|
2416
3788
|
* @param {string} role - 'user' or 'assistant'
|
|
2417
3789
|
* @param {string} content - Message text
|
|
2418
|
-
* @param {number} id - Optional message ID
|
|
3790
|
+
* @param {number} [id] - Optional message ID
|
|
3791
|
+
* @param {ChatTab} [targetTab] - Explicit tab; defaults to active tab
|
|
2419
3792
|
* @returns {HTMLElement} The message element that was appended
|
|
2420
3793
|
*/
|
|
2421
|
-
addMessage(role, content, id) {
|
|
3794
|
+
addMessage(role, content, id, targetTab) {
|
|
3795
|
+
const tab = targetTab || this._getActiveTab();
|
|
3796
|
+
if (!tab || !tab.messagesEl) return null;
|
|
3797
|
+
|
|
2422
3798
|
const msg = { role, content, id };
|
|
2423
|
-
|
|
3799
|
+
tab.messages.push(msg);
|
|
2424
3800
|
|
|
2425
3801
|
const msgEl = document.createElement('div');
|
|
2426
3802
|
msgEl.className = `chat-panel__message chat-panel__message--${role}`;
|
|
@@ -2438,8 +3814,19 @@ class ChatPanel {
|
|
|
2438
3814
|
}
|
|
2439
3815
|
|
|
2440
3816
|
msgEl.appendChild(bubble);
|
|
2441
|
-
|
|
2442
|
-
|
|
3817
|
+
tab.messagesEl.appendChild(msgEl);
|
|
3818
|
+
|
|
3819
|
+
// Update the tab title from the first user message if the user hasn't
|
|
3820
|
+
// explicitly named it. Only do this when no prior user message exists.
|
|
3821
|
+
if (role === 'user' && !tab.titleFromUser) {
|
|
3822
|
+
const preview = this._truncate(content, 28);
|
|
3823
|
+
if (preview) {
|
|
3824
|
+
tab.titleFromUser = true;
|
|
3825
|
+
this._setTabTitle(tab, preview);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
if (this._getActiveTab() === tab) this.scrollToBottom({ force: true });
|
|
2443
3830
|
return msgEl;
|
|
2444
3831
|
}
|
|
2445
3832
|
|
|
@@ -2482,31 +3869,35 @@ class ChatPanel {
|
|
|
2482
3869
|
}
|
|
2483
3870
|
|
|
2484
3871
|
/**
|
|
2485
|
-
* Add a streaming placeholder for the assistant's response
|
|
3872
|
+
* Add a streaming placeholder for the assistant's response on a tab.
|
|
3873
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2486
3874
|
*/
|
|
2487
|
-
_addStreamingPlaceholder() {
|
|
3875
|
+
_addStreamingPlaceholder(targetTab) {
|
|
3876
|
+
const tab = targetTab || this._getActiveTab();
|
|
3877
|
+
if (!tab || !tab.messagesEl) return;
|
|
2488
3878
|
const msgEl = document.createElement('div');
|
|
2489
3879
|
msgEl.className = 'chat-panel__message chat-panel__message--assistant chat-panel__message--streaming';
|
|
2490
|
-
msgEl.id = 'chat-streaming-msg';
|
|
2491
3880
|
|
|
2492
3881
|
const bubble = document.createElement('div');
|
|
2493
3882
|
bubble.className = 'chat-panel__bubble';
|
|
2494
3883
|
bubble.innerHTML = getChatSpinnerHTML();
|
|
2495
3884
|
|
|
2496
3885
|
msgEl.appendChild(bubble);
|
|
2497
|
-
|
|
2498
|
-
|
|
3886
|
+
tab.messagesEl.appendChild(msgEl);
|
|
3887
|
+
tab.streamingMsgEl = msgEl;
|
|
3888
|
+
if (this._getActiveTab() === tab) this.scrollToBottom({ force: true });
|
|
2499
3889
|
}
|
|
2500
3890
|
|
|
2501
3891
|
/**
|
|
2502
|
-
* Update the
|
|
3892
|
+
* Update the streaming message on a tab.
|
|
2503
3893
|
* @param {string} text - Full accumulated text so far
|
|
3894
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2504
3895
|
*/
|
|
2505
|
-
updateStreamingMessage(text) {
|
|
2506
|
-
const
|
|
3896
|
+
updateStreamingMessage(text, targetTab) {
|
|
3897
|
+
const tab = targetTab || this._getActiveTab();
|
|
3898
|
+
const streamingMsg = tab?.streamingMsgEl;
|
|
2507
3899
|
if (!streamingMsg) return;
|
|
2508
3900
|
|
|
2509
|
-
// Remove transient tool badge when real text arrives
|
|
2510
3901
|
const transient = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2511
3902
|
if (transient) transient.remove();
|
|
2512
3903
|
|
|
@@ -2515,96 +3906,116 @@ class ChatPanel {
|
|
|
2515
3906
|
bubble.innerHTML = this.renderMarkdown(text) + '<span class="chat-panel__cursor"></span>';
|
|
2516
3907
|
this._linkifyFileReferences(bubble);
|
|
2517
3908
|
}
|
|
2518
|
-
this.scrollToBottom();
|
|
3909
|
+
if (this._getActiveTab() === tab) this.scrollToBottom();
|
|
2519
3910
|
}
|
|
2520
3911
|
|
|
2521
3912
|
/**
|
|
2522
|
-
* Finalize the streaming message
|
|
3913
|
+
* Finalize the streaming message on a tab. Idempotent — a second call when
|
|
3914
|
+
* streamingMsgEl is already null is a no-op.
|
|
2523
3915
|
* @param {number} messageId - Database message ID
|
|
3916
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
3917
|
+
*/
|
|
3918
|
+
finalizeStreamingMessage(messageId, targetTab) {
|
|
3919
|
+
const tab = targetTab || this._getActiveTab();
|
|
3920
|
+
if (!tab) return;
|
|
3921
|
+
this._finalizeTabStream(tab, messageId);
|
|
3922
|
+
this._finalizeStreaming(tab);
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
/**
|
|
3926
|
+
* Tab-aware DOM finalization helper. Writes the final streamed content into
|
|
3927
|
+
* `tab.streamingMsgEl`, strips streaming/cursor classes, pushes the message
|
|
3928
|
+
* into `tab.messages`, and nulls `tab.streamingMsgEl`. Safe to call twice.
|
|
3929
|
+
* @param {ChatTab} tab
|
|
3930
|
+
* @param {number} [messageId]
|
|
2524
3931
|
*/
|
|
2525
|
-
|
|
2526
|
-
|
|
3932
|
+
_finalizeTabStream(tab, messageId) {
|
|
3933
|
+
if (!tab) return;
|
|
3934
|
+
const streamingMsg = tab.streamingMsgEl;
|
|
2527
3935
|
if (streamingMsg) {
|
|
2528
3936
|
streamingMsg.classList.remove('chat-panel__message--streaming');
|
|
2529
|
-
streamingMsg.id = '';
|
|
2530
3937
|
if (messageId) streamingMsg.dataset.messageId = messageId;
|
|
2531
3938
|
|
|
2532
|
-
// Remove cursor and thinking indicator
|
|
2533
3939
|
const cursor = streamingMsg.querySelector('.chat-panel__cursor');
|
|
2534
3940
|
if (cursor) cursor.remove();
|
|
2535
3941
|
const thinking = streamingMsg.querySelector('.chat-panel__thinking');
|
|
2536
3942
|
if (thinking) thinking.remove();
|
|
2537
3943
|
|
|
2538
|
-
// Remove transient tool badge
|
|
2539
3944
|
const transientBadge = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2540
3945
|
if (transientBadge) transientBadge.remove();
|
|
2541
3946
|
|
|
2542
|
-
// Remove any active tool spinners (e.g. abort mid-tool-execution)
|
|
2543
3947
|
const spinners = streamingMsg.querySelectorAll('.chat-panel__tool-spinner');
|
|
2544
3948
|
spinners.forEach(s => s.remove());
|
|
2545
3949
|
|
|
2546
|
-
// Final render
|
|
2547
3950
|
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2548
3951
|
if (bubble) {
|
|
2549
|
-
if (
|
|
2550
|
-
bubble.innerHTML = this.renderMarkdown(
|
|
3952
|
+
if (tab.streamingContent) {
|
|
3953
|
+
bubble.innerHTML = this.renderMarkdown(tab.streamingContent);
|
|
2551
3954
|
this._linkifyFileReferences(bubble);
|
|
2552
|
-
bubble.appendChild(this._createCopyButton(
|
|
3955
|
+
bubble.appendChild(this._createCopyButton(tab.streamingContent));
|
|
2553
3956
|
} else {
|
|
2554
|
-
// Empty response - show a subtle message
|
|
2555
3957
|
bubble.innerHTML = '<em class="chat-panel__empty-response">No response generated.</em>';
|
|
2556
3958
|
}
|
|
2557
3959
|
}
|
|
2558
3960
|
}
|
|
2559
3961
|
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
this.messages.push({ role: 'assistant', content: this._streamingContent, id: messageId });
|
|
3962
|
+
if (tab.streamingContent) {
|
|
3963
|
+
tab.messages.push({ role: 'assistant', content: tab.streamingContent, id: messageId });
|
|
2563
3964
|
}
|
|
2564
|
-
|
|
2565
|
-
this._finalizeStreaming();
|
|
3965
|
+
tab.streamingMsgEl = null;
|
|
2566
3966
|
}
|
|
2567
3967
|
|
|
2568
3968
|
/**
|
|
2569
|
-
* Abort the current agent turn
|
|
3969
|
+
* Abort the current agent turn on the originating tab. Captures the tab
|
|
3970
|
+
* at entry so a focus change between the user clicking Stop and the abort
|
|
3971
|
+
* round-trip resolving can't finalize the wrong tab's stream.
|
|
2570
3972
|
*/
|
|
2571
3973
|
async _stopAgent() {
|
|
2572
|
-
|
|
2573
|
-
|
|
3974
|
+
const tab = this._getActiveTab();
|
|
3975
|
+
if (!tab || !tab.isStreaming || tab.sessionId == null) return;
|
|
2574
3976
|
try {
|
|
2575
|
-
await fetch(`/api/chat/session/${
|
|
2576
|
-
method: 'POST'
|
|
2577
|
-
});
|
|
3977
|
+
await fetch(`/api/chat/session/${tab.sessionId}/abort`, { method: 'POST' });
|
|
2578
3978
|
} catch (error) {
|
|
2579
3979
|
console.error('[ChatPanel] Error aborting:', error);
|
|
2580
3980
|
}
|
|
2581
|
-
|
|
2582
|
-
// Finalize the streaming message with whatever content we have so far
|
|
2583
|
-
this.finalizeStreamingMessage(null);
|
|
3981
|
+
if (!this.tabs.includes(tab)) return;
|
|
3982
|
+
// Finalize the streaming message with whatever content we have so far.
|
|
3983
|
+
this.finalizeStreamingMessage(null, tab);
|
|
2584
3984
|
}
|
|
2585
3985
|
|
|
2586
3986
|
/**
|
|
2587
|
-
* Clean up streaming state
|
|
3987
|
+
* Clean up streaming state on a tab. UI controls are only touched when the
|
|
3988
|
+
* tab is currently active.
|
|
3989
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2588
3990
|
*/
|
|
2589
|
-
_finalizeStreaming() {
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
3991
|
+
_finalizeStreaming(targetTab) {
|
|
3992
|
+
const tab = targetTab || this._getActiveTab();
|
|
3993
|
+
if (tab) {
|
|
3994
|
+
tab.isStreaming = false;
|
|
3995
|
+
tab.streamingContent = '';
|
|
3996
|
+
tab.streamingMsgEl = null;
|
|
3997
|
+
this._updateTabStatus(tab, tab.errorMessage ? 'error' : 'idle');
|
|
3998
|
+
}
|
|
3999
|
+
if (!tab || this._getActiveTab() === tab) {
|
|
4000
|
+
this.sendBtn.style.display = '';
|
|
4001
|
+
this.stopBtn.style.display = 'none';
|
|
4002
|
+
this.sendBtn.disabled = !this.inputEl?.value?.trim();
|
|
4003
|
+
this._updateActionButtons();
|
|
4004
|
+
this.inputEl?.focus();
|
|
4005
|
+
}
|
|
2597
4006
|
}
|
|
2598
4007
|
|
|
2599
4008
|
/**
|
|
2600
|
-
* Show a tool use indicator in
|
|
4009
|
+
* Show a tool use indicator in a tab's streaming message.
|
|
2601
4010
|
* @param {string} toolName - Name of the tool being used
|
|
2602
4011
|
* @param {string} status - 'start' or 'end'
|
|
2603
4012
|
* @param {Object} [toolInput] - Tool input/arguments (optional)
|
|
4013
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2604
4014
|
*/
|
|
2605
|
-
_showToolUse(toolName, status, toolInput) {
|
|
4015
|
+
_showToolUse(toolName, status, toolInput, targetTab) {
|
|
2606
4016
|
if (!toolName) return;
|
|
2607
|
-
const
|
|
4017
|
+
const tab = targetTab || this._getActiveTab();
|
|
4018
|
+
const streamingMsg = tab?.streamingMsgEl;
|
|
2608
4019
|
if (!streamingMsg) return;
|
|
2609
4020
|
|
|
2610
4021
|
const isTask = toolName.toLowerCase() === 'task' || toolName.toLowerCase() === 'agent';
|
|
@@ -2657,7 +4068,7 @@ class ChatPanel {
|
|
|
2657
4068
|
if (spinner) spinner.remove();
|
|
2658
4069
|
}
|
|
2659
4070
|
}
|
|
2660
|
-
this._showThinkingIndicator();
|
|
4071
|
+
this._showThinkingIndicator(tab);
|
|
2661
4072
|
}
|
|
2662
4073
|
}
|
|
2663
4074
|
|
|
@@ -2704,32 +4115,46 @@ class ChatPanel {
|
|
|
2704
4115
|
/**
|
|
2705
4116
|
* Handle agent status events from the backend.
|
|
2706
4117
|
* @param {string} status - 'working' or 'turn_complete'
|
|
4118
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2707
4119
|
*/
|
|
2708
|
-
_handleAgentStatus(status) {
|
|
4120
|
+
_handleAgentStatus(status, targetTab) {
|
|
4121
|
+
const tab = targetTab || this._getActiveTab();
|
|
2709
4122
|
if (status === 'working') {
|
|
2710
|
-
this._showThinkingIndicator();
|
|
4123
|
+
this._showThinkingIndicator(tab);
|
|
4124
|
+
this._markStreaming(tab);
|
|
2711
4125
|
}
|
|
2712
4126
|
// 'turn_complete' is informational; the agent may start another turn
|
|
2713
4127
|
}
|
|
2714
4128
|
|
|
2715
4129
|
/**
|
|
2716
|
-
*
|
|
2717
|
-
*
|
|
4130
|
+
* Mark a tab as streaming if it is not already. Used by foreground and
|
|
4131
|
+
* background status arms to set the per-tab flag + status dot in one place.
|
|
4132
|
+
* Short-circuits if already streaming — preserves errorMessage from being
|
|
4133
|
+
* cleared mid-stream.
|
|
4134
|
+
* @param {ChatTab} tab
|
|
4135
|
+
*/
|
|
4136
|
+
_markStreaming(tab) {
|
|
4137
|
+
if (!tab || tab.isStreaming) return;
|
|
4138
|
+
tab.isStreaming = true;
|
|
4139
|
+
this._updateTabStatus(tab, 'streaming');
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
/**
|
|
4143
|
+
* Show the pulsing thinking indicator on a tab's streaming message.
|
|
4144
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2718
4145
|
*/
|
|
2719
|
-
_showThinkingIndicator() {
|
|
2720
|
-
const
|
|
4146
|
+
_showThinkingIndicator(targetTab) {
|
|
4147
|
+
const tab = targetTab || this._getActiveTab();
|
|
4148
|
+
const streamingMsg = tab?.streamingMsgEl;
|
|
2721
4149
|
if (!streamingMsg) return;
|
|
2722
4150
|
|
|
2723
4151
|
// Don't add duplicate
|
|
2724
4152
|
if (streamingMsg.querySelector('.chat-panel__thinking')) return;
|
|
2725
4153
|
|
|
2726
4154
|
// Don't add if the bubble still has its initial spinner (no content yet).
|
|
2727
|
-
// The bubble's own indicator is sufficient — adding a second would show two.
|
|
2728
4155
|
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2729
4156
|
if (bubble && (bubble.querySelector('.chat-panel__typing-indicator') || bubble.querySelector('.chat-panel__loop-spinner'))) return;
|
|
2730
4157
|
|
|
2731
|
-
// Remove the cursor — the thinking indicator replaces it as the "working" signal.
|
|
2732
|
-
// When new text arrives, updateStreamingMessage() will re-add the cursor naturally.
|
|
2733
4158
|
const cursor = bubble?.querySelector('.chat-panel__cursor');
|
|
2734
4159
|
if (cursor) cursor.remove();
|
|
2735
4160
|
|
|
@@ -2737,24 +4162,34 @@ class ChatPanel {
|
|
|
2737
4162
|
indicator.className = 'chat-panel__thinking';
|
|
2738
4163
|
indicator.innerHTML = getChatSpinnerHTML();
|
|
2739
4164
|
streamingMsg.appendChild(indicator);
|
|
2740
|
-
this.scrollToBottom();
|
|
4165
|
+
if (this._getActiveTab() === tab) this.scrollToBottom();
|
|
2741
4166
|
}
|
|
2742
4167
|
|
|
2743
4168
|
/**
|
|
2744
|
-
* Hide the thinking indicator
|
|
4169
|
+
* Hide the thinking indicator on a tab's streaming message.
|
|
4170
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2745
4171
|
*/
|
|
2746
|
-
_hideThinkingIndicator() {
|
|
2747
|
-
const
|
|
4172
|
+
_hideThinkingIndicator(targetTab) {
|
|
4173
|
+
const tab = targetTab || this._getActiveTab();
|
|
4174
|
+
const streamingMsg = tab?.streamingMsgEl;
|
|
2748
4175
|
if (!streamingMsg) return;
|
|
2749
4176
|
const thinking = streamingMsg.querySelector('.chat-panel__thinking');
|
|
2750
4177
|
if (thinking) thinking.remove();
|
|
2751
4178
|
}
|
|
2752
4179
|
|
|
2753
4180
|
/**
|
|
2754
|
-
* Show an error message in
|
|
4181
|
+
* Show an error message in a tab.
|
|
2755
4182
|
* @param {string} message - Error text
|
|
4183
|
+
* @param {ChatTab} [targetTab] - Defaults to active tab
|
|
2756
4184
|
*/
|
|
2757
|
-
_showError(message) {
|
|
4185
|
+
_showError(message, targetTab) {
|
|
4186
|
+
const tab = targetTab || this._getActiveTab();
|
|
4187
|
+
if (tab) {
|
|
4188
|
+
tab.errorMessage = message;
|
|
4189
|
+
this._updateTabStatus(tab, 'error');
|
|
4190
|
+
}
|
|
4191
|
+
const messagesEl = tab?.messagesEl;
|
|
4192
|
+
if (!messagesEl) return;
|
|
2758
4193
|
const errorEl = document.createElement('div');
|
|
2759
4194
|
errorEl.className = 'chat-panel__message chat-panel__message--error';
|
|
2760
4195
|
errorEl.innerHTML = `
|
|
@@ -2765,8 +4200,8 @@ class ChatPanel {
|
|
|
2765
4200
|
${this._escapeHtml(message)}
|
|
2766
4201
|
</div>
|
|
2767
4202
|
`;
|
|
2768
|
-
|
|
2769
|
-
this.scrollToBottom({ force: true });
|
|
4203
|
+
messagesEl.appendChild(errorEl);
|
|
4204
|
+
if (this._getActiveTab() === tab) this.scrollToBottom({ force: true });
|
|
2770
4205
|
}
|
|
2771
4206
|
|
|
2772
4207
|
/**
|
|
@@ -3093,6 +4528,28 @@ class ChatPanel {
|
|
|
3093
4528
|
return div.innerHTML;
|
|
3094
4529
|
}
|
|
3095
4530
|
|
|
4531
|
+
/**
|
|
4532
|
+
* Allow only http/https/mailto URLs in href attributes. Used to gate
|
|
4533
|
+
* server-supplied URLs (external comment permalinks, profile URLs) so a
|
|
4534
|
+
* malicious upstream cannot smuggle `javascript:` or `data:` schemes
|
|
4535
|
+
* into our DOM.
|
|
4536
|
+
* @param {string} url
|
|
4537
|
+
* @returns {boolean}
|
|
4538
|
+
*/
|
|
4539
|
+
_isSafeUrl(url) {
|
|
4540
|
+
if (typeof url !== 'string' || !url) return false;
|
|
4541
|
+
const trimmed = url.trim();
|
|
4542
|
+
if (!trimmed) return false;
|
|
4543
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?')) return true;
|
|
4544
|
+
try {
|
|
4545
|
+
const base = (typeof window !== 'undefined' && window.location) ? window.location.href : 'http://localhost/';
|
|
4546
|
+
const u = new URL(trimmed, base);
|
|
4547
|
+
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:';
|
|
4548
|
+
} catch {
|
|
4549
|
+
return false;
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
|
|
3096
4553
|
/**
|
|
3097
4554
|
* Auto-scroll messages to bottom.
|
|
3098
4555
|
* When force is true (user-initiated actions), always scrolls.
|
|
@@ -3172,7 +4629,9 @@ class ChatPanel {
|
|
|
3172
4629
|
*/
|
|
3173
4630
|
_handleAdoptClick() {
|
|
3174
4631
|
if (this.isStreaming || !this._contextItemId) return;
|
|
3175
|
-
|
|
4632
|
+
const tab = this._getActiveTab();
|
|
4633
|
+
if (!tab) return;
|
|
4634
|
+
tab.pendingActionContext = { type: 'adopt', itemId: tab.contextItemId };
|
|
3176
4635
|
this.inputEl.value = 'Based on our conversation, please refine and adopt this AI suggestion.';
|
|
3177
4636
|
this.sendMessage();
|
|
3178
4637
|
}
|
|
@@ -3183,7 +4642,9 @@ class ChatPanel {
|
|
|
3183
4642
|
*/
|
|
3184
4643
|
_handleUpdateClick() {
|
|
3185
4644
|
if (this.isStreaming || !this._contextItemId) return;
|
|
3186
|
-
|
|
4645
|
+
const tab = this._getActiveTab();
|
|
4646
|
+
if (!tab) return;
|
|
4647
|
+
tab.pendingActionContext = { type: 'update', itemId: tab.contextItemId };
|
|
3187
4648
|
this.inputEl.value = 'Based on our conversation, please update my comment.';
|
|
3188
4649
|
this.sendMessage();
|
|
3189
4650
|
}
|
|
@@ -3194,7 +4655,9 @@ class ChatPanel {
|
|
|
3194
4655
|
*/
|
|
3195
4656
|
_handleDismissSuggestionClick() {
|
|
3196
4657
|
if (this.isStreaming || !this._contextItemId) return;
|
|
3197
|
-
|
|
4658
|
+
const tab = this._getActiveTab();
|
|
4659
|
+
if (!tab) return;
|
|
4660
|
+
tab.pendingActionContext = { type: 'dismiss-suggestion', itemId: tab.contextItemId };
|
|
3198
4661
|
this.inputEl.value = 'Please dismiss this AI suggestion.';
|
|
3199
4662
|
this.sendMessage();
|
|
3200
4663
|
}
|
|
@@ -3205,7 +4668,9 @@ class ChatPanel {
|
|
|
3205
4668
|
*/
|
|
3206
4669
|
_handleDismissCommentClick() {
|
|
3207
4670
|
if (this.isStreaming || !this._contextItemId) return;
|
|
3208
|
-
|
|
4671
|
+
const tab = this._getActiveTab();
|
|
4672
|
+
if (!tab) return;
|
|
4673
|
+
tab.pendingActionContext = { type: 'dismiss-comment', itemId: tab.contextItemId };
|
|
3209
4674
|
this.inputEl.value = 'Please dismiss this comment.';
|
|
3210
4675
|
this.sendMessage();
|
|
3211
4676
|
}
|
|
@@ -3227,11 +4692,13 @@ class ChatPanel {
|
|
|
3227
4692
|
*/
|
|
3228
4693
|
_handleCreateCommentClick() {
|
|
3229
4694
|
if (this.isStreaming) return;
|
|
3230
|
-
|
|
4695
|
+
const tab = this._getActiveTab();
|
|
4696
|
+
if (!tab) return;
|
|
4697
|
+
tab.pendingActionContext = {
|
|
3231
4698
|
type: 'create-comment',
|
|
3232
|
-
file:
|
|
3233
|
-
line_start:
|
|
3234
|
-
line_end:
|
|
4699
|
+
file: tab.contextLineMeta?.file,
|
|
4700
|
+
line_start: tab.contextLineMeta?.line_start,
|
|
4701
|
+
line_end: tab.contextLineMeta?.line_end,
|
|
3235
4702
|
};
|
|
3236
4703
|
this.inputEl.value = 'Based on our conversation, please create a review comment for this code.';
|
|
3237
4704
|
this.sendMessage();
|
|
@@ -3248,7 +4715,13 @@ class ChatPanel {
|
|
|
3248
4715
|
document.body.appendChild(this._ctxTooltipEl);
|
|
3249
4716
|
this._ctxTooltipTimer = null;
|
|
3250
4717
|
|
|
3251
|
-
|
|
4718
|
+
// Delegate hover events from the persistent stack element so the tooltip
|
|
4719
|
+
// works for every per-tab messagesEl (which are created lazily by
|
|
4720
|
+
// _createTabMessagesEl as tabs are opened/restored). Reading
|
|
4721
|
+
// `this.messagesEl` here would always be null at construction time —
|
|
4722
|
+
// there's no active tab yet — so the listeners would never bind.
|
|
4723
|
+
const host = this.messagesStackEl;
|
|
4724
|
+
if (!host) return;
|
|
3252
4725
|
|
|
3253
4726
|
this._onCtxCardEnter = (e) => {
|
|
3254
4727
|
const card = e.target.closest('.chat-panel__context-card[data-tooltip-body]');
|
|
@@ -3264,8 +4737,8 @@ class ChatPanel {
|
|
|
3264
4737
|
this._ctxTooltipEl.style.display = 'none';
|
|
3265
4738
|
};
|
|
3266
4739
|
|
|
3267
|
-
|
|
3268
|
-
|
|
4740
|
+
host.addEventListener('mouseenter', this._onCtxCardEnter, true);
|
|
4741
|
+
host.addEventListener('mouseleave', this._onCtxCardLeave, true);
|
|
3269
4742
|
}
|
|
3270
4743
|
|
|
3271
4744
|
/**
|
|
@@ -3311,7 +4784,8 @@ class ChatPanel {
|
|
|
3311
4784
|
document.removeEventListener('keydown', this._onKeydown);
|
|
3312
4785
|
window.removeEventListener('chat-state-changed', this._onChatStateChanged);
|
|
3313
4786
|
this._closeSubscriptions();
|
|
3314
|
-
this.
|
|
4787
|
+
this.tabs = [];
|
|
4788
|
+
this.activeTabKey = null;
|
|
3315
4789
|
|
|
3316
4790
|
// Clean up context tooltip
|
|
3317
4791
|
clearTimeout(this._ctxTooltipTimer);
|