@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,723 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * PanelGroup - Manages the right panel group layout
4
+ * Coordinates the AI Review panel and Chat panel within a shared flex container.
5
+ * Supports four layout arrangements: horizontal and vertical, each with two orderings.
6
+ * Provides a popover layout picker and keyboard shortcuts for quick switching.
7
+ */
8
+
9
+ class PanelGroup {
10
+ static LAYOUTS = ['h-review-chat', 'h-chat-review', 'v-review-chat', 'v-chat-review'];
11
+ static STORAGE_KEY = 'panel-group-layout';
12
+ static CHAT_VISIBLE_KEY = 'panel-group-chat-visible';
13
+ static V_RATIO_KEY = 'panel-group-v-ratio';
14
+ static MIN_PANEL_HEIGHT = 150;
15
+
16
+ // Tooltip text for each layout
17
+ static LAYOUT_LABELS = {
18
+ 'h-review-chat': 'Review left, Chat right',
19
+ 'h-chat-review': 'Chat left, Review right',
20
+ 'v-review-chat': 'Review top, Chat bottom',
21
+ 'v-chat-review': 'Chat top, Review bottom'
22
+ };
23
+
24
+ // SVG icons for popover thumbnails
25
+ static POPOVER_ICONS = {
26
+ sparkle: `<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10">
27
+ <path d="M7.53 1.282a.5.5 0 0 1 .94 0l.478 1.306a7.492 7.492 0 0 0 4.464 4.464l1.305.478a.5.5 0 0 1 0 .94l-1.305.478a7.492 7.492 0 0 0-4.464 4.464l-.478 1.305a.5.5 0 0 1-.94 0l-.478-1.305a7.492 7.492 0 0 0-4.464-4.464L1.282 8.47a.5.5 0 0 1 0-.94l1.306-.478a7.492 7.492 0 0 0 4.464-4.464l.478-1.306Z"/>
28
+ </svg>`,
29
+ discussion: `<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10">
30
+ <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
31
+ </svg>`
32
+ };
33
+
34
+ constructor() {
35
+ // DOM elements
36
+ this.groupEl = document.getElementById('right-panel-group');
37
+ this.chatToggleBtn = document.getElementById('chat-toggle-btn');
38
+ this.layoutToggleBtn = document.getElementById('panel-layout-toggle');
39
+
40
+ // State
41
+ this._reviewVisible = !document.getElementById('ai-panel')?.classList.contains('collapsed');
42
+ this._chatVisible = false;
43
+ this._popoverVisible = false;
44
+
45
+ // Read persisted layout
46
+ const savedLayout = localStorage.getItem(PanelGroup.STORAGE_KEY);
47
+ this._layout = PanelGroup.LAYOUTS.includes(savedLayout) ? savedLayout : PanelGroup.LAYOUTS[0];
48
+
49
+ // Restore direction preferences from localStorage
50
+ const savedLastH = localStorage.getItem('panel-group-last-h');
51
+ this._lastHorizontalLayout = PanelGroup.LAYOUTS.includes(savedLastH) && savedLastH.startsWith('h-')
52
+ ? savedLastH : 'h-review-chat';
53
+
54
+ const savedLastV = localStorage.getItem('panel-group-last-v');
55
+ this._lastVerticalLayout = PanelGroup.LAYOUTS.includes(savedLastV) && savedLastV.startsWith('v-')
56
+ ? savedLastV : 'v-review-chat';
57
+
58
+ // Create ChatPanel instance
59
+ this.chatPanel = new ChatPanel('chat-panel-container');
60
+ window.chatPanel = this.chatPanel;
61
+
62
+ // Create a full-height group resize handle for vertical layouts.
63
+ // Uses data-panel="ai-panel" so the existing PanelResizer picks it up automatically.
64
+ if (this.groupEl) {
65
+ this._groupResizeHandle = document.createElement('div');
66
+ this._groupResizeHandle.className = 'panel-group-resize-handle resize-handle resize-handle-left';
67
+ this._groupResizeHandle.dataset.panel = 'ai-panel';
68
+ this.groupEl.insertBefore(this._groupResizeHandle, this.groupEl.firstChild);
69
+ }
70
+
71
+ // Create the vertical resize divider between panels
72
+ this._createVerticalDivider();
73
+
74
+ // Render the popover DOM
75
+ this._renderPopover();
76
+
77
+ // Apply initial layout
78
+ this._applyLayout(this._layout);
79
+
80
+ // Restore chat visibility from last session (only if chat is available)
81
+ const chatState = document.documentElement.getAttribute('data-chat');
82
+ if (chatState === 'available') {
83
+ this._restoreChatFromStorage();
84
+ } else {
85
+ // Chat not available yet — zero out CSS variable so max-width calcs are correct.
86
+ document.documentElement.style.setProperty('--chat-panel-width', '0px');
87
+ }
88
+
89
+ // Listen for late chat-state transitions (config fetch may complete after constructor)
90
+ window.addEventListener('chat-state-changed', (e) => {
91
+ const state = e.detail?.state;
92
+ if (state === 'available') {
93
+ this._restoreChatFromStorage();
94
+ } else if (state === 'unavailable' && this.chatToggleBtn) {
95
+ this.chatToggleBtn.title = 'Install and configure Pi to enable chat';
96
+ }
97
+ });
98
+
99
+ // Bind events
100
+ this._bindEvents();
101
+
102
+ // Initial state update
103
+ this._updateGroupState();
104
+ this._updateLayoutToggleVisibility();
105
+ this._updateRightPanelGroupWidth();
106
+
107
+ // Register shortcuts after KeyboardShortcuts is initialized
108
+ requestAnimationFrame(() => this._registerKeyboardShortcuts());
109
+ }
110
+
111
+ /**
112
+ * Bind event listeners
113
+ */
114
+ _bindEvents() {
115
+ // Chat toggle button
116
+ if (this.chatToggleBtn) {
117
+ this.chatToggleBtn.addEventListener('click', () => this.toggleChat());
118
+ }
119
+
120
+ // Layout toggle button opens popover
121
+ if (this.layoutToggleBtn) {
122
+ this.layoutToggleBtn.addEventListener('click', (e) => {
123
+ e.stopPropagation();
124
+ if (this._popoverVisible) {
125
+ this._hidePopover();
126
+ } else {
127
+ this._showPopover();
128
+ }
129
+ });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Apply a layout arrangement to the panel group
135
+ * @param {string} layout - One of PanelGroup.LAYOUTS
136
+ */
137
+ _applyLayout(layout) {
138
+ if (!this.groupEl) return;
139
+
140
+ // Remove all layout classes
141
+ PanelGroup.LAYOUTS.forEach(l => {
142
+ this.groupEl.classList.remove(`layout-${l}`);
143
+ });
144
+
145
+ // Add the current layout class
146
+ this.groupEl.classList.add(`layout-${layout}`);
147
+ this._layout = layout;
148
+
149
+ // Update direction preferences
150
+ if (layout.startsWith('h-')) {
151
+ this._lastHorizontalLayout = layout;
152
+ localStorage.setItem('panel-group-last-h', layout);
153
+ // Clear explicit heights when switching to horizontal so panels revert to flex defaults
154
+ this._clearVerticalHeights();
155
+ } else {
156
+ this._lastVerticalLayout = layout;
157
+ localStorage.setItem('panel-group-last-v', layout);
158
+ // Restore persisted vertical split ratio
159
+ this._restoreVerticalRatio();
160
+ }
161
+
162
+ // Update popover active state and recalculate group width
163
+ this._updatePopoverActiveState();
164
+ this._updateRightPanelGroupWidth();
165
+ }
166
+
167
+ /**
168
+ * Render the popover DOM element and append to document.body
169
+ */
170
+ _renderPopover() {
171
+ const popover = document.createElement('div');
172
+ popover.className = 'layout-popover';
173
+ popover.id = 'layout-popover';
174
+
175
+ const grid = document.createElement('div');
176
+ grid.className = 'layout-popover__grid';
177
+
178
+ PanelGroup.LAYOUTS.forEach((layout, i) => {
179
+ const isHorizontal = layout.startsWith('h-');
180
+ const isReviewFirst = layout.endsWith('-review-chat');
181
+
182
+ const btn = document.createElement('button');
183
+ btn.className = 'layout-popover__thumb';
184
+ btn.dataset.layout = layout;
185
+ btn.title = PanelGroup.LAYOUT_LABELS[layout];
186
+ if (layout === this._layout) {
187
+ btn.classList.add('layout-popover__thumb--active');
188
+ }
189
+
190
+ // Badge with number
191
+ const badge = document.createElement('span');
192
+ badge.className = 'layout-popover__badge';
193
+ badge.textContent = String(i + 1);
194
+
195
+ // Preview container
196
+ const preview = document.createElement('div');
197
+ preview.className = `layout-popover__preview layout-popover__preview--${isHorizontal ? 'h' : 'v'}`;
198
+
199
+ // First pane and second pane depend on order
200
+ const firstType = isReviewFirst ? 'review' : 'chat';
201
+ const secondType = isReviewFirst ? 'chat' : 'review';
202
+
203
+ const firstPane = document.createElement('div');
204
+ firstPane.className = `layout-popover__pane layout-popover__pane--${firstType}`;
205
+ firstPane.innerHTML = firstType === 'review'
206
+ ? PanelGroup.POPOVER_ICONS.sparkle
207
+ : PanelGroup.POPOVER_ICONS.discussion;
208
+
209
+ const secondPane = document.createElement('div');
210
+ secondPane.className = `layout-popover__pane layout-popover__pane--${secondType}`;
211
+ secondPane.innerHTML = secondType === 'review'
212
+ ? PanelGroup.POPOVER_ICONS.sparkle
213
+ : PanelGroup.POPOVER_ICONS.discussion;
214
+
215
+ preview.appendChild(firstPane);
216
+ preview.appendChild(secondPane);
217
+
218
+ btn.appendChild(badge);
219
+ btn.appendChild(preview);
220
+
221
+ btn.addEventListener('click', (e) => {
222
+ e.stopPropagation();
223
+ this._selectLayout(layout);
224
+ });
225
+
226
+ grid.appendChild(btn);
227
+ });
228
+
229
+ popover.appendChild(grid);
230
+ document.body.appendChild(popover);
231
+ this._popoverEl = popover;
232
+ }
233
+
234
+ /**
235
+ * Show the popover positioned below the layout toggle button
236
+ */
237
+ _showPopover() {
238
+ if (!this._popoverEl || !this.layoutToggleBtn) return;
239
+
240
+ // Position below the button
241
+ const rect = this.layoutToggleBtn.getBoundingClientRect();
242
+ this._popoverEl.style.top = `${rect.bottom + 4}px`;
243
+ this._popoverEl.style.left = `${rect.left + rect.width / 2}px`;
244
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
245
+
246
+ // Update active state before showing
247
+ this._updatePopoverActiveState();
248
+
249
+ // Show with animation
250
+ this._popoverEl.classList.add('layout-popover--visible');
251
+ this._popoverVisible = true;
252
+
253
+ // Override transform after making visible for animation
254
+ requestAnimationFrame(() => {
255
+ if (this._popoverEl) {
256
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(0)';
257
+ }
258
+ });
259
+
260
+ // Click-outside-to-close handler
261
+ this._outsideClickHandler = (e) => {
262
+ if (!this._popoverEl.contains(e.target) && !this.layoutToggleBtn.contains(e.target)) {
263
+ this._hidePopover();
264
+ }
265
+ };
266
+ document.addEventListener('click', this._outsideClickHandler, true);
267
+ }
268
+
269
+ /**
270
+ * Hide the popover
271
+ */
272
+ _hidePopover() {
273
+ if (!this._popoverEl) return;
274
+
275
+ this._popoverEl.classList.remove('layout-popover--visible');
276
+ this._popoverVisible = false;
277
+
278
+ // Remove click-outside handler
279
+ if (this._outsideClickHandler) {
280
+ document.removeEventListener('click', this._outsideClickHandler, true);
281
+ this._outsideClickHandler = null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Update the --active class on popover thumbnails
287
+ */
288
+ _updatePopoverActiveState() {
289
+ if (!this._popoverEl) return;
290
+
291
+ const thumbs = this._popoverEl.querySelectorAll('.layout-popover__thumb');
292
+ thumbs.forEach(thumb => {
293
+ thumb.classList.toggle(
294
+ 'layout-popover__thumb--active',
295
+ thumb.dataset.layout === this._layout
296
+ );
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Select a layout, apply it, persist, update popover, hide popover.
302
+ * Also auto-opens both panels if only one is visible.
303
+ * @param {string} layout - One of PanelGroup.LAYOUTS
304
+ */
305
+ _selectLayout(layout) {
306
+ this._ensureBothPanelsVisible();
307
+ this._applyLayout(layout);
308
+ localStorage.setItem(PanelGroup.STORAGE_KEY, layout);
309
+ this._hidePopover();
310
+ }
311
+
312
+ /**
313
+ * Ensure both review and chat panels are visible
314
+ */
315
+ _ensureBothPanelsVisible() {
316
+ if (!this._reviewVisible) {
317
+ window.aiPanel?.expand();
318
+ }
319
+ if (!this._chatVisible && this._isChatAvailable()) {
320
+ this.chatPanel.open();
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Register keyboard shortcuts on the global KeyboardShortcuts instance
326
+ */
327
+ _registerKeyboardShortcuts() {
328
+ const ks = window.prManager?.keyboardShortcuts;
329
+ if (!ks) return;
330
+
331
+ // Panel visibility
332
+ ks.registerShortcut(['p', 'n'], 'Toggle file navigator', () => this._toggleSidebar());
333
+ ks.registerShortcut(['p', 'r'], 'Toggle Review panel', () => this._toggleReviewPanel());
334
+ ks.registerShortcut(['p', 'c'], 'Toggle Chat panel', () => {
335
+ if (this._isChatAvailable()) this.toggleChat();
336
+ });
337
+
338
+ // Direction shortcuts
339
+ ks.registerShortcut(['p', 'h'], 'Horizontal layout', () => this._switchToHorizontal());
340
+ ks.registerShortcut(['p', 'v'], 'Vertical layout', () => this._switchToVertical());
341
+ ks.registerShortcut(['p', 'f'], 'Flip panel order', () => this._flipPanelOrder());
342
+
343
+ // Direct layout selection
344
+ PanelGroup.LAYOUTS.forEach((layout, i) => {
345
+ ks.registerShortcut(['p', String(i + 1)], PanelGroup.LAYOUT_LABELS[layout], () => this._selectLayout(layout));
346
+ });
347
+
348
+ // Recreate help overlay to include new shortcuts
349
+ ks.createHelpOverlay();
350
+ }
351
+
352
+ /**
353
+ * Toggle the file sidebar open/closed
354
+ */
355
+ _toggleSidebar() {
356
+ const sidebar = document.getElementById('files-sidebar');
357
+ if (!sidebar) return;
358
+
359
+ const isCollapsed = sidebar.classList.contains('collapsed');
360
+ if (isCollapsed) {
361
+ // Click the expand button in the diff toolbar
362
+ const expandBtn = document.getElementById('sidebar-toggle-collapsed');
363
+ if (expandBtn) expandBtn.click();
364
+ } else {
365
+ // Click the collapse button in the sidebar header
366
+ const collapseBtn = document.getElementById('sidebar-collapse-btn');
367
+ if (collapseBtn) collapseBtn.click();
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Toggle the Review (AI) panel
373
+ */
374
+ _toggleReviewPanel() {
375
+ window.aiPanel?.toggle();
376
+ }
377
+
378
+ /**
379
+ * Switch to horizontal layout, using the last-used horizontal arrangement
380
+ */
381
+ _switchToHorizontal() {
382
+ this._ensureBothPanelsVisible();
383
+ const layout = this._lastHorizontalLayout || 'h-review-chat';
384
+ this._applyLayout(layout);
385
+ localStorage.setItem(PanelGroup.STORAGE_KEY, layout);
386
+ }
387
+
388
+ /**
389
+ * Switch to vertical layout, using the last-used vertical arrangement
390
+ */
391
+ _switchToVertical() {
392
+ this._ensureBothPanelsVisible();
393
+ const layout = this._lastVerticalLayout || 'v-review-chat';
394
+ this._applyLayout(layout);
395
+ localStorage.setItem(PanelGroup.STORAGE_KEY, layout);
396
+ }
397
+
398
+ /**
399
+ * Flip the panel order within the current direction
400
+ * e.g. h-review-chat -> h-chat-review, v-chat-review -> v-review-chat
401
+ */
402
+ _flipPanelOrder() {
403
+ this._ensureBothPanelsVisible();
404
+ let newLayout;
405
+ if (this._layout.endsWith('-review-chat')) {
406
+ newLayout = this._layout.replace('-review-chat', '-chat-review');
407
+ } else {
408
+ newLayout = this._layout.replace('-chat-review', '-review-chat');
409
+ }
410
+ this._applyLayout(newLayout);
411
+ localStorage.setItem(PanelGroup.STORAGE_KEY, newLayout);
412
+ }
413
+
414
+ /**
415
+ * Check if chat is currently available (data-chat="available")
416
+ * @returns {boolean}
417
+ */
418
+ _isChatAvailable() {
419
+ return document.documentElement.getAttribute('data-chat') === 'available';
420
+ }
421
+
422
+ /**
423
+ * Restore chat visibility from localStorage (called when chat becomes available)
424
+ */
425
+ _restoreChatFromStorage() {
426
+ const savedChatVisible = localStorage.getItem(PanelGroup.CHAT_VISIBLE_KEY);
427
+ if (savedChatVisible === 'true') {
428
+ this._chatVisible = true;
429
+ this.chatPanel.open({ suppressFocus: true });
430
+ if (this.chatToggleBtn) {
431
+ this.chatToggleBtn.classList.add('active');
432
+ }
433
+ } else {
434
+ document.documentElement.style.setProperty('--chat-panel-width', '0px');
435
+ }
436
+ this._updateGroupState();
437
+ this._updateLayoutToggleVisibility();
438
+ this._updateRightPanelGroupWidth();
439
+ }
440
+
441
+ /**
442
+ * Toggle chat panel visibility
443
+ */
444
+ toggleChat() {
445
+ if (!this._isChatAvailable()) return;
446
+ if (this._chatVisible) {
447
+ this.chatPanel.close();
448
+ } else {
449
+ this.chatPanel.open();
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Ensure chat is visible (for external callers like "Ask about this")
455
+ * @param {Object} [options] - Options to pass to ChatPanel.open()
456
+ */
457
+ showChat(options) {
458
+ if (!this._isChatAvailable()) return;
459
+ if (!this._chatVisible) {
460
+ this.chatPanel.open(options);
461
+ } else if (options) {
462
+ // Already visible, but re-open with new context
463
+ this.chatPanel.open(options);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Called by ChatPanel when it opens or closes
469
+ * @param {boolean} visible
470
+ */
471
+ _onChatVisibilityChanged(visible) {
472
+ this._chatVisible = visible;
473
+
474
+ // Update toolbar button active state
475
+ if (this.chatToggleBtn) {
476
+ this.chatToggleBtn.classList.toggle('active', visible);
477
+ }
478
+
479
+ // Persist chat visibility
480
+ localStorage.setItem(PanelGroup.CHAT_VISIBLE_KEY, visible ? 'true' : 'false');
481
+
482
+ // Clear inline flex heights so the remaining panel fills the space
483
+ if (this._layout.startsWith('v-')) {
484
+ this._clearVerticalHeights();
485
+ }
486
+
487
+ this._updateGroupState();
488
+ this._updateLayoutToggleVisibility();
489
+ this._updateRightPanelGroupWidth();
490
+ }
491
+
492
+ /**
493
+ * Called by AIPanel when it collapses or expands
494
+ * @param {boolean} visible
495
+ */
496
+ _onReviewVisibilityChanged(visible) {
497
+ this._reviewVisible = visible;
498
+
499
+ // Clear inline flex heights so the remaining panel fills the space
500
+ if (this._layout.startsWith('v-')) {
501
+ this._clearVerticalHeights();
502
+ }
503
+
504
+ this._updateGroupState();
505
+ this._updateLayoutToggleVisibility();
506
+ this._updateRightPanelGroupWidth();
507
+ }
508
+
509
+ /**
510
+ * Update the group-collapsed class based on panel visibility
511
+ */
512
+ _updateGroupState() {
513
+ if (!this.groupEl) return;
514
+
515
+ const bothHidden = !this._reviewVisible && !this._chatVisible;
516
+ this.groupEl.classList.toggle('group-collapsed', bothHidden);
517
+ // When only one panel is visible, expose per-panel resize handles
518
+ this.groupEl.classList.toggle('chat-only', !this._reviewVisible && this._chatVisible);
519
+ this.groupEl.classList.toggle('review-only', this._reviewVisible && !this._chatVisible);
520
+ }
521
+
522
+ /**
523
+ * Compute and set --right-panel-group-width based on current layout and panel visibility.
524
+ * In horizontal layouts, the group width is the SUM of visible panels.
525
+ * In vertical layouts, the group width is the MAX of visible panels.
526
+ * This single variable is used by max-width calcs on .ai-suggestion and .user-comment.
527
+ */
528
+ _updateRightPanelGroupWidth() {
529
+ const aiWidth = this._reviewVisible
530
+ ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ai-panel-width'), 10) || 0
531
+ : 0;
532
+ const chatWidth = this._chatVisible
533
+ ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--chat-panel-width'), 10) || 0
534
+ : 0;
535
+
536
+ const isVertical = this._layout.startsWith('v-');
537
+ const groupWidth = isVertical
538
+ ? Math.max(aiWidth, chatWidth)
539
+ : aiWidth + chatWidth;
540
+
541
+ document.documentElement.style.setProperty('--right-panel-group-width', `${groupWidth}px`);
542
+ }
543
+
544
+ /**
545
+ * Show/hide the layout toggle button.
546
+ * Only shown when both panels are visible (layout switching only matters with two panels).
547
+ */
548
+ _updateLayoutToggleVisibility() {
549
+ if (!this.layoutToggleBtn) return;
550
+
551
+ const bothVisible = this._reviewVisible && this._chatVisible;
552
+ this.layoutToggleBtn.style.display = bothVisible ? '' : 'none';
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Vertical resize divider
557
+ // ---------------------------------------------------------------------------
558
+
559
+ /**
560
+ * Create the vertical resize divider element and insert it into the panel group.
561
+ * The divider sits between the AI panel and chat panel container in the DOM.
562
+ * CSS order rules position it correctly for each layout arrangement.
563
+ */
564
+ _createVerticalDivider() {
565
+ if (!this.groupEl) return;
566
+
567
+ this._dividerEl = document.createElement('div');
568
+ this._dividerEl.className = 'panel-group-divider';
569
+ this._dividerEl.title = 'Drag to resize';
570
+
571
+ // Insert between the ai-panel aside and chat-panel-container div
572
+ const chatContainer = document.getElementById('chat-panel-container');
573
+ if (chatContainer) {
574
+ this.groupEl.insertBefore(this._dividerEl, chatContainer);
575
+ } else {
576
+ this.groupEl.appendChild(this._dividerEl);
577
+ }
578
+
579
+ this._bindVerticalResizeEvents();
580
+ }
581
+
582
+ /**
583
+ * Bind mousedown/mousemove/mouseup drag events on the vertical divider.
584
+ * Adjusts the flex-basis of both panels to resize the vertical split.
585
+ */
586
+ _bindVerticalResizeEvents() {
587
+ if (!this._dividerEl) return;
588
+
589
+ let startY = 0;
590
+ let startTopHeight = 0;
591
+ let startBottomHeight = 0;
592
+
593
+ const onMouseMove = (e) => {
594
+ const deltaY = e.clientY - startY;
595
+ const totalHeight = startTopHeight + startBottomHeight;
596
+ const minH = PanelGroup.MIN_PANEL_HEIGHT;
597
+
598
+ let newTopHeight = startTopHeight + deltaY;
599
+ let newBottomHeight = startBottomHeight - deltaY;
600
+
601
+ // Enforce minimum heights
602
+ if (newTopHeight < minH) {
603
+ newTopHeight = minH;
604
+ newBottomHeight = totalHeight - minH;
605
+ } else if (newBottomHeight < minH) {
606
+ newBottomHeight = minH;
607
+ newTopHeight = totalHeight - minH;
608
+ }
609
+
610
+ const { topPanel, bottomPanel } = this._getOrderedPanels();
611
+ if (topPanel) topPanel.style.flex = `0 0 ${newTopHeight}px`;
612
+ if (bottomPanel) bottomPanel.style.flex = `0 0 ${newBottomHeight}px`;
613
+ };
614
+
615
+ const onMouseUp = () => {
616
+ this._dividerEl.classList.remove('dragging');
617
+ document.body.classList.remove('resizing');
618
+
619
+ document.removeEventListener('mousemove', onMouseMove);
620
+ document.removeEventListener('mouseup', onMouseUp);
621
+
622
+ // Persist the ratio (top panel proportion of total height)
623
+ const { topPanel, bottomPanel } = this._getOrderedPanels();
624
+ if (topPanel && bottomPanel) {
625
+ const topH = topPanel.getBoundingClientRect().height;
626
+ const bottomH = bottomPanel.getBoundingClientRect().height;
627
+ const ratio = topH / (topH + bottomH);
628
+ localStorage.setItem(PanelGroup.V_RATIO_KEY, ratio.toFixed(4));
629
+ }
630
+ };
631
+
632
+ this._dividerEl.addEventListener('mousedown', (e) => {
633
+ e.preventDefault();
634
+ startY = e.clientY;
635
+
636
+ const { topPanel, bottomPanel } = this._getOrderedPanels();
637
+ if (!topPanel || !bottomPanel) return;
638
+
639
+ startTopHeight = topPanel.getBoundingClientRect().height;
640
+ startBottomHeight = bottomPanel.getBoundingClientRect().height;
641
+
642
+ this._dividerEl.classList.add('dragging');
643
+ document.body.classList.add('resizing');
644
+
645
+ document.addEventListener('mousemove', onMouseMove);
646
+ document.addEventListener('mouseup', onMouseUp);
647
+ });
648
+ }
649
+
650
+ /**
651
+ * Get the two panels in visual order (top panel first, bottom panel second)
652
+ * based on the current vertical layout arrangement.
653
+ * @returns {{ topPanel: HTMLElement|null, bottomPanel: HTMLElement|null }}
654
+ */
655
+ _getOrderedPanels() {
656
+ const aiPanel = document.getElementById('ai-panel');
657
+ const chatPanel = this.groupEl?.querySelector('.chat-panel');
658
+
659
+ if (!aiPanel || !chatPanel) return { topPanel: null, bottomPanel: null };
660
+
661
+ if (this._layout === 'v-review-chat') {
662
+ return { topPanel: aiPanel, bottomPanel: chatPanel };
663
+ }
664
+ // v-chat-review
665
+ return { topPanel: chatPanel, bottomPanel: aiPanel };
666
+ }
667
+
668
+ /**
669
+ * Restore the persisted vertical split ratio from localStorage.
670
+ * Applied when switching to a vertical layout.
671
+ */
672
+ _restoreVerticalRatio() {
673
+ const saved = localStorage.getItem(PanelGroup.V_RATIO_KEY);
674
+ if (!saved) return;
675
+
676
+ const ratio = parseFloat(saved);
677
+ if (isNaN(ratio) || ratio <= 0 || ratio >= 1) return;
678
+
679
+ // Defer to next frame so flex container has settled
680
+ requestAnimationFrame(() => {
681
+ if (!this.groupEl || !this._layout.startsWith('v-')) return;
682
+
683
+ const groupHeight = this.groupEl.clientHeight;
684
+ const dividerHeight = this._dividerEl ? this._dividerEl.offsetHeight : 6;
685
+ const available = groupHeight - dividerHeight;
686
+ if (available <= 0) return;
687
+
688
+ const minH = PanelGroup.MIN_PANEL_HEIGHT;
689
+ let topHeight = Math.round(available * ratio);
690
+ let bottomHeight = available - topHeight;
691
+
692
+ // Enforce minimums
693
+ if (topHeight < minH) { topHeight = minH; bottomHeight = available - minH; }
694
+ if (bottomHeight < minH) { bottomHeight = minH; topHeight = available - minH; }
695
+
696
+ const { topPanel, bottomPanel } = this._getOrderedPanels();
697
+ if (topPanel) topPanel.style.flex = `0 0 ${topHeight}px`;
698
+ if (bottomPanel) bottomPanel.style.flex = `0 0 ${bottomHeight}px`;
699
+ });
700
+ }
701
+
702
+ /**
703
+ * Clear explicit flex heights set during vertical resize.
704
+ * Called when switching back to horizontal layout so panels revert to
705
+ * their normal width-based flex behavior.
706
+ */
707
+ _clearVerticalHeights() {
708
+ const aiPanel = document.getElementById('ai-panel');
709
+ const chatPanel = this.groupEl?.querySelector('.chat-panel');
710
+ if (aiPanel) aiPanel.style.flex = '';
711
+ if (chatPanel) chatPanel.style.flex = '';
712
+ }
713
+ }
714
+
715
+ // Initialize when DOM is ready
716
+ document.addEventListener('DOMContentLoaded', () => {
717
+ window.panelGroup = new PanelGroup();
718
+ });
719
+
720
+ // Export for CommonJS testing environments
721
+ if (typeof module !== 'undefined' && module.exports) {
722
+ module.exports = { PanelGroup };
723
+ }