@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.
@@ -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._analysisContextRemoved = false;
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, '&amp;')
661
+ .replace(/"/g, '&quot;')
662
+ .replace(/'/g, '&#39;')
663
+ .replace(/</g, '&lt;')
664
+ .replace(/>/g, '&gt;');
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>Ask questions about this review, or the changes</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.messagesEl = this.container.querySelector('#chat-messages');
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.newBtn = this.container.querySelector('.chat-panel__new-btn');
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 conversation button
200
- this.newBtn.addEventListener('click', () => this._startNewConversation());
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.messagesEl?.addEventListener('click', (e) => {
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 - User comment details for context
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 WebSocket subscriptions are active (but don't create a session yet — lazy creation)
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
- // Load MRU session with message history (if any previous sessions exist).
557
- // Skip when opening with explicit context (suggestion/comment/file) — the
558
- // user wants a *new* conversation about that item, not to resume the last one.
559
- const hasExplicitContext = !!(options.suggestionContext || options.commentContext || options.fileContext);
560
- if (!this.currentSessionId && !hasExplicitContext) {
561
- await this._loadMRUSession();
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
- this._pendingContext = [];
624
- this._pendingContextData = [];
625
- this._contextSource = null;
626
- this._contextItemId = null;
627
- this._contextLineMeta = null;
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 (reset session)
650
- * Preserves any unsent pending context cards and re-adds them to the new conversation.
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
- // 1. Snapshot pending context before clearing (these are unsent context cards)
656
- const savedContext = this._pendingContext.slice();
657
- const savedContextData = this._pendingContextData.slice();
658
- const savedContextSource = this._contextSource;
659
- const savedContextItemId = this._contextItemId;
660
- const savedContextLineMeta = this._contextLineMeta;
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 for the current review.
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
- if (!this.reviewId) return;
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
- const mru = sessions[0];
748
- this.currentSessionId = mru.id;
749
- this._sessionWarm = false;
750
- this._resubscribeChat();
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
- this._updateTitle(mru.provider, mru.model);
755
- this._activeProvider = mru.provider;
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
- * @param {number} sessionId
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
- const response = await fetch(`/api/chat/session/${sessionId}/messages`);
774
- if (!response.ok) return;
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
- const result = await response.json();
777
- const messages = result.data?.messages || [];
778
- if (messages.length === 0) return;
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
- // Remove empty state
781
- const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
782
- if (emptyState) emptyState.remove();
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
- } catch (err) {
808
- console.warn('[ChatPanel] Failed to load message history:', err);
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. Updates active provider and title. Only affects new sessions.
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
- this._startNewConversation();
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 === this.currentSessionId;
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="chat-panel__session-item${isActive ? ' chat-panel__session-item--active' : ''}"
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
- * Switch to a different chat session.
1016
- * Tears down current state and loads the target session.
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
- if (sessionId === this.currentSessionId) return;
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. Reset state
1027
- this.currentSessionId = sessionId;
1028
- this._sessionWarm = false;
1029
- this._resubscribeChat();
1030
- this.messages = [];
1031
- this._streamingContent = '';
1032
- this._pendingContext = [];
1033
- this._pendingContextData = [];
1034
- this._pendingDiffStateNotifications = [];
1035
- this._pendingUserActionHints = [];
1036
- this._contextSource = null;
1037
- this._contextItemId = null;
1038
- this._contextLineMeta = null;
1039
- this._pendingActionContext = null;
1040
- this._analysisContextRemoved = false;
1041
- this._sessionAnalysisRunId = null;
1042
-
1043
- // 3. Clear UI
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
- this._updateTitle(sessionData.provider, sessionData.model);
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
- // 5. Load message history
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
- // 6. Ensure analysis context for the new session
1061
- this._ensureAnalysisContext();
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
- this.messagesEl.innerHTML = `
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, load the MRU session now
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
- await this._loadMRUSession();
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
- body.contextCommentId = contextCommentId;
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.currentSessionId = result.data.id;
1201
- this._sessionWarm = true;
1202
- this._resubscribeChat();
1203
- console.debug('[ChatPanel] Session created:', this.currentSessionId);
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 || this.isStreaming) return;
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 = this.messagesEl.querySelector('.chat-panel__empty');
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 (!this.currentSessionId) {
2251
+ if (tab.sessionId == null) {
1237
2252
  this._ensureSubscriptions();
1238
- const sessionData = await this.createSession();
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
- this.inputEl.value = messageText;
1242
- this._autoResizeTextarea();
1243
- this.sendBtn.disabled = false;
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
- this.messages.pop();
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
- this._showAnalysisContextIfPresent(sessionData);
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 currentSessionId is set (from MRU), just send — server auto-resumes
1254
-
1255
- // Prepare streaming UI
1256
- this.isStreaming = true;
1257
- this.sendBtn.disabled = true;
1258
- this.sendBtn.style.display = 'none';
1259
- this.stopBtn.style.display = '';
1260
- this._updateActionButtons();
1261
- this._streamingContent = '';
1262
- this._addStreamingPlaceholder();
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 queue for error recovery (invisible to user, no UI cards)
1268
- const savedDiffState = this._pendingDiffStateNotifications.slice();
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 (this._pendingDiffStateNotifications.length > 0) {
1271
- diffStatePrefix = '[Diff State Update]\n' + this._pendingDiffStateNotifications.join('\n');
1272
- this._pendingDiffStateNotifications = [];
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 (invisible to user, no UI cards)
1276
- const savedUserActionHints = this._pendingUserActionHints.slice();
2302
+ // Snapshot user-action-hints queue for error recovery (ordered, per-tab)
2303
+ const savedUserActionHints = tab.pendingUserActionHints.slice();
1277
2304
  let userActionPrefix = '';
1278
- if (this._pendingUserActionHints.length > 0) {
1279
- userActionPrefix = '[User Action Hints]\n' + this._pendingUserActionHints.join('\n');
1280
- this._pendingUserActionHints = [];
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 = this._pendingContext;
1292
- const savedContextData = this._pendingContextData;
1293
- if (this._pendingContext.length > 0) {
1294
- const userContext = this._pendingContext.join('\n\n');
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 = this._pendingContextData;
1299
- this._pendingContext = [];
1300
- this._pendingContextData = [];
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 = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
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 = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis] .chat-panel__context-remove');
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
- if (this._pendingActionContext) {
1319
- payload.actionContext = this._pendingActionContext;
1320
- this._pendingActionContext = null;
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() && !this._sessionWarm;
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', this.currentSessionId);
1332
- let response = await fetch(`/api/chat/session/${this.currentSessionId}/message`, {
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
- this._sessionWarm = true;
2369
+ tab.sessionWarm = true;
1341
2370
  }
1342
2371
 
1343
- // Handle 410 Gone: session is not resumable — transparently create a new one and retry once.
1344
- // Note: we do NOT call _hideStatusFlash() here. createSession() will call
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
- this.currentSessionId = null;
1350
- this._resubscribeChat();
1351
- this._ensureSubscriptions();
1352
- const sessionData = await this.createSession();
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/${this.currentSessionId}/message`, {
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 context so it's not lost
1372
- this._pendingContext = savedContext;
1373
- this._pendingContextData = savedContextData;
1374
- this._pendingDiffStateNotifications = [...savedDiffState, ...this._pendingDiffStateNotifications];
1375
- this._pendingUserActionHints = [...savedUserActionHints, ...this._pendingUserActionHints];
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
- * Unlike _pendingContext, these do NOT render UI cards and survive panel close.
1387
- * Drained into the context parameter on the next sendMessage() call.
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
- this._pendingDiffStateNotifications.push(message);
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
- * Like diff-state notifications, these do NOT render UI cards and survive panel close.
1397
- * Drained into the context parameter on the next sendMessage() call.
1398
- * @param {string} message - Description of the user action (e.g., "[User Action: adopted suggestion 42]")
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._pendingUserActionHints.push(message);
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 = this.messagesEl.querySelector('.chat-panel__empty');
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
- this._pendingContextData.push(contextData);
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
- this._pendingContext.push(lines.join('\n'));
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, or clicks
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 {commentId, type, body, file, line_start, line_end, source, isFileLevel}
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 = this.messagesEl.querySelector('.chat-panel__empty');
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
- this._pendingContextData.push(contextData);
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
- : ['The user wants to discuss a review comment:'];
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
- this._pendingContext.push(lines.join('\n'));
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 = this._pendingContext.some(c => c === contextText || c.startsWith(contextText)) ||
1562
- this.messages.some(m => m.role === 'context' && (m.content === contextText || m.content.startsWith(contextText)));
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 = this.messagesEl.querySelector('.chat-panel__empty');
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
- this._pendingContextData.push(contextData);
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
- this._pendingContext.push(contextText);
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 = this.messagesEl?.querySelector(
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
- this._pendingContext.push(data.text);
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
- this._pendingContextData.push(contextData);
2847
+ tab.pendingContextData.push(contextData);
1645
2848
 
1646
2849
  // 5. Remove empty state if present
1647
- const emptyState = this.messagesEl?.querySelector('.chat-panel__empty');
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
- this._addAnalysisRunContextCard(contextData, { removable: true });
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 idx = this._pendingContextData.length - 1;
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 _sessionAnalysisRunId was explicitly set.
1715
- const isNewRunVsSession = currentRunId && this._sessionAnalysisRunId &&
1716
- String(currentRunId) !== String(this._sessionAnalysisRunId);
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:', this._sessionAnalysisRunId + ')');
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 = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
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
- this._analysisContextRemoved = false;
1725
- this._sessionAnalysisRunId = null;
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 _sessionAnalysisRunId is not set, this card may be stale — compare its
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 = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
2940
+ const existingCard = tab.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
1733
2941
  if (existingCard) {
1734
- if (!this._sessionAnalysisRunId && currentRunId) {
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
- this._sessionAnalysisRunId = String(currentRunId);
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
- this._analysisContextRemoved = false;
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 (this._sessionAnalysisRunId) {
1756
- console.debug('[ChatPanel] _ensureAnalysisContext: skipped — runId already set:', this._sessionAnalysisRunId);
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 (this._analysisContextRemoved) {
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 = this.messagesEl.querySelector('.chat-panel__empty');
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 = this.messagesEl.querySelectorAll('.chat-panel__message').length > 0;
2997
+ const hasExistingMessages = tab.messagesEl.querySelectorAll('.chat-panel__message').length > 0;
1790
2998
  const contextData = this._buildAnalysisContextData(currentRunId, count);
1791
- this._addAnalysisContextCard(contextData, { removable: true, prepend: !hasExistingMessages });
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
- this._persistAnalysisContext(contextData);
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
- this._sessionAnalysisRunId = currentRunId || 'dom';
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
- if (this._pendingContext.length === 0 && this.messages.length === 0 &&
1877
- !this.messagesEl.querySelector('.chat-panel__context-card')) {
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
- * @param {Object} ctx - Comment context {commentId, body, file, line_start, line_end, isFileLevel}
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
- card.className = 'chat-panel__context-card';
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 label = ctx.isFileLevel ? 'file comment' : 'comment';
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 = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
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 = this.messagesEl.querySelectorAll('.chat-panel__context-card:not([data-analysis])');
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 < this._pendingContext.length) {
2037
- this._pendingContext.splice(idx, 1);
2038
- this._pendingContextData.splice(idx, 1);
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 remainingCards = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
2048
- remainingCards.forEach((card, i) => {
2049
- card.dataset.contextIndex = i;
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 (this._pendingContext.length === 0 && this.messages.length === 0 &&
2054
- !this.messagesEl.querySelector('.chat-panel__context-card')) {
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
- * @param {Object} sessionData - Response data from createSession ({ id, status, context? })
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 && sessionData.context.suggestionCount > 0) {
2066
- const existingCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
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 = this.messagesEl.querySelector('.chat-panel__empty');
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
- // Persist richer analysis context to DB (includes provider, model, summary, etc.)
2081
- const contextData = { type: 'analysis', ...sessionData.context };
2082
- this._persistAnalysisContext(contextData);
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 = this._pendingContext.length - 1;
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
- if (removable) this._makeCardRemovable(card);
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
- this.messagesEl.appendChild(card);
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
- if (!this.currentSessionId) return;
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/${this.currentSessionId}/context`, {
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 subscriptions are established for review and chat topics.
2245
- * Subscribes to review events (stable for page lifetime) and chat events
2246
- * (changes when session changes). Subsequent calls are no-ops if already subscribed.
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
- // Subscribe to chat session
2263
- if (this.currentSessionId && !this._chatUnsub) {
2264
- this._chatUnsub = window.wsClient.subscribe('chat:' + this.currentSessionId, (msg) => {
2265
- this._handleChatMessage(msg);
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
- * If a stream was in progress when the connection dropped, deltas broadcast
2280
- * during the gap are lost. Re-fetch the full message history via HTTP and
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
- if (!this.isStreaming || !this.currentSessionId) return;
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/${this.currentSessionId}/messages`);
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
- if (lastAssistant && lastAssistant.content) {
2304
- this._streamingContent = lastAssistant.content;
2305
- // The message is already persisted in the DB, so the stream is
2306
- // definitively complete. Finalize rather than continuing the
2307
- // streaming UI (which would leave the Stop button visible, etc.).
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
- this.messages.push({ role: 'assistant', content: lastAssistant.content, id: lastAssistant.id });
2312
- this._finalizeStreaming();
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
- * Unsubscribe from the current chat topic and re-subscribe to the new one.
2322
- * Called whenever `this.currentSessionId` changes.
2323
- */
2324
- _resubscribeChat() {
2325
- if (this._chatUnsub) { this._chatUnsub(); this._chatUnsub = null; }
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
- _handleChatMessage(data) {
3697
+ _handleChatMessageForTab(tab, data) {
2338
3698
  try {
2339
- // Assertion: WebSocket topic scoping guarantees sessionId match.
2340
- // This warn is a safety net if it fires, something is wrong upstream.
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, 'session:', data.sessionId);
3705
+ console.debug('[ChatPanel] WS event:', data.type, 'tab:', tab.sessionId);
2348
3706
  }
2349
3707
 
2350
- // When the panel is closed, still accumulate internal state
2351
- // so messages are available when the panel reopens.
2352
- if (!this.isOpen) {
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
- this._streamingContent += data.text;
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
- if (this._streamingContent) {
2359
- this.messages.push({ role: 'assistant', content: this._streamingContent, id: data.messageId });
2360
- }
2361
- this._streamingContent = '';
2362
- this.isStreaming = false;
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
- this._streamingContent = '';
2366
- this.isStreaming = false;
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
- this._streamingContent += data.text;
2377
- this.updateStreamingMessage(this._streamingContent);
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
- this.finalizeStreamingMessage(data.messageId);
3757
+ tab.errorMessage = null;
3758
+ this.finalizeStreamingMessage(data.messageId, tab);
2390
3759
  break;
2391
-
2392
3760
  case 'error':
2393
- this._showError(data.message || 'An error occurred');
2394
- this._finalizeStreaming();
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 all WebSocket subscriptions (chat and review).
3773
+ * Close the review-scope subscription and every tab's chat subscription.
2404
3774
  */
2405
3775
  _closeSubscriptions() {
2406
- if (this._chatUnsub) { this._chatUnsub(); this._chatUnsub = null; }
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
- this.messages.push(msg);
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
- this.messagesEl.appendChild(msgEl);
2442
- this.scrollToBottom({ force: true });
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
- this.messagesEl.appendChild(msgEl);
2498
- this.scrollToBottom({ force: true });
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 currently streaming message
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 streamingMsg = document.getElementById('chat-streaming-msg');
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 with final ID
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
- finalizeStreamingMessage(messageId) {
2526
- const streamingMsg = document.getElementById('chat-streaming-msg');
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 (this._streamingContent) {
2550
- bubble.innerHTML = this.renderMarkdown(this._streamingContent);
3952
+ if (tab.streamingContent) {
3953
+ bubble.innerHTML = this.renderMarkdown(tab.streamingContent);
2551
3954
  this._linkifyFileReferences(bubble);
2552
- bubble.appendChild(this._createCopyButton(this._streamingContent));
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
- // Store in messages array
2561
- if (this._streamingContent) {
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
- if (!this.isStreaming || !this.currentSessionId) return;
2573
-
3974
+ const tab = this._getActiveTab();
3975
+ if (!tab || !tab.isStreaming || tab.sessionId == null) return;
2574
3976
  try {
2575
- await fetch(`/api/chat/session/${this.currentSessionId}/abort`, {
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
- this.isStreaming = false;
2591
- this._streamingContent = '';
2592
- this.sendBtn.style.display = '';
2593
- this.stopBtn.style.display = 'none';
2594
- this.sendBtn.disabled = !this.inputEl?.value?.trim();
2595
- this._updateActionButtons();
2596
- this.inputEl?.focus();
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 the streaming message
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 streamingMsg = document.getElementById('chat-streaming-msg');
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
- * Show the pulsing thinking indicator in/below the streaming message.
2717
- * If there's already content, append it after the content. If no content, it's the typing dots.
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 streamingMsg = document.getElementById('chat-streaming-msg');
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 from the streaming message.
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 streamingMsg = document.getElementById('chat-streaming-msg');
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 the chat
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
- this.messagesEl.appendChild(errorEl);
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
- this._pendingActionContext = { type: 'adopt', itemId: this._contextItemId };
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
- this._pendingActionContext = { type: 'update', itemId: this._contextItemId };
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
- this._pendingActionContext = { type: 'dismiss-suggestion', itemId: this._contextItemId };
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
- this._pendingActionContext = { type: 'dismiss-comment', itemId: this._contextItemId };
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
- this._pendingActionContext = {
4695
+ const tab = this._getActiveTab();
4696
+ if (!tab) return;
4697
+ tab.pendingActionContext = {
3231
4698
  type: 'create-comment',
3232
- file: this._contextLineMeta?.file,
3233
- line_start: this._contextLineMeta?.line_start,
3234
- line_end: this._contextLineMeta?.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
- if (!this.messagesEl) return;
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
- this.messagesEl.addEventListener('mouseenter', this._onCtxCardEnter, true);
3268
- this.messagesEl.addEventListener('mouseleave', this._onCtxCardLeave, true);
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.messages = [];
4787
+ this.tabs = [];
4788
+ this.activeTabKey = null;
3315
4789
 
3316
4790
  // Clean up context tooltip
3317
4791
  clearTimeout(this._ctxTooltipTimer);