@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.
- package/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- 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
|
+
}
|