@in-the-loop-labs/pair-review 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/git-diff-lines +1 -1
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  6. package/public/css/pr.css +201 -0
  7. package/public/index.html +168 -3
  8. package/public/js/components/AIPanel.js +16 -2
  9. package/public/js/components/ChatPanel.js +41 -6
  10. package/public/js/components/ConfirmDialog.js +21 -2
  11. package/public/js/components/CouncilProgressModal.js +13 -0
  12. package/public/js/components/DiffOptionsDropdown.js +410 -23
  13. package/public/js/components/SuggestionNavigator.js +12 -5
  14. package/public/js/components/TabTitle.js +96 -0
  15. package/public/js/components/Toast.js +6 -0
  16. package/public/js/index.js +648 -43
  17. package/public/js/local.js +569 -76
  18. package/public/js/modules/analysis-history.js +3 -2
  19. package/public/js/modules/comment-manager.js +5 -0
  20. package/public/js/modules/comment-minimizer.js +304 -0
  21. package/public/js/pr.js +82 -6
  22. package/public/local.html +14 -0
  23. package/public/pr.html +3 -0
  24. package/src/ai/analyzer.js +22 -16
  25. package/src/ai/cursor-agent-provider.js +21 -12
  26. package/src/chat/prompt-builder.js +3 -3
  27. package/src/config.js +2 -0
  28. package/src/database.js +590 -39
  29. package/src/git/base-branch.js +173 -0
  30. package/src/git/sha-abbrev.js +35 -0
  31. package/src/git/worktree.js +3 -2
  32. package/src/github/client.js +32 -1
  33. package/src/hooks/hook-runner.js +100 -0
  34. package/src/hooks/payloads.js +212 -0
  35. package/src/local-review.js +468 -129
  36. package/src/local-scope.js +58 -0
  37. package/src/main.js +57 -6
  38. package/src/routes/analyses.js +73 -10
  39. package/src/routes/chat.js +33 -0
  40. package/src/routes/config.js +1 -0
  41. package/src/routes/github-collections.js +2 -2
  42. package/src/routes/local.js +734 -68
  43. package/src/routes/mcp.js +20 -10
  44. package/src/routes/pr.js +92 -14
  45. package/src/routes/setup.js +1 -0
  46. package/src/routes/worktrees.js +212 -148
  47. package/src/server.js +30 -0
  48. package/src/setup/local-setup.js +46 -5
  49. package/src/setup/pr-setup.js +28 -5
  50. package/src/utils/diff-file-list.js +1 -1
@@ -68,7 +68,7 @@ function executeGitDiff(args, cwd = null) {
68
68
  if (cwd) {
69
69
  spawnOptions.cwd = cwd;
70
70
  }
71
- const gitProcess = spawn('git', ['diff', ...args], spawnOptions);
71
+ const gitProcess = spawn('git', ['diff', '--no-ext-diff', ...args], spawnOptions);
72
72
 
73
73
  let stdout = '';
74
74
  let stderr = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -84,7 +84,7 @@ GIT_CMD=(git)
84
84
  if [[ -n "$GIT_CWD" ]]; then
85
85
  GIT_CMD+=(-C "$GIT_CWD")
86
86
  fi
87
- GIT_CMD+=(diff)
87
+ GIT_CMD+=(diff --no-ext-diff)
88
88
  # Guard: expanding an empty array with set -u fails in Bash < 4.4
89
89
  if [[ ${#GIT_ARGS[@]} -gt 0 ]]; then
90
90
  GIT_CMD+=("${GIT_ARGS[@]}")
package/public/css/pr.css CHANGED
@@ -1587,6 +1587,13 @@
1587
1587
  color: #f85149;
1588
1588
  }
1589
1589
 
1590
+ /* When collapsed, the wrapper is only as tall as the header, so the header's
1591
+ sticky positioning can't push it below the toolbar. Adding scroll-margin
1592
+ ensures scrollIntoView() lands the header below the sticky toolbar. */
1593
+ .d2h-file-wrapper.collapsed {
1594
+ scroll-margin-top: var(--toolbar-height, 0px);
1595
+ }
1596
+
1590
1597
  /* Hide diff content when collapsed */
1591
1598
  .d2h-file-wrapper.collapsed .d2h-file-body,
1592
1599
  .d2h-file-wrapper.collapsed .d2h-diff-table {
@@ -2689,6 +2696,36 @@ tr.newly-expanded .d2h-code-line-ctn {
2689
2696
  justify-content: center;
2690
2697
  }
2691
2698
 
2699
+ /* When a secondary button is visible (3 buttons), widen and stack vertically.
2700
+ column-reverse puts Confirm (primary) on top, Cancel on bottom. */
2701
+ .confirm-dialog-container.has-secondary {
2702
+ min-width: 440px;
2703
+ }
2704
+
2705
+ .confirm-dialog-container.has-secondary .modal-footer {
2706
+ flex-direction: column-reverse;
2707
+ align-items: stretch;
2708
+ }
2709
+
2710
+ .confirm-dialog-container.has-secondary .modal-footer .btn {
2711
+ display: flex;
2712
+ flex-direction: column;
2713
+ align-items: center;
2714
+ }
2715
+
2716
+ /* Button description subtitles */
2717
+ .modal-footer .btn .btn-label {
2718
+ display: block;
2719
+ }
2720
+
2721
+ .modal-footer .btn .btn-desc {
2722
+ display: block;
2723
+ font-size: 12px;
2724
+ font-weight: normal;
2725
+ opacity: 0.7;
2726
+ margin-top: 2px;
2727
+ }
2728
+
2692
2729
  /* Dialogs spawned from within other modals need higher z-index */
2693
2730
  #text-input-dialog,
2694
2731
  #confirm-dialog {
@@ -7237,6 +7274,7 @@ body.resizing * {
7237
7274
  .diff-options-popover {
7238
7275
  position: fixed;
7239
7276
  z-index: 1100;
7277
+ min-width: 320px;
7240
7278
  background: var(--color-bg-primary);
7241
7279
  border: 1px solid var(--color-border-primary);
7242
7280
  border-radius: 8px;
@@ -7308,6 +7346,150 @@ body.resizing * {
7308
7346
  background: rgba(88, 166, 255, 0.15);
7309
7347
  }
7310
7348
 
7349
+ /* --------------------------------------------------------------------------
7350
+ Comment Minimize Mode
7351
+ -------------------------------------------------------------------------- */
7352
+
7353
+ /* When minimize mode is active, hide all inline comment and suggestion rows */
7354
+ .comments-minimized .user-comment-row,
7355
+ .comments-minimized .ai-suggestion-row {
7356
+ display: none;
7357
+ }
7358
+
7359
+ /* Per-line expansion override — clicking an indicator reveals that line's rows */
7360
+ .comments-minimized .user-comment-row.comment-expanded,
7361
+ .comments-minimized .ai-suggestion-row.comment-expanded {
7362
+ display: table-row;
7363
+ }
7364
+
7365
+ /* Indicator button on the right edge of diff code cells */
7366
+ .comment-indicator {
7367
+ position: absolute;
7368
+ right: 4px;
7369
+ top: 50%;
7370
+ transform: translateY(-50%);
7371
+ display: inline-flex;
7372
+ align-items: center;
7373
+ gap: 3px;
7374
+ padding: 2px 6px;
7375
+ border: 1px solid var(--color-border-primary);
7376
+ border-radius: 12px;
7377
+ background: var(--color-bg-secondary);
7378
+ cursor: pointer;
7379
+ font-size: 11px;
7380
+ line-height: 1;
7381
+ color: var(--color-text-secondary);
7382
+ opacity: 1;
7383
+ transition: background 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
7384
+ z-index: 2; /* must be below sticky file headers (z-index: 4) */
7385
+ }
7386
+
7387
+ .comment-indicator:hover {
7388
+ background: var(--color-bg-tertiary);
7389
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
7390
+ }
7391
+
7392
+ .comment-indicator.expanded {
7393
+ border-width: 2px;
7394
+ padding: 1px 5px; /* compensate for thicker border */
7395
+ }
7396
+
7397
+ .comment-indicator .indicator-icon {
7398
+ display: inline-flex;
7399
+ align-items: center;
7400
+ }
7401
+
7402
+ .comment-indicator .indicator-icon svg {
7403
+ display: block;
7404
+ }
7405
+
7406
+ .comment-indicator .indicator-user {
7407
+ color: var(--comment-primary, #8250df);
7408
+ }
7409
+
7410
+ .comment-indicator .indicator-adopted {
7411
+ color: var(--comment-primary, #8250df);
7412
+ }
7413
+
7414
+ .comment-indicator:has(.indicator-user),
7415
+ .comment-indicator:has(.indicator-adopted) {
7416
+ border-color: var(--comment-primary, #8250df);
7417
+ background: rgba(130, 80, 223, 0.06);
7418
+ }
7419
+
7420
+ .comment-indicator:has(.indicator-user):hover,
7421
+ .comment-indicator:has(.indicator-adopted):hover {
7422
+ background: rgba(130, 80, 223, 0.12);
7423
+ box-shadow: 0 1px 3px rgba(130, 80, 223, 0.15);
7424
+ }
7425
+
7426
+ .comment-indicator .indicator-ai {
7427
+ color: var(--ai-accent, #d97706);
7428
+ }
7429
+
7430
+ .comment-indicator:has(.indicator-ai) {
7431
+ border-color: var(--ai-accent, #d97706);
7432
+ background: rgba(217, 119, 6, 0.06);
7433
+ }
7434
+
7435
+ .comment-indicator:has(.indicator-ai):hover {
7436
+ background: rgba(217, 119, 6, 0.12);
7437
+ box-shadow: 0 1px 3px rgba(217, 119, 6, 0.15);
7438
+ }
7439
+
7440
+ .comment-indicator .indicator-count {
7441
+ font-weight: 600;
7442
+ font-size: 10px;
7443
+ min-width: 14px;
7444
+ text-align: center;
7445
+ }
7446
+
7447
+ /* Dark theme overrides */
7448
+ [data-theme="dark"] .comment-indicator {
7449
+ background: var(--color-bg-tertiary);
7450
+ border-color: var(--color-border-secondary);
7451
+ }
7452
+
7453
+ [data-theme="dark"] .comment-indicator:hover {
7454
+ background: var(--color-bg-primary);
7455
+ }
7456
+
7457
+ [data-theme="dark"] .comment-indicator:has(.indicator-user),
7458
+ [data-theme="dark"] .comment-indicator:has(.indicator-adopted) {
7459
+ border-color: var(--comment-primary, #a371f7);
7460
+ background: rgba(163, 113, 247, 0.1);
7461
+ }
7462
+
7463
+ [data-theme="dark"] .comment-indicator .indicator-user,
7464
+ [data-theme="dark"] .comment-indicator .indicator-adopted {
7465
+ color: var(--comment-primary, #a371f7);
7466
+ }
7467
+
7468
+ [data-theme="dark"] .comment-indicator:has(.indicator-user):hover,
7469
+ [data-theme="dark"] .comment-indicator:has(.indicator-adopted):hover {
7470
+ background: rgba(163, 113, 247, 0.18);
7471
+ box-shadow: 0 1px 3px rgba(163, 113, 247, 0.25);
7472
+ }
7473
+
7474
+ [data-theme="dark"] .comment-indicator:has(.indicator-ai) {
7475
+ border-color: var(--color-accent-ai, #fbbf24);
7476
+ background: rgba(251, 191, 36, 0.1);
7477
+ }
7478
+
7479
+ [data-theme="dark"] .comment-indicator .indicator-ai {
7480
+ color: var(--color-accent-ai, #fbbf24);
7481
+ }
7482
+
7483
+ [data-theme="dark"] .comment-indicator:has(.indicator-ai):hover {
7484
+ background: rgba(251, 191, 36, 0.18);
7485
+ box-shadow: 0 1px 3px rgba(251, 191, 36, 0.25);
7486
+ }
7487
+
7488
+ [data-theme="dark"] .comment-indicator.expanded {
7489
+ border-width: 2px;
7490
+ padding: 1px 5px;
7491
+ }
7492
+
7311
7493
  .ai-panel-header {
7312
7494
  display: flex;
7313
7495
  align-items: center;
@@ -11825,6 +12007,25 @@ body.resizing * {
11825
12007
  30% { opacity: 1; transform: scale(1); }
11826
12008
  }
11827
12009
 
12010
+ /* Loop logo spinner (easter egg: chat_spinner = "loop") */
12011
+ .chat-panel__loop-spinner {
12012
+ display: inline-flex;
12013
+ align-items: center;
12014
+ padding: 2px 0;
12015
+ }
12016
+
12017
+ .chat-panel__loop-spinner svg {
12018
+ width: 20px;
12019
+ height: 20px;
12020
+ color: var(--color-accent-ai, #8b5cf6);
12021
+ animation: loop-spin 1.4s linear infinite;
12022
+ }
12023
+
12024
+ @keyframes loop-spin {
12025
+ 0% { transform: rotate(0deg); }
12026
+ 100% { transform: rotate(360deg); }
12027
+ }
12028
+
11828
12029
  /* Blinking cursor during streaming */
11829
12030
  .chat-panel__cursor {
11830
12031
  display: inline-block;
package/public/index.html CHANGED
@@ -61,6 +61,7 @@
61
61
 
62
62
  --color-accent-primary: #0969da;
63
63
  --color-accent-hover: #0860ca;
64
+ --color-accent-subtle: rgba(9, 105, 218, 0.04);
64
65
  --color-accent-emphasis: #238636;
65
66
  --color-accent-emphasis-hover: #2ea043;
66
67
 
@@ -94,6 +95,7 @@
94
95
 
95
96
  --color-accent-primary: #58a6ff;
96
97
  --color-accent-hover: #1f6feb;
98
+ --color-accent-subtle: rgba(56, 139, 253, 0.06);
97
99
  --color-accent-emphasis: #238636;
98
100
  --color-accent-emphasis-hover: #2ea043;
99
101
 
@@ -687,7 +689,7 @@
687
689
  margin-right: 4px;
688
690
  }
689
691
 
690
- .btn-delete-worktree,
692
+ .btn-delete-review,
691
693
  .btn-repo-settings {
692
694
  display: inline-flex;
693
695
  align-items: center;
@@ -703,13 +705,13 @@
703
705
  transition: all var(--transition-fast);
704
706
  }
705
707
 
706
- .btn-delete-worktree:hover {
708
+ .btn-delete-review:hover {
707
709
  background-color: rgba(208, 36, 47, 0.08);
708
710
  border-color: rgba(208, 36, 47, 0.3);
709
711
  color: var(--color-danger);
710
712
  }
711
713
 
712
- [data-theme="dark"] .btn-delete-worktree:hover {
714
+ [data-theme="dark"] .btn-delete-review:hover {
713
715
  background-color: rgba(248, 81, 73, 0.1);
714
716
  border-color: rgba(248, 81, 73, 0.3);
715
717
  }
@@ -1053,6 +1055,168 @@
1053
1055
  color: var(--color-text-primary);
1054
1056
  }
1055
1057
 
1058
+ /* ─── Selection Mode ─────────────────────────────────────── */
1059
+
1060
+ /* Select toggle button — matches .btn-refresh sizing */
1061
+ .btn-select-toggle {
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ gap: 6px;
1065
+ padding: 4px 12px;
1066
+ font-family: var(--font-sans);
1067
+ font-size: 12px;
1068
+ font-weight: 500;
1069
+ color: var(--color-text-secondary);
1070
+ background: transparent;
1071
+ border: 1px solid var(--color-border-primary);
1072
+ border-radius: var(--radius-md);
1073
+ cursor: pointer;
1074
+ transition: all var(--transition-fast);
1075
+ }
1076
+
1077
+ .btn-select-toggle:hover {
1078
+ background: var(--color-bg-secondary);
1079
+ color: var(--color-text-primary);
1080
+ }
1081
+
1082
+ .btn-select-toggle.active {
1083
+ background: var(--color-accent-subtle);
1084
+ border-color: var(--color-accent-primary);
1085
+ color: var(--color-accent-primary);
1086
+ }
1087
+
1088
+ /* Checkbox column */
1089
+ .col-select {
1090
+ width: 32px;
1091
+ text-align: center;
1092
+ padding: 6px 8px !important;
1093
+ }
1094
+
1095
+ .col-select input[type="checkbox"] {
1096
+ width: 15px;
1097
+ height: 15px;
1098
+ cursor: pointer;
1099
+ accent-color: var(--color-accent-primary);
1100
+ vertical-align: middle;
1101
+ }
1102
+
1103
+ /* Selected row highlight */
1104
+ .recent-reviews-table tbody tr.bulk-selected td {
1105
+ background-color: var(--color-accent-subtle);
1106
+ }
1107
+
1108
+ /* Hide per-row delete buttons in selection mode */
1109
+ .selection-mode .btn-delete-review,
1110
+ .selection-mode .btn-delete-session {
1111
+ display: none;
1112
+ }
1113
+
1114
+ /* Inline bulk action controls — appear next to Select button */
1115
+ .bulk-inline-actions {
1116
+ display: inline-flex;
1117
+ align-items: center;
1118
+ gap: 8px;
1119
+ }
1120
+
1121
+ .bulk-action-count {
1122
+ font-size: 12px;
1123
+ font-weight: 500;
1124
+ color: var(--color-text-secondary);
1125
+ }
1126
+
1127
+ .bulk-action-buttons {
1128
+ display: inline-flex;
1129
+ gap: 6px;
1130
+ }
1131
+
1132
+ .btn-bulk-delete {
1133
+ padding: 4px 12px;
1134
+ font-size: 12px;
1135
+ font-weight: 500;
1136
+ background: var(--color-danger);
1137
+ color: #ffffff;
1138
+ border: none;
1139
+ border-radius: var(--radius-sm);
1140
+ cursor: pointer;
1141
+ }
1142
+
1143
+ .btn-bulk-delete:hover:not(:disabled) {
1144
+ background: var(--color-danger-hover);
1145
+ }
1146
+
1147
+ .btn-bulk-delete:disabled {
1148
+ opacity: 0.4;
1149
+ cursor: default;
1150
+ }
1151
+
1152
+ .btn-bulk-open,
1153
+ .btn-bulk-analyze {
1154
+ padding: 4px 12px;
1155
+ font-size: 12px;
1156
+ font-weight: 500;
1157
+ background: var(--color-accent-primary);
1158
+ color: #ffffff;
1159
+ border: none;
1160
+ border-radius: var(--radius-sm);
1161
+ cursor: pointer;
1162
+ }
1163
+
1164
+ .btn-bulk-open:hover:not(:disabled),
1165
+ .btn-bulk-analyze:hover:not(:disabled) {
1166
+ opacity: 0.9;
1167
+ }
1168
+
1169
+ .btn-bulk-open:disabled,
1170
+ .btn-bulk-analyze:disabled {
1171
+ opacity: 0.4;
1172
+ cursor: default;
1173
+ }
1174
+
1175
+ .btn-bulk-cancel {
1176
+ padding: 4px 12px;
1177
+ font-size: 12px;
1178
+ font-weight: 500;
1179
+ background: transparent;
1180
+ color: var(--color-text-secondary);
1181
+ border: 1px solid var(--color-border-primary);
1182
+ border-radius: var(--radius-sm);
1183
+ cursor: pointer;
1184
+ }
1185
+
1186
+ .btn-bulk-cancel:hover {
1187
+ background: var(--color-bg-secondary);
1188
+ color: var(--color-text-primary);
1189
+ }
1190
+
1191
+ /* Confirmation state — hide normal action buttons, show confirm buttons */
1192
+ .bulk-inline-actions .bulk-confirm-buttons {
1193
+ display: none;
1194
+ gap: 6px;
1195
+ }
1196
+
1197
+ .bulk-inline-actions.confirming .bulk-action-buttons {
1198
+ display: none;
1199
+ }
1200
+
1201
+ .bulk-inline-actions.confirming .bulk-confirm-buttons {
1202
+ display: inline-flex;
1203
+ }
1204
+
1205
+ .bulk-inline-actions.confirming > .btn-bulk-cancel {
1206
+ display: none;
1207
+ }
1208
+
1209
+ /* Select mode header row (between form and table, for PR/Local tabs) */
1210
+ .select-mode-header {
1211
+ display: none;
1212
+ justify-content: flex-end;
1213
+ padding: 0 0 8px 0;
1214
+ }
1215
+
1216
+ .select-mode-header.visible {
1217
+ display: flex;
1218
+ }
1219
+
1056
1220
  /* Start review section spacing inside unified tabs */
1057
1221
  .tab-pane .start-review-section {
1058
1222
  max-width: 100%;
@@ -1266,6 +1430,7 @@
1266
1430
  </div>
1267
1431
  </div>
1268
1432
 
1433
+ <script src="/js/components/Toast.js"></script>
1269
1434
  <script src="/js/index.js"></script>
1270
1435
  </body>
1271
1436
  </html>
@@ -1000,7 +1000,14 @@ class AIPanel {
1000
1000
  }
1001
1001
 
1002
1002
  if (targetSuggestion) {
1003
- targetSuggestion.scrollIntoView({ behavior: 'smooth', block: 'center' });
1003
+ const minimizer = window.prManager?.commentMinimizer;
1004
+ if (minimizer?.active) {
1005
+ // Comments are minimized — scroll to the parent diff line instead
1006
+ const diffRow = minimizer.findDiffRowFor(targetSuggestion);
1007
+ (diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
1008
+ } else {
1009
+ targetSuggestion.scrollIntoView({ behavior: 'smooth', block: 'center' });
1010
+ }
1004
1011
  targetSuggestion.classList.add('current-suggestion');
1005
1012
  setTimeout(() => targetSuggestion.classList.remove('current-suggestion'), 2000);
1006
1013
  }
@@ -1061,7 +1068,14 @@ class AIPanel {
1061
1068
  }
1062
1069
 
1063
1070
  if (targetElement) {
1064
- targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1071
+ const minimizer = window.prManager?.commentMinimizer;
1072
+ if (minimizer?.active && !isFileLevel) {
1073
+ // Comments are minimized — scroll to the parent diff line instead
1074
+ const diffRow = minimizer.findDiffRowFor(targetElement);
1075
+ (diffRow || targetElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
1076
+ } else {
1077
+ targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1078
+ }
1065
1079
  // Add highlight effect
1066
1080
  const commentDiv = isFileLevel ? targetElement : targetElement.querySelector('.user-comment');
1067
1081
  if (commentDiv) {
@@ -10,6 +10,13 @@ const DISMISS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" width="12" he
10
10
  /** Pixel threshold for considering the user "near the bottom" of the messages container. */
11
11
  const NEAR_BOTTOM_THRESHOLD = 80;
12
12
 
13
+ const LOOP_SPINNER_HTML = `<span class="chat-panel__loop-spinner"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="20" height="20"><path transform="rotate(-50 12 12)" d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.356-8-5.096 0-5.096 8 0 8 5.223 0 7.26-8 12.356-8z"/></svg></span>`;
14
+ const DOTS_SPINNER_HTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
15
+
16
+ function getChatSpinnerHTML() {
17
+ return window.__pairReview?.chatSpinner === 'loop' ? LOOP_SPINNER_HTML : DOTS_SPINNER_HTML;
18
+ }
19
+
13
20
  class ChatPanel {
14
21
  constructor(containerId) {
15
22
  this.containerId = containerId;
@@ -24,6 +31,7 @@ class ChatPanel {
24
31
  this._streamingContent = '';
25
32
  this._pendingContext = [];
26
33
  this._pendingContextData = [];
34
+ this._pendingDiffStateNotifications = [];
27
35
  this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
28
36
  this._contextItemId = null; // suggestion ID or comment ID from context
29
37
  this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
@@ -641,6 +649,7 @@ class ChatPanel {
641
649
  this._streamingContent = '';
642
650
  this._pendingContext = [];
643
651
  this._pendingContextData = [];
652
+ this._pendingDiffStateNotifications = [];
644
653
  this._contextSource = null;
645
654
  this._contextItemId = null;
646
655
  this._contextLineMeta = null;
@@ -1003,6 +1012,7 @@ class ChatPanel {
1003
1012
  this._streamingContent = '';
1004
1013
  this._pendingContext = [];
1005
1014
  this._pendingContextData = [];
1015
+ this._pendingDiffStateNotifications = [];
1006
1016
  this._contextSource = null;
1007
1017
  this._contextItemId = null;
1008
1018
  this._contextLineMeta = null;
@@ -1233,10 +1243,22 @@ class ChatPanel {
1233
1243
 
1234
1244
  // Build the API payload — may include pending context from "Ask about this"
1235
1245
  const payload = { content };
1246
+
1247
+ // Snapshot diff-state queue for error recovery (invisible to user, no UI cards)
1248
+ const savedDiffState = this._pendingDiffStateNotifications.slice();
1249
+ let diffStatePrefix = '';
1250
+ if (this._pendingDiffStateNotifications.length > 0) {
1251
+ diffStatePrefix = '[Diff State Update]\n' + this._pendingDiffStateNotifications.join('\n');
1252
+ this._pendingDiffStateNotifications = [];
1253
+ }
1254
+
1236
1255
  const savedContext = this._pendingContext;
1237
1256
  const savedContextData = this._pendingContextData;
1238
1257
  if (this._pendingContext.length > 0) {
1239
- payload.context = this._pendingContext.join('\n\n');
1258
+ const userContext = this._pendingContext.join('\n\n');
1259
+ payload.context = diffStatePrefix
1260
+ ? diffStatePrefix + '\n\n' + userContext
1261
+ : userContext;
1240
1262
  payload.contextData = this._pendingContextData;
1241
1263
  this._pendingContext = [];
1242
1264
  this._pendingContextData = [];
@@ -1248,6 +1270,8 @@ class ChatPanel {
1248
1270
  if (btn) btn.remove();
1249
1271
  delete card.dataset.contextIndex;
1250
1272
  });
1273
+ } else if (diffStatePrefix) {
1274
+ payload.context = diffStatePrefix;
1251
1275
  }
1252
1276
 
1253
1277
  // Lock analysis context card (not indexed, handled separately from pending context)
@@ -1311,6 +1335,7 @@ class ChatPanel {
1311
1335
  // Restore pending context so it's not lost
1312
1336
  this._pendingContext = savedContext;
1313
1337
  this._pendingContextData = savedContextData;
1338
+ this._pendingDiffStateNotifications = [...savedDiffState, ...this._pendingDiffStateNotifications];
1314
1339
  // Restore removability on context cards that were locked before the failed send
1315
1340
  this._restoreRemovableCards();
1316
1341
  console.error('[ChatPanel] Error sending message:', error);
@@ -1319,6 +1344,16 @@ class ChatPanel {
1319
1344
  }
1320
1345
  }
1321
1346
 
1347
+ /**
1348
+ * Queue an invisible diff-state notification for the chat agent.
1349
+ * Unlike _pendingContext, these do NOT render UI cards and survive panel close.
1350
+ * Drained into the context parameter on the next sendMessage() call.
1351
+ * @param {string} message - Description of the diff state change
1352
+ */
1353
+ queueDiffStateNotification(message) {
1354
+ this._pendingDiffStateNotifications.push(message);
1355
+ }
1356
+
1322
1357
  /**
1323
1358
  * Store pending context and render a compact context card in the UI.
1324
1359
  * Called when the user clicks "Ask about this" on a suggestion.
@@ -2409,7 +2444,7 @@ class ChatPanel {
2409
2444
 
2410
2445
  const bubble = document.createElement('div');
2411
2446
  bubble.className = 'chat-panel__bubble';
2412
- bubble.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
2447
+ bubble.innerHTML = getChatSpinnerHTML();
2413
2448
 
2414
2449
  msgEl.appendChild(bubble);
2415
2450
  this.messagesEl.appendChild(msgEl);
@@ -2641,10 +2676,10 @@ class ChatPanel {
2641
2676
  // Don't add duplicate
2642
2677
  if (streamingMsg.querySelector('.chat-panel__thinking')) return;
2643
2678
 
2644
- // Don't add if the bubble still has its initial typing indicator (no content yet).
2645
- // The bubble's own dots are sufficient — adding a second set would show two pulsing indicators.
2679
+ // Don't add if the bubble still has its initial spinner (no content yet).
2680
+ // The bubble's own indicator is sufficient — adding a second would show two.
2646
2681
  const bubble = streamingMsg.querySelector('.chat-panel__bubble');
2647
- if (bubble && bubble.querySelector('.chat-panel__typing-indicator')) return;
2682
+ if (bubble && (bubble.querySelector('.chat-panel__typing-indicator') || bubble.querySelector('.chat-panel__loop-spinner'))) return;
2648
2683
 
2649
2684
  // Remove the cursor — the thinking indicator replaces it as the "working" signal.
2650
2685
  // When new text arrives, updateStreamingMessage() will re-add the cursor naturally.
@@ -2653,7 +2688,7 @@ class ChatPanel {
2653
2688
 
2654
2689
  const indicator = document.createElement('div');
2655
2690
  indicator.className = 'chat-panel__thinking';
2656
- indicator.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
2691
+ indicator.innerHTML = getChatSpinnerHTML();
2657
2692
  streamingMsg.appendChild(indicator);
2658
2693
  this.scrollToBottom();
2659
2694
  }