@in-the-loop-labs/pair-review 1.6.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
|
@@ -0,0 +1,2955 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* ChatPanel - AI chat sidebar component
|
|
4
|
+
* Provides a sliding chat panel for conversing with AI about the current review.
|
|
5
|
+
* Works in both PR mode and Local mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DISMISS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>`;
|
|
9
|
+
|
|
10
|
+
/** Pixel threshold for considering the user "near the bottom" of the messages container. */
|
|
11
|
+
const NEAR_BOTTOM_THRESHOLD = 80;
|
|
12
|
+
|
|
13
|
+
class ChatPanel {
|
|
14
|
+
constructor(containerId) {
|
|
15
|
+
this.containerId = containerId;
|
|
16
|
+
this.container = document.getElementById(containerId);
|
|
17
|
+
this.currentSessionId = null;
|
|
18
|
+
this.reviewId = null;
|
|
19
|
+
this.isOpen = false;
|
|
20
|
+
this.isStreaming = false;
|
|
21
|
+
this.eventSource = null;
|
|
22
|
+
this._sseReconnectTimer = null;
|
|
23
|
+
this.messages = [];
|
|
24
|
+
this._streamingContent = '';
|
|
25
|
+
this._pendingContext = [];
|
|
26
|
+
this._pendingContextData = [];
|
|
27
|
+
this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
|
|
28
|
+
this._contextItemId = null; // suggestion ID or comment ID from context
|
|
29
|
+
this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
|
|
30
|
+
this._pendingActionContext = null; // { type, itemId } — set by action button handlers, consumed by sendMessage
|
|
31
|
+
this._resizeConfig = { min: 300, default: 400, storageKey: 'chat-panel-width' };
|
|
32
|
+
this._analysisContextRemoved = false;
|
|
33
|
+
this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
|
|
34
|
+
this._openPromise = null; // concurrency guard for open()
|
|
35
|
+
|
|
36
|
+
this._render();
|
|
37
|
+
this._bindEvents();
|
|
38
|
+
this._initContextTooltip();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render the chat panel DOM structure into the container
|
|
43
|
+
*/
|
|
44
|
+
_render() {
|
|
45
|
+
if (!this.container) return;
|
|
46
|
+
|
|
47
|
+
this.container.innerHTML = `
|
|
48
|
+
<div id="chat-panel" class="chat-panel chat-panel--closed">
|
|
49
|
+
<div class="chat-panel__resize-handle" title="Drag to resize"></div>
|
|
50
|
+
<div class="chat-panel__header">
|
|
51
|
+
<div class="chat-panel__session-picker">
|
|
52
|
+
<button class="chat-panel__session-picker-btn" title="Switch conversation">
|
|
53
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
54
|
+
<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
|
+
</svg>
|
|
56
|
+
<span class="chat-panel__title-text">Chat · Pi</span>
|
|
57
|
+
<span class="chat-panel__chevron-sep">·</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>
|
|
61
|
+
</button>
|
|
62
|
+
<div class="chat-panel__session-dropdown" style="display: none;"></div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="chat-panel__actions">
|
|
65
|
+
<button class="chat-panel__new-btn" title="New conversation">
|
|
66
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
67
|
+
<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"/>
|
|
68
|
+
</svg>
|
|
69
|
+
</button>
|
|
70
|
+
<button class="chat-panel__close-btn" title="Close">
|
|
71
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
72
|
+
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>
|
|
73
|
+
</svg>
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="chat-panel__messages-wrapper">
|
|
78
|
+
<div class="chat-panel__messages" id="chat-messages">
|
|
79
|
+
<div class="chat-panel__empty">
|
|
80
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
|
|
81
|
+
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
|
|
82
|
+
</svg>
|
|
83
|
+
<p>Ask questions about this review, or the changes</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<button class="chat-panel__new-content-pill" style="display:none">\u2193 New content</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="chat-panel__action-bar" style="display: none;">
|
|
89
|
+
<button class="chat-panel__action-btn chat-panel__action-btn--adopt" style="display: none;" title="Adopt this suggestion with edits based on the conversation">
|
|
90
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
91
|
+
<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"/>
|
|
92
|
+
</svg>
|
|
93
|
+
Adopt with AI edits
|
|
94
|
+
</button>
|
|
95
|
+
<button class="chat-panel__action-btn chat-panel__action-btn--update" style="display: none;" title="Update the comment based on the conversation">
|
|
96
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
97
|
+
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>
|
|
98
|
+
</svg>
|
|
99
|
+
Update comment
|
|
100
|
+
</button>
|
|
101
|
+
<button class="chat-panel__action-btn chat-panel__action-btn--dismiss-suggestion" style="display: none;" title="Dismiss this AI suggestion">
|
|
102
|
+
${DISMISS_ICON}
|
|
103
|
+
Dismiss suggestion
|
|
104
|
+
</button>
|
|
105
|
+
<button class="chat-panel__action-btn chat-panel__action-btn--dismiss-comment" style="display: none;" title="Dismiss this comment">
|
|
106
|
+
${DISMISS_ICON}
|
|
107
|
+
Dismiss comment
|
|
108
|
+
</button>
|
|
109
|
+
<button class="chat-panel__action-btn chat-panel__action-btn--create-comment" style="display: none;" title="Create a review comment for this line">
|
|
110
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
111
|
+
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
|
|
112
|
+
</svg>
|
|
113
|
+
Create comment
|
|
114
|
+
</button>
|
|
115
|
+
<button class="chat-panel__action-bar-dismiss" title="Dismiss shortcuts">
|
|
116
|
+
${DISMISS_ICON}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="chat-panel__input-area">
|
|
120
|
+
<textarea class="chat-panel__input" placeholder="Ask about this review..." rows="1"></textarea>
|
|
121
|
+
<div class="chat-panel__input-footer">
|
|
122
|
+
<span class="chat-panel__input-hint">${typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+Enter to send</span>
|
|
123
|
+
<div class="chat-panel__input-actions">
|
|
124
|
+
<button class="chat-panel__send-btn" title="Send" disabled>
|
|
125
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
126
|
+
<path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-4.776L2.296 7.25h5.954a.75.75 0 0 1 0 1.5H2.296l-.704 4.026L13.788 8Z"/>
|
|
127
|
+
</svg>
|
|
128
|
+
</button>
|
|
129
|
+
<button class="chat-panel__stop-btn" title="Stop" style="display: none;">
|
|
130
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
131
|
+
<path d="M4.5 2A2.5 2.5 0 0 0 2 4.5v7A2.5 2.5 0 0 0 4.5 14h7a2.5 2.5 0 0 0 2.5-2.5v-7A2.5 2.5 0 0 0 11.5 2h-7Z"/>
|
|
132
|
+
</svg>
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
// Cache element references
|
|
141
|
+
this.panel = this.container.querySelector('#chat-panel');
|
|
142
|
+
this.messagesEl = this.container.querySelector('#chat-messages');
|
|
143
|
+
this.inputEl = this.container.querySelector('.chat-panel__input');
|
|
144
|
+
this.sendBtn = this.container.querySelector('.chat-panel__send-btn');
|
|
145
|
+
this.stopBtn = this.container.querySelector('.chat-panel__stop-btn');
|
|
146
|
+
this.closeBtn = this.container.querySelector('.chat-panel__close-btn');
|
|
147
|
+
this.newBtn = this.container.querySelector('.chat-panel__new-btn');
|
|
148
|
+
this.actionBar = this.container.querySelector('.chat-panel__action-bar');
|
|
149
|
+
this.adoptBtn = this.container.querySelector('.chat-panel__action-btn--adopt');
|
|
150
|
+
this.updateBtn = this.container.querySelector('.chat-panel__action-btn--update');
|
|
151
|
+
this.dismissSuggestionBtn = this.container.querySelector('.chat-panel__action-btn--dismiss-suggestion');
|
|
152
|
+
this.dismissCommentBtn = this.container.querySelector('.chat-panel__action-btn--dismiss-comment');
|
|
153
|
+
this.createCommentBtn = this.container.querySelector('.chat-panel__action-btn--create-comment');
|
|
154
|
+
this.actionBarDismissBtn = this.container.querySelector('.chat-panel__action-bar-dismiss');
|
|
155
|
+
this.sessionPickerEl = this.container.querySelector('.chat-panel__session-picker');
|
|
156
|
+
this.sessionPickerBtn = this.container.querySelector('.chat-panel__session-picker-btn');
|
|
157
|
+
this.sessionDropdown = this.container.querySelector('.chat-panel__session-dropdown');
|
|
158
|
+
this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
|
|
159
|
+
this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Bind event listeners
|
|
164
|
+
*/
|
|
165
|
+
_bindEvents() {
|
|
166
|
+
if (!this.panel) return;
|
|
167
|
+
|
|
168
|
+
// Close button
|
|
169
|
+
this.closeBtn.addEventListener('click', () => this.close());
|
|
170
|
+
|
|
171
|
+
// New conversation button
|
|
172
|
+
this.newBtn.addEventListener('click', () => this._startNewConversation());
|
|
173
|
+
|
|
174
|
+
// Session picker button
|
|
175
|
+
this.sessionPickerBtn.addEventListener('click', () => this._toggleSessionDropdown());
|
|
176
|
+
|
|
177
|
+
// Send button
|
|
178
|
+
this.sendBtn.addEventListener('click', () => this.sendMessage());
|
|
179
|
+
|
|
180
|
+
// Stop button
|
|
181
|
+
this.stopBtn.addEventListener('click', () => this._stopAgent());
|
|
182
|
+
|
|
183
|
+
// Action buttons
|
|
184
|
+
this.adoptBtn.addEventListener('click', () => this._handleAdoptClick());
|
|
185
|
+
this.updateBtn.addEventListener('click', () => this._handleUpdateClick());
|
|
186
|
+
this.dismissSuggestionBtn.addEventListener('click', () => this._handleDismissSuggestionClick());
|
|
187
|
+
this.dismissCommentBtn.addEventListener('click', () => this._handleDismissCommentClick());
|
|
188
|
+
this.createCommentBtn.addEventListener('click', () => this._handleCreateCommentClick());
|
|
189
|
+
this.actionBarDismissBtn.addEventListener('click', () => this._handleActionBarDismiss());
|
|
190
|
+
|
|
191
|
+
// New-content pill: click to scroll to bottom
|
|
192
|
+
if (this.newContentPill) {
|
|
193
|
+
this.newContentPill.addEventListener('click', () => this.scrollToBottom({ force: true }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Track scroll direction to disengage/re-engage auto-scroll
|
|
197
|
+
if (this.messagesEl) {
|
|
198
|
+
let lastScrollTop = 0;
|
|
199
|
+
this.messagesEl.addEventListener('scroll', () => {
|
|
200
|
+
const { scrollTop, scrollHeight, clientHeight } = this.messagesEl;
|
|
201
|
+
const distance = scrollHeight - scrollTop - clientHeight;
|
|
202
|
+
|
|
203
|
+
if (scrollTop < lastScrollTop && distance >= NEAR_BOTTOM_THRESHOLD) {
|
|
204
|
+
// Scrolling UP and not near bottom — disengage
|
|
205
|
+
this._userScrolledAway = true;
|
|
206
|
+
this._showNewContentPill();
|
|
207
|
+
} else if (distance < NEAR_BOTTOM_THRESHOLD) {
|
|
208
|
+
// Back near bottom — re-engage
|
|
209
|
+
this._userScrolledAway = false;
|
|
210
|
+
this._hideNewContentPill();
|
|
211
|
+
}
|
|
212
|
+
lastScrollTop = scrollTop;
|
|
213
|
+
}, { passive: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Textarea input handling
|
|
217
|
+
this.inputEl.addEventListener('input', () => {
|
|
218
|
+
this._autoResizeTextarea();
|
|
219
|
+
this.sendBtn.disabled = !this.inputEl.value.trim() || this.isStreaming;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Keyboard shortcuts
|
|
223
|
+
this.inputEl.addEventListener('keydown', (e) => {
|
|
224
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
if (this.inputEl.value.trim() && !this.isStreaming) {
|
|
227
|
+
this.sendMessage();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Escape: close dropdown if open, stop agent if streaming, blur textarea if focused, otherwise close panel
|
|
233
|
+
this._onKeydown = (e) => {
|
|
234
|
+
if (e.key === 'Escape' && this.isOpen) {
|
|
235
|
+
if (this._isSessionDropdownOpen()) {
|
|
236
|
+
this._hideSessionDropdown();
|
|
237
|
+
} else if (this.isStreaming) {
|
|
238
|
+
this._stopAgent();
|
|
239
|
+
} else if (document.activeElement === this.inputEl) {
|
|
240
|
+
this.inputEl.blur();
|
|
241
|
+
} else {
|
|
242
|
+
this.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
document.addEventListener('keydown', this._onKeydown);
|
|
247
|
+
|
|
248
|
+
// Chat file link click handler (event delegation)
|
|
249
|
+
this.messagesEl?.addEventListener('click', (e) => {
|
|
250
|
+
const link = e.target.closest('.chat-file-link');
|
|
251
|
+
if (link) {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
this._handleFileLinkClick(link);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this._bindResizeEvents();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Bind resize drag events on the left edge handle
|
|
262
|
+
*/
|
|
263
|
+
_bindResizeEvents() {
|
|
264
|
+
const handle = this.panel.querySelector('.chat-panel__resize-handle');
|
|
265
|
+
if (!handle) return;
|
|
266
|
+
|
|
267
|
+
const { min, storageKey } = this._resizeConfig;
|
|
268
|
+
|
|
269
|
+
let startX = 0;
|
|
270
|
+
let startWidth = 0;
|
|
271
|
+
|
|
272
|
+
const onMouseMove = (e) => {
|
|
273
|
+
// Compute dynamic max: leave room for the sidebar and a minimum content area
|
|
274
|
+
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10) || 260;
|
|
275
|
+
const dynamicMax = window.innerWidth - sidebarWidth - 100;
|
|
276
|
+
|
|
277
|
+
// Panel is right-anchored, so dragging left (decreasing clientX) should increase width
|
|
278
|
+
const delta = startX - e.clientX;
|
|
279
|
+
const newWidth = Math.max(min, Math.min(dynamicMax, startWidth + delta));
|
|
280
|
+
document.documentElement.style.setProperty('--chat-panel-width', newWidth + 'px');
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const onMouseUp = () => {
|
|
284
|
+
// Persist the final width
|
|
285
|
+
const finalWidth = this.panel.getBoundingClientRect().width;
|
|
286
|
+
localStorage.setItem(storageKey, Math.round(finalWidth));
|
|
287
|
+
|
|
288
|
+
handle.classList.remove('dragging');
|
|
289
|
+
this.panel.classList.remove('chat-panel--resizing');
|
|
290
|
+
document.body.classList.remove('resizing');
|
|
291
|
+
|
|
292
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
293
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
294
|
+
|
|
295
|
+
// Notify PanelGroup so --right-panel-group-width stays in sync
|
|
296
|
+
window.panelGroup?._updateRightPanelGroupWidth();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
handle.addEventListener('mousedown', (e) => {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
startX = e.clientX;
|
|
302
|
+
startWidth = this.panel.getBoundingClientRect().width;
|
|
303
|
+
|
|
304
|
+
handle.classList.add('dragging');
|
|
305
|
+
this.panel.classList.add('chat-panel--resizing');
|
|
306
|
+
document.body.classList.add('resizing');
|
|
307
|
+
|
|
308
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
309
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Auto-resize textarea based on content.
|
|
315
|
+
* Grows with content up to maxHeight, then switches to scrollable overflow.
|
|
316
|
+
* Shrinks back down when content is deleted.
|
|
317
|
+
*/
|
|
318
|
+
_autoResizeTextarea() {
|
|
319
|
+
const el = this.inputEl;
|
|
320
|
+
const maxHeight = 120;
|
|
321
|
+
|
|
322
|
+
// Collapse to auto so scrollHeight reflects actual content height
|
|
323
|
+
el.style.height = 'auto';
|
|
324
|
+
el.style.overflowY = 'hidden';
|
|
325
|
+
|
|
326
|
+
const contentHeight = el.scrollHeight;
|
|
327
|
+
if (contentHeight > maxHeight) {
|
|
328
|
+
el.style.height = maxHeight + 'px';
|
|
329
|
+
el.style.overflowY = 'auto';
|
|
330
|
+
} else {
|
|
331
|
+
el.style.height = contentHeight + 'px';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Disable chat input and send button (e.g. while reviewId is unavailable).
|
|
337
|
+
* Saves the original placeholder so _enableInput() can restore it.
|
|
338
|
+
*/
|
|
339
|
+
_disableInput() {
|
|
340
|
+
this._savedPlaceholder = this.inputEl.placeholder;
|
|
341
|
+
this.inputEl.disabled = true;
|
|
342
|
+
this.inputEl.placeholder = 'Connecting to review\u2026';
|
|
343
|
+
this.sendBtn.disabled = true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Re-enable chat input and send button after reviewId becomes available.
|
|
348
|
+
* Restores the original placeholder saved by _disableInput().
|
|
349
|
+
*/
|
|
350
|
+
_enableInput() {
|
|
351
|
+
this.inputEl.disabled = false;
|
|
352
|
+
this.inputEl.placeholder = this._savedPlaceholder || 'Ask about this review...';
|
|
353
|
+
this._savedPlaceholder = null;
|
|
354
|
+
this.sendBtn.disabled = !this.inputEl.value.trim() || this.isStreaming;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Update the chat panel title with provider and model info.
|
|
359
|
+
* @param {string} [provider='Pi'] - Provider display name
|
|
360
|
+
* @param {string} [model] - Model ID or display name (e.g. 'default', 'multi-model')
|
|
361
|
+
*/
|
|
362
|
+
_updateTitle(provider = 'Pi', model) {
|
|
363
|
+
if (!this.titleTextEl) return;
|
|
364
|
+
const modelDisplay = model
|
|
365
|
+
? model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
|
366
|
+
: null;
|
|
367
|
+
const parts = ['Chat', provider];
|
|
368
|
+
if (modelDisplay) parts.push(modelDisplay);
|
|
369
|
+
this.titleTextEl.textContent = parts.join(' \u00b7 ');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Open the chat panel
|
|
374
|
+
* @param {Object} options - Optional context
|
|
375
|
+
* @param {number} options.reviewId - Review ID
|
|
376
|
+
* @param {number} options.suggestionId - Suggestion ID to ask about
|
|
377
|
+
* @param {Object} options.suggestionContext - AI suggestion details for context
|
|
378
|
+
* @param {Object} options.commentContext - User comment details for context
|
|
379
|
+
* @param {string} options.commentContext.commentId - Comment ID
|
|
380
|
+
* @param {string} options.commentContext.body - Comment body text
|
|
381
|
+
* @param {string} options.commentContext.file - File path
|
|
382
|
+
* @param {number} options.commentContext.line_start - Start line number
|
|
383
|
+
* @param {number} options.commentContext.line_end - End line number
|
|
384
|
+
* @param {string} options.commentContext.source - 'user' for user comments
|
|
385
|
+
* @param {boolean} options.commentContext.isFileLevel - True if file-level comment
|
|
386
|
+
*/
|
|
387
|
+
async open(options = {}) {
|
|
388
|
+
// Concurrency guard: if a previous open() is still loading MRU / messages,
|
|
389
|
+
// wait for it to finish before proceeding. This prevents a race where a
|
|
390
|
+
// second open() call sees `currentSessionId` already set (by the first
|
|
391
|
+
// call's _loadMRUSession midway through) and skips MRU loading, causing
|
|
392
|
+
// _ensureAnalysisContext to run before message history is rendered.
|
|
393
|
+
if (this._openPromise) {
|
|
394
|
+
await this._openPromise;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Wrap the async body in a tracked promise so subsequent callers can wait
|
|
398
|
+
this._openPromise = this._openInner(options);
|
|
399
|
+
try {
|
|
400
|
+
await this._openPromise;
|
|
401
|
+
} finally {
|
|
402
|
+
this._openPromise = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Inner implementation of open(), separated so the concurrency guard in
|
|
408
|
+
* open() can track the full async lifecycle.
|
|
409
|
+
* @param {Object} options - Same options as open()
|
|
410
|
+
*/
|
|
411
|
+
async _openInner(options) {
|
|
412
|
+
// Resolve reviewId from options or from prManager
|
|
413
|
+
if (options.reviewId) {
|
|
414
|
+
this.reviewId = options.reviewId;
|
|
415
|
+
} else if (window.prManager?.currentPR?.id) {
|
|
416
|
+
this.reviewId = window.prManager.currentPR.id;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Restore persisted width before opening (mirrors AIPanel.expand pattern)
|
|
420
|
+
const { min, default: defaultWidth, storageKey } = this._resizeConfig;
|
|
421
|
+
const savedWidth = localStorage.getItem(storageKey);
|
|
422
|
+
if (savedWidth) {
|
|
423
|
+
const width = parseInt(savedWidth, 10);
|
|
424
|
+
if (width >= min) {
|
|
425
|
+
document.documentElement.style.setProperty('--chat-panel-width', width + 'px');
|
|
426
|
+
} else {
|
|
427
|
+
document.documentElement.style.setProperty('--chat-panel-width', defaultWidth + 'px');
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
document.documentElement.style.setProperty('--chat-panel-width', defaultWidth + 'px');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this.isOpen = true;
|
|
434
|
+
this.panel.classList.remove('chat-panel--closed');
|
|
435
|
+
this.panel.classList.add('chat-panel--open');
|
|
436
|
+
|
|
437
|
+
// Ensure SSE is connected (but don't create a session yet — lazy creation)
|
|
438
|
+
this._ensureGlobalSSE();
|
|
439
|
+
|
|
440
|
+
// Load MRU session with message history (if any previous sessions exist).
|
|
441
|
+
// Skip when opening with explicit context (suggestion/comment/file) — the
|
|
442
|
+
// user wants a *new* conversation about that item, not to resume the last one.
|
|
443
|
+
const hasExplicitContext = !!(options.suggestionContext || options.commentContext || options.fileContext);
|
|
444
|
+
if (!this.currentSessionId && !hasExplicitContext) {
|
|
445
|
+
await this._loadMRUSession();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Ensure analysis context is added on every expand — not just when opening
|
|
449
|
+
// with suggestion/comment context. This detects new analysis runs that
|
|
450
|
+
// completed while the panel was closed and adds them as pending context.
|
|
451
|
+
this._ensureAnalysisContext();
|
|
452
|
+
|
|
453
|
+
// If opening with suggestion context, inject it as a context card
|
|
454
|
+
if (options.suggestionContext) {
|
|
455
|
+
this._sendContextMessage(options.suggestionContext);
|
|
456
|
+
this._contextSource = 'suggestion';
|
|
457
|
+
this._contextItemId = options.suggestionId || null;
|
|
458
|
+
} else if (options.commentContext) {
|
|
459
|
+
// If opening with user comment context, inject it as a context card
|
|
460
|
+
this._sendCommentContextMessage(options.commentContext);
|
|
461
|
+
if (options.commentContext.type === 'line') {
|
|
462
|
+
this._contextSource = 'line';
|
|
463
|
+
this._contextItemId = null;
|
|
464
|
+
this._contextLineMeta = {
|
|
465
|
+
file: options.commentContext.file,
|
|
466
|
+
line_start: options.commentContext.line_start,
|
|
467
|
+
line_end: options.commentContext.line_end,
|
|
468
|
+
};
|
|
469
|
+
} else {
|
|
470
|
+
this._contextSource = 'user';
|
|
471
|
+
this._contextItemId = options.commentContext.commentId || null;
|
|
472
|
+
}
|
|
473
|
+
} else if (options.fileContext) {
|
|
474
|
+
// If opening with file context, inject it as a context card
|
|
475
|
+
this._sendFileContextMessage(options.fileContext);
|
|
476
|
+
this._contextSource = 'file';
|
|
477
|
+
this._contextItemId = null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Gate input when reviewId is not yet available (PanelGroup auto-restore race)
|
|
481
|
+
if (!this.reviewId) {
|
|
482
|
+
this._disableInput();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this._updateActionButtons();
|
|
486
|
+
window.panelGroup?._onChatVisibilityChanged(true);
|
|
487
|
+
if (!options.suppressFocus) {
|
|
488
|
+
this.inputEl.focus();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Close the chat panel
|
|
494
|
+
*/
|
|
495
|
+
close() {
|
|
496
|
+
this._hideSessionDropdown();
|
|
497
|
+
// Reset UI streaming state (buttons) but keep isStreaming and _streamingContent
|
|
498
|
+
// intact so the background SSE handler can continue accumulating events.
|
|
499
|
+
this.sendBtn.style.display = '';
|
|
500
|
+
this.stopBtn.style.display = 'none';
|
|
501
|
+
this.sendBtn.disabled = !this.inputEl?.value?.trim();
|
|
502
|
+
|
|
503
|
+
this.isOpen = false;
|
|
504
|
+
this.panel.classList.remove('chat-panel--open');
|
|
505
|
+
this.panel.classList.add('chat-panel--closed');
|
|
506
|
+
this._pendingContext = [];
|
|
507
|
+
this._pendingContextData = [];
|
|
508
|
+
this._contextSource = null;
|
|
509
|
+
this._contextItemId = null;
|
|
510
|
+
this._contextLineMeta = null;
|
|
511
|
+
// Zero out CSS variable so max-width calcs don't reserve space (mirrors AIPanel.collapse)
|
|
512
|
+
document.documentElement.style.setProperty('--chat-panel-width', '0px');
|
|
513
|
+
// Preserve _analysisContextRemoved and _sessionAnalysisRunId across
|
|
514
|
+
// close/reopen so _ensureAnalysisContext can detect NEW runs on the next
|
|
515
|
+
// expand. These are reset by _startNewConversation() or when a new run
|
|
516
|
+
// is detected in _ensureAnalysisContext().
|
|
517
|
+
window.panelGroup?._onChatVisibilityChanged(false);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Toggle panel visibility
|
|
522
|
+
*/
|
|
523
|
+
toggle() {
|
|
524
|
+
if (this.isOpen) {
|
|
525
|
+
this.close();
|
|
526
|
+
} else {
|
|
527
|
+
this.open();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Start a new conversation (reset session)
|
|
533
|
+
* Preserves any unsent pending context cards and re-adds them to the new conversation.
|
|
534
|
+
*/
|
|
535
|
+
async _startNewConversation() {
|
|
536
|
+
this._hideSessionDropdown();
|
|
537
|
+
// 1. Snapshot pending context before clearing (these are unsent context cards)
|
|
538
|
+
const savedContext = this._pendingContext.slice();
|
|
539
|
+
const savedContextData = this._pendingContextData.slice();
|
|
540
|
+
const savedContextSource = this._contextSource;
|
|
541
|
+
const savedContextItemId = this._contextItemId;
|
|
542
|
+
const savedContextLineMeta = this._contextLineMeta;
|
|
543
|
+
|
|
544
|
+
// 2. Clear everything as normal
|
|
545
|
+
this._finalizeStreaming();
|
|
546
|
+
this.currentSessionId = null;
|
|
547
|
+
this.messages = [];
|
|
548
|
+
this._streamingContent = '';
|
|
549
|
+
this._pendingContext = [];
|
|
550
|
+
this._pendingContextData = [];
|
|
551
|
+
this._contextSource = null;
|
|
552
|
+
this._contextItemId = null;
|
|
553
|
+
this._contextLineMeta = null;
|
|
554
|
+
this._analysisContextRemoved = false;
|
|
555
|
+
this._sessionAnalysisRunId = null;
|
|
556
|
+
this._clearMessages();
|
|
557
|
+
this._updateActionButtons();
|
|
558
|
+
this._updateTitle(); // Reset title for new conversation
|
|
559
|
+
// SSE stays connected — it's multiplexed and will filter by sessionId
|
|
560
|
+
|
|
561
|
+
// 3. Re-add analysis context (appears first, handled separately from pending arrays)
|
|
562
|
+
this._ensureAnalysisContext();
|
|
563
|
+
|
|
564
|
+
// 4. Re-add saved pending context cards (if any were unsent)
|
|
565
|
+
if (savedContext.length > 0) {
|
|
566
|
+
// Remove empty state since we're about to add context cards
|
|
567
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
568
|
+
if (emptyState) emptyState.remove();
|
|
569
|
+
|
|
570
|
+
// Restore context metadata
|
|
571
|
+
this._contextSource = savedContextSource;
|
|
572
|
+
this._contextItemId = savedContextItemId;
|
|
573
|
+
this._contextLineMeta = savedContextLineMeta;
|
|
574
|
+
|
|
575
|
+
for (let i = 0; i < savedContextData.length; i++) {
|
|
576
|
+
const ctxData = savedContextData[i];
|
|
577
|
+
this._pendingContext.push(savedContext[i]);
|
|
578
|
+
this._pendingContextData.push(ctxData);
|
|
579
|
+
|
|
580
|
+
// Render the appropriate card type based on the context data
|
|
581
|
+
if (ctxData.type === 'file') {
|
|
582
|
+
this._addFileContextCard(ctxData, { removable: true });
|
|
583
|
+
} else if (ctxData.type === 'line') {
|
|
584
|
+
this._addLineContextCard(ctxData, { removable: true });
|
|
585
|
+
} else if (ctxData.type === 'comment') {
|
|
586
|
+
this._addCommentContextCard(ctxData, { removable: true });
|
|
587
|
+
} else if (ctxData.type === 'analysis-run') {
|
|
588
|
+
this._addAnalysisRunContextCard(ctxData, { removable: true });
|
|
589
|
+
} else {
|
|
590
|
+
this._addContextCard(ctxData, { removable: true });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
this._updateActionButtons();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Fetch sessions for the current review.
|
|
600
|
+
* Extracted from _loadMRUSession for reuse by the session picker dropdown.
|
|
601
|
+
* @returns {Promise<Array>} Array of session objects with message_count and first_message
|
|
602
|
+
*/
|
|
603
|
+
async _fetchSessions() {
|
|
604
|
+
if (!this.reviewId) return [];
|
|
605
|
+
try {
|
|
606
|
+
const response = await fetch(`/api/review/${this.reviewId}/chat/sessions`);
|
|
607
|
+
if (!response.ok) return [];
|
|
608
|
+
const result = await response.json();
|
|
609
|
+
return result.data?.sessions || [];
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.warn('[ChatPanel] Failed to fetch sessions:', err);
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Load the most recently used session for the current review.
|
|
618
|
+
* Picks the first session (MRU) and loads its message history.
|
|
619
|
+
*/
|
|
620
|
+
async _loadMRUSession() {
|
|
621
|
+
if (!this.reviewId) return;
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
const sessions = await this._fetchSessions();
|
|
625
|
+
if (sessions.length === 0) return;
|
|
626
|
+
|
|
627
|
+
const mru = sessions[0];
|
|
628
|
+
this.currentSessionId = mru.id;
|
|
629
|
+
console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
|
|
630
|
+
|
|
631
|
+
if (mru.provider) {
|
|
632
|
+
const providerName = mru.provider.charAt(0).toUpperCase() + mru.provider.slice(1);
|
|
633
|
+
this._updateTitle(providerName, mru.model);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (mru.message_count > 0) {
|
|
637
|
+
await this._loadMessageHistory(mru.id);
|
|
638
|
+
}
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.warn('[ChatPanel] Failed to load MRU session:', err);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Load and render message history for a session.
|
|
646
|
+
* Fetches messages from the API and renders context cards and message bubbles.
|
|
647
|
+
* @param {number} sessionId
|
|
648
|
+
*/
|
|
649
|
+
async _loadMessageHistory(sessionId) {
|
|
650
|
+
try {
|
|
651
|
+
const response = await fetch(`/api/chat/session/${sessionId}/messages`);
|
|
652
|
+
if (!response.ok) return;
|
|
653
|
+
|
|
654
|
+
const result = await response.json();
|
|
655
|
+
const messages = result.data?.messages || [];
|
|
656
|
+
if (messages.length === 0) return;
|
|
657
|
+
|
|
658
|
+
// Remove empty state
|
|
659
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
660
|
+
if (emptyState) emptyState.remove();
|
|
661
|
+
|
|
662
|
+
for (const msg of messages) {
|
|
663
|
+
if (msg.type === 'context') {
|
|
664
|
+
// Render context card from stored context data
|
|
665
|
+
try {
|
|
666
|
+
const ctxData = JSON.parse(msg.content);
|
|
667
|
+
if (ctxData.type === 'analysis') {
|
|
668
|
+
this._addAnalysisContextCard(ctxData);
|
|
669
|
+
} else if (ctxData.type === 'file') {
|
|
670
|
+
this._addFileContextCard(ctxData);
|
|
671
|
+
} else if (ctxData.type === 'line') {
|
|
672
|
+
this._addLineContextCard(ctxData);
|
|
673
|
+
} else if (ctxData.type === 'comment') {
|
|
674
|
+
this._addCommentContextCard(ctxData);
|
|
675
|
+
} else {
|
|
676
|
+
this._addContextCard(ctxData);
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
// Not JSON — skip malformed context
|
|
680
|
+
}
|
|
681
|
+
} else if (msg.type === 'message') {
|
|
682
|
+
this.addMessage(msg.role, msg.content, msg.id);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
console.warn('[ChatPanel] Failed to load message history:', err);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── Session picker dropdown ────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
_isSessionDropdownOpen() {
|
|
693
|
+
return this.sessionDropdown && this.sessionDropdown.style.display !== 'none';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
_toggleSessionDropdown() {
|
|
697
|
+
if (this._isSessionDropdownOpen()) {
|
|
698
|
+
this._hideSessionDropdown();
|
|
699
|
+
} else {
|
|
700
|
+
this._showSessionDropdown();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async _showSessionDropdown() {
|
|
705
|
+
if (!this.sessionDropdown) return;
|
|
706
|
+
|
|
707
|
+
const sessions = await this._fetchSessions();
|
|
708
|
+
this._renderSessionDropdown(sessions);
|
|
709
|
+
this.sessionDropdown.style.display = '';
|
|
710
|
+
this.sessionPickerBtn.classList.add('chat-panel__session-picker-btn--open');
|
|
711
|
+
|
|
712
|
+
// Bind outside-click-to-close (one-shot)
|
|
713
|
+
this._outsideClickHandler = (e) => {
|
|
714
|
+
if (!this.sessionPickerEl.contains(e.target)) {
|
|
715
|
+
this._hideSessionDropdown();
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
// Use setTimeout so the current click event doesn't immediately trigger close
|
|
719
|
+
setTimeout(() => {
|
|
720
|
+
document.addEventListener('click', this._outsideClickHandler);
|
|
721
|
+
}, 0);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_hideSessionDropdown() {
|
|
725
|
+
if (!this.sessionDropdown) return;
|
|
726
|
+
this.sessionDropdown.style.display = 'none';
|
|
727
|
+
this.sessionPickerBtn.classList.remove('chat-panel__session-picker-btn--open');
|
|
728
|
+
if (this._outsideClickHandler) {
|
|
729
|
+
document.removeEventListener('click', this._outsideClickHandler);
|
|
730
|
+
this._outsideClickHandler = null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
_renderSessionDropdown(sessions) {
|
|
735
|
+
if (!this.sessionDropdown) return;
|
|
736
|
+
|
|
737
|
+
if (sessions.length === 0) {
|
|
738
|
+
this.sessionDropdown.innerHTML = `
|
|
739
|
+
<div class="chat-panel__session-empty">No conversations yet</div>
|
|
740
|
+
`;
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const items = sessions.map(s => {
|
|
745
|
+
const isActive = s.id === this.currentSessionId;
|
|
746
|
+
const preview = s.first_message
|
|
747
|
+
? this._truncate(s.first_message, 60)
|
|
748
|
+
: 'New conversation';
|
|
749
|
+
const timeAgo = this._formatRelativeTime(s.updated_at);
|
|
750
|
+
|
|
751
|
+
return `
|
|
752
|
+
<button class="chat-panel__session-item${isActive ? ' chat-panel__session-item--active' : ''}"
|
|
753
|
+
data-session-id="${s.id}">
|
|
754
|
+
<span class="chat-panel__session-preview">${this._escapeHtml(preview)}</span>
|
|
755
|
+
<span class="chat-panel__session-meta">${this._escapeHtml(timeAgo)}</span>
|
|
756
|
+
</button>
|
|
757
|
+
`;
|
|
758
|
+
}).join('');
|
|
759
|
+
|
|
760
|
+
this.sessionDropdown.innerHTML = items;
|
|
761
|
+
|
|
762
|
+
// Bind click handlers on each item
|
|
763
|
+
this.sessionDropdown.querySelectorAll('.chat-panel__session-item').forEach(btn => {
|
|
764
|
+
btn.addEventListener('click', () => {
|
|
765
|
+
const sessionId = parseInt(btn.dataset.sessionId, 10);
|
|
766
|
+
const sessionData = sessions.find(s => s.id === sessionId);
|
|
767
|
+
if (sessionData) {
|
|
768
|
+
this._switchToSession(sessionId, sessionData);
|
|
769
|
+
}
|
|
770
|
+
this._hideSessionDropdown();
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Switch to a different chat session.
|
|
777
|
+
* Tears down current state and loads the target session.
|
|
778
|
+
* @param {number} sessionId - The session ID to switch to
|
|
779
|
+
* @param {Object} sessionData - Session metadata (provider, model, message_count, etc.)
|
|
780
|
+
*/
|
|
781
|
+
async _switchToSession(sessionId, sessionData) {
|
|
782
|
+
if (sessionId === this.currentSessionId) return;
|
|
783
|
+
|
|
784
|
+
// 1. Finalize any active stream
|
|
785
|
+
this._finalizeStreaming();
|
|
786
|
+
|
|
787
|
+
// 2. Reset state
|
|
788
|
+
this.currentSessionId = sessionId;
|
|
789
|
+
this.messages = [];
|
|
790
|
+
this._streamingContent = '';
|
|
791
|
+
this._pendingContext = [];
|
|
792
|
+
this._pendingContextData = [];
|
|
793
|
+
this._contextSource = null;
|
|
794
|
+
this._contextItemId = null;
|
|
795
|
+
this._contextLineMeta = null;
|
|
796
|
+
this._pendingActionContext = null;
|
|
797
|
+
this._analysisContextRemoved = false;
|
|
798
|
+
this._sessionAnalysisRunId = null;
|
|
799
|
+
|
|
800
|
+
// 3. Clear UI
|
|
801
|
+
this._clearMessages();
|
|
802
|
+
this._updateActionButtons();
|
|
803
|
+
|
|
804
|
+
// 4. Update title
|
|
805
|
+
if (sessionData.provider) {
|
|
806
|
+
const providerName = sessionData.provider.charAt(0).toUpperCase() + sessionData.provider.slice(1);
|
|
807
|
+
this._updateTitle(providerName, sessionData.model);
|
|
808
|
+
} else {
|
|
809
|
+
this._updateTitle();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 5. Load message history
|
|
813
|
+
if (sessionData.message_count > 0) {
|
|
814
|
+
await this._loadMessageHistory(sessionId);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// 6. Ensure analysis context for the new session
|
|
818
|
+
this._ensureAnalysisContext();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Format a timestamp as relative time (same logic as AnalysisHistoryManager.formatRelativeTime).
|
|
823
|
+
* @param {string} timestamp - ISO or SQLite timestamp
|
|
824
|
+
* @returns {string} Relative time string
|
|
825
|
+
*/
|
|
826
|
+
_formatRelativeTime(timestamp) {
|
|
827
|
+
if (!timestamp) return 'Unknown';
|
|
828
|
+
|
|
829
|
+
const now = new Date();
|
|
830
|
+
const date = window.parseTimestamp ? window.parseTimestamp(timestamp) : new Date(timestamp);
|
|
831
|
+
const diffMs = now - date;
|
|
832
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
833
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
834
|
+
|
|
835
|
+
if (diffHours < 1) {
|
|
836
|
+
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
837
|
+
} else if (diffHours < 24) {
|
|
838
|
+
return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
|
839
|
+
} else if (diffDays < 7) {
|
|
840
|
+
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
|
841
|
+
} else {
|
|
842
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Truncate text to maxLen characters with ellipsis.
|
|
848
|
+
* @param {string} text
|
|
849
|
+
* @param {number} maxLen
|
|
850
|
+
* @returns {string}
|
|
851
|
+
*/
|
|
852
|
+
_truncate(text, maxLen) {
|
|
853
|
+
if (!text || text.length <= maxLen) return text || '';
|
|
854
|
+
return text.substring(0, maxLen) + '\u2026';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Clear all messages from the display and show empty state
|
|
859
|
+
*/
|
|
860
|
+
_clearMessages() {
|
|
861
|
+
this.messagesEl.innerHTML = `
|
|
862
|
+
<div class="chat-panel__empty">
|
|
863
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32">
|
|
864
|
+
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
|
|
865
|
+
</svg>
|
|
866
|
+
<p>Ask questions about this review, or click "Ask about this" on any suggestion.</p>
|
|
867
|
+
</div>
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Ensure the global SSE connection is active.
|
|
873
|
+
* No longer creates sessions — that happens lazily on first message.
|
|
874
|
+
* @returns {{sessionData: null}}
|
|
875
|
+
*/
|
|
876
|
+
_ensureConnected() {
|
|
877
|
+
this._ensureGlobalSSE();
|
|
878
|
+
return { sessionData: null };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Late-bind a reviewId after the panel has already been opened.
|
|
883
|
+
* Called by PRManager._initReviewEventListeners() (or equivalent in local.js)
|
|
884
|
+
* to handle the race condition where PanelGroup auto-restores an open chat
|
|
885
|
+
* panel during DOMContentLoaded, before prManager has loaded the review.
|
|
886
|
+
* If the panel is open and has no reviewId yet, this sets it and loads
|
|
887
|
+
* the MRU session so the user sees their previous conversation.
|
|
888
|
+
* @param {number} reviewId - The review ID from prManager
|
|
889
|
+
*/
|
|
890
|
+
async _lateBindReview(reviewId) {
|
|
891
|
+
if (!reviewId) return;
|
|
892
|
+
if (this.reviewId) return; // already bound
|
|
893
|
+
this.reviewId = reviewId;
|
|
894
|
+
console.debug('[ChatPanel] Late-bound reviewId:', reviewId);
|
|
895
|
+
|
|
896
|
+
// Re-enable input now that reviewId is available
|
|
897
|
+
if (this.inputEl.disabled) {
|
|
898
|
+
this._enableInput();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// If the panel is already open, load the MRU session now
|
|
902
|
+
if (this.isOpen && !this.currentSessionId) {
|
|
903
|
+
await this._loadMRUSession();
|
|
904
|
+
this._ensureAnalysisContext();
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Create a new chat session via API
|
|
910
|
+
* @param {number} contextCommentId - Optional AI suggestion ID for context
|
|
911
|
+
* @returns {Object|null} Session data ({ id, status, context? }) or null on failure
|
|
912
|
+
*/
|
|
913
|
+
async createSession(contextCommentId) {
|
|
914
|
+
if (!this.reviewId) {
|
|
915
|
+
console.warn('[ChatPanel] No reviewId available');
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
const body = {
|
|
921
|
+
provider: 'pi',
|
|
922
|
+
reviewId: this.reviewId
|
|
923
|
+
};
|
|
924
|
+
if (contextCommentId) {
|
|
925
|
+
body.contextCommentId = contextCommentId;
|
|
926
|
+
}
|
|
927
|
+
if (this._analysisContextRemoved) {
|
|
928
|
+
body.skipAnalysisContext = true;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
console.debug('[ChatPanel] Creating session for review', this.reviewId);
|
|
932
|
+
const response = await fetch('/api/chat/session', {
|
|
933
|
+
method: 'POST',
|
|
934
|
+
headers: { 'Content-Type': 'application/json' },
|
|
935
|
+
body: JSON.stringify(body)
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
if (!response.ok) {
|
|
939
|
+
const err = await response.json().catch(() => ({}));
|
|
940
|
+
throw new Error(err.error || 'Failed to create chat session');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const result = await response.json();
|
|
944
|
+
this.currentSessionId = result.data.id;
|
|
945
|
+
console.debug('[ChatPanel] Session created:', this.currentSessionId);
|
|
946
|
+
return result.data;
|
|
947
|
+
} catch (error) {
|
|
948
|
+
console.error('[ChatPanel] Error creating session:', error);
|
|
949
|
+
this._showError('Failed to start chat session. ' + error.message);
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Send the current input text as a message
|
|
956
|
+
*/
|
|
957
|
+
async sendMessage() {
|
|
958
|
+
const content = this.inputEl.value.trim();
|
|
959
|
+
if (!content || this.isStreaming) return;
|
|
960
|
+
|
|
961
|
+
// Save message text before clearing (for error recovery)
|
|
962
|
+
const messageText = content;
|
|
963
|
+
|
|
964
|
+
// Clear input
|
|
965
|
+
this.inputEl.value = '';
|
|
966
|
+
this._autoResizeTextarea();
|
|
967
|
+
this.sendBtn.disabled = true;
|
|
968
|
+
|
|
969
|
+
// Remove empty state if present
|
|
970
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
971
|
+
if (emptyState) emptyState.remove();
|
|
972
|
+
|
|
973
|
+
// Display user message (just the user's actual text)
|
|
974
|
+
const msgElRef = this.addMessage('user', content);
|
|
975
|
+
|
|
976
|
+
// Lazy session creation: create on first message, not on panel open
|
|
977
|
+
if (!this.currentSessionId) {
|
|
978
|
+
this._ensureGlobalSSE();
|
|
979
|
+
const sessionData = await this.createSession();
|
|
980
|
+
if (!sessionData) {
|
|
981
|
+
// Restore the user's message text into the input
|
|
982
|
+
this.inputEl.value = messageText;
|
|
983
|
+
this._autoResizeTextarea();
|
|
984
|
+
this.sendBtn.disabled = false;
|
|
985
|
+
// Remove the phantom message bubble
|
|
986
|
+
if (msgElRef) msgElRef.remove();
|
|
987
|
+
this.messages.pop();
|
|
988
|
+
// Show error
|
|
989
|
+
this._showError('Unable to start chat session. Please try again.');
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
this._showAnalysisContextIfPresent(sessionData);
|
|
993
|
+
}
|
|
994
|
+
// If currentSessionId is set (from MRU), just send — server auto-resumes
|
|
995
|
+
|
|
996
|
+
// Prepare streaming UI
|
|
997
|
+
this.isStreaming = true;
|
|
998
|
+
this.sendBtn.disabled = true;
|
|
999
|
+
this.sendBtn.style.display = 'none';
|
|
1000
|
+
this.stopBtn.style.display = '';
|
|
1001
|
+
this._updateActionButtons();
|
|
1002
|
+
this._streamingContent = '';
|
|
1003
|
+
this._addStreamingPlaceholder();
|
|
1004
|
+
|
|
1005
|
+
// Build the API payload — may include pending context from "Ask about this"
|
|
1006
|
+
const payload = { content };
|
|
1007
|
+
const savedContext = this._pendingContext;
|
|
1008
|
+
const savedContextData = this._pendingContextData;
|
|
1009
|
+
if (this._pendingContext.length > 0) {
|
|
1010
|
+
payload.context = this._pendingContext.join('\n\n');
|
|
1011
|
+
payload.contextData = this._pendingContextData;
|
|
1012
|
+
this._pendingContext = [];
|
|
1013
|
+
this._pendingContextData = [];
|
|
1014
|
+
|
|
1015
|
+
// Lock context cards — remove close buttons and index attributes
|
|
1016
|
+
const removableCards = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
|
|
1017
|
+
removableCards.forEach((card) => {
|
|
1018
|
+
const btn = card.querySelector('.chat-panel__context-remove');
|
|
1019
|
+
if (btn) btn.remove();
|
|
1020
|
+
delete card.dataset.contextIndex;
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Lock analysis context card (not indexed, handled separately from pending context)
|
|
1025
|
+
const analysisRemoveBtn = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis] .chat-panel__context-remove');
|
|
1026
|
+
if (analysisRemoveBtn) analysisRemoveBtn.remove();
|
|
1027
|
+
|
|
1028
|
+
// Attach action context (set by action button handlers — adopt, update, dismiss)
|
|
1029
|
+
if (this._pendingActionContext) {
|
|
1030
|
+
payload.actionContext = this._pendingActionContext;
|
|
1031
|
+
this._pendingActionContext = null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Send to API
|
|
1035
|
+
try {
|
|
1036
|
+
console.debug('[ChatPanel] Sending message to session', this.currentSessionId);
|
|
1037
|
+
let response = await fetch(`/api/chat/session/${this.currentSessionId}/message`, {
|
|
1038
|
+
method: 'POST',
|
|
1039
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1040
|
+
body: JSON.stringify(payload)
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// Handle 410 Gone: session is not resumable — transparently create a new one and retry once
|
|
1044
|
+
if (response.status === 410) {
|
|
1045
|
+
console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
|
|
1046
|
+
this.currentSessionId = null;
|
|
1047
|
+
this._ensureGlobalSSE();
|
|
1048
|
+
const sessionData = await this.createSession();
|
|
1049
|
+
if (!sessionData) {
|
|
1050
|
+
throw new Error('Failed to create replacement session');
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
response = await fetch(`/api/chat/session/${this.currentSessionId}/message`, {
|
|
1054
|
+
method: 'POST',
|
|
1055
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1056
|
+
body: JSON.stringify(payload)
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (!response.ok) {
|
|
1061
|
+
const err = await response.json().catch(() => ({}));
|
|
1062
|
+
throw new Error(err.error || 'Failed to send message');
|
|
1063
|
+
}
|
|
1064
|
+
console.debug('[ChatPanel] Message accepted, waiting for SSE events');
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
// Restore pending context so it's not lost
|
|
1067
|
+
this._pendingContext = savedContext;
|
|
1068
|
+
this._pendingContextData = savedContextData;
|
|
1069
|
+
// Restore removability on context cards that were locked before the failed send
|
|
1070
|
+
this._restoreRemovableCards();
|
|
1071
|
+
console.error('[ChatPanel] Error sending message:', error);
|
|
1072
|
+
this._showError('Failed to send message. ' + error.message);
|
|
1073
|
+
this._finalizeStreaming();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Store pending context and render a compact context card in the UI.
|
|
1079
|
+
* Called when the user clicks "Ask about this" on a suggestion.
|
|
1080
|
+
* The context is NOT sent to the agent immediately — it is prepended
|
|
1081
|
+
* to the next user message so the agent receives question + context together.
|
|
1082
|
+
* @param {Object} ctx - Suggestion context {title, type, file, line_start, line_end, body}
|
|
1083
|
+
*/
|
|
1084
|
+
_sendContextMessage(ctx) {
|
|
1085
|
+
// Remove empty state if present
|
|
1086
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
1087
|
+
if (emptyState) emptyState.remove();
|
|
1088
|
+
|
|
1089
|
+
// Store structured context data for DB persistence (session resumption)
|
|
1090
|
+
const contextData = {
|
|
1091
|
+
type: ctx.type || 'general',
|
|
1092
|
+
title: ctx.title || 'Untitled',
|
|
1093
|
+
file: ctx.file || null,
|
|
1094
|
+
line_start: ctx.line_start || null,
|
|
1095
|
+
line_end: ctx.line_end || null,
|
|
1096
|
+
side: ctx.side || null,
|
|
1097
|
+
body: ctx.body || null
|
|
1098
|
+
};
|
|
1099
|
+
this._pendingContextData.push(contextData);
|
|
1100
|
+
|
|
1101
|
+
// Build the plain text context for the agent (will be prepended to next message)
|
|
1102
|
+
const lines = ['The user wants to discuss this specific suggestion:'];
|
|
1103
|
+
lines.push(`- Type: ${contextData.type}`);
|
|
1104
|
+
lines.push(`- Title: ${contextData.title}`);
|
|
1105
|
+
if (contextData.file) {
|
|
1106
|
+
let fileLine = `- File: ${contextData.file}`;
|
|
1107
|
+
if (contextData.line_start) {
|
|
1108
|
+
fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
|
|
1109
|
+
}
|
|
1110
|
+
lines.push(fileLine);
|
|
1111
|
+
}
|
|
1112
|
+
if (contextData.body) {
|
|
1113
|
+
lines.push(`- Details: ${contextData.body}`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Enrich with diff hunk if available
|
|
1117
|
+
const patch = window.prManager?.filePatches?.get(contextData.file);
|
|
1118
|
+
if (patch && window.DiffContext) {
|
|
1119
|
+
if (contextData.line_start) {
|
|
1120
|
+
const hunk = window.DiffContext.extractHunkForLines(
|
|
1121
|
+
patch, contextData.line_start, contextData.line_end || contextData.line_start, contextData.side
|
|
1122
|
+
);
|
|
1123
|
+
if (hunk) {
|
|
1124
|
+
lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
|
|
1125
|
+
}
|
|
1126
|
+
} else {
|
|
1127
|
+
const ranges = window.DiffContext.extractHunkRangesForFile(patch);
|
|
1128
|
+
if (ranges.length) {
|
|
1129
|
+
lines.push(`- Diff hunk ranges: ${JSON.stringify(ranges)}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
this._pendingContext.push(lines.join('\n'));
|
|
1135
|
+
|
|
1136
|
+
// Render the compact context card in the UI
|
|
1137
|
+
this._addContextCard(ctx, { removable: true });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Store pending context and render a compact context card for a user comment or line reference.
|
|
1142
|
+
* Called when the user clicks "Ask about this" on a user comment, or clicks
|
|
1143
|
+
* the gutter chat button (line reference with no comment body).
|
|
1144
|
+
* The context is NOT sent to the agent immediately -- it is prepended
|
|
1145
|
+
* to the next user message so the agent receives question + context together.
|
|
1146
|
+
* @param {Object} ctx - Comment context {commentId, type, body, file, line_start, line_end, source, isFileLevel}
|
|
1147
|
+
*/
|
|
1148
|
+
_sendCommentContextMessage(ctx) {
|
|
1149
|
+
// Remove empty state if present
|
|
1150
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
1151
|
+
if (emptyState) emptyState.remove();
|
|
1152
|
+
|
|
1153
|
+
const isLine = ctx.type === 'line';
|
|
1154
|
+
|
|
1155
|
+
// Store structured context data for DB persistence
|
|
1156
|
+
const lineLabel = !ctx.line_start
|
|
1157
|
+
? (ctx.file || 'File').split('/').pop()
|
|
1158
|
+
: (ctx.line_end && ctx.line_end !== ctx.line_start ? `Lines ${ctx.line_start}-${ctx.line_end}` : `Line ${ctx.line_start}`);
|
|
1159
|
+
const contextData = {
|
|
1160
|
+
type: isLine ? 'line' : 'comment',
|
|
1161
|
+
title: isLine
|
|
1162
|
+
? lineLabel
|
|
1163
|
+
: (ctx.isFileLevel ? 'File comment' : `Comment on line ${ctx.line_start || '?'}`),
|
|
1164
|
+
file: ctx.file || null,
|
|
1165
|
+
line_start: ctx.line_start || null,
|
|
1166
|
+
line_end: ctx.line_end || null,
|
|
1167
|
+
body: ctx.body || null,
|
|
1168
|
+
source: 'user'
|
|
1169
|
+
};
|
|
1170
|
+
this._pendingContextData.push(contextData);
|
|
1171
|
+
|
|
1172
|
+
// Build the plain text context for the agent
|
|
1173
|
+
const lines = isLine
|
|
1174
|
+
? [ctx.line_start
|
|
1175
|
+
? `The user wants to discuss code at ${lineLabel} in ${contextData.file || 'unknown file'}:`
|
|
1176
|
+
: `The user wants to discuss the file ${contextData.file || 'unknown file'}:`]
|
|
1177
|
+
: ['The user wants to discuss a review comment:'];
|
|
1178
|
+
if (contextData.file) {
|
|
1179
|
+
let fileLine = `- File: ${contextData.file}`;
|
|
1180
|
+
if (contextData.line_start) {
|
|
1181
|
+
fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
|
|
1182
|
+
}
|
|
1183
|
+
lines.push(fileLine);
|
|
1184
|
+
}
|
|
1185
|
+
if (ctx.isFileLevel) {
|
|
1186
|
+
lines.push('- Scope: File-level comment');
|
|
1187
|
+
}
|
|
1188
|
+
if (ctx.parentId) {
|
|
1189
|
+
lines.push('- Origin: adopted from AI suggestion');
|
|
1190
|
+
}
|
|
1191
|
+
if (contextData.body) {
|
|
1192
|
+
lines.push(`- Comment: ${contextData.body}`);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Enrich with diff hunk if available
|
|
1196
|
+
const patch = window.prManager?.filePatches?.get(contextData.file);
|
|
1197
|
+
if (patch && window.DiffContext) {
|
|
1198
|
+
if (contextData.line_start && !ctx.isFileLevel) {
|
|
1199
|
+
const hunk = window.DiffContext.extractHunkForLines(
|
|
1200
|
+
patch, contextData.line_start, contextData.line_end || contextData.line_start
|
|
1201
|
+
);
|
|
1202
|
+
if (hunk) {
|
|
1203
|
+
lines.push(`- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``);
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
const ranges = window.DiffContext.extractHunkRangesForFile(patch);
|
|
1207
|
+
if (ranges.length) {
|
|
1208
|
+
lines.push(`- Diff hunk ranges: ${JSON.stringify(ranges)}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
this._pendingContext.push(lines.join('\n'));
|
|
1214
|
+
|
|
1215
|
+
// Render the compact context card in the UI
|
|
1216
|
+
if (isLine) {
|
|
1217
|
+
this._addLineContextCard(ctx, { removable: true });
|
|
1218
|
+
} else {
|
|
1219
|
+
this._addCommentContextCard(ctx, { removable: true });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Send a file context message to the chat panel.
|
|
1225
|
+
* Called when the user clicks "Chat about file" on a file header.
|
|
1226
|
+
* @param {Object} fileContext - File context data
|
|
1227
|
+
* @param {string} fileContext.file - File path
|
|
1228
|
+
*/
|
|
1229
|
+
_sendFileContextMessage(fileContext) {
|
|
1230
|
+
let contextText = `The user wants to discuss ${fileContext.file}`;
|
|
1231
|
+
|
|
1232
|
+
// Check for duplicate context (use startsWith because contextText may
|
|
1233
|
+
// get enriched with diff hunk ranges after this check)
|
|
1234
|
+
const isDuplicate = this._pendingContext.some(c => c === contextText || c.startsWith(contextText)) ||
|
|
1235
|
+
this.messages.some(m => m.role === 'context' && (m.content === contextText || m.content.startsWith(contextText)));
|
|
1236
|
+
if (isDuplicate) return;
|
|
1237
|
+
|
|
1238
|
+
// Remove empty state if present
|
|
1239
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
1240
|
+
if (emptyState) emptyState.remove();
|
|
1241
|
+
|
|
1242
|
+
// Store structured context data for DB persistence
|
|
1243
|
+
const contextData = {
|
|
1244
|
+
type: 'file',
|
|
1245
|
+
title: fileContext.file,
|
|
1246
|
+
file: fileContext.file,
|
|
1247
|
+
line_start: null,
|
|
1248
|
+
line_end: null,
|
|
1249
|
+
body: null
|
|
1250
|
+
};
|
|
1251
|
+
this._pendingContextData.push(contextData);
|
|
1252
|
+
|
|
1253
|
+
// Enrich with diff hunk ranges if available
|
|
1254
|
+
const patch = window.prManager?.filePatches?.get(fileContext.file);
|
|
1255
|
+
if (patch && window.DiffContext) {
|
|
1256
|
+
const ranges = window.DiffContext.extractHunkRangesForFile(patch);
|
|
1257
|
+
if (ranges.length) {
|
|
1258
|
+
contextText += `\n- Diff hunk ranges: ${JSON.stringify(ranges)}`;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
this._pendingContext.push(contextText);
|
|
1263
|
+
|
|
1264
|
+
// Render the compact context card in the UI
|
|
1265
|
+
this._addFileContextCard(contextData, { removable: true });
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Add an analysis run as context for the chat conversation.
|
|
1270
|
+
* Fetches run metadata from the backend and creates a removable context card
|
|
1271
|
+
* that participates in the pending context arrays (data-context-index path).
|
|
1272
|
+
* Unlike the auto-added analysis card (data-analysis="true"), this is a
|
|
1273
|
+
* manually-added card that goes through the standard pending context flow.
|
|
1274
|
+
* @param {string} runId - The analysis run ID to add as context
|
|
1275
|
+
*/
|
|
1276
|
+
async addAnalysisRunContext(runId) {
|
|
1277
|
+
// 1. Check for duplicate - look for any card with this run ID (both auto-added and manually-added)
|
|
1278
|
+
const existingCard = this.messagesEl?.querySelector(`[data-analysis-run-id="${runId}"]`);
|
|
1279
|
+
if (existingCard) {
|
|
1280
|
+
this._showToast('Analysis run already added');
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// 2. Open panel if closed
|
|
1285
|
+
await this.open({ suppressFocus: true });
|
|
1286
|
+
|
|
1287
|
+
// Re-check: open() may have auto-added a card for this run via _ensureAnalysisContext
|
|
1288
|
+
const existingCardPostOpen = this.messagesEl?.querySelector(
|
|
1289
|
+
`[data-analysis-run-id="${runId}"]`
|
|
1290
|
+
);
|
|
1291
|
+
if (existingCardPostOpen) {
|
|
1292
|
+
this._showToast('Analysis run already added');
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// 3. Fetch context from backend
|
|
1297
|
+
const response = await fetch(`/api/chat/analysis-context/${runId}?reviewId=${this.reviewId}`);
|
|
1298
|
+
if (!response.ok) {
|
|
1299
|
+
console.error('[ChatPanel] Failed to fetch analysis context:', response.statusText);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const result = await response.json();
|
|
1303
|
+
const data = result.data;
|
|
1304
|
+
|
|
1305
|
+
// 4. Push to pending context arrays
|
|
1306
|
+
this._pendingContext.push(data.text);
|
|
1307
|
+
const contextData = {
|
|
1308
|
+
type: 'analysis-run',
|
|
1309
|
+
aiRunId: runId,
|
|
1310
|
+
provider: data.run.provider,
|
|
1311
|
+
model: data.run.model,
|
|
1312
|
+
summary: data.run.summary,
|
|
1313
|
+
suggestionCount: data.suggestionCount,
|
|
1314
|
+
configType: data.run.configType,
|
|
1315
|
+
completedAt: data.run.completedAt
|
|
1316
|
+
};
|
|
1317
|
+
this._pendingContextData.push(contextData);
|
|
1318
|
+
|
|
1319
|
+
// 5. Remove empty state if present
|
|
1320
|
+
const emptyState = this.messagesEl?.querySelector('.chat-panel__empty');
|
|
1321
|
+
if (emptyState) emptyState.remove();
|
|
1322
|
+
|
|
1323
|
+
// 6. Create the card and append
|
|
1324
|
+
this._addAnalysisRunContextCard(contextData, { removable: true });
|
|
1325
|
+
|
|
1326
|
+
// 7. Focus input
|
|
1327
|
+
if (this.inputEl) this.inputEl.focus();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Make a context card removable by adding a data-context-index and a remove button.
|
|
1332
|
+
* Shared helper used by _addContextCard, _addCommentContextCard, and _addFileContextCard.
|
|
1333
|
+
* @param {HTMLElement} card - The context card element
|
|
1334
|
+
*/
|
|
1335
|
+
_makeCardRemovable(card) {
|
|
1336
|
+
const idx = this._pendingContextData.length - 1;
|
|
1337
|
+
card.dataset.contextIndex = idx;
|
|
1338
|
+
const removeBtn = document.createElement('button');
|
|
1339
|
+
removeBtn.className = 'chat-panel__context-remove';
|
|
1340
|
+
removeBtn.title = 'Remove context';
|
|
1341
|
+
removeBtn.innerHTML = '\u00d7';
|
|
1342
|
+
removeBtn.addEventListener('click', (e) => {
|
|
1343
|
+
e.stopPropagation();
|
|
1344
|
+
this._removeContextCard(card);
|
|
1345
|
+
});
|
|
1346
|
+
card.appendChild(removeBtn);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Add a compact file context card to the messages area.
|
|
1351
|
+
* @param {Object} ctx - File context data { file, title }
|
|
1352
|
+
* @param {Object} [options] - Options
|
|
1353
|
+
* @param {boolean} [options.removable=false] - Whether the card should have a remove button
|
|
1354
|
+
*/
|
|
1355
|
+
_addFileContextCard(ctx, { removable = false } = {}) {
|
|
1356
|
+
const card = document.createElement('div');
|
|
1357
|
+
card.className = 'chat-panel__context-card';
|
|
1358
|
+
|
|
1359
|
+
const filePath = ctx.file || ctx.title || '';
|
|
1360
|
+
|
|
1361
|
+
card.innerHTML = `
|
|
1362
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
1363
|
+
<path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/>
|
|
1364
|
+
</svg>
|
|
1365
|
+
<span class="chat-panel__context-label"><strong>FILE</strong></span>
|
|
1366
|
+
<span class="chat-panel__context-title">${this._escapeHtml(filePath)}</span>
|
|
1367
|
+
`;
|
|
1368
|
+
|
|
1369
|
+
if (removable) this._makeCardRemovable(card);
|
|
1370
|
+
|
|
1371
|
+
this.messagesEl.appendChild(card);
|
|
1372
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Ensure the latest AI analysis context is added as the first context item.
|
|
1377
|
+
* Called on every panel expand (not just when opening with specific context).
|
|
1378
|
+
* Detects new analysis runs by comparing the latest completed run ID
|
|
1379
|
+
* against the one already loaded in the session. Only adds if suggestions exist.
|
|
1380
|
+
*/
|
|
1381
|
+
_ensureAnalysisContext() {
|
|
1382
|
+
// Determine the latest completed run ID from the analysis history manager or prManager
|
|
1383
|
+
const currentRunId = this._getLatestCompletedRunId();
|
|
1384
|
+
|
|
1385
|
+
// Detect whether a NEW analysis run has appeared since we last loaded context.
|
|
1386
|
+
// If the run ID changed, we need to replace the old card with a new one.
|
|
1387
|
+
// This handles the case where _sessionAnalysisRunId was explicitly set.
|
|
1388
|
+
const isNewRunVsSession = currentRunId && this._sessionAnalysisRunId &&
|
|
1389
|
+
String(currentRunId) !== String(this._sessionAnalysisRunId);
|
|
1390
|
+
|
|
1391
|
+
if (isNewRunVsSession) {
|
|
1392
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: new run detected:', currentRunId, '(was:', this._sessionAnalysisRunId + ')');
|
|
1393
|
+
// Remove the old analysis card from the DOM (if present)
|
|
1394
|
+
const oldCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1395
|
+
if (oldCard) oldCard.remove();
|
|
1396
|
+
// Reset flags — the user removed the OLD run's context, but this is a different run
|
|
1397
|
+
this._analysisContextRemoved = false;
|
|
1398
|
+
this._sessionAnalysisRunId = null;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Check for an existing card in the DOM (e.g., loaded from MRU session history).
|
|
1402
|
+
// If _sessionAnalysisRunId is not set, this card may be stale — compare its
|
|
1403
|
+
// stamped run ID against the latest completed run to detect new analyses that
|
|
1404
|
+
// completed while the panel was closed.
|
|
1405
|
+
const existingCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1406
|
+
if (existingCard) {
|
|
1407
|
+
if (!this._sessionAnalysisRunId && currentRunId) {
|
|
1408
|
+
const cardRunId = existingCard.dataset.analysisRunId || null;
|
|
1409
|
+
if (cardRunId && String(cardRunId) === String(currentRunId)) {
|
|
1410
|
+
// Card matches the latest run — adopt its run ID so future opens can detect changes
|
|
1411
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: adopting existing card runId:', cardRunId);
|
|
1412
|
+
this._sessionAnalysisRunId = String(currentRunId);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
// Card has no run ID stamp or a different run ID — it's stale.
|
|
1416
|
+
// Remove it so a fresh card for the current run is added below.
|
|
1417
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: replacing stale DOM card (card:', cardRunId, 'latest:', currentRunId + ')');
|
|
1418
|
+
existingCard.remove();
|
|
1419
|
+
this._analysisContextRemoved = false;
|
|
1420
|
+
} else {
|
|
1421
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — card already in DOM');
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Skip if the current session already has analysis context loaded (by run ID)
|
|
1427
|
+
// and no new run was detected (handled above)
|
|
1428
|
+
if (this._sessionAnalysisRunId) {
|
|
1429
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — runId already set:', this._sessionAnalysisRunId);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Skip if analysis context was explicitly removed in this conversation
|
|
1434
|
+
if (this._analysisContextRemoved) {
|
|
1435
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — explicitly removed');
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Count suggestions from the DOM (from the latest analysis run)
|
|
1440
|
+
const suggestionEls = typeof document !== 'undefined' && document.querySelectorAll
|
|
1441
|
+
? document.querySelectorAll('.ai-suggestion[data-suggestion-id]')
|
|
1442
|
+
: [];
|
|
1443
|
+
const count = suggestionEls.length;
|
|
1444
|
+
if (count === 0) {
|
|
1445
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: skipped — no suggestions in DOM');
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
console.debug('[ChatPanel] _ensureAnalysisContext: adding card with', count, 'suggestions');
|
|
1449
|
+
|
|
1450
|
+
// Remove empty state
|
|
1451
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
1452
|
+
if (emptyState) emptyState.remove();
|
|
1453
|
+
|
|
1454
|
+
// Render the analysis context card (removable).
|
|
1455
|
+
// Prepend only when the messages area is empty (fresh conversation) so the card
|
|
1456
|
+
// appears first. When re-opening an existing chat that already has messages,
|
|
1457
|
+
// append instead so the card lands at the bottom where the user can see it
|
|
1458
|
+
// (prepending + scrollToBottom would hide it above the fold).
|
|
1459
|
+
// Note: analysis card is NOT added to _pendingContext/_pendingContextData —
|
|
1460
|
+
// the backend includes full suggestion data via initialContext at session creation.
|
|
1461
|
+
// The card is a visual indicator that controls whether the backend includes it.
|
|
1462
|
+
const hasExistingMessages = this.messagesEl.querySelectorAll('.chat-panel__message').length > 0;
|
|
1463
|
+
const contextData = this._buildAnalysisContextData(currentRunId, count);
|
|
1464
|
+
this._addAnalysisContextCard(contextData, { removable: true, prepend: !hasExistingMessages });
|
|
1465
|
+
|
|
1466
|
+
// Persist to DB so the card is restored on session reload
|
|
1467
|
+
this._persistAnalysisContext(contextData);
|
|
1468
|
+
|
|
1469
|
+
// Mark that analysis context is loaded for this session.
|
|
1470
|
+
// Use the actual run ID if available, otherwise fall back to 'dom'.
|
|
1471
|
+
this._sessionAnalysisRunId = currentRunId || 'dom';
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Build enriched analysis context data for the card.
|
|
1476
|
+
* Pulls metadata (provider, model, summary, configType) from the
|
|
1477
|
+
* cached runs in analysisHistoryManager so the card's tooltip
|
|
1478
|
+
* shows rich info even before a session is created.
|
|
1479
|
+
* @param {string|null} runId - The run ID to look up metadata for
|
|
1480
|
+
* @param {number} count - Number of suggestions in the DOM
|
|
1481
|
+
* @returns {Object} Context data with type, suggestionCount, and optional metadata
|
|
1482
|
+
*/
|
|
1483
|
+
_buildAnalysisContextData(runId, count) {
|
|
1484
|
+
const contextData = { type: 'analysis', suggestionCount: count };
|
|
1485
|
+
|
|
1486
|
+
if (!runId) return contextData;
|
|
1487
|
+
|
|
1488
|
+
// Look up the run in the cached analysisHistoryManager.runs array
|
|
1489
|
+
const mgr = window.prManager;
|
|
1490
|
+
const historyMgr = mgr?.analysisHistoryManager;
|
|
1491
|
+
if (!historyMgr?.runs?.length) return contextData;
|
|
1492
|
+
|
|
1493
|
+
const run = historyMgr.runs.find(r => String(r.id) === String(runId));
|
|
1494
|
+
if (!run) return contextData;
|
|
1495
|
+
|
|
1496
|
+
// Enrich with available metadata
|
|
1497
|
+
if (run.provider) contextData.provider = run.provider;
|
|
1498
|
+
if (run.model) contextData.model = run.model;
|
|
1499
|
+
if (run.summary) contextData.summary = run.summary;
|
|
1500
|
+
if (run.config_type) contextData.configType = run.config_type;
|
|
1501
|
+
if (run.completed_at) contextData.completedAt = run.completed_at;
|
|
1502
|
+
contextData.aiRunId = String(run.id);
|
|
1503
|
+
|
|
1504
|
+
return contextData;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Get the ID of the latest completed (successful) analysis run.
|
|
1509
|
+
* Looks at the cached runs in analysisHistoryManager (sorted by date DESC)
|
|
1510
|
+
* and returns the first one with status 'completed'.
|
|
1511
|
+
* Falls back to the selected run ID or prManager.selectedRunId for
|
|
1512
|
+
* backward compatibility when the runs array is not available.
|
|
1513
|
+
* @returns {string|null}
|
|
1514
|
+
*/
|
|
1515
|
+
_getLatestCompletedRunId() {
|
|
1516
|
+
const mgr = window.prManager;
|
|
1517
|
+
if (!mgr) return null;
|
|
1518
|
+
|
|
1519
|
+
// Prefer the analysisHistoryManager's runs array — find latest completed run
|
|
1520
|
+
const historyMgr = mgr.analysisHistoryManager;
|
|
1521
|
+
if (historyMgr?.runs?.length > 0) {
|
|
1522
|
+
// Runs are sorted by date DESC; find the first completed one
|
|
1523
|
+
const completedRun = historyMgr.runs.find(r => r.status === 'completed');
|
|
1524
|
+
if (completedRun) return String(completedRun.id);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// Fall back to selectedRunId on the history manager (for cases where
|
|
1528
|
+
// runs array is empty but a selection exists)
|
|
1529
|
+
if (historyMgr?.getSelectedRunId) {
|
|
1530
|
+
const id = historyMgr.getSelectedRunId();
|
|
1531
|
+
if (id) return String(id);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Fall back to prManager.selectedRunId
|
|
1535
|
+
if (mgr.selectedRunId) return String(mgr.selectedRunId);
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Remove the analysis context card and mark it as explicitly removed.
|
|
1541
|
+
* When removed, the backend will skip including analysis suggestions in the session.
|
|
1542
|
+
* @param {HTMLElement} cardEl - The analysis context card element
|
|
1543
|
+
*/
|
|
1544
|
+
_removeAnalysisContextCard(cardEl) {
|
|
1545
|
+
this._analysisContextRemoved = true;
|
|
1546
|
+
cardEl.remove();
|
|
1547
|
+
|
|
1548
|
+
// If no pending context, no messages, and no other context cards, restore empty state
|
|
1549
|
+
if (this._pendingContext.length === 0 && this.messages.length === 0 &&
|
|
1550
|
+
!this.messagesEl.querySelector('.chat-panel__context-card')) {
|
|
1551
|
+
this._clearMessages();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Add a compact context card for a user comment to the messages area.
|
|
1557
|
+
* @param {Object} ctx - Comment context {commentId, body, file, line_start, line_end, isFileLevel}
|
|
1558
|
+
*/
|
|
1559
|
+
_addCommentContextCard(ctx, { removable = false } = {}) {
|
|
1560
|
+
const card = document.createElement('div');
|
|
1561
|
+
card.className = 'chat-panel__context-card';
|
|
1562
|
+
|
|
1563
|
+
const label = ctx.isFileLevel ? 'file comment' : 'comment';
|
|
1564
|
+
const bodyPreview = ctx.body ? (ctx.body.length > 60 ? ctx.body.substring(0, 60) + '...' : ctx.body) : 'Comment';
|
|
1565
|
+
const fileInfo = ctx.file
|
|
1566
|
+
? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}`
|
|
1567
|
+
: '';
|
|
1568
|
+
|
|
1569
|
+
card.innerHTML = `
|
|
1570
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
1571
|
+
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
1572
|
+
</svg>
|
|
1573
|
+
<span class="chat-panel__context-label">${this._escapeHtml(label)}</span>
|
|
1574
|
+
<span class="chat-panel__context-title">${this._renderInlineMarkdown(bodyPreview)}</span>
|
|
1575
|
+
${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
|
|
1576
|
+
`;
|
|
1577
|
+
|
|
1578
|
+
// Store tooltip data for rich hover preview
|
|
1579
|
+
if (ctx.body) card.dataset.tooltipBody = ctx.body;
|
|
1580
|
+
|
|
1581
|
+
if (removable) this._makeCardRemovable(card);
|
|
1582
|
+
|
|
1583
|
+
this.messagesEl.appendChild(card);
|
|
1584
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Add a compact context card for a line reference (optionally with body text).
|
|
1589
|
+
* @param {Object} ctx - Line context {file, line_start, line_end, body}
|
|
1590
|
+
*/
|
|
1591
|
+
_addLineContextCard(ctx, { removable = false } = {}) {
|
|
1592
|
+
const card = document.createElement('div');
|
|
1593
|
+
card.className = 'chat-panel__context-card';
|
|
1594
|
+
|
|
1595
|
+
const lineLabel = !ctx.line_start
|
|
1596
|
+
? (ctx.file || 'File').split('/').pop()
|
|
1597
|
+
: (ctx.line_end && ctx.line_end !== ctx.line_start ? `Lines ${ctx.line_start}-${ctx.line_end}` : `Line ${ctx.line_start}`);
|
|
1598
|
+
const fileInfo = ctx.file
|
|
1599
|
+
? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}`
|
|
1600
|
+
: '';
|
|
1601
|
+
|
|
1602
|
+
// When body text is provided (e.g. unsaved comment text), show it as the title
|
|
1603
|
+
const titleText = ctx.body
|
|
1604
|
+
? (ctx.body.length > 60 ? ctx.body.substring(0, 60) + '...' : ctx.body)
|
|
1605
|
+
: lineLabel;
|
|
1606
|
+
|
|
1607
|
+
const label = !ctx.line_start ? 'FILE' : (ctx.line_end && ctx.line_end !== ctx.line_start ? 'LINES' : 'LINE');
|
|
1608
|
+
|
|
1609
|
+
// Code icon (octicon code-square)
|
|
1610
|
+
card.innerHTML = `
|
|
1611
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
1612
|
+
<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>
|
|
1613
|
+
</svg>
|
|
1614
|
+
<span class="chat-panel__context-label"><strong>${label}</strong></span>
|
|
1615
|
+
<span class="chat-panel__context-title">${this._escapeHtml(titleText)}</span>
|
|
1616
|
+
${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
|
|
1617
|
+
`;
|
|
1618
|
+
|
|
1619
|
+
// Store tooltip data for rich hover preview when body text is present
|
|
1620
|
+
if (ctx.body) card.dataset.tooltipBody = ctx.body;
|
|
1621
|
+
|
|
1622
|
+
if (removable) this._makeCardRemovable(card);
|
|
1623
|
+
|
|
1624
|
+
this.messagesEl.appendChild(card);
|
|
1625
|
+
requestAnimationFrame(() => this.scrollToBottom());
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Add a compact context card to the messages area.
|
|
1630
|
+
* Visually indicates which suggestion the user is asking about,
|
|
1631
|
+
* without taking up space as a full message bubble.
|
|
1632
|
+
* @param {Object} ctx - Suggestion context {title, type, file, line_start, line_end, body}
|
|
1633
|
+
*/
|
|
1634
|
+
_addContextCard(ctx, { removable = false } = {}) {
|
|
1635
|
+
const card = document.createElement('div');
|
|
1636
|
+
card.className = 'chat-panel__context-card';
|
|
1637
|
+
|
|
1638
|
+
const typeLabel = ctx.type || 'suggestion';
|
|
1639
|
+
const fileInfo = ctx.file ? `${ctx.file}${ctx.line_start ? ':' + ctx.line_start : ''}` : '';
|
|
1640
|
+
|
|
1641
|
+
card.innerHTML = `
|
|
1642
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
1643
|
+
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>
|
|
1644
|
+
</svg>
|
|
1645
|
+
<span class="chat-panel__context-label">${this._renderInlineMarkdown(typeLabel)}</span>
|
|
1646
|
+
<span class="chat-panel__context-title">${this._renderInlineMarkdown(ctx.title || 'Untitled')}</span>
|
|
1647
|
+
${fileInfo ? `<span class="chat-panel__context-file">${this._escapeHtml(fileInfo)}</span>` : ''}
|
|
1648
|
+
`;
|
|
1649
|
+
|
|
1650
|
+
// Store tooltip data for rich hover preview
|
|
1651
|
+
if (ctx.body) card.dataset.tooltipBody = ctx.body;
|
|
1652
|
+
if (ctx.type) card.dataset.tooltipType = ctx.type;
|
|
1653
|
+
if (ctx.title) card.dataset.tooltipTitle = ctx.title;
|
|
1654
|
+
|
|
1655
|
+
if (removable) this._makeCardRemovable(card);
|
|
1656
|
+
|
|
1657
|
+
this.messagesEl.appendChild(card);
|
|
1658
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Restore remove buttons and data-context-index on all pending context cards.
|
|
1663
|
+
* Called after a failed send to unlock cards that were locked prematurely.
|
|
1664
|
+
*/
|
|
1665
|
+
_restoreRemovableCards() {
|
|
1666
|
+
// Restore analysis context card if it was locked
|
|
1667
|
+
const analysisCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1668
|
+
if (analysisCard && !analysisCard.querySelector('.chat-panel__context-remove')) {
|
|
1669
|
+
const removeBtn = document.createElement('button');
|
|
1670
|
+
removeBtn.className = 'chat-panel__context-remove';
|
|
1671
|
+
removeBtn.title = 'Remove context';
|
|
1672
|
+
removeBtn.innerHTML = '\u00d7';
|
|
1673
|
+
removeBtn.addEventListener('click', (e) => {
|
|
1674
|
+
e.stopPropagation();
|
|
1675
|
+
this._removeAnalysisContextCard(analysisCard);
|
|
1676
|
+
});
|
|
1677
|
+
analysisCard.appendChild(removeBtn);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const cards = this.messagesEl.querySelectorAll('.chat-panel__context-card:not([data-analysis])');
|
|
1681
|
+
let idx = 0;
|
|
1682
|
+
cards.forEach((card) => {
|
|
1683
|
+
// Only restore cards that don't already have a remove button
|
|
1684
|
+
if (!card.querySelector('.chat-panel__context-remove')) {
|
|
1685
|
+
card.dataset.contextIndex = idx;
|
|
1686
|
+
const removeBtn = document.createElement('button');
|
|
1687
|
+
removeBtn.className = 'chat-panel__context-remove';
|
|
1688
|
+
removeBtn.title = 'Remove context';
|
|
1689
|
+
removeBtn.innerHTML = '\u00d7';
|
|
1690
|
+
removeBtn.addEventListener('click', (e) => {
|
|
1691
|
+
e.stopPropagation();
|
|
1692
|
+
this._removeContextCard(card);
|
|
1693
|
+
});
|
|
1694
|
+
card.appendChild(removeBtn);
|
|
1695
|
+
} else {
|
|
1696
|
+
card.dataset.contextIndex = idx;
|
|
1697
|
+
}
|
|
1698
|
+
idx++;
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Remove a pending context card from the UI and data arrays.
|
|
1704
|
+
* Re-indexes remaining cards so data-context-index stays in sync.
|
|
1705
|
+
* @param {HTMLElement} cardEl - The context card element to remove
|
|
1706
|
+
*/
|
|
1707
|
+
_removeContextCard(cardEl) {
|
|
1708
|
+
const idx = parseInt(cardEl.dataset.contextIndex, 10);
|
|
1709
|
+
if (!isNaN(idx) && idx >= 0 && idx < this._pendingContext.length) {
|
|
1710
|
+
this._pendingContext.splice(idx, 1);
|
|
1711
|
+
this._pendingContextData.splice(idx, 1);
|
|
1712
|
+
}
|
|
1713
|
+
// Hide context tooltip – mouseleave won't fire on a removed element
|
|
1714
|
+
clearTimeout(this._ctxTooltipTimer);
|
|
1715
|
+
if (this._ctxTooltipEl) this._ctxTooltipEl.style.display = 'none';
|
|
1716
|
+
|
|
1717
|
+
cardEl.remove();
|
|
1718
|
+
|
|
1719
|
+
// Re-index remaining removable context cards
|
|
1720
|
+
const remainingCards = this.messagesEl.querySelectorAll('.chat-panel__context-card[data-context-index]');
|
|
1721
|
+
remainingCards.forEach((card, i) => {
|
|
1722
|
+
card.dataset.contextIndex = i;
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
// If no pending context, no messages, and no other context cards, restore empty state
|
|
1726
|
+
if (this._pendingContext.length === 0 && this.messages.length === 0 &&
|
|
1727
|
+
!this.messagesEl.querySelector('.chat-panel__context-card')) {
|
|
1728
|
+
this._clearMessages();
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Show analysis context card if the session response includes context metadata.
|
|
1734
|
+
* Removes the empty state first so the card appears as the first element.
|
|
1735
|
+
* @param {Object} sessionData - Response data from createSession ({ id, status, context? })
|
|
1736
|
+
*/
|
|
1737
|
+
_showAnalysisContextIfPresent(sessionData) {
|
|
1738
|
+
if (sessionData.context && sessionData.context.suggestionCount > 0) {
|
|
1739
|
+
const existingCard = this.messagesEl.querySelector('.chat-panel__context-card[data-analysis]');
|
|
1740
|
+
if (existingCard) {
|
|
1741
|
+
// Upgrade a bare-bones card (no metadata) with richer data from the backend.
|
|
1742
|
+
// Update IN-PLACE to preserve the card's DOM position (avoids jumping below user message).
|
|
1743
|
+
const hasRicherContext = !existingCard.dataset.hasMetadata &&
|
|
1744
|
+
(sessionData.context.provider || sessionData.context.model || sessionData.context.summary);
|
|
1745
|
+
if (!hasRicherContext) return;
|
|
1746
|
+
this._updateAnalysisCardContent(existingCard, sessionData.context);
|
|
1747
|
+
} else {
|
|
1748
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
1749
|
+
if (emptyState) emptyState.remove();
|
|
1750
|
+
this._addAnalysisContextCard(sessionData.context);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Persist richer analysis context to DB (includes provider, model, summary, etc.)
|
|
1754
|
+
const contextData = { type: 'analysis', ...sessionData.context };
|
|
1755
|
+
this._persistAnalysisContext(contextData);
|
|
1756
|
+
|
|
1757
|
+
// Track which run's context is loaded so _ensureAnalysisContext can skip if already present
|
|
1758
|
+
this._sessionAnalysisRunId = sessionData.context.aiRunId || 'session';
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Build the inner HTML string for an analysis context card.
|
|
1764
|
+
* Shared by _addAnalysisContextCard (new card) and _updateAnalysisCardContent (in-place upgrade).
|
|
1765
|
+
* @param {Object} context - Context metadata { suggestionCount, provider, model, summary, configType }
|
|
1766
|
+
* @returns {string} HTML string for the card's content (SVG icon + label + title span)
|
|
1767
|
+
*/
|
|
1768
|
+
_buildAnalysisCardInnerHTML(context) {
|
|
1769
|
+
const count = context.suggestionCount;
|
|
1770
|
+
const title = count === 1 ? '1 suggestion loaded' : `${count} suggestions loaded`;
|
|
1771
|
+
|
|
1772
|
+
// Build metadata details string (model/provider info)
|
|
1773
|
+
const metaParts = [];
|
|
1774
|
+
if (context.provider) metaParts.push(this._escapeHtml(context.provider));
|
|
1775
|
+
if (context.model) metaParts.push(this._escapeHtml(context.model));
|
|
1776
|
+
const metaStr = metaParts.length > 0 ? ` (${metaParts.join(' / ')})` : '';
|
|
1777
|
+
|
|
1778
|
+
// Build tooltip with provider, model, config, and summary (no title, no completedAt here since it's formatted for display)
|
|
1779
|
+
const tooltipParts = [];
|
|
1780
|
+
if (context.provider || context.model) {
|
|
1781
|
+
tooltipParts.push(`Provider: ${context.provider || 'unknown'}, Model: ${context.model || 'unknown'}`);
|
|
1782
|
+
}
|
|
1783
|
+
if (context.configType) tooltipParts.push(`Config: ${context.configType}`);
|
|
1784
|
+
if (context.completedAt) {
|
|
1785
|
+
const completedDate = window.parseTimestamp(context.completedAt);
|
|
1786
|
+
tooltipParts.push(`Completed: ${completedDate.toLocaleString()}`);
|
|
1787
|
+
}
|
|
1788
|
+
if (context.summary) tooltipParts.push(`Summary: ${context.summary}`);
|
|
1789
|
+
const tooltip = tooltipParts.join('\n');
|
|
1790
|
+
|
|
1791
|
+
return `
|
|
1792
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
|
|
1793
|
+
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
|
|
1794
|
+
</svg>
|
|
1795
|
+
<span class="chat-panel__context-label">analysis run</span>
|
|
1796
|
+
<span class="chat-panel__context-title" title="${window.escapeHtmlAttribute(tooltip)}">${this._escapeHtml(title)}${metaStr}</span>
|
|
1797
|
+
`;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* Update an existing analysis context card's content and dataset in-place.
|
|
1802
|
+
* Preserves the card's DOM position (avoids remove+recreate which causes ordering bugs).
|
|
1803
|
+
* @param {HTMLElement} card - The existing analysis context card element
|
|
1804
|
+
* @param {Object} context - Richer context metadata from the backend
|
|
1805
|
+
*/
|
|
1806
|
+
_updateAnalysisCardContent(card, context) {
|
|
1807
|
+
// Preserve existing remove button (if card is removable) before replacing innerHTML
|
|
1808
|
+
const removeBtn = card.querySelector('.chat-panel__context-remove');
|
|
1809
|
+
|
|
1810
|
+
// Rebuild card innerHTML with richer metadata
|
|
1811
|
+
card.innerHTML = this._buildAnalysisCardInnerHTML(context);
|
|
1812
|
+
|
|
1813
|
+
// Re-append the remove button if it existed
|
|
1814
|
+
if (removeBtn) {
|
|
1815
|
+
card.appendChild(removeBtn);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// Update dataset attributes
|
|
1819
|
+
if (context.aiRunId) {
|
|
1820
|
+
card.dataset.analysisRunId = context.aiRunId;
|
|
1821
|
+
}
|
|
1822
|
+
if (context.provider || context.model || context.summary) {
|
|
1823
|
+
card.dataset.hasMetadata = 'true';
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Add a compact analysis-run context card to the messages area.
|
|
1829
|
+
* Used for manually-added analysis run cards that participate in the pending
|
|
1830
|
+
* context arrays (data-context-index path). Unlike _addAnalysisContextCard
|
|
1831
|
+
* (auto-added, data-analysis="true"), these cards use _removeContextCard.
|
|
1832
|
+
* @param {Object} ctxData - Context data { type, aiRunId, provider, model, summary, suggestionCount, configType }
|
|
1833
|
+
* @param {Object} [opts] - Options
|
|
1834
|
+
* @param {boolean} [opts.removable=false] - Whether the card should have a remove button
|
|
1835
|
+
*/
|
|
1836
|
+
_addAnalysisRunContextCard(ctxData, { removable = false } = {}) {
|
|
1837
|
+
const card = document.createElement('div');
|
|
1838
|
+
card.className = 'chat-panel__context-card';
|
|
1839
|
+
card.dataset.contextIndex = this._pendingContext.length - 1;
|
|
1840
|
+
card.dataset.analysisRunId = ctxData.aiRunId;
|
|
1841
|
+
card.innerHTML = this._buildAnalysisCardInnerHTML(ctxData);
|
|
1842
|
+
|
|
1843
|
+
if (removable) this._makeCardRemovable(card);
|
|
1844
|
+
|
|
1845
|
+
this.messagesEl.appendChild(card);
|
|
1846
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Add a compact analysis context card to the messages area.
|
|
1851
|
+
* Visually indicates that the agent has analysis suggestions loaded as context.
|
|
1852
|
+
* Displays run metadata (model, provider, summary) when available.
|
|
1853
|
+
* @param {Object} context - Context metadata { suggestionCount, aiRunId, provider, model, summary, completedAt, configType, parentRunId }
|
|
1854
|
+
*/
|
|
1855
|
+
_addAnalysisContextCard(context, { removable = false, prepend = false } = {}) {
|
|
1856
|
+
const card = document.createElement('div');
|
|
1857
|
+
card.className = 'chat-panel__context-card';
|
|
1858
|
+
card.dataset.analysis = 'true';
|
|
1859
|
+
if (context.aiRunId) {
|
|
1860
|
+
card.dataset.analysisRunId = context.aiRunId;
|
|
1861
|
+
}
|
|
1862
|
+
if (context.provider || context.model || context.summary) {
|
|
1863
|
+
card.dataset.hasMetadata = 'true';
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
card.innerHTML = this._buildAnalysisCardInnerHTML(context);
|
|
1867
|
+
|
|
1868
|
+
if (removable) {
|
|
1869
|
+
const removeBtn = document.createElement('button');
|
|
1870
|
+
removeBtn.className = 'chat-panel__context-remove';
|
|
1871
|
+
removeBtn.title = 'Remove context';
|
|
1872
|
+
removeBtn.innerHTML = '\u00d7';
|
|
1873
|
+
removeBtn.addEventListener('click', (e) => {
|
|
1874
|
+
e.stopPropagation();
|
|
1875
|
+
this._removeAnalysisContextCard(card);
|
|
1876
|
+
});
|
|
1877
|
+
card.appendChild(removeBtn);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (prepend) {
|
|
1881
|
+
const firstChild = this.messagesEl.firstChild;
|
|
1882
|
+
if (firstChild) {
|
|
1883
|
+
this.messagesEl.insertBefore(card, firstChild);
|
|
1884
|
+
} else {
|
|
1885
|
+
this.messagesEl.appendChild(card);
|
|
1886
|
+
}
|
|
1887
|
+
} else {
|
|
1888
|
+
this.messagesEl.appendChild(card);
|
|
1889
|
+
}
|
|
1890
|
+
requestAnimationFrame(() => this.scrollToBottom({ force: true }));
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* Persist an analysis context card to the backend as a 'context' message.
|
|
1895
|
+
* Called immediately when an analysis context card is added, so it appears
|
|
1896
|
+
* in the conversation history on reload.
|
|
1897
|
+
* @param {Object} contextData - Analysis context metadata (type, suggestionCount, etc.)
|
|
1898
|
+
*/
|
|
1899
|
+
async _persistAnalysisContext(contextData) {
|
|
1900
|
+
if (!this.currentSessionId) return;
|
|
1901
|
+
|
|
1902
|
+
try {
|
|
1903
|
+
const response = await fetch(`/api/chat/session/${this.currentSessionId}/context`, {
|
|
1904
|
+
method: 'POST',
|
|
1905
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1906
|
+
body: JSON.stringify({ contextData })
|
|
1907
|
+
});
|
|
1908
|
+
if (!response.ok) {
|
|
1909
|
+
console.warn('[ChatPanel] Failed to persist analysis context:', response.status);
|
|
1910
|
+
}
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
console.warn('[ChatPanel] Failed to persist analysis context:', err);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Ensure the global multiplexed SSE connection is established.
|
|
1918
|
+
* Creates the EventSource once; subsequent calls are no-ops if already connected.
|
|
1919
|
+
* Events are filtered by sessionId to dispatch only to the active session.
|
|
1920
|
+
*/
|
|
1921
|
+
_ensureGlobalSSE() {
|
|
1922
|
+
// Already connected or connecting — nothing to do
|
|
1923
|
+
if (this.eventSource &&
|
|
1924
|
+
this.eventSource.readyState !== EventSource.CLOSED) {
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Clear any pending reconnect timer
|
|
1929
|
+
clearTimeout(this._sseReconnectTimer);
|
|
1930
|
+
this._sseReconnectTimer = null;
|
|
1931
|
+
|
|
1932
|
+
const url = '/api/chat/stream';
|
|
1933
|
+
console.debug('[ChatPanel] Connecting multiplexed SSE:', url);
|
|
1934
|
+
this.eventSource = new EventSource(url);
|
|
1935
|
+
|
|
1936
|
+
this.eventSource.onmessage = (event) => {
|
|
1937
|
+
try {
|
|
1938
|
+
const data = JSON.parse(event.data);
|
|
1939
|
+
|
|
1940
|
+
// Initial connection acknowledgement — no sessionId, just log
|
|
1941
|
+
if (data.type === 'connected' && !data.sessionId) {
|
|
1942
|
+
console.debug('[ChatPanel] Multiplexed SSE connected');
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// Route review-scoped events to document as CustomEvents
|
|
1947
|
+
if (data.reviewId && data.type?.startsWith('review:')) {
|
|
1948
|
+
document.dispatchEvent(new CustomEvent(data.type, {
|
|
1949
|
+
detail: { ...data }
|
|
1950
|
+
}));
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// Filter: only process events for the active session
|
|
1955
|
+
if (data.sessionId !== this.currentSessionId) return;
|
|
1956
|
+
|
|
1957
|
+
if (data.type !== 'delta') {
|
|
1958
|
+
console.debug('[ChatPanel] SSE event:', data.type, 'session:', data.sessionId);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// When the panel is closed, still accumulate internal state
|
|
1962
|
+
// so messages are available when the panel reopens.
|
|
1963
|
+
if (!this.isOpen) {
|
|
1964
|
+
switch (data.type) {
|
|
1965
|
+
case 'delta':
|
|
1966
|
+
this._streamingContent += data.text;
|
|
1967
|
+
break;
|
|
1968
|
+
case 'complete':
|
|
1969
|
+
if (this._streamingContent) {
|
|
1970
|
+
this.messages.push({ role: 'assistant', content: this._streamingContent, id: data.messageId });
|
|
1971
|
+
}
|
|
1972
|
+
this._streamingContent = '';
|
|
1973
|
+
this.isStreaming = false;
|
|
1974
|
+
break;
|
|
1975
|
+
case 'error':
|
|
1976
|
+
this._streamingContent = '';
|
|
1977
|
+
this.isStreaming = false;
|
|
1978
|
+
break;
|
|
1979
|
+
// tool_use, status: purely visual, skip when closed
|
|
1980
|
+
}
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
switch (data.type) {
|
|
1985
|
+
case 'delta':
|
|
1986
|
+
this._hideThinkingIndicator();
|
|
1987
|
+
this._streamingContent += data.text;
|
|
1988
|
+
this.updateStreamingMessage(this._streamingContent);
|
|
1989
|
+
break;
|
|
1990
|
+
|
|
1991
|
+
case 'tool_use':
|
|
1992
|
+
this._showToolUse(data.toolName, data.status, data.toolInput);
|
|
1993
|
+
break;
|
|
1994
|
+
|
|
1995
|
+
case 'status':
|
|
1996
|
+
this._handleAgentStatus(data.status);
|
|
1997
|
+
break;
|
|
1998
|
+
|
|
1999
|
+
case 'complete':
|
|
2000
|
+
this.finalizeStreamingMessage(data.messageId);
|
|
2001
|
+
break;
|
|
2002
|
+
|
|
2003
|
+
case 'error':
|
|
2004
|
+
this._showError(data.message || 'An error occurred');
|
|
2005
|
+
this._finalizeStreaming();
|
|
2006
|
+
break;
|
|
2007
|
+
}
|
|
2008
|
+
} catch (e) {
|
|
2009
|
+
console.error('[ChatPanel] SSE parse error:', e);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
this.eventSource.onerror = () => {
|
|
2014
|
+
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
2015
|
+
console.warn('[ChatPanel] Multiplexed SSE connection closed, reconnecting in 2s');
|
|
2016
|
+
this.eventSource = null;
|
|
2017
|
+
this._sseReconnectTimer = setTimeout(() => {
|
|
2018
|
+
this._ensureGlobalSSE();
|
|
2019
|
+
}, 2000);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* Close the global SSE connection and cancel any reconnect timer.
|
|
2026
|
+
*/
|
|
2027
|
+
_closeGlobalSSE() {
|
|
2028
|
+
clearTimeout(this._sseReconnectTimer);
|
|
2029
|
+
this._sseReconnectTimer = null;
|
|
2030
|
+
if (this.eventSource) {
|
|
2031
|
+
this.eventSource.close();
|
|
2032
|
+
this.eventSource = null;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Add a message to the display
|
|
2038
|
+
* @param {string} role - 'user' or 'assistant'
|
|
2039
|
+
* @param {string} content - Message text
|
|
2040
|
+
* @param {number} id - Optional message ID
|
|
2041
|
+
* @returns {HTMLElement} The message element that was appended
|
|
2042
|
+
*/
|
|
2043
|
+
addMessage(role, content, id) {
|
|
2044
|
+
const msg = { role, content, id };
|
|
2045
|
+
this.messages.push(msg);
|
|
2046
|
+
|
|
2047
|
+
const msgEl = document.createElement('div');
|
|
2048
|
+
msgEl.className = `chat-panel__message chat-panel__message--${role}`;
|
|
2049
|
+
if (id) msgEl.dataset.messageId = id;
|
|
2050
|
+
|
|
2051
|
+
const bubble = document.createElement('div');
|
|
2052
|
+
bubble.className = 'chat-panel__bubble';
|
|
2053
|
+
|
|
2054
|
+
if (role === 'assistant') {
|
|
2055
|
+
bubble.innerHTML = this.renderMarkdown(content);
|
|
2056
|
+
this._linkifyFileReferences(bubble);
|
|
2057
|
+
bubble.appendChild(this._createCopyButton(content));
|
|
2058
|
+
} else {
|
|
2059
|
+
bubble.textContent = content;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
msgEl.appendChild(bubble);
|
|
2063
|
+
this.messagesEl.appendChild(msgEl);
|
|
2064
|
+
this.scrollToBottom({ force: true });
|
|
2065
|
+
return msgEl;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Create a copy-to-clipboard button for an assistant message bubble.
|
|
2070
|
+
* @param {string} rawContent - Raw markdown to copy
|
|
2071
|
+
* @returns {HTMLButtonElement}
|
|
2072
|
+
*/
|
|
2073
|
+
_createCopyButton(rawContent) {
|
|
2074
|
+
const btn = document.createElement('button');
|
|
2075
|
+
btn.className = 'chat-panel__copy-btn';
|
|
2076
|
+
btn.title = 'Copy message';
|
|
2077
|
+
const clipboardIcon = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
2078
|
+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
|
|
2079
|
+
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
|
|
2080
|
+
</svg>`;
|
|
2081
|
+
const checkIcon = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
2082
|
+
<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"/>
|
|
2083
|
+
</svg>`;
|
|
2084
|
+
btn.innerHTML = clipboardIcon;
|
|
2085
|
+
|
|
2086
|
+
btn.addEventListener('click', async (e) => {
|
|
2087
|
+
e.stopPropagation();
|
|
2088
|
+
try {
|
|
2089
|
+
await navigator.clipboard.writeText(rawContent);
|
|
2090
|
+
btn.innerHTML = checkIcon;
|
|
2091
|
+
btn.classList.add('chat-panel__copy-btn--success');
|
|
2092
|
+
setTimeout(() => {
|
|
2093
|
+
btn.innerHTML = clipboardIcon;
|
|
2094
|
+
btn.classList.remove('chat-panel__copy-btn--success');
|
|
2095
|
+
}, 2000);
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
console.error('[ChatPanel] Copy failed:', err);
|
|
2098
|
+
btn.title = 'Copy failed';
|
|
2099
|
+
setTimeout(() => { btn.title = 'Copy message'; }, 2000);
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
return btn;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Add a streaming placeholder for the assistant's response
|
|
2108
|
+
*/
|
|
2109
|
+
_addStreamingPlaceholder() {
|
|
2110
|
+
const msgEl = document.createElement('div');
|
|
2111
|
+
msgEl.className = 'chat-panel__message chat-panel__message--assistant chat-panel__message--streaming';
|
|
2112
|
+
msgEl.id = 'chat-streaming-msg';
|
|
2113
|
+
|
|
2114
|
+
const bubble = document.createElement('div');
|
|
2115
|
+
bubble.className = 'chat-panel__bubble';
|
|
2116
|
+
bubble.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
|
|
2117
|
+
|
|
2118
|
+
msgEl.appendChild(bubble);
|
|
2119
|
+
this.messagesEl.appendChild(msgEl);
|
|
2120
|
+
this.scrollToBottom({ force: true });
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Update the currently streaming message
|
|
2125
|
+
* @param {string} text - Full accumulated text so far
|
|
2126
|
+
*/
|
|
2127
|
+
updateStreamingMessage(text) {
|
|
2128
|
+
const streamingMsg = document.getElementById('chat-streaming-msg');
|
|
2129
|
+
if (!streamingMsg) return;
|
|
2130
|
+
|
|
2131
|
+
// Remove transient tool badge when real text arrives
|
|
2132
|
+
const transient = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2133
|
+
if (transient) transient.remove();
|
|
2134
|
+
|
|
2135
|
+
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2136
|
+
if (bubble) {
|
|
2137
|
+
bubble.innerHTML = this.renderMarkdown(text) + '<span class="chat-panel__cursor"></span>';
|
|
2138
|
+
this._linkifyFileReferences(bubble);
|
|
2139
|
+
}
|
|
2140
|
+
this.scrollToBottom();
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Finalize the streaming message with final ID
|
|
2145
|
+
* @param {number} messageId - Database message ID
|
|
2146
|
+
*/
|
|
2147
|
+
finalizeStreamingMessage(messageId) {
|
|
2148
|
+
const streamingMsg = document.getElementById('chat-streaming-msg');
|
|
2149
|
+
if (streamingMsg) {
|
|
2150
|
+
streamingMsg.classList.remove('chat-panel__message--streaming');
|
|
2151
|
+
streamingMsg.id = '';
|
|
2152
|
+
if (messageId) streamingMsg.dataset.messageId = messageId;
|
|
2153
|
+
|
|
2154
|
+
// Remove cursor and thinking indicator
|
|
2155
|
+
const cursor = streamingMsg.querySelector('.chat-panel__cursor');
|
|
2156
|
+
if (cursor) cursor.remove();
|
|
2157
|
+
const thinking = streamingMsg.querySelector('.chat-panel__thinking');
|
|
2158
|
+
if (thinking) thinking.remove();
|
|
2159
|
+
|
|
2160
|
+
// Remove transient tool badge
|
|
2161
|
+
const transientBadge = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2162
|
+
if (transientBadge) transientBadge.remove();
|
|
2163
|
+
|
|
2164
|
+
// Remove any active tool spinners (e.g. abort mid-tool-execution)
|
|
2165
|
+
const spinners = streamingMsg.querySelectorAll('.chat-panel__tool-spinner');
|
|
2166
|
+
spinners.forEach(s => s.remove());
|
|
2167
|
+
|
|
2168
|
+
// Final render
|
|
2169
|
+
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2170
|
+
if (bubble) {
|
|
2171
|
+
if (this._streamingContent) {
|
|
2172
|
+
bubble.innerHTML = this.renderMarkdown(this._streamingContent);
|
|
2173
|
+
this._linkifyFileReferences(bubble);
|
|
2174
|
+
bubble.appendChild(this._createCopyButton(this._streamingContent));
|
|
2175
|
+
} else {
|
|
2176
|
+
// Empty response - show a subtle message
|
|
2177
|
+
bubble.innerHTML = '<em class="chat-panel__empty-response">No response generated.</em>';
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Store in messages array
|
|
2183
|
+
if (this._streamingContent) {
|
|
2184
|
+
this.messages.push({ role: 'assistant', content: this._streamingContent, id: messageId });
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
this._finalizeStreaming();
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Abort the current agent turn
|
|
2192
|
+
*/
|
|
2193
|
+
async _stopAgent() {
|
|
2194
|
+
if (!this.isStreaming || !this.currentSessionId) return;
|
|
2195
|
+
|
|
2196
|
+
try {
|
|
2197
|
+
await fetch(`/api/chat/session/${this.currentSessionId}/abort`, {
|
|
2198
|
+
method: 'POST'
|
|
2199
|
+
});
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
console.error('[ChatPanel] Error aborting:', error);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// Finalize the streaming message with whatever content we have so far
|
|
2205
|
+
this.finalizeStreamingMessage(null);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Clean up streaming state
|
|
2210
|
+
*/
|
|
2211
|
+
_finalizeStreaming() {
|
|
2212
|
+
this.isStreaming = false;
|
|
2213
|
+
this._streamingContent = '';
|
|
2214
|
+
this.sendBtn.style.display = '';
|
|
2215
|
+
this.stopBtn.style.display = 'none';
|
|
2216
|
+
this.sendBtn.disabled = !this.inputEl?.value?.trim();
|
|
2217
|
+
this._updateActionButtons();
|
|
2218
|
+
this.inputEl?.focus();
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* Show a tool use indicator in the streaming message
|
|
2223
|
+
* @param {string} toolName - Name of the tool being used
|
|
2224
|
+
* @param {string} status - 'start' or 'end'
|
|
2225
|
+
* @param {Object} [toolInput] - Tool input/arguments (optional)
|
|
2226
|
+
*/
|
|
2227
|
+
_showToolUse(toolName, status, toolInput) {
|
|
2228
|
+
const streamingMsg = document.getElementById('chat-streaming-msg');
|
|
2229
|
+
if (!streamingMsg) return;
|
|
2230
|
+
|
|
2231
|
+
const isTask = toolName.toLowerCase() === 'task';
|
|
2232
|
+
|
|
2233
|
+
if (status === 'start') {
|
|
2234
|
+
this._hideThinkingIndicator();
|
|
2235
|
+
const argSummary = this._summarizeToolInput(toolName, toolInput);
|
|
2236
|
+
|
|
2237
|
+
const badgeHTML = `
|
|
2238
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10">
|
|
2239
|
+
<path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
2240
|
+
</svg>
|
|
2241
|
+
<span>${this._escapeHtml(toolName)}</span>${argSummary ? `<span class="chat-panel__tool-args" title="${window.escapeHtmlAttribute(argSummary)}">${this._escapeHtml(argSummary)}</span>` : ''}
|
|
2242
|
+
<span class="chat-panel__tool-spinner"></span>
|
|
2243
|
+
`;
|
|
2244
|
+
|
|
2245
|
+
if (isTask) {
|
|
2246
|
+
// Task tools get persistent badges (meaningful delegated work)
|
|
2247
|
+
const badge = document.createElement('div');
|
|
2248
|
+
badge.className = 'chat-panel__tool-badge';
|
|
2249
|
+
badge.dataset.tool = toolName;
|
|
2250
|
+
badge.innerHTML = badgeHTML;
|
|
2251
|
+
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2252
|
+
streamingMsg.insertBefore(badge, bubble);
|
|
2253
|
+
} else {
|
|
2254
|
+
// Non-Task tools reuse a single transient badge
|
|
2255
|
+
let badge = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2256
|
+
if (!badge) {
|
|
2257
|
+
badge = document.createElement('div');
|
|
2258
|
+
badge.className = 'chat-panel__tool-badge chat-panel__tool-badge--transient';
|
|
2259
|
+
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2260
|
+
streamingMsg.insertBefore(badge, bubble);
|
|
2261
|
+
}
|
|
2262
|
+
badge.dataset.tool = toolName;
|
|
2263
|
+
badge.innerHTML = badgeHTML;
|
|
2264
|
+
}
|
|
2265
|
+
} else {
|
|
2266
|
+
if (isTask) {
|
|
2267
|
+
// Remove spinner from completed Task badge
|
|
2268
|
+
const badges = streamingMsg.querySelectorAll('.chat-panel__tool-badge[data-tool="Task"]:not(.chat-panel__tool-badge--transient)');
|
|
2269
|
+
badges.forEach(b => {
|
|
2270
|
+
const spinner = b.querySelector('.chat-panel__tool-spinner');
|
|
2271
|
+
if (spinner) spinner.remove();
|
|
2272
|
+
});
|
|
2273
|
+
} else {
|
|
2274
|
+
// Remove spinner from transient badge (badge stays until text arrives or next tool starts)
|
|
2275
|
+
const transient = streamingMsg.querySelector('.chat-panel__tool-badge--transient');
|
|
2276
|
+
if (transient) {
|
|
2277
|
+
const spinner = transient.querySelector('.chat-panel__tool-spinner');
|
|
2278
|
+
if (spinner) spinner.remove();
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
this._showThinkingIndicator();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* Extract a compact summary string from tool input for display.
|
|
2287
|
+
* @param {string} toolName - Name of the tool
|
|
2288
|
+
* @param {Object} [input] - Tool input/arguments
|
|
2289
|
+
* @returns {string} Compact summary or empty string
|
|
2290
|
+
*/
|
|
2291
|
+
_summarizeToolInput(toolName, input) {
|
|
2292
|
+
if (!input || typeof input !== 'object') return '';
|
|
2293
|
+
|
|
2294
|
+
const name = toolName.toLowerCase();
|
|
2295
|
+
switch (name) {
|
|
2296
|
+
case 'bash': {
|
|
2297
|
+
let cmd = input.command || '';
|
|
2298
|
+
// Strip "cd <path> && " prefix — the actual command is more interesting
|
|
2299
|
+
cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, '');
|
|
2300
|
+
return cmd;
|
|
2301
|
+
}
|
|
2302
|
+
case 'read':
|
|
2303
|
+
return input.file_path || input.path || '';
|
|
2304
|
+
case 'grep':
|
|
2305
|
+
return input.pattern || '';
|
|
2306
|
+
case 'glob':
|
|
2307
|
+
return input.pattern || '';
|
|
2308
|
+
case 'find':
|
|
2309
|
+
case 'ls':
|
|
2310
|
+
return input.path || '';
|
|
2311
|
+
case 'write':
|
|
2312
|
+
case 'edit':
|
|
2313
|
+
return input.file_path || input.path || '';
|
|
2314
|
+
default: {
|
|
2315
|
+
// For unknown tools, show the first string-valued argument
|
|
2316
|
+
const vals = Object.values(input);
|
|
2317
|
+
for (const v of vals) {
|
|
2318
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
2319
|
+
}
|
|
2320
|
+
return '';
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* Handle agent status events from the backend.
|
|
2327
|
+
* @param {string} status - 'working' or 'turn_complete'
|
|
2328
|
+
*/
|
|
2329
|
+
_handleAgentStatus(status) {
|
|
2330
|
+
if (status === 'working') {
|
|
2331
|
+
this._showThinkingIndicator();
|
|
2332
|
+
}
|
|
2333
|
+
// 'turn_complete' is informational; the agent may start another turn
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* Show the pulsing thinking indicator in/below the streaming message.
|
|
2338
|
+
* If there's already content, append it after the content. If no content, it's the typing dots.
|
|
2339
|
+
*/
|
|
2340
|
+
_showThinkingIndicator() {
|
|
2341
|
+
const streamingMsg = document.getElementById('chat-streaming-msg');
|
|
2342
|
+
if (!streamingMsg) return;
|
|
2343
|
+
|
|
2344
|
+
// Don't add duplicate
|
|
2345
|
+
if (streamingMsg.querySelector('.chat-panel__thinking')) return;
|
|
2346
|
+
|
|
2347
|
+
// Don't add if the bubble still has its initial typing indicator (no content yet).
|
|
2348
|
+
// The bubble's own dots are sufficient — adding a second set would show two pulsing indicators.
|
|
2349
|
+
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2350
|
+
if (bubble && bubble.querySelector('.chat-panel__typing-indicator')) return;
|
|
2351
|
+
|
|
2352
|
+
// Remove the cursor — the thinking indicator replaces it as the "working" signal.
|
|
2353
|
+
// When new text arrives, updateStreamingMessage() will re-add the cursor naturally.
|
|
2354
|
+
const cursor = bubble?.querySelector('.chat-panel__cursor');
|
|
2355
|
+
if (cursor) cursor.remove();
|
|
2356
|
+
|
|
2357
|
+
const indicator = document.createElement('div');
|
|
2358
|
+
indicator.className = 'chat-panel__thinking';
|
|
2359
|
+
indicator.innerHTML = '<span class="chat-panel__typing-indicator"><span></span><span></span><span></span></span>';
|
|
2360
|
+
streamingMsg.appendChild(indicator);
|
|
2361
|
+
this.scrollToBottom();
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Hide the thinking indicator from the streaming message.
|
|
2366
|
+
*/
|
|
2367
|
+
_hideThinkingIndicator() {
|
|
2368
|
+
const streamingMsg = document.getElementById('chat-streaming-msg');
|
|
2369
|
+
if (!streamingMsg) return;
|
|
2370
|
+
const thinking = streamingMsg.querySelector('.chat-panel__thinking');
|
|
2371
|
+
if (thinking) thinking.remove();
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Show an error message in the chat
|
|
2376
|
+
* @param {string} message - Error text
|
|
2377
|
+
*/
|
|
2378
|
+
_showError(message) {
|
|
2379
|
+
const errorEl = document.createElement('div');
|
|
2380
|
+
errorEl.className = 'chat-panel__message chat-panel__message--error';
|
|
2381
|
+
errorEl.innerHTML = `
|
|
2382
|
+
<div class="chat-panel__error-bubble">
|
|
2383
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
2384
|
+
<path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"/>
|
|
2385
|
+
</svg>
|
|
2386
|
+
${this._escapeHtml(message)}
|
|
2387
|
+
</div>
|
|
2388
|
+
`;
|
|
2389
|
+
this.messagesEl.appendChild(errorEl);
|
|
2390
|
+
this.scrollToBottom({ force: true });
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Show an auto-dismissing toast notification overlaid at the border between
|
|
2395
|
+
* the header and messages area. Appended to the outer .chat-panel container
|
|
2396
|
+
* (which has position:relative) so it stays visible regardless of scroll
|
|
2397
|
+
* position in the messages area.
|
|
2398
|
+
* @param {string} message - Text to display
|
|
2399
|
+
*/
|
|
2400
|
+
_showToast(message) {
|
|
2401
|
+
// Remove any existing toast
|
|
2402
|
+
const existing = this.panel?.querySelector('.chat-panel__toast');
|
|
2403
|
+
if (existing) existing.remove();
|
|
2404
|
+
|
|
2405
|
+
const toast = document.createElement('div');
|
|
2406
|
+
toast.className = 'chat-panel__toast';
|
|
2407
|
+
toast.textContent = message;
|
|
2408
|
+
|
|
2409
|
+
// Append to the outer chat-panel container so the toast is positioned
|
|
2410
|
+
// relative to it (not inside the scrollable messages area).
|
|
2411
|
+
if (this.panel) {
|
|
2412
|
+
this.panel.appendChild(toast);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// Auto-dismiss after 2.5 seconds
|
|
2416
|
+
setTimeout(() => {
|
|
2417
|
+
toast.classList.add('chat-panel__toast--dismissing');
|
|
2418
|
+
toast.addEventListener('animationend', () => toast.remove());
|
|
2419
|
+
}, 2500);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Post-process a container element to convert [[file:...]] tokens into
|
|
2424
|
+
* clickable links that scroll to the file in the diff.
|
|
2425
|
+
*
|
|
2426
|
+
* Supported formats:
|
|
2427
|
+
* [[file:path/to/file.ext]] -> file only
|
|
2428
|
+
* [[file:path/to/file.ext:42]] -> file + line
|
|
2429
|
+
* [[file:path/to/file.ext:42-78]] -> file + line range
|
|
2430
|
+
*
|
|
2431
|
+
* Tokens inside <pre> blocks are left untouched.
|
|
2432
|
+
*
|
|
2433
|
+
* @param {HTMLElement} container - Element whose text nodes to scan
|
|
2434
|
+
*/
|
|
2435
|
+
_linkifyFileReferences(container) {
|
|
2436
|
+
if (!container || typeof document.createTreeWalker !== 'function') return;
|
|
2437
|
+
|
|
2438
|
+
const FILE_TOKEN = /\[\[file:([^\]]+?)(?::(\d+)(?:-(\d+))?)?\]\]/g;
|
|
2439
|
+
|
|
2440
|
+
// Collect text nodes that contain tokens (avoid mutating during traversal)
|
|
2441
|
+
// NodeFilter.SHOW_TEXT === 4; use literal for environments without NodeFilter global
|
|
2442
|
+
const SHOW_TEXT = typeof NodeFilter !== 'undefined' ? NodeFilter.SHOW_TEXT : 4;
|
|
2443
|
+
const walker = document.createTreeWalker(container, SHOW_TEXT);
|
|
2444
|
+
const candidates = [];
|
|
2445
|
+
while (walker.nextNode()) {
|
|
2446
|
+
const node = walker.currentNode;
|
|
2447
|
+
// Skip anything inside a <pre> (code block)
|
|
2448
|
+
if (node.parentElement?.closest('pre')) continue;
|
|
2449
|
+
FILE_TOKEN.lastIndex = 0;
|
|
2450
|
+
if (FILE_TOKEN.test(node.textContent)) {
|
|
2451
|
+
candidates.push(node);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
for (const node of candidates) {
|
|
2456
|
+
const fragment = document.createDocumentFragment();
|
|
2457
|
+
const text = node.textContent;
|
|
2458
|
+
let lastIndex = 0;
|
|
2459
|
+
|
|
2460
|
+
FILE_TOKEN.lastIndex = 0;
|
|
2461
|
+
let match;
|
|
2462
|
+
while ((match = FILE_TOKEN.exec(text)) !== null) {
|
|
2463
|
+
// Add any text before this match
|
|
2464
|
+
if (match.index > lastIndex) {
|
|
2465
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
const filePath = match[1];
|
|
2469
|
+
const lineStart = match[2] || null;
|
|
2470
|
+
const lineEnd = match[3] || null;
|
|
2471
|
+
|
|
2472
|
+
// Build the clickable link
|
|
2473
|
+
const link = document.createElement('a');
|
|
2474
|
+
link.className = 'chat-file-link';
|
|
2475
|
+
link.dataset.file = filePath;
|
|
2476
|
+
if (lineStart) link.dataset.lineStart = lineStart;
|
|
2477
|
+
if (lineEnd) link.dataset.lineEnd = lineEnd;
|
|
2478
|
+
link.title = 'View in diff';
|
|
2479
|
+
|
|
2480
|
+
// File icon SVG
|
|
2481
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2482
|
+
icon.setAttribute('viewBox', '0 0 16 16');
|
|
2483
|
+
icon.setAttribute('fill', 'currentColor');
|
|
2484
|
+
icon.setAttribute('width', '12');
|
|
2485
|
+
icon.setAttribute('height', '12');
|
|
2486
|
+
icon.classList.add('chat-file-link__icon');
|
|
2487
|
+
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2488
|
+
pathEl.setAttribute('d', 'M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z');
|
|
2489
|
+
icon.appendChild(pathEl);
|
|
2490
|
+
|
|
2491
|
+
// Display text: show the file reference naturally
|
|
2492
|
+
let displayText = filePath;
|
|
2493
|
+
if (lineStart && lineEnd) {
|
|
2494
|
+
displayText += `:${lineStart}-${lineEnd}`;
|
|
2495
|
+
} else if (lineStart) {
|
|
2496
|
+
displayText += `:${lineStart}`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
link.appendChild(icon);
|
|
2500
|
+
link.appendChild(document.createTextNode(' ' + displayText));
|
|
2501
|
+
|
|
2502
|
+
fragment.appendChild(link);
|
|
2503
|
+
lastIndex = match.index + match[0].length;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// Add any remaining text after the last match
|
|
2507
|
+
if (lastIndex < text.length) {
|
|
2508
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
node.parentNode.replaceChild(fragment, node);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/**
|
|
2516
|
+
* Handle click on a chat file link. Scrolls to the referenced file and line
|
|
2517
|
+
* in the diff view.
|
|
2518
|
+
* @param {HTMLElement} linkEl - The clicked .chat-file-link element
|
|
2519
|
+
*/
|
|
2520
|
+
async _handleFileLinkClick(linkEl) {
|
|
2521
|
+
const file = linkEl.dataset.file;
|
|
2522
|
+
const lineStart = linkEl.dataset.lineStart ? parseInt(linkEl.dataset.lineStart, 10) : null;
|
|
2523
|
+
const lineEnd = linkEl.dataset.lineEnd ? parseInt(linkEl.dataset.lineEnd, 10) : null;
|
|
2524
|
+
|
|
2525
|
+
// Check if file wrapper exists in DOM
|
|
2526
|
+
const wrapper = document.querySelector(`[data-file-name="${CSS.escape(file)}"]`);
|
|
2527
|
+
|
|
2528
|
+
if (wrapper) {
|
|
2529
|
+
// File is already rendered — scroll to it
|
|
2530
|
+
const contextEl = wrapper.closest('.context-file');
|
|
2531
|
+
if (contextEl) {
|
|
2532
|
+
// Context file — find the right chunk by line number or use first chunk
|
|
2533
|
+
let contextFileId = contextEl.dataset?.contextId; // legacy: on wrapper itself
|
|
2534
|
+
let lineFoundInChunk = !!contextFileId; // legacy mode assumes line is present
|
|
2535
|
+
if (!contextFileId && lineStart) {
|
|
2536
|
+
// Merged wrapper: find chunk tbody containing this line
|
|
2537
|
+
const chunks = [...contextEl.querySelectorAll('tbody.context-chunk[data-context-id]')];
|
|
2538
|
+
for (const chunk of chunks) {
|
|
2539
|
+
const row = chunk.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
2540
|
+
if (row) {
|
|
2541
|
+
contextFileId = chunk.dataset.contextId;
|
|
2542
|
+
lineFoundInChunk = true;
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
if (lineFoundInChunk || !lineStart) {
|
|
2549
|
+
if (!contextFileId) {
|
|
2550
|
+
const firstChunk = contextEl.querySelector('tbody.context-chunk[data-context-id]');
|
|
2551
|
+
if (firstChunk) contextFileId = firstChunk.dataset.contextId;
|
|
2552
|
+
}
|
|
2553
|
+
if (window.prManager?.scrollToContextFile) {
|
|
2554
|
+
window.prManager.scrollToContextFile(file, lineStart, contextFileId);
|
|
2555
|
+
}
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
// Line not found in any existing chunk — fall through to add new range
|
|
2559
|
+
} else {
|
|
2560
|
+
// Diff file
|
|
2561
|
+
if (lineStart) {
|
|
2562
|
+
await this._scrollToLine(file, lineStart, lineEnd);
|
|
2563
|
+
} else if (window.prManager?.scrollToFile) {
|
|
2564
|
+
window.prManager.scrollToFile(file);
|
|
2565
|
+
}
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// File not in DOM — try to add as context file
|
|
2571
|
+
if (!window.prManager?.ensureContextFile) return;
|
|
2572
|
+
|
|
2573
|
+
linkEl.classList.add('chat-file-link--loading');
|
|
2574
|
+
try {
|
|
2575
|
+
const result = await window.prManager.ensureContextFile(file, lineStart, lineEnd);
|
|
2576
|
+
|
|
2577
|
+
if (!result) {
|
|
2578
|
+
this._showToast('Could not load file');
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
if (result.type === 'diff') {
|
|
2583
|
+
if (lineStart) {
|
|
2584
|
+
await this._scrollToLine(file, lineStart, lineEnd);
|
|
2585
|
+
} else if (window.prManager?.scrollToFile) {
|
|
2586
|
+
window.prManager.scrollToFile(file);
|
|
2587
|
+
}
|
|
2588
|
+
} else if (result.type === 'context') {
|
|
2589
|
+
// Brief delay for DOM to settle after loadContextFiles
|
|
2590
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2591
|
+
if (window.prManager.scrollToContextFile) {
|
|
2592
|
+
window.prManager.scrollToContextFile(file, lineStart, result.contextFile?.id);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
} catch (err) {
|
|
2596
|
+
console.error('[ChatPanel] Error handling file link click:', err);
|
|
2597
|
+
this._showToast('Could not load file');
|
|
2598
|
+
} finally {
|
|
2599
|
+
linkEl.classList.remove('chat-file-link--loading');
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/**
|
|
2604
|
+
* Scroll to a specific line within a file wrapper, applying a bold
|
|
2605
|
+
* left-border + background highlight that fades over ~3.5s.
|
|
2606
|
+
* Supports line ranges: if lineEnd is provided, all rows from
|
|
2607
|
+
* lineStart to lineEnd are highlighted. If the line is in a collapsed
|
|
2608
|
+
* diff chunk, expands the chunk first via ensureLinesVisible().
|
|
2609
|
+
* @param {string} file - File path
|
|
2610
|
+
* @param {number} lineStart - Target line number (start of range)
|
|
2611
|
+
* @param {number|null} [lineEnd] - End of target line range (used for expansion)
|
|
2612
|
+
* @param {HTMLElement} [fileWrapper] - Pre-resolved file wrapper element
|
|
2613
|
+
*/
|
|
2614
|
+
async _scrollToLine(file, lineStart, lineEnd, fileWrapper) {
|
|
2615
|
+
if (!fileWrapper) {
|
|
2616
|
+
const escaped = CSS.escape(file);
|
|
2617
|
+
fileWrapper = document.querySelector(`[data-file-name="${escaped}"]`) ||
|
|
2618
|
+
document.querySelector(`[data-file-name$="/${escaped}"]`);
|
|
2619
|
+
}
|
|
2620
|
+
if (!fileWrapper) return;
|
|
2621
|
+
|
|
2622
|
+
// Collect all target rows (single line or range)
|
|
2623
|
+
const end = lineEnd || lineStart;
|
|
2624
|
+
let targetRows = this._findLineRows(fileWrapper, lineStart, end);
|
|
2625
|
+
|
|
2626
|
+
// If not found, try expanding the collapsed diff context
|
|
2627
|
+
if (targetRows.length === 0 && window.prManager?.ensureLinesVisible) {
|
|
2628
|
+
await window.prManager.ensureLinesVisible([
|
|
2629
|
+
{ file, line_start: lineStart, line_end: end, side: 'RIGHT' }
|
|
2630
|
+
]);
|
|
2631
|
+
targetRows = this._findLineRows(fileWrapper, lineStart, end);
|
|
2632
|
+
}
|
|
2633
|
+
if (targetRows.length === 0) return;
|
|
2634
|
+
|
|
2635
|
+
const primaryRow = targetRows[0];
|
|
2636
|
+
|
|
2637
|
+
// Check if the primary target row is already visible in the viewport
|
|
2638
|
+
const rect = primaryRow.getBoundingClientRect();
|
|
2639
|
+
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
2640
|
+
|
|
2641
|
+
if (!isVisible) {
|
|
2642
|
+
primaryRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// Apply the highlight to all target rows
|
|
2646
|
+
for (const row of targetRows) {
|
|
2647
|
+
// Remove any existing highlight first (in case of rapid re-clicks)
|
|
2648
|
+
row.classList.remove('chat-line-highlight');
|
|
2649
|
+
// Force reflow so re-adding the class restarts the animation
|
|
2650
|
+
void row.offsetWidth;
|
|
2651
|
+
row.classList.add('chat-line-highlight');
|
|
2652
|
+
row.addEventListener('animationend', () => {
|
|
2653
|
+
row.classList.remove('chat-line-highlight');
|
|
2654
|
+
}, { once: true });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* Find all table rows matching a line range within a file wrapper.
|
|
2660
|
+
* @param {HTMLElement} fileWrapper - The file wrapper element
|
|
2661
|
+
* @param {number} lineStart - Start line number
|
|
2662
|
+
* @param {number} lineEnd - End line number (inclusive)
|
|
2663
|
+
* @returns {HTMLElement[]} Matching rows
|
|
2664
|
+
*/
|
|
2665
|
+
_findLineRows(fileWrapper, lineStart, lineEnd) {
|
|
2666
|
+
const rows = [];
|
|
2667
|
+
const lineNums = fileWrapper.querySelectorAll('.line-num2');
|
|
2668
|
+
for (const ln of lineNums) {
|
|
2669
|
+
const num = parseInt(ln.textContent.trim(), 10);
|
|
2670
|
+
if (!isNaN(num) && num >= lineStart && num <= lineEnd) {
|
|
2671
|
+
const row = ln.closest('tr');
|
|
2672
|
+
if (row) rows.push(row);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return rows;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/**
|
|
2679
|
+
* Render markdown text to HTML
|
|
2680
|
+
* @param {string} text - Markdown text
|
|
2681
|
+
* @returns {string} HTML string
|
|
2682
|
+
*/
|
|
2683
|
+
renderMarkdown(text) {
|
|
2684
|
+
if (!text) return '';
|
|
2685
|
+
// Use the global renderMarkdown if available (from markdown.js utility)
|
|
2686
|
+
if (window.renderMarkdown) {
|
|
2687
|
+
return window.renderMarkdown(text);
|
|
2688
|
+
}
|
|
2689
|
+
// Basic fallback: escape and convert newlines
|
|
2690
|
+
return this._escapeHtml(text).replace(/\n/g, '<br>');
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* Render markdown to inline HTML (strips outer <p> wrapper).
|
|
2695
|
+
* Useful for context card labels/titles where block-level wrapping is unwanted.
|
|
2696
|
+
* @param {string} text - Markdown text
|
|
2697
|
+
* @returns {string} Inline HTML string
|
|
2698
|
+
*/
|
|
2699
|
+
_renderInlineMarkdown(text) {
|
|
2700
|
+
if (!text) return '';
|
|
2701
|
+
const html = this.renderMarkdown(text);
|
|
2702
|
+
return html.replace(/^<p>([\s\S]*?)<\/p>\s*$/, '$1');
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* Escape HTML special characters
|
|
2707
|
+
* @param {string} text - Raw text
|
|
2708
|
+
* @returns {string} Escaped text
|
|
2709
|
+
*/
|
|
2710
|
+
_escapeHtml(text) {
|
|
2711
|
+
if (!text) return '';
|
|
2712
|
+
const div = document.createElement('div');
|
|
2713
|
+
div.textContent = text;
|
|
2714
|
+
return div.innerHTML;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/**
|
|
2718
|
+
* Auto-scroll messages to bottom.
|
|
2719
|
+
* When force is true (user-initiated actions), always scrolls.
|
|
2720
|
+
* When force is false (streaming content), only scrolls if already near the bottom.
|
|
2721
|
+
* @param {{ force?: boolean }} options
|
|
2722
|
+
*/
|
|
2723
|
+
scrollToBottom({ force = false } = {}) {
|
|
2724
|
+
if (!this.messagesEl) return;
|
|
2725
|
+
if (!force && this._userScrolledAway) {
|
|
2726
|
+
this._showNewContentPill();
|
|
2727
|
+
return; // instant bail, no threshold fight
|
|
2728
|
+
}
|
|
2729
|
+
const { scrollTop, scrollHeight, clientHeight } = this.messagesEl;
|
|
2730
|
+
const nearBottom = scrollHeight - scrollTop - clientHeight < NEAR_BOTTOM_THRESHOLD;
|
|
2731
|
+
if (force || nearBottom) {
|
|
2732
|
+
this.messagesEl.scrollTop = scrollHeight;
|
|
2733
|
+
this._userScrolledAway = false;
|
|
2734
|
+
this._hideNewContentPill();
|
|
2735
|
+
} else {
|
|
2736
|
+
this._userScrolledAway = true;
|
|
2737
|
+
this._showNewContentPill();
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
/**
|
|
2742
|
+
* Show the "new content" pill indicator at the bottom of the messages area.
|
|
2743
|
+
*/
|
|
2744
|
+
_showNewContentPill() {
|
|
2745
|
+
if (this.newContentPill) {
|
|
2746
|
+
this.newContentPill.style.display = '';
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/**
|
|
2751
|
+
* Hide the "new content" pill indicator.
|
|
2752
|
+
*/
|
|
2753
|
+
_hideNewContentPill() {
|
|
2754
|
+
if (this.newContentPill) {
|
|
2755
|
+
this.newContentPill.style.display = 'none';
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/**
|
|
2760
|
+
* Update visibility and disabled state of action buttons based on context and streaming state.
|
|
2761
|
+
*/
|
|
2762
|
+
_updateActionButtons() {
|
|
2763
|
+
// Check if shortcuts are disabled via config
|
|
2764
|
+
if (document.documentElement.getAttribute('data-chat-shortcuts') === 'disabled') {
|
|
2765
|
+
this.actionBar.style.display = 'none';
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const hasSuggestion = this._contextSource === 'suggestion' && this._contextItemId;
|
|
2770
|
+
const hasComment = this._contextSource === 'user' && this._contextItemId;
|
|
2771
|
+
const hasLine = this._contextSource === 'line';
|
|
2772
|
+
|
|
2773
|
+
// Show the bar only if at least one button is relevant
|
|
2774
|
+
const showBar = hasSuggestion || hasComment || hasLine;
|
|
2775
|
+
this.actionBar.style.display = showBar ? '' : 'none';
|
|
2776
|
+
this.adoptBtn.style.display = hasSuggestion ? '' : 'none';
|
|
2777
|
+
this.dismissSuggestionBtn.style.display = hasSuggestion ? '' : 'none';
|
|
2778
|
+
this.updateBtn.style.display = hasComment ? '' : 'none';
|
|
2779
|
+
this.dismissCommentBtn.style.display = hasComment ? '' : 'none';
|
|
2780
|
+
this.createCommentBtn.style.display = hasLine ? '' : 'none';
|
|
2781
|
+
this.createCommentBtn.disabled = this.isStreaming;
|
|
2782
|
+
|
|
2783
|
+
// Disable while streaming
|
|
2784
|
+
this.adoptBtn.disabled = this.isStreaming;
|
|
2785
|
+
this.updateBtn.disabled = this.isStreaming;
|
|
2786
|
+
this.dismissSuggestionBtn.disabled = this.isStreaming;
|
|
2787
|
+
this.dismissCommentBtn.disabled = this.isStreaming;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* Handle click on "Adopt with AI edits" button.
|
|
2792
|
+
* Sends a message asking the agent to refine and adopt the suggestion.
|
|
2793
|
+
*/
|
|
2794
|
+
_handleAdoptClick() {
|
|
2795
|
+
if (this.isStreaming || !this._contextItemId) return;
|
|
2796
|
+
this._pendingActionContext = { type: 'adopt', itemId: this._contextItemId };
|
|
2797
|
+
this.inputEl.value = 'Based on our conversation, please refine and adopt this AI suggestion.';
|
|
2798
|
+
this.sendMessage();
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Handle click on "Update comment" button.
|
|
2803
|
+
* Sends a message asking the agent to update the user's comment.
|
|
2804
|
+
*/
|
|
2805
|
+
_handleUpdateClick() {
|
|
2806
|
+
if (this.isStreaming || !this._contextItemId) return;
|
|
2807
|
+
this._pendingActionContext = { type: 'update', itemId: this._contextItemId };
|
|
2808
|
+
this.inputEl.value = 'Based on our conversation, please update my comment.';
|
|
2809
|
+
this.sendMessage();
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
/**
|
|
2813
|
+
* Handle click on "Dismiss suggestion" button.
|
|
2814
|
+
* Sends a message asking the agent to dismiss the AI suggestion.
|
|
2815
|
+
*/
|
|
2816
|
+
_handleDismissSuggestionClick() {
|
|
2817
|
+
if (this.isStreaming || !this._contextItemId) return;
|
|
2818
|
+
this._pendingActionContext = { type: 'dismiss-suggestion', itemId: this._contextItemId };
|
|
2819
|
+
this.inputEl.value = 'Please dismiss this AI suggestion.';
|
|
2820
|
+
this.sendMessage();
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/**
|
|
2824
|
+
* Handle click on "Dismiss comment" button.
|
|
2825
|
+
* Sends a message asking the agent to dismiss the user comment.
|
|
2826
|
+
*/
|
|
2827
|
+
_handleDismissCommentClick() {
|
|
2828
|
+
if (this.isStreaming || !this._contextItemId) return;
|
|
2829
|
+
this._pendingActionContext = { type: 'dismiss-comment', itemId: this._contextItemId };
|
|
2830
|
+
this.inputEl.value = 'Please delete this comment.';
|
|
2831
|
+
this.sendMessage();
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/**
|
|
2835
|
+
* Handle click on action bar dismiss button.
|
|
2836
|
+
* Hides the action bar for this conversation by clearing context source.
|
|
2837
|
+
*/
|
|
2838
|
+
_handleActionBarDismiss() {
|
|
2839
|
+
this._contextSource = null;
|
|
2840
|
+
this._contextItemId = null;
|
|
2841
|
+
this._contextLineMeta = null;
|
|
2842
|
+
this._updateActionButtons();
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/**
|
|
2846
|
+
* Handle click on "Create comment" button.
|
|
2847
|
+
* Sends a message asking the agent to create a review comment for the referenced lines.
|
|
2848
|
+
*/
|
|
2849
|
+
_handleCreateCommentClick() {
|
|
2850
|
+
if (this.isStreaming) return;
|
|
2851
|
+
this._pendingActionContext = {
|
|
2852
|
+
type: 'create-comment',
|
|
2853
|
+
file: this._contextLineMeta?.file,
|
|
2854
|
+
line_start: this._contextLineMeta?.line_start,
|
|
2855
|
+
line_end: this._contextLineMeta?.line_end,
|
|
2856
|
+
};
|
|
2857
|
+
this.inputEl.value = 'Based on our conversation, please create a review comment for this code.';
|
|
2858
|
+
this.sendMessage();
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* Initialize the shared context tooltip with event delegation on the messages area.
|
|
2863
|
+
* Uses mouseenter/mouseleave with a short delay to avoid flickering.
|
|
2864
|
+
*/
|
|
2865
|
+
_initContextTooltip() {
|
|
2866
|
+
this._ctxTooltipEl = document.createElement('div');
|
|
2867
|
+
this._ctxTooltipEl.className = 'chat-panel__ctx-tooltip';
|
|
2868
|
+
this._ctxTooltipEl.style.display = 'none';
|
|
2869
|
+
document.body.appendChild(this._ctxTooltipEl);
|
|
2870
|
+
this._ctxTooltipTimer = null;
|
|
2871
|
+
|
|
2872
|
+
if (!this.messagesEl) return;
|
|
2873
|
+
|
|
2874
|
+
this._onCtxCardEnter = (e) => {
|
|
2875
|
+
const card = e.target.closest('.chat-panel__context-card[data-tooltip-body]');
|
|
2876
|
+
if (!card) return;
|
|
2877
|
+
clearTimeout(this._ctxTooltipTimer);
|
|
2878
|
+
this._ctxTooltipTimer = setTimeout(() => this._showContextTooltip(card), 200);
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
this._onCtxCardLeave = (e) => {
|
|
2882
|
+
const card = e.target.closest('.chat-panel__context-card[data-tooltip-body]');
|
|
2883
|
+
if (!card) return;
|
|
2884
|
+
clearTimeout(this._ctxTooltipTimer);
|
|
2885
|
+
this._ctxTooltipEl.style.display = 'none';
|
|
2886
|
+
};
|
|
2887
|
+
|
|
2888
|
+
this.messagesEl.addEventListener('mouseenter', this._onCtxCardEnter, true);
|
|
2889
|
+
this.messagesEl.addEventListener('mouseleave', this._onCtxCardLeave, true);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Show the context tooltip positioned relative to a context card element.
|
|
2894
|
+
* @param {HTMLElement} card - The context card to show tooltip for
|
|
2895
|
+
*/
|
|
2896
|
+
_showContextTooltip(card) {
|
|
2897
|
+
const body = card.dataset.tooltipBody;
|
|
2898
|
+
if (!body) return;
|
|
2899
|
+
|
|
2900
|
+
const type = card.dataset.tooltipType;
|
|
2901
|
+
const title = card.dataset.tooltipTitle;
|
|
2902
|
+
|
|
2903
|
+
let headerHTML = '';
|
|
2904
|
+
if (type || title) {
|
|
2905
|
+
headerHTML = `<div class="chat-panel__ctx-tooltip-header">${type ? `<span class="chat-panel__ctx-tooltip-type">${this._renderInlineMarkdown(type)}</span>` : ''}${title ? `<span class="chat-panel__ctx-tooltip-title">${this._renderInlineMarkdown(title)}</span>` : ''}</div>`;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
this._ctxTooltipEl.innerHTML = `${headerHTML}<div class="chat-panel__ctx-tooltip-body">${this.renderMarkdown(body)}</div>`;
|
|
2909
|
+
|
|
2910
|
+
const rect = card.getBoundingClientRect();
|
|
2911
|
+
const tooltipHeight = 300; // max-height
|
|
2912
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
2913
|
+
|
|
2914
|
+
this._ctxTooltipEl.style.display = '';
|
|
2915
|
+
this._ctxTooltipEl.style.left = `${rect.left}px`;
|
|
2916
|
+
|
|
2917
|
+
if (spaceBelow >= tooltipHeight || spaceBelow >= rect.top) {
|
|
2918
|
+
// Show below
|
|
2919
|
+
this._ctxTooltipEl.style.top = `${rect.bottom + 4}px`;
|
|
2920
|
+
this._ctxTooltipEl.style.bottom = '';
|
|
2921
|
+
} else {
|
|
2922
|
+
// Flip above
|
|
2923
|
+
this._ctxTooltipEl.style.top = '';
|
|
2924
|
+
this._ctxTooltipEl.style.bottom = `${window.innerHeight - rect.top + 4}px`;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* Clean up on page unload
|
|
2930
|
+
*/
|
|
2931
|
+
destroy() {
|
|
2932
|
+
document.removeEventListener('keydown', this._onKeydown);
|
|
2933
|
+
this._closeGlobalSSE();
|
|
2934
|
+
this.messages = [];
|
|
2935
|
+
|
|
2936
|
+
// Clean up context tooltip
|
|
2937
|
+
clearTimeout(this._ctxTooltipTimer);
|
|
2938
|
+
if (this._ctxTooltipEl && this._ctxTooltipEl.parentNode) {
|
|
2939
|
+
this._ctxTooltipEl.parentNode.removeChild(this._ctxTooltipEl);
|
|
2940
|
+
}
|
|
2941
|
+
this._ctxTooltipEl = null;
|
|
2942
|
+
|
|
2943
|
+
if (this.container) {
|
|
2944
|
+
this.container.innerHTML = '';
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// Make ChatPanel available globally
|
|
2950
|
+
window.ChatPanel = ChatPanel;
|
|
2951
|
+
|
|
2952
|
+
// Export for CommonJS testing environments
|
|
2953
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
2954
|
+
module.exports = { ChatPanel, NEAR_BOTTOM_THRESHOLD };
|
|
2955
|
+
}
|