@in-the-loop-labs/pair-review 2.3.2 → 2.4.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 (47) hide show
  1. package/.pi/skills/review-model-guidance/SKILL.md +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/README.md +15 -1
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/skills/review-requests/SKILL.md +1 -1
  7. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  8. package/public/css/pr.css +296 -15
  9. package/public/index.html +121 -57
  10. package/public/js/components/AIPanel.js +2 -1
  11. package/public/js/components/AdvancedConfigTab.js +2 -2
  12. package/public/js/components/AnalysisConfigModal.js +2 -2
  13. package/public/js/components/ChatPanel.js +187 -28
  14. package/public/js/components/CouncilProgressModal.js +4 -7
  15. package/public/js/components/SplitButton.js +66 -1
  16. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  17. package/public/js/index.js +274 -21
  18. package/public/js/modules/comment-manager.js +16 -12
  19. package/public/js/modules/file-comment-manager.js +8 -6
  20. package/public/js/pr.js +194 -5
  21. package/public/local.html +8 -1
  22. package/public/pr.html +17 -2
  23. package/src/ai/codex-provider.js +14 -2
  24. package/src/ai/copilot-provider.js +1 -10
  25. package/src/ai/cursor-agent-provider.js +1 -10
  26. package/src/ai/gemini-provider.js +8 -17
  27. package/src/chat/acp-bridge.js +442 -0
  28. package/src/chat/api-reference.js +539 -0
  29. package/src/chat/chat-providers.js +290 -0
  30. package/src/chat/claude-code-bridge.js +499 -0
  31. package/src/chat/codex-bridge.js +601 -0
  32. package/src/chat/pi-bridge.js +56 -3
  33. package/src/chat/prompt-builder.js +12 -11
  34. package/src/chat/session-manager.js +110 -29
  35. package/src/config.js +4 -2
  36. package/src/database.js +50 -2
  37. package/src/github/client.js +43 -0
  38. package/src/routes/chat.js +60 -27
  39. package/src/routes/config.js +24 -1
  40. package/src/routes/github-collections.js +126 -0
  41. package/src/routes/mcp.js +2 -1
  42. package/src/routes/pr.js +166 -2
  43. package/src/routes/reviews.js +2 -1
  44. package/src/routes/shared.js +70 -49
  45. package/src/server.js +27 -1
  46. package/src/utils/safe-parse-json.js +19 -0
  47. package/.pi/skills/pair-review-api/SKILL.md +0 -448
package/public/index.html CHANGED
@@ -542,55 +542,6 @@
542
542
  to { transform: rotate(360deg); }
543
543
  }
544
544
 
545
- /* Usage Info (shown when no reviews) */
546
- .usage-info {
547
- background-color: var(--color-bg-primary);
548
- border: 1px solid var(--color-border-primary);
549
- border-radius: var(--radius-lg);
550
- padding: 24px;
551
- text-align: left;
552
- max-width: 600px;
553
- width: 100%;
554
- margin-bottom: 32px;
555
- }
556
-
557
- .usage-info h3 {
558
- font-size: 16px;
559
- font-weight: 600;
560
- margin-bottom: 16px;
561
- color: var(--color-text-primary);
562
- }
563
-
564
- .usage-info p {
565
- color: var(--color-text-secondary);
566
- margin-bottom: 12px;
567
- }
568
-
569
- .usage-info code {
570
- background-color: var(--color-bg-secondary);
571
- padding: 2px 6px;
572
- border-radius: var(--radius-sm);
573
- font-family: var(--font-mono);
574
- font-size: 13px;
575
- }
576
-
577
- .usage-info pre {
578
- background-color: var(--color-bg-tertiary);
579
- color: var(--color-text-primary);
580
- padding: 16px;
581
- border-radius: var(--radius-md);
582
- margin: 12px 0;
583
- overflow-x: auto;
584
- font-family: var(--font-mono);
585
- font-size: 13px;
586
- line-height: 1.5;
587
- }
588
-
589
- .usage-info pre code {
590
- background: none;
591
- padding: 0;
592
- }
593
-
594
545
  /* Recent Reviews Section */
595
546
  .recent-reviews-section {
596
547
  width: 100%;
@@ -754,6 +705,28 @@
754
705
  color: var(--color-text-primary);
755
706
  }
756
707
 
708
+ .btn-github-link {
709
+ display: inline-flex;
710
+ align-items: center;
711
+ justify-content: center;
712
+ width: 32px;
713
+ height: 32px;
714
+ padding: 0;
715
+ background: transparent;
716
+ border: 1px solid transparent;
717
+ border-radius: var(--radius-md);
718
+ color: var(--color-text-tertiary);
719
+ cursor: pointer;
720
+ transition: all var(--transition-fast);
721
+ text-decoration: none;
722
+ }
723
+
724
+ .btn-github-link:hover {
725
+ background-color: var(--color-bg-secondary);
726
+ border-color: var(--color-border-primary);
727
+ color: var(--color-text-primary);
728
+ }
729
+
757
730
  .recent-reviews-empty {
758
731
  text-align: center;
759
732
  padding: 40px 20px;
@@ -870,6 +843,76 @@
870
843
  display: block;
871
844
  }
872
845
 
846
+ .tab-divider {
847
+ width: 1px;
848
+ height: 16px;
849
+ background: var(--color-border-secondary);
850
+ align-self: center;
851
+ margin: 0 4px;
852
+ }
853
+
854
+ .tab-pane-header {
855
+ display: flex;
856
+ justify-content: flex-end;
857
+ align-items: center;
858
+ gap: 8px;
859
+ margin-bottom: 12px;
860
+ }
861
+
862
+ .fetched-at-label {
863
+ font-size: 12px;
864
+ color: var(--color-fg-muted);
865
+ }
866
+
867
+ .btn-refresh {
868
+ display: inline-flex;
869
+ align-items: center;
870
+ justify-content: center;
871
+ width: 32px;
872
+ height: 32px;
873
+ padding: 0;
874
+ background: transparent;
875
+ border: 1px solid var(--color-border-primary);
876
+ border-radius: var(--radius-md);
877
+ color: var(--color-text-tertiary);
878
+ cursor: pointer;
879
+ transition: all var(--transition-fast);
880
+ }
881
+
882
+ .btn-refresh:hover {
883
+ background: var(--color-bg-secondary);
884
+ color: var(--color-text-primary);
885
+ }
886
+
887
+ .btn-refresh.refreshing {
888
+ pointer-events: none;
889
+ opacity: 0.6;
890
+ }
891
+
892
+ .btn-refresh.refreshing svg {
893
+ animation: spin 0.8s linear infinite;
894
+ }
895
+
896
+ .collection-pr-row {
897
+ cursor: pointer;
898
+ }
899
+
900
+ .collection-pr-row:hover {
901
+ background: var(--color-bg-secondary);
902
+ }
903
+
904
+ .collection-pr-number {
905
+ color: var(--ai-primary);
906
+ font-weight: 500;
907
+ font-family: var(--font-mono);
908
+ cursor: pointer;
909
+ }
910
+
911
+ .collection-pr-number:hover {
912
+ color: var(--ai-secondary);
913
+ text-decoration: underline;
914
+ }
915
+
873
916
  /* Local Reviews Table - tighter spacing */
874
917
  .recent-reviews-table.local-table th,
875
918
  .recent-reviews-table.local-table td {
@@ -1021,10 +1064,6 @@
1021
1064
  width: 100%;
1022
1065
  }
1023
1066
 
1024
- .usage-info {
1025
- padding: 20px;
1026
- }
1027
-
1028
1067
  .recent-reviews-table {
1029
1068
  font-size: 13px;
1030
1069
  }
@@ -1073,6 +1112,9 @@
1073
1112
  <div class="section-header" id="recent-reviews-header">
1074
1113
  <div class="tab-bar" id="unified-tab-bar">
1075
1114
  <button class="tab-btn active" data-tab="pr-tab" type="button">Pull Requests</button>
1115
+ <button class="tab-btn" data-tab="review-requests-tab" type="button">My Review Requests</button>
1116
+ <button class="tab-btn" data-tab="my-prs-tab" type="button">My PRs</button>
1117
+ <span class="tab-divider"></span>
1076
1118
  <button class="tab-btn" data-tab="local-tab" type="button">Local Reviews</button>
1077
1119
  </div>
1078
1120
  </div>
@@ -1104,6 +1146,32 @@
1104
1146
  </div>
1105
1147
  </div>
1106
1148
 
1149
+ <!-- My Review Requests Tab -->
1150
+ <div class="tab-pane" id="review-requests-tab">
1151
+ <div class="tab-pane-header">
1152
+ <span class="fetched-at-label" id="review-requests-fetched-at"></span>
1153
+ <button class="btn-refresh" id="refresh-review-requests" title="Refresh from GitHub">
1154
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
1155
+ </button>
1156
+ </div>
1157
+ <div id="review-requests-container" class="recent-reviews-loading">
1158
+ Loading...
1159
+ </div>
1160
+ </div>
1161
+
1162
+ <!-- My PRs Tab -->
1163
+ <div class="tab-pane" id="my-prs-tab">
1164
+ <div class="tab-pane-header">
1165
+ <span class="fetched-at-label" id="my-prs-fetched-at"></span>
1166
+ <button class="btn-refresh" id="refresh-my-prs" title="Refresh from GitHub">
1167
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
1168
+ </button>
1169
+ </div>
1170
+ <div id="my-prs-container" class="recent-reviews-loading">
1171
+ Loading...
1172
+ </div>
1173
+ </div>
1174
+
1107
1175
  <!-- Local Reviews Tab: Input + Listing -->
1108
1176
  <div class="tab-pane" id="local-tab">
1109
1177
  <div class="start-review-section">
@@ -1134,10 +1202,6 @@
1134
1202
  </div>
1135
1203
  </div>
1136
1204
  </div>
1137
-
1138
- <!-- Usage Info (shown when no reviews, hidden initially during loading) -->
1139
- <!-- Content is populated from help modal via JS to avoid duplication -->
1140
- <div class="usage-info loading-hidden" id="usage-info"></div>
1141
1205
  </div>
1142
1206
  </main>
1143
1207
  </div>
@@ -1432,7 +1432,8 @@ class AIPanel {
1432
1432
  */
1433
1433
  clearAllFindings() {
1434
1434
  this.findings = [];
1435
- this.comments = [];
1435
+ // NOTE: Do NOT clear this.comments here. User comments are independent
1436
+ // of AI analysis and must persist across analysis runs.
1436
1437
  this.currentIndex = -1; // Reset navigation
1437
1438
  this.updateSegmentCounts();
1438
1439
  this.renderFindings();
@@ -708,7 +708,7 @@ class AdvancedConfigTab {
708
708
  const providerIds = Object.keys(this.providers).filter(id => {
709
709
  const p = this.providers[id];
710
710
  return !p.availability || p.availability.available;
711
- });
711
+ }).sort((a, b) => (this.providers[a].name || a).localeCompare(this.providers[b].name || b));
712
712
 
713
713
  for (const id of providerIds) {
714
714
  const opt = document.createElement('option');
@@ -737,7 +737,7 @@ class AdvancedConfigTab {
737
737
  if (!modelSelect) return;
738
738
 
739
739
  const currentModel = modelSelect.value;
740
- const models = provider.models || [];
740
+ const models = [...(provider.models || [])].sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
741
741
  modelSelect.innerHTML = '';
742
742
  for (const model of models) {
743
743
  const opt = document.createElement('option');
@@ -452,12 +452,12 @@ class AnalysisConfigModal {
452
452
  const container = this.modal.querySelector('#provider-toggle-container');
453
453
  if (!container) return;
454
454
 
455
- // Filter to only show available providers
455
+ // Filter to only show available providers, sorted alphabetically by name
456
456
  const availableProviderIds = Object.keys(this.providers).filter(providerId => {
457
457
  const provider = this.providers[providerId];
458
458
  // Show provider if no availability info (check pending) or if explicitly available
459
459
  return !provider.availability || provider.availability.available;
460
- });
460
+ }).sort((a, b) => (this.providers[a].name || a).localeCompare(this.providers[b].name || b));
461
461
 
462
462
  // If selected provider is no longer available, select first available
463
463
  if (availableProviderIds.length > 0 && !availableProviderIds.includes(this.selectedProvider)) {
@@ -32,10 +32,13 @@ class ChatPanel {
32
32
  this._analysisContextRemoved = false;
33
33
  this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
34
  this._openPromise = null; // concurrency guard for open()
35
+ this._activeProvider = window.__pairReview?.chatProvider || 'pi';
36
+ this._chatProviders = window.__pairReview?.chatProviders || [];
35
37
 
36
38
  this._render();
37
39
  this._bindEvents();
38
40
  this._initContextTooltip();
41
+ this._updateTitle();
39
42
  }
40
43
 
41
44
  /**
@@ -48,20 +51,27 @@ class ChatPanel {
48
51
  <div id="chat-panel" class="chat-panel chat-panel--closed">
49
52
  <div class="chat-panel__resize-handle" title="Drag to resize"></div>
50
53
  <div class="chat-panel__header">
51
- <div class="chat-panel__session-picker">
52
- <button class="chat-panel__session-picker-btn" title="Switch conversation">
54
+ <div class="chat-panel__provider-picker">
55
+ <button class="chat-panel__provider-picker-btn" title="Switch provider">
53
56
  <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
54
57
  <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"/>
55
58
  </svg>
56
59
  <span class="chat-panel__title-text">Chat &middot; Pi</span>
57
- <span class="chat-panel__chevron-sep">&middot;</span>
58
- <svg class="chat-panel__chevron" viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
59
- <path d="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>
60
+ <svg class="chat-panel__provider-chevron" viewBox="0 0 16 16" fill="currentColor" width="10" height="10">
61
+ <path d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>
60
62
  </svg>
61
63
  </button>
64
+ <div class="chat-panel__provider-dropdown" style="display: none;"></div>
65
+ </div>
66
+ <div class="chat-panel__session-picker">
62
67
  <div class="chat-panel__session-dropdown" style="display: none;"></div>
63
68
  </div>
64
69
  <div class="chat-panel__actions">
70
+ <button class="chat-panel__history-btn" title="Session history">
71
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
72
+ <path d="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>
73
+ </svg>
74
+ </button>
65
75
  <button class="chat-panel__new-btn" title="New conversation">
66
76
  <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
67
77
  <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>
@@ -152,9 +162,12 @@ class ChatPanel {
152
162
  this.dismissCommentBtn = this.container.querySelector('.chat-panel__action-btn--dismiss-comment');
153
163
  this.createCommentBtn = this.container.querySelector('.chat-panel__action-btn--create-comment');
154
164
  this.actionBarDismissBtn = this.container.querySelector('.chat-panel__action-bar-dismiss');
165
+ this.providerPickerEl = this.container.querySelector('.chat-panel__provider-picker');
166
+ this.providerPickerBtn = this.container.querySelector('.chat-panel__provider-picker-btn');
167
+ this.providerDropdown = this.container.querySelector('.chat-panel__provider-dropdown');
155
168
  this.sessionPickerEl = this.container.querySelector('.chat-panel__session-picker');
156
- this.sessionPickerBtn = this.container.querySelector('.chat-panel__session-picker-btn');
157
169
  this.sessionDropdown = this.container.querySelector('.chat-panel__session-dropdown');
170
+ this.historyBtn = this.container.querySelector('.chat-panel__history-btn');
158
171
  this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
159
172
  this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
160
173
  }
@@ -171,8 +184,11 @@ class ChatPanel {
171
184
  // New conversation button
172
185
  this.newBtn.addEventListener('click', () => this._startNewConversation());
173
186
 
174
- // Session picker button
175
- this.sessionPickerBtn.addEventListener('click', () => this._toggleSessionDropdown());
187
+ // Provider picker button
188
+ this.providerPickerBtn.addEventListener('click', () => this._toggleProviderDropdown());
189
+
190
+ // Session history button
191
+ this.historyBtn.addEventListener('click', () => this._toggleSessionDropdown());
176
192
 
177
193
  // Send button
178
194
  this.sendBtn.addEventListener('click', () => this.sendMessage());
@@ -232,7 +248,9 @@ class ChatPanel {
232
248
  // Escape: close dropdown if open, stop agent if streaming, blur textarea if focused, otherwise close panel
233
249
  this._onKeydown = (e) => {
234
250
  if (e.key === 'Escape' && this.isOpen) {
235
- if (this._isSessionDropdownOpen()) {
251
+ if (this._isProviderDropdownOpen()) {
252
+ this._hideProviderDropdown();
253
+ } else if (this._isSessionDropdownOpen()) {
236
254
  this._hideSessionDropdown();
237
255
  } else if (this.isStreaming) {
238
256
  this._stopAgent();
@@ -254,6 +272,13 @@ class ChatPanel {
254
272
  }
255
273
  });
256
274
 
275
+ // Re-read chat providers once the <head> config fetch resolves
276
+ this._onChatStateChanged = () => {
277
+ this._chatProviders = window.__pairReview?.chatProviders || [];
278
+ this._updateTitle();
279
+ };
280
+ window.addEventListener('chat-state-changed', this._onChatStateChanged);
281
+
257
282
  this._bindResizeEvents();
258
283
  }
259
284
 
@@ -356,19 +381,32 @@ class ChatPanel {
356
381
 
357
382
  /**
358
383
  * Update the chat panel title with provider and model info.
359
- * @param {string} [provider='Pi'] - Provider display name
384
+ * @param {string} [providerId] - Provider ID (looked up in _chatProviders for display name)
360
385
  * @param {string} [model] - Model ID or display name (e.g. 'default', 'multi-model')
361
386
  */
362
- _updateTitle(provider = 'Pi', model) {
387
+ _updateTitle(providerId, model) {
363
388
  if (!this.titleTextEl) return;
389
+ const providerName = this._getProviderDisplayName(providerId || this._activeProvider);
364
390
  const modelDisplay = model
365
391
  ? model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
366
392
  : null;
367
- const parts = ['Chat', provider];
393
+ const parts = ['Chat', providerName];
368
394
  if (modelDisplay) parts.push(modelDisplay);
369
395
  this.titleTextEl.textContent = parts.join(' \u00b7 ');
370
396
  }
371
397
 
398
+ /**
399
+ * Get display name for a provider ID from the _chatProviders array.
400
+ * Falls back to capitalized provider ID if not found.
401
+ * @param {string} providerId
402
+ * @returns {string}
403
+ */
404
+ _getProviderDisplayName(providerId) {
405
+ const entry = this._chatProviders.find(p => p.id === providerId);
406
+ if (entry) return entry.name;
407
+ return providerId.charAt(0).toUpperCase() + providerId.slice(1);
408
+ }
409
+
372
410
  /**
373
411
  * Open the chat panel
374
412
  * @param {Object} options - Optional context
@@ -493,6 +531,7 @@ class ChatPanel {
493
531
  * Close the chat panel
494
532
  */
495
533
  close() {
534
+ this._hideProviderDropdown();
496
535
  this._hideSessionDropdown();
497
536
  // Reset UI streaming state (buttons) but keep isStreaming and _streamingContent
498
537
  // intact so the background WebSocket handler can continue accumulating events.
@@ -533,6 +572,7 @@ class ChatPanel {
533
572
  * Preserves any unsent pending context cards and re-adds them to the new conversation.
534
573
  */
535
574
  async _startNewConversation() {
575
+ this._hideProviderDropdown();
536
576
  this._hideSessionDropdown();
537
577
  // 1. Snapshot pending context before clearing (these are unsent context cards)
538
578
  const savedContext = this._pendingContext.slice();
@@ -630,8 +670,8 @@ class ChatPanel {
630
670
  console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
631
671
 
632
672
  if (mru.provider) {
633
- const providerName = mru.provider.charAt(0).toUpperCase() + mru.provider.slice(1);
634
- this._updateTitle(providerName, mru.model);
673
+ this._updateTitle(mru.provider, mru.model);
674
+ this._activeProvider = mru.provider;
635
675
  }
636
676
 
637
677
  if (mru.message_count > 0) {
@@ -688,6 +728,108 @@ class ChatPanel {
688
728
  }
689
729
  }
690
730
 
731
+ // ── Provider picker dropdown ──────────────────────────────────────────
732
+
733
+ _isProviderDropdownOpen() {
734
+ return this.providerDropdown && this.providerDropdown.style.display !== 'none';
735
+ }
736
+
737
+ _toggleProviderDropdown() {
738
+ if (this._isProviderDropdownOpen()) {
739
+ this._hideProviderDropdown();
740
+ } else {
741
+ this._showProviderDropdown();
742
+ }
743
+ }
744
+
745
+ _showProviderDropdown() {
746
+ if (!this.providerDropdown) return;
747
+ // Close session dropdown if open
748
+ this._hideSessionDropdown();
749
+
750
+ this._renderProviderDropdown();
751
+ this.providerDropdown.style.display = '';
752
+ this.providerPickerBtn.classList.add('chat-panel__provider-picker-btn--open');
753
+
754
+ // Bind outside-click-to-close (one-shot)
755
+ this._providerOutsideClickHandler = (e) => {
756
+ if (!this.providerPickerEl.contains(e.target)) {
757
+ this._hideProviderDropdown();
758
+ }
759
+ };
760
+ setTimeout(() => {
761
+ document.addEventListener('click', this._providerOutsideClickHandler);
762
+ }, 0);
763
+ }
764
+
765
+ _hideProviderDropdown() {
766
+ if (!this.providerDropdown) return;
767
+ this.providerDropdown.style.display = 'none';
768
+ this.providerPickerBtn.classList.remove('chat-panel__provider-picker-btn--open');
769
+ if (this._providerOutsideClickHandler) {
770
+ document.removeEventListener('click', this._providerOutsideClickHandler);
771
+ this._providerOutsideClickHandler = null;
772
+ }
773
+ }
774
+
775
+ _renderProviderDropdown() {
776
+ if (!this.providerDropdown) return;
777
+ const providers = [...this._chatProviders].sort((a, b) =>
778
+ a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
779
+ );
780
+
781
+ if (providers.length === 0) {
782
+ this.providerDropdown.innerHTML = `
783
+ <div class="chat-panel__provider-empty">No providers configured</div>
784
+ `;
785
+ return;
786
+ }
787
+
788
+ const items = providers.map(p => {
789
+ const isActive = p.id === this._activeProvider;
790
+ const isUnavailable = !p.available;
791
+ const classes = ['chat-panel__provider-item'];
792
+ if (isActive) classes.push('chat-panel__provider-item--active');
793
+ if (isUnavailable) classes.push('chat-panel__provider-item--unavailable');
794
+
795
+ const checkmark = isActive
796
+ ? `<svg class="chat-panel__provider-check" viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
797
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
798
+ </svg>`
799
+ : '';
800
+
801
+ return `
802
+ <button class="${classes.join(' ')}"
803
+ data-provider-id="${this._escapeHtml(p.id)}"
804
+ ${isUnavailable ? 'disabled' : ''}>
805
+ <span class="chat-panel__provider-name">${this._escapeHtml(p.name)}</span>
806
+ ${checkmark}
807
+ </button>
808
+ `;
809
+ }).join('');
810
+
811
+ this.providerDropdown.innerHTML = items;
812
+
813
+ // Bind click handlers
814
+ this.providerDropdown.querySelectorAll('.chat-panel__provider-item:not([disabled])').forEach(btn => {
815
+ btn.addEventListener('click', () => {
816
+ this._selectProvider(btn.dataset.providerId);
817
+ this._hideProviderDropdown();
818
+ });
819
+ });
820
+ }
821
+
822
+ /**
823
+ * Select a provider. Updates active provider and title. Only affects new sessions.
824
+ * @param {string} id - Provider ID to activate
825
+ */
826
+ _selectProvider(id) {
827
+ if (id === this._activeProvider) return;
828
+ this._activeProvider = id;
829
+ this._updateTitle();
830
+ this._startNewConversation();
831
+ }
832
+
691
833
  // ── Session picker dropdown ────────────────────────────────────────────
692
834
 
693
835
  _isSessionDropdownOpen() {
@@ -704,31 +846,43 @@ class ChatPanel {
704
846
 
705
847
  async _showSessionDropdown() {
706
848
  if (!this.sessionDropdown) return;
849
+ // Close provider dropdown if open
850
+ this._hideProviderDropdown();
707
851
 
708
852
  const sessions = await this._fetchSessions();
709
853
  this._renderSessionDropdown(sessions);
710
854
  this.sessionDropdown.style.display = '';
711
- this.sessionPickerBtn.classList.add('chat-panel__session-picker-btn--open');
855
+ this.historyBtn.classList.add('chat-panel__history-btn--open');
856
+
857
+ // Position the fixed dropdown relative to the history button
858
+ this._positionSessionDropdown();
712
859
 
713
860
  // Bind outside-click-to-close (one-shot)
714
- this._outsideClickHandler = (e) => {
715
- if (!this.sessionPickerEl.contains(e.target)) {
861
+ this._sessionOutsideClickHandler = (e) => {
862
+ if (!this.sessionPickerEl.contains(e.target) && !this.historyBtn.contains(e.target)) {
716
863
  this._hideSessionDropdown();
717
864
  }
718
865
  };
719
866
  // Use setTimeout so the current click event doesn't immediately trigger close
720
867
  setTimeout(() => {
721
- document.addEventListener('click', this._outsideClickHandler);
868
+ document.addEventListener('click', this._sessionOutsideClickHandler);
722
869
  }, 0);
723
870
  }
724
871
 
872
+ _positionSessionDropdown() {
873
+ if (!this.sessionDropdown || !this.historyBtn) return;
874
+ const rect = this.historyBtn.getBoundingClientRect();
875
+ this.sessionDropdown.style.top = `${rect.bottom + 4}px`;
876
+ this.sessionDropdown.style.right = `${window.innerWidth - rect.right}px`;
877
+ }
878
+
725
879
  _hideSessionDropdown() {
726
880
  if (!this.sessionDropdown) return;
727
881
  this.sessionDropdown.style.display = 'none';
728
- this.sessionPickerBtn.classList.remove('chat-panel__session-picker-btn--open');
729
- if (this._outsideClickHandler) {
730
- document.removeEventListener('click', this._outsideClickHandler);
731
- this._outsideClickHandler = null;
882
+ this.historyBtn.classList.remove('chat-panel__history-btn--open');
883
+ if (this._sessionOutsideClickHandler) {
884
+ document.removeEventListener('click', this._sessionOutsideClickHandler);
885
+ this._sessionOutsideClickHandler = null;
732
886
  }
733
887
  }
734
888
 
@@ -748,12 +902,15 @@ class ChatPanel {
748
902
  ? this._truncate(s.first_message, 60)
749
903
  : 'New conversation';
750
904
  const timeAgo = this._formatRelativeTime(s.updated_at);
905
+ const providerLabel = s.provider
906
+ ? `<span class="chat-panel__session-provider">${this._escapeHtml(this._getProviderDisplayName(s.provider))}</span>`
907
+ : '';
751
908
 
752
909
  return `
753
910
  <button class="chat-panel__session-item${isActive ? ' chat-panel__session-item--active' : ''}"
754
911
  data-session-id="${s.id}">
755
912
  <span class="chat-panel__session-preview">${this._escapeHtml(preview)}</span>
756
- <span class="chat-panel__session-meta">${this._escapeHtml(timeAgo)}</span>
913
+ <span class="chat-panel__session-meta">${providerLabel}${this._escapeHtml(timeAgo)}</span>
757
914
  </button>
758
915
  `;
759
916
  }).join('');
@@ -805,8 +962,8 @@ class ChatPanel {
805
962
 
806
963
  // 4. Update title
807
964
  if (sessionData.provider) {
808
- const providerName = sessionData.provider.charAt(0).toUpperCase() + sessionData.provider.slice(1);
809
- this._updateTitle(providerName, sessionData.model);
965
+ this._updateTitle(sessionData.provider, sessionData.model);
966
+ this._activeProvider = sessionData.provider;
810
967
  } else {
811
968
  this._updateTitle();
812
969
  }
@@ -926,7 +1083,7 @@ class ChatPanel {
926
1083
 
927
1084
  try {
928
1085
  const body = {
929
- provider: 'pi',
1086
+ provider: this._activeProvider,
930
1087
  reviewId: this.reviewId
931
1088
  };
932
1089
  if (contextCommentId) {
@@ -2286,10 +2443,11 @@ class ChatPanel {
2286
2443
  * @param {Object} [toolInput] - Tool input/arguments (optional)
2287
2444
  */
2288
2445
  _showToolUse(toolName, status, toolInput) {
2446
+ if (!toolName) return;
2289
2447
  const streamingMsg = document.getElementById('chat-streaming-msg');
2290
2448
  if (!streamingMsg) return;
2291
2449
 
2292
- const isTask = toolName.toLowerCase() === 'task';
2450
+ const isTask = toolName.toLowerCase() === 'task' || toolName.toLowerCase() === 'agent';
2293
2451
 
2294
2452
  if (status === 'start') {
2295
2453
  this._hideThinkingIndicator();
@@ -2326,7 +2484,7 @@ class ChatPanel {
2326
2484
  } else {
2327
2485
  if (isTask) {
2328
2486
  // Remove spinner from completed Task badge
2329
- const badges = streamingMsg.querySelectorAll('.chat-panel__tool-badge[data-tool="Task"]:not(.chat-panel__tool-badge--transient)');
2487
+ const badges = streamingMsg.querySelectorAll('.chat-panel__tool-badge[data-tool="Task"]:not(.chat-panel__tool-badge--transient), .chat-panel__tool-badge[data-tool="Agent"]:not(.chat-panel__tool-badge--transient)');
2330
2488
  badges.forEach(b => {
2331
2489
  const spinner = b.querySelector('.chat-panel__tool-spinner');
2332
2490
  if (spinner) spinner.remove();
@@ -2991,6 +3149,7 @@ class ChatPanel {
2991
3149
  */
2992
3150
  destroy() {
2993
3151
  document.removeEventListener('keydown', this._onKeydown);
3152
+ window.removeEventListener('chat-state-changed', this._onChatStateChanged);
2994
3153
  this._closeSubscriptions();
2995
3154
  this.messages = [];
2996
3155