@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,655 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Chat Routes
4
+ *
5
+ * Handles chat session endpoints:
6
+ * - Creating chat sessions
7
+ * - Sending messages
8
+ * - SSE streaming for real-time responses
9
+ * - Message history
10
+ * - Closing sessions
11
+ * - Listing sessions for a review
12
+ */
13
+
14
+ const express = require('express');
15
+ const path = require('path');
16
+ const { queryOne, query, AnalysisRunRepository, RepoSettingsRepository } = require('../database');
17
+ const { buildChatPrompt, buildInitialContext } = require('../chat/prompt-builder');
18
+ const { GitWorktreeManager } = require('../git/worktree');
19
+ const logger = require('../utils/logger');
20
+ const { sseClients } = require('../sse/review-events');
21
+
22
+ const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
23
+
24
+ const router = express.Router();
25
+
26
+ /**
27
+ * Resolve the working directory for a chat session.
28
+ * - Local reviews: use review.local_path (the git root being reviewed)
29
+ * - PR reviews: look up the worktree path from the worktrees table
30
+ * @param {Object} db - Database instance
31
+ * @param {Object} review - Review record from the database
32
+ * @returns {Promise<string|null>} Absolute path to the code directory, or null
33
+ */
34
+ async function resolveReviewCwd(db, review) {
35
+ // Local reviews store the path directly
36
+ if (review.local_path) {
37
+ return review.local_path;
38
+ }
39
+
40
+ // PR reviews: resolve worktree via the worktree manager
41
+ if (review.pr_number && review.repository) {
42
+ const [owner, repo] = review.repository.split('/');
43
+ if (owner && repo) {
44
+ const worktreeManager = new GitWorktreeManager(db);
45
+ return worktreeManager.getWorktreePath({ owner, repo, number: review.pr_number });
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Fetch PR data (base_sha, head_sha) from pr_metadata for a PR review.
54
+ * Returns null for local reviews or if pr_data is unavailable.
55
+ * @param {Object} db - Database instance
56
+ * @param {Object} review - Review record from the database
57
+ * @returns {Promise<Object|null>} Parsed PR data with base_sha/head_sha, or null
58
+ */
59
+ async function fetchPrData(db, review) {
60
+ if (review.review_type === 'local' || !review.pr_number || !review.repository) {
61
+ return null;
62
+ }
63
+ const row = await queryOne(db, `
64
+ SELECT pr_data FROM pr_metadata
65
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
66
+ `, [review.pr_number, review.repository]);
67
+ if (row?.pr_data) {
68
+ try {
69
+ return JSON.parse(row.pr_data);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Unsubscribe functions for SSE broadcast listeners, keyed by session ID.
79
+ * Each value is an array of unsubscribe functions returned by the on* methods.
80
+ * Used to clean up listeners when a session is closed.
81
+ * @type {Map<number, function[]>}
82
+ */
83
+ const sseUnsubscribers = new Map();
84
+
85
+ /**
86
+ * Build a regex that matches bash commands curling the pair-review
87
+ * server's own API on a specific port. The port is required so we
88
+ * don't accidentally suppress tool badges for unrelated local services.
89
+ * @param {number} port - The server's listening port
90
+ * @returns {RegExp}
91
+ */
92
+ function buildPairReviewApiRe(port) {
93
+ return new RegExp(`\\bcurl\\b.*\\bhttps?://(?:localhost|127\\.0\\.0\\.1):${port}/api/`);
94
+ }
95
+
96
+ /**
97
+ * Broadcast an SSE event to all connected clients.
98
+ * @param {number} sessionId - Chat session ID to include in the event
99
+ * @param {Object} payload - Event data (will be merged with sessionId)
100
+ */
101
+ function broadcastSSE(sessionId, payload) {
102
+ const data = JSON.stringify({ ...payload, sessionId });
103
+ for (const client of sseClients) {
104
+ try {
105
+ client.write(`data: ${data}\n\n`);
106
+ } catch {
107
+ // Client disconnected — remove from set
108
+ sseClients.delete(client);
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Register SSE broadcast listeners on a chat session so that all events
115
+ * (delta, tool_use, complete, status, error) are forwarded to connected SSE clients.
116
+ * @param {Object} chatSessionManager
117
+ * @param {number} sessionId
118
+ * @param {number} port - The server's listening port (used to scope API-call suppression)
119
+ */
120
+ function registerSSEBroadcast(chatSessionManager, sessionId, port) {
121
+ // Guard against double-registration
122
+ if (sseUnsubscribers.has(sessionId)) {
123
+ logger.debug(`[ChatRoute] SSE broadcast already registered for session ${sessionId}, skipping`);
124
+ return;
125
+ }
126
+
127
+ try {
128
+ const unsubs = [];
129
+
130
+ unsubs.push(chatSessionManager.onDelta(sessionId, (data) => {
131
+ broadcastSSE(sessionId, { type: 'delta', text: data.text });
132
+ }));
133
+
134
+ const hiddenToolCallIds = new Set();
135
+ const pairReviewApiRe = buildPairReviewApiRe(port);
136
+
137
+ unsubs.push(chatSessionManager.onToolUse(sessionId, (data) => {
138
+ // Suppress tool badges for curl commands hitting the pair-review API
139
+ if (data.toolName?.toLowerCase() === 'bash') {
140
+ if (data.status === 'start' && pairReviewApiRe.test(data.args?.command || '')) {
141
+ hiddenToolCallIds.add(data.toolCallId);
142
+ return;
143
+ }
144
+ if (hiddenToolCallIds.has(data.toolCallId)) {
145
+ if (data.status === 'end') hiddenToolCallIds.delete(data.toolCallId);
146
+ return;
147
+ }
148
+ }
149
+
150
+ // Suppress tool badges for reading the API skill file
151
+ if (data.toolName?.toLowerCase() === 'read') {
152
+ const readPath = data.args?.path || data.args?.file_path || '';
153
+ if (readPath.endsWith('pair-review-api/SKILL.md')) {
154
+ hiddenToolCallIds.add(data.toolCallId);
155
+ return;
156
+ }
157
+ }
158
+
159
+ // Suppress follow-up events (update/end) for any hidden tool call
160
+ if (hiddenToolCallIds.has(data.toolCallId)) {
161
+ if (data.status === 'end') hiddenToolCallIds.delete(data.toolCallId);
162
+ return;
163
+ }
164
+
165
+ const event = { type: 'tool_use', toolName: data.toolName, status: data.status };
166
+ if (data.args) {
167
+ event.toolInput = data.args;
168
+ }
169
+ broadcastSSE(sessionId, event);
170
+ }));
171
+
172
+ unsubs.push(chatSessionManager.onComplete(sessionId, (data) => {
173
+ logger.debug(`[ChatRoute] SSE broadcast complete for session ${sessionId}, messageId=${data.messageId}`);
174
+ broadcastSSE(sessionId, { type: 'complete', messageId: data.messageId });
175
+ }));
176
+
177
+ unsubs.push(chatSessionManager.onStatus(sessionId, (data) => {
178
+ broadcastSSE(sessionId, { type: 'status', status: data.status });
179
+ }));
180
+
181
+ unsubs.push(chatSessionManager.onError(sessionId, (data) => {
182
+ logger.debug(`[ChatRoute] SSE broadcast error for session ${sessionId}: ${data.message}`);
183
+ broadcastSSE(sessionId, { type: 'error', message: data.message });
184
+ }));
185
+
186
+ sseUnsubscribers.set(sessionId, unsubs);
187
+ logger.debug(`[ChatRoute] SSE broadcast listeners registered for session ${sessionId}`);
188
+ } catch (err) {
189
+ logger.warn(`[ChatRoute] Failed to register SSE broadcast for session ${sessionId}: ${err.message}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Unsubscribe all SSE broadcast listeners for a session.
195
+ * @param {number} sessionId
196
+ */
197
+ function unregisterSSEBroadcast(sessionId) {
198
+ const unsubs = sseUnsubscribers.get(sessionId);
199
+ if (unsubs) {
200
+ for (const unsub of unsubs) {
201
+ try { unsub(); } catch { /* session may already be closed */ }
202
+ }
203
+ sseUnsubscribers.delete(sessionId);
204
+ logger.debug(`[ChatRoute] SSE broadcast listeners unregistered for session ${sessionId}`);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Fetch chat instructions from repo settings for a review.
210
+ * @param {Database} db - Database instance
211
+ * @param {Object} review - Review record with repository field
212
+ * @returns {Promise<string|null>} Chat instructions or null
213
+ */
214
+ async function getChatInstructions(db, review) {
215
+ if (!review || !review.repository) return null;
216
+ const repoSettingsRepo = new RepoSettingsRepository(db);
217
+ const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
218
+ return repoSettings ? repoSettings.default_chat_instructions : null;
219
+ }
220
+
221
+ /**
222
+ * Create a new chat session
223
+ */
224
+ router.post('/api/chat/session', async (req, res) => {
225
+ try {
226
+ // contextCommentId: stored in session metadata (no longer used for prompt enrichment)
227
+ const { provider, model, contextCommentId, systemPrompt, cwd, skipAnalysisContext } = req.body || {};
228
+ const reviewId = parseInt(req.body?.reviewId, 10);
229
+
230
+ if (!provider || !reviewId || isNaN(reviewId)) {
231
+ return res.status(400).json({
232
+ error: 'Missing required fields: provider, reviewId'
233
+ });
234
+ }
235
+
236
+ const chatSessionManager = req.app.chatSessionManager;
237
+ const db = req.app.get('db');
238
+
239
+ // Always load the review so we can resolve the worktree CWD
240
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [reviewId]);
241
+ if (!review) {
242
+ return res.status(404).json({ error: 'Review not found' });
243
+ }
244
+
245
+ // Build system prompt if not provided directly
246
+ let finalSystemPrompt = systemPrompt;
247
+ let initialContext = null;
248
+ let suggestions = null;
249
+ let analysisRun = null;
250
+
251
+ if (!finalSystemPrompt) {
252
+
253
+ const chatInstructions = await getChatInstructions(db, review);
254
+ const prData = await fetchPrData(db, review);
255
+
256
+ finalSystemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
257
+
258
+ if (!skipAnalysisContext) {
259
+ // Fetch all AI suggestions from the latest analysis run
260
+ suggestions = await query(db, `
261
+ SELECT
262
+ id, ai_run_id, ai_level, ai_confidence,
263
+ file, line_start, line_end, type, title, body,
264
+ reasoning, status, is_file_level
265
+ FROM comments
266
+ WHERE review_id = ?
267
+ AND source = 'ai'
268
+ -- ai_level IS NULL = orchestrated/final suggestions only
269
+ -- TODO: If single-level results can be saved without orchestration,
270
+ -- we may need an \`is_final\` flag to identify displayable suggestions.
271
+ AND ai_level IS NULL
272
+ AND (is_raw = 0 OR is_raw IS NULL)
273
+ AND ai_run_id = (
274
+ SELECT ai_run_id FROM comments
275
+ WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
276
+ ORDER BY created_at DESC
277
+ LIMIT 1
278
+ )
279
+ ORDER BY file, line_start
280
+ `, [reviewId, reviewId]);
281
+
282
+ // Fetch the analysis run record for metadata and summary
283
+ if (suggestions && suggestions.length > 0 && suggestions[0].ai_run_id) {
284
+ const analysisRunRepo = new AnalysisRunRepository(db);
285
+ analysisRun = await analysisRunRepo.getById(suggestions[0].ai_run_id);
286
+ }
287
+
288
+ initialContext = buildInitialContext({
289
+ suggestions,
290
+ analysisRun
291
+ });
292
+ }
293
+ }
294
+
295
+ // Resolve cwd: explicit from request body, or the review's code directory
296
+ const resolvedCwd = cwd || await resolveReviewCwd(db, review);
297
+
298
+ // Inject the server port into the initial context so the agent learns it
299
+ // once at session start. This avoids wasting tokens by repeating the port
300
+ // with every user message. If the server restarts on a new port, the next
301
+ // session will pick up the new value automatically.
302
+ const serverPort = req.socket.localPort;
303
+ const portContext = `[Server port: ${serverPort}] The pair-review API is at http://localhost:${serverPort}`;
304
+ const initialContextWithPort = initialContext
305
+ ? portContext + '\n\n' + initialContext
306
+ : portContext;
307
+
308
+ const session = await chatSessionManager.createSession({
309
+ provider,
310
+ model,
311
+ reviewId,
312
+ contextCommentId: contextCommentId || null,
313
+ systemPrompt: finalSystemPrompt,
314
+ cwd: resolvedCwd,
315
+ initialContext: initialContextWithPort
316
+ });
317
+
318
+ logger.info(`Chat session created: ${session.id} (provider=${provider}, model=${model})`);
319
+
320
+ // Register SSE broadcast listeners so events reach all connected clients
321
+ registerSSEBroadcast(chatSessionManager, session.id, serverPort);
322
+
323
+ const responseData = { id: session.id, status: session.status };
324
+
325
+ // Include analysis context metadata so the frontend can show a context indicator
326
+ if (initialContext && suggestions && suggestions.length > 0) {
327
+ responseData.context = {
328
+ suggestionCount: suggestions.length,
329
+ aiRunId: suggestions[0].ai_run_id || null
330
+ };
331
+ // Attach run metadata for richer frontend display
332
+ if (analysisRun) {
333
+ responseData.context.provider = analysisRun.provider || null;
334
+ responseData.context.model = analysisRun.model || null;
335
+ responseData.context.summary = analysisRun.summary || null;
336
+ responseData.context.completedAt = analysisRun.completed_at || null;
337
+ responseData.context.configType = analysisRun.config_type || null;
338
+ responseData.context.parentRunId = analysisRun.parent_run_id || null;
339
+ }
340
+ }
341
+
342
+ res.json({ data: responseData });
343
+ } catch (error) {
344
+ logger.error(`Error creating chat session: ${error.message}`);
345
+ res.status(500).json({ error: 'Failed to create chat session' });
346
+ }
347
+ });
348
+
349
+ /**
350
+ * Send a user message to a chat session (auto-resumes if needed)
351
+ */
352
+ router.post('/api/chat/session/:id/message', async (req, res) => {
353
+ try {
354
+ const sessionId = parseInt(req.params.id, 10);
355
+ const { content, context, contextData, actionContext } = req.body || {};
356
+
357
+ if (!content) {
358
+ return res.status(400).json({ error: 'Missing required field: content' });
359
+ }
360
+
361
+ const chatSessionManager = req.app.chatSessionManager;
362
+ const db = req.app.get('db');
363
+
364
+ // Auto-resume: if session is not active in memory, try to resume it
365
+ if (!chatSessionManager.isSessionActive(sessionId)) {
366
+ const session = chatSessionManager.getSession(sessionId);
367
+ if (!session) {
368
+ return res.status(404).json({ error: 'Chat session not found' });
369
+ }
370
+
371
+ if (!session.agent_session_id) {
372
+ return res.status(410).json({ error: 'Session is not resumable (no session file)' });
373
+ }
374
+
375
+ // Build system prompt and cwd from the review
376
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [session.review_id]);
377
+ if (!review) {
378
+ return res.status(404).json({ error: 'Review not found for session' });
379
+ }
380
+ const chatInstructions = await getChatInstructions(db, review);
381
+ const prData = await fetchPrData(db, review);
382
+
383
+ const systemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
384
+ const cwd = await resolveReviewCwd(db, review);
385
+
386
+ try {
387
+ await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
388
+ unregisterSSEBroadcast(sessionId);
389
+ registerSSEBroadcast(chatSessionManager, sessionId, req.socket.localPort);
390
+ logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
391
+ } catch (err) {
392
+ logger.error(`[ChatRoute] Failed to auto-resume session ${sessionId}: ${err.message}`);
393
+ return res.status(410).json({ error: 'Failed to resume session: ' + err.message });
394
+ }
395
+ }
396
+
397
+ logger.debug(`[ChatRoute] Forwarding message to session ${sessionId} (${content.length} chars)`);
398
+ const result = await chatSessionManager.sendMessage(sessionId, content, { context, contextData, actionContext });
399
+ logger.debug(`[ChatRoute] Message stored as ID ${result.id}, awaiting agent response via SSE`);
400
+ res.json({ data: { messageId: result.id } });
401
+ } catch (error) {
402
+ logger.error(`Error sending chat message: ${error.message}`);
403
+ res.status(500).json({ error: 'Failed to send message' });
404
+ }
405
+ });
406
+
407
+ /**
408
+ * Multiplexed SSE stream for all chat sessions.
409
+ * Clients connect once and receive events tagged with sessionId.
410
+ */
411
+ router.get('/api/chat/stream', (req, res) => {
412
+ // Set up SSE headers
413
+ res.writeHead(200, {
414
+ 'Content-Type': 'text/event-stream',
415
+ 'Cache-Control': 'no-cache',
416
+ 'Connection': 'keep-alive'
417
+ });
418
+
419
+ // Send initial connection acknowledgement
420
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
421
+ logger.debug(`[ChatRoute] Multiplexed SSE client connected (total: ${sseClients.size + 1})`);
422
+
423
+ sseClients.add(res);
424
+
425
+ // Handle client disconnect
426
+ const cleanup = () => {
427
+ sseClients.delete(res);
428
+ logger.debug(`[ChatRoute] Multiplexed SSE client disconnected (total: ${sseClients.size})`);
429
+ };
430
+
431
+ req.on('close', cleanup);
432
+ req.on('error', cleanup);
433
+ });
434
+
435
+ /**
436
+ * Abort the current agent turn in a chat session
437
+ */
438
+ router.post('/api/chat/session/:id/abort', async (req, res) => {
439
+ try {
440
+ const sessionId = parseInt(req.params.id, 10);
441
+ const chatSessionManager = req.app.chatSessionManager;
442
+
443
+ if (!chatSessionManager.isSessionActive(sessionId)) {
444
+ return res.status(404).json({ error: 'Chat session not found or not active' });
445
+ }
446
+
447
+ chatSessionManager.abortSession(sessionId);
448
+ res.json({ data: { success: true } });
449
+ } catch (error) {
450
+ logger.error(`Error aborting chat session: ${error.message}`);
451
+ res.status(500).json({ error: 'Failed to abort' });
452
+ }
453
+ });
454
+
455
+ /**
456
+ * Get message history for a chat session
457
+ */
458
+ router.get('/api/chat/session/:id/messages', (req, res) => {
459
+ try {
460
+ const sessionId = parseInt(req.params.id, 10);
461
+ const chatSessionManager = req.app.chatSessionManager;
462
+
463
+ const session = chatSessionManager.getSession(sessionId);
464
+ if (!session) {
465
+ return res.status(404).json({ error: 'Chat session not found' });
466
+ }
467
+
468
+ const messages = chatSessionManager.getMessages(sessionId);
469
+ res.json({ data: { messages } });
470
+ } catch (error) {
471
+ logger.error(`Error fetching chat messages: ${error.message}`);
472
+ res.status(500).json({ error: 'Failed to fetch messages' });
473
+ }
474
+ });
475
+
476
+ /**
477
+ * Save a context message to a chat session (e.g., analysis context card).
478
+ * Used to persist context cards immediately without waiting for the next user message.
479
+ */
480
+ router.post('/api/chat/session/:id/context', (req, res) => {
481
+ try {
482
+ const sessionId = parseInt(req.params.id, 10);
483
+ const { contextData } = req.body || {};
484
+
485
+ if (!contextData) {
486
+ return res.status(400).json({ error: 'Missing required field: contextData' });
487
+ }
488
+
489
+ const chatSessionManager = req.app.chatSessionManager;
490
+ const result = chatSessionManager.saveContextMessage(sessionId, contextData);
491
+ res.json({ data: { messageId: result.id } });
492
+ } catch (error) {
493
+ if (error.message.includes('not found')) {
494
+ return res.status(404).json({ error: error.message });
495
+ }
496
+ logger.error(`Error saving context message: ${error.message}`);
497
+ res.status(500).json({ error: 'Failed to save context message' });
498
+ }
499
+ });
500
+
501
+ /**
502
+ * Explicitly resume a chat session (pre-warm the bridge before sending a message)
503
+ */
504
+ router.post('/api/chat/session/:id/resume', async (req, res) => {
505
+ try {
506
+ const sessionId = parseInt(req.params.id, 10);
507
+ const chatSessionManager = req.app.chatSessionManager;
508
+ const db = req.app.get('db');
509
+
510
+ // Already active
511
+ if (chatSessionManager.isSessionActive(sessionId)) {
512
+ return res.json({ data: { id: sessionId, status: 'active' } });
513
+ }
514
+
515
+ const session = chatSessionManager.getSession(sessionId);
516
+ if (!session) {
517
+ return res.status(404).json({ error: 'Chat session not found' });
518
+ }
519
+
520
+ if (!session.agent_session_id) {
521
+ return res.status(410).json({ error: 'Session is not resumable (no session file)' });
522
+ }
523
+
524
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [session.review_id]);
525
+ if (!review) {
526
+ return res.status(404).json({ error: 'Review not found for session' });
527
+ }
528
+
529
+ // Pi's --session replays the original conversation; --append-system-prompt
530
+ // re-injects the review context so the agent retains awareness of the codebase
531
+ // even if the system prompt was only in the initial session's context.
532
+ const chatInstructions = await getChatInstructions(db, review);
533
+ const prData = await fetchPrData(db, review);
534
+
535
+ const systemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
536
+ const cwd = await resolveReviewCwd(db, review);
537
+
538
+ await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
539
+ unregisterSSEBroadcast(sessionId);
540
+ registerSSEBroadcast(chatSessionManager, sessionId, req.socket.localPort);
541
+
542
+ logger.info(`[ChatRoute] Explicitly resumed session ${sessionId}`);
543
+ res.json({ data: { id: sessionId, status: 'active' } });
544
+ } catch (error) {
545
+ logger.error(`Error resuming chat session: ${error.message}`);
546
+ res.status(500).json({ error: 'Failed to resume session: ' + error.message });
547
+ }
548
+ });
549
+
550
+ /**
551
+ * Close a chat session
552
+ */
553
+ router.delete('/api/chat/session/:id', async (req, res) => {
554
+ try {
555
+ const sessionId = parseInt(req.params.id, 10);
556
+ const chatSessionManager = req.app.chatSessionManager;
557
+
558
+ // Unregister SSE broadcast listeners before closing the session
559
+ unregisterSSEBroadcast(sessionId);
560
+
561
+ await chatSessionManager.closeSession(sessionId);
562
+ logger.info(`Chat session closed: ${sessionId}`);
563
+ res.json({ data: { success: true } });
564
+ } catch (error) {
565
+ logger.error(`Error closing chat session: ${error.message}`);
566
+ res.status(500).json({ error: 'Failed to close chat session' });
567
+ }
568
+ });
569
+
570
+ /**
571
+ * List chat sessions for a review (with message counts and live state annotations)
572
+ */
573
+ router.get('/api/review/:reviewId/chat/sessions', (req, res) => {
574
+ try {
575
+ const { reviewId } = req.params;
576
+ const chatSessionManager = req.app.chatSessionManager;
577
+
578
+ const sessions = chatSessionManager.getSessionsWithMessageCount(parseInt(reviewId, 10));
579
+
580
+ // Annotate each session with live state
581
+ const annotated = sessions.map((s) => ({
582
+ ...s,
583
+ isActive: chatSessionManager.isSessionActive(s.id),
584
+ isResumable: !chatSessionManager.isSessionActive(s.id) && !!s.agent_session_id
585
+ }));
586
+
587
+ res.json({ data: { sessions: annotated } });
588
+ } catch (error) {
589
+ logger.error(`Error fetching chat sessions: ${error.message}`);
590
+ res.status(500).json({ error: 'Failed to fetch chat sessions' });
591
+ }
592
+ });
593
+
594
+ /**
595
+ * Get formatted analysis context for a specific run.
596
+ * Returns context text and run metadata so the chat panel can add it as pending context.
597
+ */
598
+ router.get('/api/chat/analysis-context/:runId', async (req, res) => {
599
+ try {
600
+ const { runId } = req.params;
601
+ const reviewId = parseInt(req.query.reviewId, 10);
602
+
603
+ if (!runId || !reviewId || isNaN(reviewId)) {
604
+ return res.status(400).json({ error: 'Missing required params: runId (path) and reviewId (query)' });
605
+ }
606
+
607
+ const db = req.app.get('db');
608
+
609
+ // Fetch AI suggestions for this specific run (top-level only: ai_level IS NULL)
610
+ const suggestions = await query(db, `
611
+ SELECT
612
+ id, ai_run_id, ai_level, ai_confidence,
613
+ file, line_start, line_end, type, title, body,
614
+ reasoning, status, is_file_level
615
+ FROM comments
616
+ WHERE review_id = ?
617
+ AND source = 'ai'
618
+ AND ai_level IS NULL
619
+ AND (is_raw = 0 OR is_raw IS NULL)
620
+ AND ai_run_id = ?
621
+ ORDER BY file, line_start
622
+ `, [reviewId, runId]);
623
+
624
+ // Fetch the analysis run record for metadata
625
+ const analysisRunRepo = new AnalysisRunRepository(db);
626
+ const analysisRun = await analysisRunRepo.getById(runId);
627
+
628
+ const text = buildInitialContext({ suggestions, analysisRun });
629
+
630
+ res.json({
631
+ data: {
632
+ text,
633
+ suggestionCount: suggestions ? suggestions.length : 0,
634
+ run: analysisRun ? {
635
+ id: analysisRun.id,
636
+ provider: analysisRun.provider || null,
637
+ model: analysisRun.model || null,
638
+ summary: analysisRun.summary || null,
639
+ completedAt: analysisRun.completed_at || null,
640
+ configType: analysisRun.config_type || null
641
+ } : null
642
+ }
643
+ });
644
+ } catch (error) {
645
+ logger.error(`Error fetching analysis context: ${error.message}`);
646
+ res.status(500).json({ error: 'Failed to fetch analysis context' });
647
+ }
648
+ });
649
+
650
+ module.exports = router;
651
+
652
+ // Expose internals for testing
653
+ module.exports._sseClients = sseClients;
654
+ module.exports._sseUnsubscribers = sseUnsubscribers;
655
+ module.exports._buildPairReviewApiRe = buildPairReviewApiRe;