@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
@@ -5,28 +5,60 @@
5
5
  */
6
6
 
7
7
  class FileCommentManager {
8
- // Category to emoji mapping for formatting adopted comments (matches SuggestionManager)
9
- static CATEGORY_EMOJI_MAP = {
10
- 'bug': '\u{1F41B}', // bug
11
- 'improvement': '\u{1F4A1}', // lightbulb
12
- 'suggestion': '\u{1F4AD}', // thought balloon
13
- 'design': '\u{1F3D7}', // building construction
14
- 'performance': '\u{1F680}', // rocket
15
- 'security': '\u{1F512}', // lock
16
- 'refactor': '\u{1F527}', // wrench
17
- 'documentation': '\u{1F4DD}', // memo
18
- 'test': '\u{2705}', // white heavy check mark
19
- 'style': '\u{1F3A8}', // artist palette
20
- 'chore': '\u{1F9F9}', // broom
21
- 'feat': '\u{2728}', // sparkles
22
- 'fix': '\u{1F527}' // wrench
23
- };
24
-
25
8
  constructor(prManagerRef) {
26
9
  // Reference to parent PRManager for API calls and state access
27
10
  this.prManager = prManagerRef;
28
11
  // Track file-level comments by file path
29
12
  this.fileComments = new Map();
13
+
14
+ // Event delegation for "Ask about this" chat button on file-level suggestions
15
+ document.addEventListener('click', (e) => {
16
+ const chatBtn = e.target.closest('.file-comments-zone .ai-action-chat');
17
+ if (chatBtn && window.chatPanel) {
18
+ e.stopPropagation();
19
+ const suggestionCard = chatBtn.closest('.ai-suggestion');
20
+ const bodyText = suggestionCard?.dataset?.originalBody
21
+ ? JSON.parse(suggestionCard.dataset.originalBody) : '';
22
+ window.chatPanel.open({
23
+ reviewId: this.prManager?.currentPR?.id,
24
+ suggestionId: chatBtn.dataset.suggestionId,
25
+ suggestionContext: {
26
+ title: chatBtn.dataset.title || '',
27
+ body: bodyText,
28
+ type: suggestionCard?.querySelector('.ai-suggestion-badge')?.dataset?.type || '',
29
+ file: chatBtn.dataset.file || '',
30
+ line_start: null,
31
+ line_end: null,
32
+ side: suggestionCard?.dataset?.side || 'RIGHT',
33
+ reasoning: null
34
+ }
35
+ });
36
+ }
37
+ });
38
+
39
+ // Event delegation for "Ask about this" chat button on file-level user comments
40
+ document.addEventListener('click', (e) => {
41
+ const chatBtn = e.target.closest('.file-comments-zone .btn-chat-comment');
42
+ if (chatBtn && window.chatPanel) {
43
+ e.stopPropagation();
44
+ const commentCard = chatBtn.closest('.file-comment-card');
45
+ const bodyEl = commentCard?.querySelector('.user-comment-body');
46
+ const originalMarkdown = bodyEl?.dataset?.originalMarkdown || bodyEl?.textContent || '';
47
+ window.chatPanel.open({
48
+ reviewId: this.prManager?.currentPR?.id,
49
+ commentContext: {
50
+ commentId: chatBtn.dataset.chatCommentId,
51
+ body: originalMarkdown,
52
+ file: chatBtn.dataset.chatFile || '',
53
+ line_start: null,
54
+ line_end: null,
55
+ parentId: chatBtn.dataset.chatParentId || null,
56
+ source: 'user',
57
+ isFileLevel: true
58
+ }
59
+ });
60
+ }
61
+ });
30
62
  }
31
63
 
32
64
  /**
@@ -35,7 +67,7 @@ class FileCommentManager {
35
67
  * @returns {string} Emoji character
36
68
  */
37
69
  getCategoryEmoji(category) {
38
- return FileCommentManager.CATEGORY_EMOJI_MAP[category] || '\u{1F4AC}';
70
+ return window.CategoryEmoji?.getEmoji(category) || '\u{1F4AC}';
39
71
  }
40
72
 
41
73
  /**
@@ -66,50 +98,33 @@ class FileCommentManager {
66
98
  */
67
99
  _getFileCommentEndpoint(operation, options = {}) {
68
100
  const reviewId = this.prManager?.currentPR?.id;
69
- const reviewType = this.prManager?.currentPR?.reviewType;
70
101
  const headSha = this.prManager?.currentPR?.head_sha;
71
- const isLocal = reviewType === 'local';
72
102
 
73
103
  let endpoint;
74
104
  let requestBody = null;
75
105
 
76
106
  switch (operation) {
77
107
  case 'create':
78
- endpoint = isLocal
79
- ? `/api/local/${reviewId}/file-comment`
80
- : '/api/file-comment';
81
-
82
- requestBody = isLocal
83
- ? {
84
- file: options.file,
85
- body: options.body,
86
- parent_id: options.parent_id,
87
- type: options.type,
88
- title: options.title
89
- }
90
- : {
91
- review_id: reviewId,
92
- file: options.file,
93
- body: options.body,
94
- commit_sha: headSha,
95
- parent_id: options.parent_id,
96
- type: options.type,
97
- title: options.title
98
- };
108
+ endpoint = `/api/reviews/${reviewId}/comments`;
109
+
110
+ requestBody = {
111
+ file: options.file,
112
+ body: options.body,
113
+ commit_sha: headSha,
114
+ parent_id: options.parent_id,
115
+ type: options.type,
116
+ title: options.title
117
+ };
99
118
  break;
100
119
 
101
120
  case 'update':
102
- endpoint = isLocal
103
- ? `/api/local/${reviewId}/file-comment/${options.commentId}`
104
- : `/api/user-comment/${options.commentId}`;
121
+ endpoint = `/api/reviews/${reviewId}/comments/${options.commentId}`;
105
122
 
106
123
  requestBody = { body: options.body };
107
124
  break;
108
125
 
109
126
  case 'delete':
110
- endpoint = isLocal
111
- ? `/api/local/${reviewId}/file-comment/${options.commentId}`
112
- : `/api/user-comment/${options.commentId}`;
127
+ endpoint = `/api/reviews/${reviewId}/comments/${options.commentId}`;
113
128
 
114
129
  // No body needed for DELETE
115
130
  break;
@@ -174,6 +189,10 @@ class FileCommentManager {
174
189
  ></textarea>
175
190
  <div class="file-comment-form-footer">
176
191
  <button class="file-comment-form-btn submit submit-btn" disabled>Save</button>
192
+ <button class="ai-action ai-action-chat btn-chat-from-comment" title="Chat about this file">
193
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><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"/></svg>
194
+ Chat
195
+ </button>
177
196
  <button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
178
197
  </div>
179
198
  `;
@@ -221,6 +240,27 @@ class FileCommentManager {
221
240
  }
222
241
  });
223
242
 
243
+ // Chat button handler - opens chat panel with file-level context
244
+ const chatFromCommentBtn = form.querySelector('.btn-chat-from-comment');
245
+ if (chatFromCommentBtn) {
246
+ chatFromCommentBtn.addEventListener('click', () => {
247
+ if (!window.chatPanel) return;
248
+ const unsavedText = textarea.value.trim();
249
+ this.hideCommentForm(zone);
250
+ window.chatPanel.open({
251
+ commentContext: {
252
+ type: 'line',
253
+ body: unsavedText || null,
254
+ file: fileName || '',
255
+ line_start: null,
256
+ line_end: null,
257
+ source: 'user',
258
+ isFileLevel: true
259
+ }
260
+ });
261
+ });
262
+ }
263
+
224
264
  }
225
265
 
226
266
  /**
@@ -362,8 +402,10 @@ class FileCommentManager {
362
402
  <span class="file-comment-badge" title="Comment applies to the entire file">File comment</span>
363
403
  ${praiseBadge}
364
404
  ${titleHtml}
365
- <span class="user-comment-timestamp">${this.formatTimestamp(comment.created_at)}</span>
366
405
  <div class="user-comment-actions">
406
+ <button class="btn-chat-comment" title="Chat about comment" data-chat-comment-id="${comment.id}" data-chat-file="${this.escapeHtml(comment.file || '')}" data-chat-parent-id="${comment.parent_id || ''}">
407
+ <svg viewBox="0 0 16 16" fill="currentColor"><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"/></svg>
408
+ </button>
367
409
  <button class="btn-edit-comment" title="Edit comment">
368
410
  <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
369
411
  <path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
@@ -455,13 +497,13 @@ class FileCommentManager {
455
497
  ${categoryLabel ? `<span class="ai-suggestion-category">${this.escapeHtml(categoryLabel)}</span>` : ''}
456
498
  <span class="ai-title">${this.escapeHtml(suggestion.title || '')}</span>
457
499
  </div>
458
- ${suggestion.reasoning && suggestion.reasoning.length > 0 ? `
459
500
  <div class="ai-suggestion-header-right">
501
+ ${suggestion.reasoning && suggestion.reasoning.length > 0 ? `
460
502
  <button class="btn-reasoning-toggle" title="View reasoning" data-suggestion-id="${suggestion.id}" data-reasoning="${encodeURIComponent(JSON.stringify(suggestion.reasoning))}">
461
503
  <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21.33 12.91c.09 1.55-.62 3.04-1.89 3.95l.77 1.49c.23.45.26.98.06 1.45c-.19.47-.58.84-1.06 1l-.79.25a1.69 1.69 0 0 1-1.86-.55L14.44 18c-.89-.15-1.73-.53-2.44-1.1c-.5.15-1 .23-1.5.23c-.88 0-1.76-.27-2.5-.79c-.53.16-1.07.23-1.62.22c-.79.01-1.57-.15-2.3-.45a4.1 4.1 0 0 1-2.43-3.61c-.08-.72.04-1.45.35-2.11c-.29-.75-.32-1.57-.07-2.33C2.3 7.11 3 6.32 3.87 5.82c.58-1.69 2.21-2.82 4-2.7c1.6-1.5 4.05-1.66 5.83-.37c.42-.11.86-.17 1.3-.17c1.36-.03 2.65.57 3.5 1.64c2.04.53 3.5 2.35 3.58 4.47c.05 1.11-.25 2.2-.86 3.13c.07.36.11.72.11 1.09m-5-1.41c.57.07 1.02.5 1.02 1.07a1 1 0 0 1-1 1h-.63c-.32.9-.88 1.69-1.62 2.29c.25.09.51.14.77.21c5.13-.07 4.53-3.2 4.53-3.25a2.59 2.59 0 0 0-2.69-2.49a1 1 0 0 1-1-1a1 1 0 0 1 1-1c1.23.03 2.41.49 3.33 1.3c.05-.29.08-.59.08-.89c-.06-1.24-.62-2.32-2.87-2.53c-1.25-2.96-4.4-1.32-4.4-.4c-.03.23.21.72.25.75a1 1 0 0 1 1 1c0 .55-.45 1-1 1c-.53-.02-1.03-.22-1.43-.56c-.48.31-1.03.5-1.6.56c-.57.05-1.04-.35-1.07-.9a.97.97 0 0 1 .88-1.1c.16-.02.94-.14.94-.77c0-.66.25-1.29.68-1.79c-.92-.25-1.91.08-2.91 1.29C6.75 5 6 5.25 5.45 7.2C4.5 7.67 4 8 3.78 9c1.08-.22 2.19-.13 3.22.25c.5.19.78.75.59 1.29c-.19.52-.77.78-1.29.59c-.73-.32-1.55-.34-2.3-.06c-.32.27-.32.83-.32 1.27c0 .74.37 1.43 1 1.83c.53.27 1.12.41 1.71.4q-.225-.39-.39-.81a1.038 1.038 0 0 1 1.96-.68c.4 1.14 1.42 1.92 2.62 2.05c1.37-.07 2.59-.88 3.19-2.13c.23-1.38 1.34-1.5 2.56-1.5m2 7.47l-.62-1.3l-.71.16l1 1.25zm-4.65-8.61a1 1 0 0 0-.91-1.03c-.71-.04-1.4.2-1.93.67c-.57.58-.87 1.38-.84 2.19a1 1 0 0 0 1 1c.57 0 1-.45 1-1c0-.27.07-.54.23-.76c.12-.1.27-.15.43-.15c.55.03 1.02-.38 1.02-.92"/></svg>
462
504
  </button>
505
+ ` : ''}
463
506
  </div>
464
- ` : ''}
465
507
  </div>
466
508
  <div class="ai-suggestion-collapsed-content">
467
509
  ${suggestion.type === 'praise'
@@ -495,6 +537,10 @@ class FileCommentManager {
495
537
  <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path></svg>
496
538
  Edit
497
539
  </button>
540
+ <button class="ai-action ai-action-chat" title="Chat about suggestion" data-suggestion-id="${suggestion.id}" data-file="${this.escapeHtml(suggestion.file || '')}" data-title="${this.escapeHtml(suggestion.title || '')}">
541
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><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"/></svg>
542
+ Chat
543
+ </button>
498
544
  <button class="ai-action ai-action-dismiss">
499
545
  <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path></svg>
500
546
  Dismiss
@@ -534,37 +580,19 @@ class FileCommentManager {
534
580
  */
535
581
  async adoptAISuggestion(zone, suggestion) {
536
582
  try {
537
- // Format the comment body with category prefix (matches line-level behavior)
538
- const formattedBody = this.formatAdoptedComment(suggestion.body, suggestion.type);
583
+ // Use the atomic /adopt endpoint which creates the user comment, sets parent_id
584
+ // linkage, and updates suggestion status to 'adopted' in a single request
585
+ const reviewId = this.prManager?.currentPR?.id;
586
+ const adoptEndpoint = `/api/reviews/${reviewId}/suggestions/${suggestion.id}/adopt`;
539
587
 
540
- // Create a file-level user comment from the suggestion, including parent_id/type/title for adopted suggestions
541
- const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
542
- file: suggestion.file,
543
- body: formattedBody,
544
- parent_id: suggestion.id,
545
- type: suggestion.type,
546
- title: suggestion.title
547
- });
548
-
549
- const createResponse = await fetch(endpoint, {
588
+ const adoptResponse = await fetch(adoptEndpoint, {
550
589
  method: 'POST',
551
- headers: { 'Content-Type': 'application/json' },
552
- body: JSON.stringify(requestBody)
590
+ headers: { 'Content-Type': 'application/json' }
553
591
  });
554
592
 
555
- if (!createResponse.ok) throw new Error('Failed to create user comment');
556
-
557
- const createResult = await createResponse.json();
558
-
559
- // Update the AI suggestion status to adopted (mode-aware endpoint)
560
- const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
561
- const statusResponse = await fetch(statusEndpoint, {
562
- method: 'POST',
563
- headers: { 'Content-Type': 'application/json' },
564
- body: JSON.stringify({ status: 'adopted' })
565
- });
593
+ if (!adoptResponse.ok) throw new Error('Failed to adopt suggestion');
566
594
 
567
- if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
595
+ const adoptResult = await adoptResponse.json();
568
596
 
569
597
  // Collapse the AI suggestion card instead of removing it
570
598
  const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
@@ -577,9 +605,12 @@ class FileCommentManager {
577
605
  }
578
606
  }
579
607
 
608
+ // Format the comment body with category prefix for display (matches server-side formatting)
609
+ const formattedBody = this.formatAdoptedComment(suggestion.body, suggestion.type);
610
+
580
611
  // Display as user comment with formatted body
581
612
  const commentData = {
582
- id: createResult.commentId,
613
+ id: adoptResult.userCommentId,
583
614
  file: suggestion.file,
584
615
  body: formattedBody,
585
616
  source: 'user',
@@ -793,34 +824,23 @@ class FileCommentManager {
793
824
  // Format the edited body with category prefix (matches line-level behavior)
794
825
  const formattedBody = this.formatAdoptedComment(editedBody, suggestion.type);
795
826
 
796
- // Create a file-level user comment with the edited body, including parent_id/type/title for adopted suggestions
797
- const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
798
- file: suggestion.file,
799
- body: formattedBody,
800
- parent_id: suggestion.id,
801
- type: suggestion.type,
802
- title: suggestion.title
803
- });
827
+ // Use the /edit endpoint which atomically creates a user comment with the edited
828
+ // body and sets the suggestion status to 'adopted' with parent_id linkage
829
+ const reviewId = this.prManager?.currentPR?.id;
830
+ const editEndpoint = `/api/reviews/${reviewId}/suggestions/${suggestion.id}/edit`;
804
831
 
805
- const createResponse = await fetch(endpoint, {
832
+ const editResponse = await fetch(editEndpoint, {
806
833
  method: 'POST',
807
834
  headers: { 'Content-Type': 'application/json' },
808
- body: JSON.stringify(requestBody)
835
+ body: JSON.stringify({
836
+ action: 'adopt_edited',
837
+ editedText: formattedBody
838
+ })
809
839
  });
810
840
 
811
- if (!createResponse.ok) throw new Error('Failed to create user comment');
841
+ if (!editResponse.ok) throw new Error('Failed to adopt suggestion with edits');
812
842
 
813
- const createResult = await createResponse.json();
814
-
815
- // Update the AI suggestion status to adopted (mode-aware endpoint)
816
- const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
817
- const statusResponse = await fetch(statusEndpoint, {
818
- method: 'POST',
819
- headers: { 'Content-Type': 'application/json' },
820
- body: JSON.stringify({ status: 'adopted' })
821
- });
822
-
823
- if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
843
+ const editResult = await editResponse.json();
824
844
 
825
845
  // Collapse the AI suggestion card instead of removing it
826
846
  const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
@@ -835,7 +855,7 @@ class FileCommentManager {
835
855
 
836
856
  // Display as user comment with formatted body
837
857
  const commentData = {
838
- id: createResult.commentId,
858
+ id: editResult.userCommentId,
839
859
  file: suggestion.file,
840
860
  body: formattedBody,
841
861
  source: 'user',
@@ -897,6 +917,8 @@ class FileCommentManager {
897
917
  const saveBtn = bodyEl.querySelector('.save-edit-btn');
898
918
  const cancelBtn = bodyEl.querySelector('.cancel-edit-btn');
899
919
 
920
+ card.classList.add('editing-mode');
921
+
900
922
  textarea.focus();
901
923
  textarea.setSelectionRange(textarea.value.length, textarea.value.length);
902
924
 
@@ -906,6 +928,7 @@ class FileCommentManager {
906
928
  }
907
929
 
908
930
  const restoreView = () => {
931
+ card.classList.remove('editing-mode');
909
932
  const renderedBody = window.renderMarkdown
910
933
  ? window.renderMarkdown(originalMarkdown)
911
934
  : this.escapeHtml(originalMarkdown);
@@ -962,6 +985,8 @@ class FileCommentManager {
962
985
  if (!response.ok) throw new Error('Failed to update comment');
963
986
 
964
987
  // Update the display
988
+ const card = bodyEl?.closest('.file-comment-card');
989
+ if (card) card.classList.remove('editing-mode');
965
990
  const renderedBody = window.renderMarkdown
966
991
  ? window.renderMarkdown(newBody)
967
992
  : this.escapeHtml(newBody);
@@ -1042,12 +1067,8 @@ class FileCommentManager {
1042
1067
  */
1043
1068
  _getSuggestionStatusEndpoint(suggestionId) {
1044
1069
  const reviewId = this.prManager?.currentPR?.id;
1045
- const reviewType = this.prManager?.currentPR?.reviewType;
1046
- const isLocal = reviewType === 'local';
1047
1070
 
1048
- return isLocal
1049
- ? `/api/local/${reviewId}/ai-suggestion/${suggestionId}/status`
1050
- : `/api/ai-suggestion/${suggestionId}/status`;
1071
+ return `/api/reviews/${reviewId}/suggestions/${suggestionId}/status`;
1051
1072
  }
1052
1073
 
1053
1074
  /**
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * File List Merger - Merges diff files with context files for sidebar display.
4
+ *
5
+ * This module provides the core logic for rebuilding the sidebar file list
6
+ * when context files are added to a review. It merges stored diff files with
7
+ * context files, deduplicates by path, and sorts alphabetically.
8
+ *
9
+ * Used by both PR mode (pr.js) and Local mode (local.js).
10
+ */
11
+
12
+ /**
13
+ * Merge diff files with context files into a single sorted array.
14
+ *
15
+ * Rules:
16
+ * - Diff files take precedence: context files whose path matches a diff file
17
+ * are excluded (the diff version is kept).
18
+ * - Context files are deduplicated by path, keeping the first occurrence.
19
+ * - The result is sorted alphabetically by file path.
20
+ *
21
+ * @param {Array<{file: string}>} diffFiles - Files from the PR diff
22
+ * @param {Array<{file: string, id: number, label: string, line_start: number, line_end: number}>} contextFiles - Additional context files
23
+ * @returns {Array<{file: string}>} Merged and sorted file list
24
+ */
25
+ function mergeFileListWithContext(diffFiles, contextFiles) {
26
+ const merged = [...(diffFiles || [])];
27
+
28
+ // Build set of diff file paths so context files don't duplicate them
29
+ const diffPaths = new Set((diffFiles || []).map(f => f.file));
30
+
31
+ // Deduplicate context files by path and skip any that overlap with diff files
32
+ const seenContextPaths = new Set();
33
+ for (const cf of (contextFiles || [])) {
34
+ if (diffPaths.has(cf.file) || seenContextPaths.has(cf.file)) continue;
35
+ seenContextPaths.add(cf.file);
36
+ merged.push({
37
+ file: cf.file,
38
+ contextFile: true,
39
+ contextId: cf.id,
40
+ label: cf.label,
41
+ lineStart: cf.line_start,
42
+ lineEnd: cf.line_end,
43
+ });
44
+ }
45
+
46
+ // Sort by file path (context files interleave naturally)
47
+ merged.sort((a, b) => a.file.localeCompare(b.file));
48
+
49
+ return merged;
50
+ }
51
+
52
+ // Export for browser usage (attach to window)
53
+ if (typeof window !== 'undefined') {
54
+ window.FileListMerger = {
55
+ mergeFileListWithContext
56
+ };
57
+ }
58
+
59
+ // Export for Node.js/test usage
60
+ if (typeof module !== 'undefined' && module.exports) {
61
+ module.exports = {
62
+ mergeFileListWithContext
63
+ };
64
+ }
@@ -18,13 +18,27 @@ window.PanelResizer = (function() {
18
18
  },
19
19
  'ai-panel': {
20
20
  min: 200,
21
- max: 600,
21
+ max: null, // dynamic — computed from viewport in getEffectiveMax()
22
22
  default: 320,
23
23
  storageKey: 'ai-panel-width',
24
24
  cssVar: '--ai-panel-width'
25
25
  }
26
+ // Note: chat-panel resize is handled by ChatPanel itself (see ChatPanel._bindResizeEvents)
26
27
  };
27
28
 
29
+ /**
30
+ * Compute the effective max width for a panel.
31
+ * For panels with a static max, returns that value.
32
+ * For panels with max: null, computes a dynamic max from the viewport.
33
+ */
34
+ function getEffectiveMax(panelName) {
35
+ const config = CONFIG[panelName];
36
+ if (!config) return Infinity;
37
+ if (config.max != null) return config.max;
38
+ const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10) || 260;
39
+ return window.innerWidth - sidebarWidth - 100;
40
+ }
41
+
28
42
  // State
29
43
  let isDragging = false;
30
44
  let currentPanel = null;
@@ -59,7 +73,7 @@ window.PanelResizer = (function() {
59
73
 
60
74
  if (savedWidth) {
61
75
  const width = parseInt(savedWidth, 10);
62
- if (width >= config.min && width <= config.max) {
76
+ if (width >= config.min) {
63
77
  document.documentElement.style.setProperty(config.cssVar, `${width}px`);
64
78
  }
65
79
  }
@@ -92,8 +106,8 @@ window.PanelResizer = (function() {
92
106
  const config = CONFIG[panelName];
93
107
  if (!config) return;
94
108
 
95
- // Clamp to min/max
96
- const clampedWidth = Math.max(config.min, Math.min(config.max, width));
109
+ // Clamp to min/max (max may be dynamic)
110
+ const clampedWidth = Math.max(config.min, Math.min(getEffectiveMax(panelName), width));
97
111
 
98
112
  // Apply to CSS
99
113
  document.documentElement.style.setProperty(config.cssVar, `${clampedWidth}px`);
@@ -115,7 +129,9 @@ window.PanelResizer = (function() {
115
129
  const panelName = handle.dataset.panel;
116
130
  const panelEl = panelName === 'sidebar'
117
131
  ? document.getElementById('files-sidebar')
118
- : document.getElementById('ai-panel');
132
+ : panelName === 'chat-panel'
133
+ ? document.querySelector('.chat-panel')
134
+ : document.getElementById('ai-panel');
119
135
 
120
136
  // Don't allow resize if panel is collapsed
121
137
  if (panelEl && panelEl.classList.contains('collapsed')) {
@@ -181,6 +197,9 @@ window.PanelResizer = (function() {
181
197
  }
182
198
  document.body.classList.remove('resizing');
183
199
 
200
+ // Notify PanelGroup so --right-panel-group-width stays in sync
201
+ window.panelGroup?._updateRightPanelGroupWidth();
202
+
184
203
  isDragging = false;
185
204
  currentPanel = null;
186
205
  startX = 0;
@@ -199,7 +218,7 @@ window.PanelResizer = (function() {
199
218
  const saved = localStorage.getItem(config.storageKey);
200
219
  if (saved) {
201
220
  const width = parseInt(saved, 10);
202
- if (width >= config.min && width <= config.max) {
221
+ if (width >= config.min) {
203
222
  return width;
204
223
  }
205
224
  }