@in-the-loop-labs/pair-review 2.6.3 → 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 (43) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +194 -0
  5. package/public/index.html +168 -3
  6. package/public/js/components/AIPanel.js +16 -2
  7. package/public/js/components/ChatPanel.js +41 -6
  8. package/public/js/components/ConfirmDialog.js +21 -2
  9. package/public/js/components/CouncilProgressModal.js +13 -0
  10. package/public/js/components/DiffOptionsDropdown.js +410 -23
  11. package/public/js/components/SuggestionNavigator.js +12 -5
  12. package/public/js/components/TabTitle.js +96 -0
  13. package/public/js/components/Toast.js +6 -0
  14. package/public/js/index.js +648 -43
  15. package/public/js/local.js +569 -76
  16. package/public/js/modules/analysis-history.js +3 -2
  17. package/public/js/modules/comment-manager.js +5 -0
  18. package/public/js/modules/comment-minimizer.js +304 -0
  19. package/public/js/pr.js +82 -6
  20. package/public/local.html +14 -0
  21. package/public/pr.html +3 -0
  22. package/src/ai/analyzer.js +17 -11
  23. package/src/config.js +2 -0
  24. package/src/database.js +590 -39
  25. package/src/git/base-branch.js +173 -0
  26. package/src/git/sha-abbrev.js +35 -0
  27. package/src/github/client.js +32 -1
  28. package/src/hooks/hook-runner.js +100 -0
  29. package/src/hooks/payloads.js +212 -0
  30. package/src/local-review.js +468 -129
  31. package/src/local-scope.js +58 -0
  32. package/src/main.js +55 -4
  33. package/src/routes/analyses.js +73 -10
  34. package/src/routes/chat.js +33 -0
  35. package/src/routes/config.js +1 -0
  36. package/src/routes/local.js +734 -68
  37. package/src/routes/mcp.js +20 -10
  38. package/src/routes/pr.js +90 -12
  39. package/src/routes/setup.js +1 -0
  40. package/src/routes/worktrees.js +212 -148
  41. package/src/server.js +30 -0
  42. package/src/setup/local-setup.js +46 -5
  43. package/src/setup/pr-setup.js +28 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.6.3",
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.3",
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.3",
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",
package/public/css/pr.css CHANGED
@@ -2696,6 +2696,36 @@ tr.newly-expanded .d2h-code-line-ctn {
2696
2696
  justify-content: center;
2697
2697
  }
2698
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
+
2699
2729
  /* Dialogs spawned from within other modals need higher z-index */
2700
2730
  #text-input-dialog,
2701
2731
  #confirm-dialog {
@@ -7244,6 +7274,7 @@ body.resizing * {
7244
7274
  .diff-options-popover {
7245
7275
  position: fixed;
7246
7276
  z-index: 1100;
7277
+ min-width: 320px;
7247
7278
  background: var(--color-bg-primary);
7248
7279
  border: 1px solid var(--color-border-primary);
7249
7280
  border-radius: 8px;
@@ -7315,6 +7346,150 @@ body.resizing * {
7315
7346
  background: rgba(88, 166, 255, 0.15);
7316
7347
  }
7317
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
+
7318
7493
  .ai-panel-header {
7319
7494
  display: flex;
7320
7495
  align-items: center;
@@ -11832,6 +12007,25 @@ body.resizing * {
11832
12007
  30% { opacity: 1; transform: scale(1); }
11833
12008
  }
11834
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
+
11835
12029
  /* Blinking cursor during streaming */
11836
12030
  .chat-panel__cursor {
11837
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
  }
@@ -133,10 +133,20 @@ class ConfirmDialog {
133
133
  messageElement.textContent = options.message || 'Are you sure?';
134
134
  }
135
135
 
136
+ // Helper: set button label + optional description subtitle
137
+ const setBtnContent = (btn, label, description) => {
138
+ if (!btn) return;
139
+ if (description) {
140
+ btn.innerHTML = `<span class="btn-label">${label}</span><span class="btn-desc">${description}</span>`;
141
+ } else {
142
+ btn.textContent = label;
143
+ }
144
+ };
145
+
136
146
  // Set confirm button text and style
137
147
  const confirmBtn = this.modal.querySelector('#confirm-dialog-btn');
138
148
  if (confirmBtn) {
139
- confirmBtn.textContent = options.confirmText || 'Confirm';
149
+ setBtnContent(confirmBtn, options.confirmText || 'Confirm', options.confirmDesc);
140
150
  // Remove previous style classes and add new one
141
151
  confirmBtn.classList.remove('btn-primary', 'btn-secondary', 'btn-danger', 'btn-warning');
142
152
  const confirmClass = options.confirmClass || 'btn-danger';
@@ -145,19 +155,28 @@ class ConfirmDialog {
145
155
 
146
156
  // Set secondary button (optional 3rd button)
147
157
  const secondaryBtn = this.modal.querySelector('#confirm-dialog-secondary-btn');
158
+ const container = this.modal.querySelector('.confirm-dialog-container');
148
159
  if (secondaryBtn) {
149
160
  if (options.secondaryText) {
150
- secondaryBtn.textContent = options.secondaryText;
161
+ setBtnContent(secondaryBtn, options.secondaryText, options.secondaryDesc);
151
162
  secondaryBtn.style.display = '';
152
163
  // Remove previous style classes and add new one
153
164
  secondaryBtn.classList.remove('btn-primary', 'btn-secondary', 'btn-danger', 'btn-warning');
154
165
  const secondaryClass = options.secondaryClass || 'btn-secondary';
155
166
  secondaryBtn.classList.add(secondaryClass);
167
+ if (container) container.classList.add('has-secondary');
156
168
  } else {
157
169
  secondaryBtn.style.display = 'none';
170
+ if (container) container.classList.remove('has-secondary');
158
171
  }
159
172
  }
160
173
 
174
+ // Set cancel button text (optional)
175
+ const cancelBtn = this.modal.querySelector('.modal-footer [data-action="cancel"]');
176
+ if (cancelBtn) {
177
+ setBtnContent(cancelBtn, options.cancelText || 'Cancel', options.cancelDesc);
178
+ }
179
+
161
180
  // Store callbacks with promise resolution
162
181
  this.onConfirm = () => {
163
182
  if (options.onConfirm) {