@in-the-loop-labs/pair-review 2.2.0 → 2.3.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.
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ /**
4
+ * Browser-side WebSocket client singleton.
5
+ * Provides topic-based pub/sub over a single WebSocket connection
6
+ * with automatic reconnection and subscription restoration.
7
+ */
8
+ (function () {
9
+ 'use strict';
10
+
11
+ class WSClient {
12
+ constructor() {
13
+ /** @type {WebSocket|null} */
14
+ this._ws = null;
15
+ /** @type {Map<string, Set<Function>>} topic -> callbacks */
16
+ this._subscriptions = new Map();
17
+ /** @type {boolean} */
18
+ this.connected = false;
19
+ /** @type {number} current backoff delay in ms */
20
+ this._backoff = 1000;
21
+ /** @type {number} */
22
+ this._backoffMax = 10000;
23
+ /** @type {boolean} whether close() was called intentionally */
24
+ this._closed = false;
25
+ /** @type {number|null} reconnect timer id */
26
+ this._reconnectTimer = null;
27
+ /** @type {boolean} whether at least one connection has been established */
28
+ this._hasConnected = false;
29
+ }
30
+
31
+ /**
32
+ * Open the WebSocket connection. No-op if already connected or connecting.
33
+ */
34
+ connect() {
35
+ if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) {
36
+ return;
37
+ }
38
+ this._closed = false;
39
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ const url = `${protocol}//${location.host}/ws`;
41
+ this._ws = new WebSocket(url);
42
+
43
+ this._ws.onopen = () => {
44
+ this.connected = true;
45
+ this._backoff = 1000;
46
+ // Re-subscribe to all active topics (_subscriptions is authoritative)
47
+ for (const topic of this._subscriptions.keys()) {
48
+ this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
49
+ }
50
+ // Emit reconnected event on subsequent opens (not the initial connect)
51
+ if (this._hasConnected) {
52
+ window.dispatchEvent(new CustomEvent('wsReconnected'));
53
+ }
54
+ this._hasConnected = true;
55
+ };
56
+
57
+ this._ws.onmessage = (event) => {
58
+ let msg;
59
+ try {
60
+ msg = JSON.parse(event.data);
61
+ } catch {
62
+ return;
63
+ }
64
+ const callbacks = this._subscriptions.get(msg.topic);
65
+ if (callbacks) {
66
+ for (const cb of [...callbacks]) {
67
+ try {
68
+ cb(msg);
69
+ } catch (e) {
70
+ console.error('[WSClient] Subscriber error:', e);
71
+ }
72
+ }
73
+ }
74
+ };
75
+
76
+ this._ws.onclose = () => {
77
+ this.connected = false;
78
+ this._ws = null;
79
+ if (!this._closed) {
80
+ this._scheduleReconnect();
81
+ }
82
+ };
83
+
84
+ this._ws.onerror = () => {
85
+ // onclose will fire after onerror, which handles reconnection
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Subscribe to a topic. Returns an unsubscribe function.
91
+ * Safe to call before connect() — the subscribe message will be
92
+ * sent once the connection is established.
93
+ *
94
+ * @param {string} topic
95
+ * @param {Function} callback - receives the full parsed message object
96
+ * @returns {Function} unsubscribe
97
+ */
98
+ subscribe(topic, callback) {
99
+ let callbacks = this._subscriptions.get(topic);
100
+ if (!callbacks) {
101
+ callbacks = new Set();
102
+ this._subscriptions.set(topic, callbacks);
103
+ }
104
+ callbacks.add(callback);
105
+
106
+ // Send subscribe message if connected (otherwise it will be sent on connect via _subscriptions)
107
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
108
+ this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
109
+ }
110
+
111
+ // Return unsubscribe function
112
+ return () => {
113
+ callbacks.delete(callback);
114
+ if (callbacks.size === 0) {
115
+ this._subscriptions.delete(topic);
116
+ const unsub = { action: 'unsubscribe', topic };
117
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
118
+ this._ws.send(JSON.stringify(unsub));
119
+ }
120
+ }
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Close the WebSocket and stop reconnection.
126
+ */
127
+ close() {
128
+ this._closed = true;
129
+ if (this._reconnectTimer !== null) {
130
+ clearTimeout(this._reconnectTimer);
131
+ this._reconnectTimer = null;
132
+ }
133
+ if (this._ws) {
134
+ this._ws.close();
135
+ this._ws = null;
136
+ }
137
+ this.connected = false;
138
+ }
139
+
140
+ /** @private */
141
+ _scheduleReconnect() {
142
+ this._reconnectTimer = setTimeout(() => {
143
+ this._reconnectTimer = null;
144
+ this.connect();
145
+ }, this._backoff);
146
+ this._backoff = Math.min(this._backoff * 2, this._backoffMax);
147
+ }
148
+ }
149
+
150
+ // Export as singleton on window
151
+ if (typeof window !== 'undefined') {
152
+ window.WSClient = WSClient;
153
+ window.wsClient = new WSClient();
154
+ }
155
+ })();
package/public/local.html CHANGED
@@ -516,6 +516,9 @@
516
516
  <!-- Timestamp parsing utility -->
517
517
  <script src="/js/utils/time.js"></script>
518
518
 
519
+ <!-- WebSocket client -->
520
+ <script src="/js/ws-client.js"></script>
521
+
519
522
  <!-- Components -->
520
523
  <script src="/js/components/Toast.js"></script>
521
524
  <script src="/js/components/ConfirmDialog.js"></script>
package/public/pr.html CHANGED
@@ -339,6 +339,9 @@
339
339
  <!-- Timestamp parsing utility -->
340
340
  <script src="/js/utils/time.js"></script>
341
341
 
342
+ <!-- WebSocket client -->
343
+ <script src="/js/ws-client.js"></script>
344
+
342
345
  <!-- Components -->
343
346
  <script src="/js/components/Toast.js"></script>
344
347
  <script src="/js/components/ConfirmDialog.js"></script>
package/public/setup.html CHANGED
@@ -517,6 +517,9 @@
517
517
  </div>
518
518
  </div>
519
519
 
520
+ <!-- WebSocket client -->
521
+ <script src="/js/ws-client.js"></script>
522
+
520
523
  <script>
521
524
  // SPDX-License-Identifier: GPL-3.0-or-later
522
525
 
@@ -718,17 +721,15 @@
718
721
 
719
722
  /* ── Setup Flow ── */
720
723
  async function startSetup() {
721
- var postUrl, sseBaseUrl;
724
+ var postUrl;
722
725
 
723
726
  if (mode === 'pr') {
724
727
  var o = encodeURIComponent(context.owner);
725
728
  var r = encodeURIComponent(context.repo);
726
729
  var n = encodeURIComponent(context.number);
727
730
  postUrl = '/api/setup/pr/' + o + '/' + r + '/' + n;
728
- sseBaseUrl = postUrl + '/progress';
729
731
  } else if (mode === 'local') {
730
732
  postUrl = '/api/setup/local';
731
- sseBaseUrl = '/api/setup/local';
732
733
  } else {
733
734
  showError('Unable to determine review mode. Please return home and try again.');
734
735
  return;
@@ -765,15 +766,7 @@
765
766
  throw new Error('No setup ID returned from server');
766
767
  }
767
768
 
768
- // Connect to SSE endpoint
769
- var sseUrl;
770
- if (mode === 'pr') {
771
- sseUrl = sseBaseUrl + '?setupId=' + encodeURIComponent(data.setupId);
772
- } else {
773
- sseUrl = sseBaseUrl + '/' + encodeURIComponent(data.setupId) + '/progress';
774
- }
775
-
776
- connectSSE(sseUrl);
769
+ connectWS(data.setupId);
777
770
 
778
771
  } catch (err) {
779
772
  showError(err.message || 'Failed to start setup. Please try again.');
@@ -784,98 +777,66 @@
784
777
  }
785
778
  }
786
779
 
787
- /* ── SSE Connection ── */
788
- function connectSSE(url) {
789
- var eventSource = new EventSource(url);
790
-
791
- eventSource.addEventListener('step', function(e) {
792
- try {
793
- var payload = JSON.parse(e.data);
794
- var stepId = payload.step || payload.id;
795
- var status = payload.status || 'running';
796
- var message = payload.message || '';
797
-
798
- stepStates[stepId] = { status: status, message: message };
799
- renderSteps();
800
- updateProgressBar();
801
- } catch (err) {
802
- console.error('Error parsing step event:', err);
803
- }
804
- });
805
-
806
- eventSource.addEventListener('complete', function(e) {
807
- try {
808
- var payload = JSON.parse(e.data);
780
+ /* ── WebSocket Connection ── */
781
+ // Note: No wsReconnected recovery here. Setup is a short-lived,
782
+ // one-time flow if the WS drops mid-setup, the user can simply
783
+ // reload the page. Not worth the complexity of state recovery.
784
+ function connectWS(setupId) {
785
+ window.wsClient.connect();
786
+
787
+ var unsub = window.wsClient.subscribe('setup:' + setupId, function(msg) {
788
+ switch (msg.type) {
789
+ case 'step': {
790
+ var stepId = msg.step || msg.id;
791
+ var status = msg.status || 'running';
792
+ var message = msg.message || '';
793
+
794
+ stepStates[stepId] = { status: status, message: message };
795
+ renderSteps();
796
+ updateProgressBar();
797
+ break;
798
+ }
809
799
 
810
- // Mark all steps as completed
811
- for (var i = 0; i < steps.length; i++) {
812
- if (!stepStates[steps[i].id] || stepStates[steps[i].id].status !== 'completed') {
813
- stepStates[steps[i].id] = { status: 'completed', message: '' };
800
+ case 'complete': {
801
+ unsub();
802
+ // Mark all steps as completed
803
+ for (var i = 0; i < steps.length; i++) {
804
+ if (!stepStates[steps[i].id] || stepStates[steps[i].id].status !== 'completed') {
805
+ stepStates[steps[i].id] = { status: 'completed', message: '' };
806
+ }
814
807
  }
808
+ renderSteps();
809
+ updateProgressBar();
810
+ showRedirect();
811
+
812
+ // Small delay so the user sees the completed state
813
+ setTimeout(function() {
814
+ if (msg.reviewUrl) {
815
+ var targetUrl = new URL(msg.reviewUrl, window.location.origin);
816
+ var qs = new URLSearchParams(window.location.search);
817
+ if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
818
+ window.location.href = targetUrl.toString();
819
+ }
820
+ }, 400);
821
+ break;
815
822
  }
816
- renderSteps();
817
- updateProgressBar();
818
-
819
- showRedirect();
820
- eventSource.close();
821
-
822
- // Small delay so the user sees the completed state
823
- setTimeout(function() {
824
- if (payload.reviewUrl) {
825
- var targetUrl = new URL(payload.reviewUrl, window.location.origin);
826
- var qs = new URLSearchParams(window.location.search);
827
- if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
828
- window.location.href = targetUrl.toString();
829
- }
830
- }, 400);
831
- } catch (err) {
832
- console.error('Error parsing complete event:', err);
833
- }
834
- });
835
823
 
836
- eventSource.addEventListener('error', function(e) {
837
- // SSE 'error' event type from server (custom)
838
- if (e.data) {
839
- try {
840
- var payload = JSON.parse(e.data);
841
- var failedStep = payload.step;
842
- var errorMsg = payload.message || 'An unexpected error occurred';
824
+ case 'error': {
825
+ unsub();
826
+ var failedStep = msg.step;
827
+ var errorMsg = msg.message || 'An unexpected error occurred';
843
828
 
844
- if (failedStep && stepStates[failedStep]) {
845
- stepStates[failedStep] = { status: 'error', message: errorMsg };
846
- } else if (failedStep) {
829
+ if (failedStep) {
847
830
  stepStates[failedStep] = { status: 'error', message: errorMsg };
848
831
  }
849
832
 
850
833
  renderSteps();
851
834
  updateProgressBar();
852
835
  showError(errorMsg);
853
- eventSource.close();
854
- return;
855
- } catch (parseErr) {
856
- // Not a JSON error event, fall through
857
- }
858
- }
859
-
860
- // Native EventSource error (connection lost, etc.)
861
- if (eventSource.readyState === EventSource.CLOSED) {
862
- // Check if we got any errors already shown
863
- if (!errorSection.classList.contains('visible')) {
864
- showError('Connection to server was lost. Please retry.');
865
- updateProgressBar();
836
+ break;
866
837
  }
867
838
  }
868
839
  });
869
-
870
- eventSource.onerror = function() {
871
- // EventSource connection failure
872
- if (eventSource.readyState === EventSource.CLOSED) {
873
- if (!errorSection.classList.contains('visible') && !redirectSection.classList.contains('visible')) {
874
- showError('Connection to server was lost. Please retry.');
875
- updateProgressBar();
876
- }
877
- }
878
- };
879
840
  }
880
841
 
881
842
  /* ── Kick Off ── */
@@ -3505,12 +3505,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3505
3505
  continue;
3506
3506
  }
3507
3507
 
3508
- const suggestionText = suggestion.suggestion;
3509
- const hasSuggestionBlock = suggestionText?.trimStart().startsWith('```suggestion');
3510
- const body = suggestion.description +
3511
- (suggestionText
3512
- ? (hasSuggestionBlock ? '\n\n' + suggestionText : '\n\n**Suggestion:** ' + suggestionText)
3513
- : '');
3508
+ const body = suggestion.description;
3509
+ const suggestionText = suggestion.suggestion || null;
3514
3510
 
3515
3511
  const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
3516
3512
  const side = suggestion.old_or_new === 'OLD' ? 'LEFT' : 'RIGHT';
@@ -3518,9 +3514,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3518
3514
  await dbRun(this.db, `
3519
3515
  INSERT INTO comments (
3520
3516
  review_id, source, author, ai_run_id, ai_level, ai_confidence,
3521
- file, line_start, line_end, side, type, title, body, reasoning, status, is_file_level,
3517
+ file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level,
3522
3518
  voice_id, is_raw
3523
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3519
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3524
3520
  `, [
3525
3521
  reviewId,
3526
3522
  'ai',
@@ -3535,6 +3531,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3535
3531
  suggestion.type,
3536
3532
  suggestion.title,
3537
3533
  body,
3534
+ suggestionText,
3538
3535
  suggestion.reasoning ? JSON.stringify(suggestion.reasoning) : null,
3539
3536
  'active',
3540
3537
  isFileLevel,
package/src/config.js CHANGED
@@ -7,6 +7,7 @@ const logger = require('./utils/logger');
7
7
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
8
8
  const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
9
9
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
10
+ const CONFIG_LOCAL_FILE = path.join(CONFIG_DIR, 'config.local.json');
10
11
  const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
11
12
  const PACKAGE_ROOT = path.join(__dirname, '..');
12
13
 
@@ -22,6 +23,7 @@ const DEFAULT_CONFIG = {
22
23
  db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
23
24
  yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
24
25
  enable_chat: true, // When true, enables the chat panel feature (requires Pi AI provider)
26
+ comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
25
27
  chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
26
28
  providers: {}, // Custom provider configurations (overrides built-in defaults)
27
29
  monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
@@ -37,6 +39,36 @@ function validatePort(port) {
37
39
  return Number.isInteger(port) && port >= 1024 && port <= 65535;
38
40
  }
39
41
 
42
+ /**
43
+ * Recursively merges source into target for plain objects.
44
+ * Arrays and scalars in source replace the corresponding value in target.
45
+ * Null in source overwrites target. Returns a new object; inputs are not mutated.
46
+ * @param {Object} target - Base object
47
+ * @param {Object} source - Object to merge on top
48
+ * @returns {Object} - Merged result
49
+ */
50
+ function deepMerge(target, source) {
51
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return target;
52
+ if (!target || typeof target !== 'object' || Array.isArray(target)) return { ...source };
53
+
54
+ const result = { ...target };
55
+ for (const key of Object.keys(source)) {
56
+ const srcVal = source[key];
57
+ const tgtVal = target[key];
58
+ if (
59
+ srcVal !== null &&
60
+ typeof srcVal === 'object' && !Array.isArray(srcVal) &&
61
+ tgtVal !== null &&
62
+ typeof tgtVal === 'object' && !Array.isArray(tgtVal)
63
+ ) {
64
+ result[key] = deepMerge(tgtVal, srcVal);
65
+ } else {
66
+ result[key] = srcVal;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+
40
72
  /**
41
73
  * Gets a config value with fallback to legacy key names
42
74
  * Supports backwards compatibility without modifying the config file
@@ -135,63 +167,51 @@ async function ensureConfigDir() {
135
167
  async function loadConfig() {
136
168
  await ensureConfigDir();
137
169
 
138
- try {
139
- const configData = await fs.readFile(CONFIG_FILE, 'utf8');
140
- const config = JSON.parse(configData);
141
-
142
- // Merge with defaults to ensure all keys exist
143
- // Legacy keys ('provider', 'model') are handled lazily via getDefaultProvider/getDefaultModel
144
- const mergedConfig = { ...DEFAULT_CONFIG, ...config };
145
- // Deep-merge one level for object-valued defaults (e.g. chat, providers, monorepos)
146
- for (const key of Object.keys(DEFAULT_CONFIG)) {
147
- if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
148
- mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key] };
149
- }
150
- }
170
+ const localDir = path.join(process.cwd(), '.pair-review');
171
+ const sources = [
172
+ { path: CONFIG_FILE, label: 'global config', required: true },
173
+ { path: CONFIG_LOCAL_FILE, label: 'global local config', required: false },
174
+ { path: path.join(localDir, 'config.json'), label: 'project config', required: false },
175
+ { path: path.join(localDir, 'config.local.json'), label: 'project local config', required: false },
176
+ ];
177
+
178
+ let mergedConfig = { ...DEFAULT_CONFIG };
179
+ let isFirstRun = false;
151
180
 
152
- // Merge local config (CWD/.pair-review/config.json) on top of global config
181
+ for (const source of sources) {
153
182
  try {
154
- const localConfigPath = path.join(process.cwd(), '.pair-review', 'config.json');
155
- const localConfigData = await fs.readFile(localConfigPath, 'utf8');
156
- const localConfig = JSON.parse(localConfigData);
157
- Object.assign(mergedConfig, localConfig);
158
- // Deep-merge one level for object-valued local config overrides
159
- for (const key of Object.keys(DEFAULT_CONFIG)) {
160
- if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
161
- mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key], ...localConfig[key] };
183
+ const data = await fs.readFile(source.path, 'utf8');
184
+ const parsed = JSON.parse(data);
185
+ mergedConfig = deepMerge(mergedConfig, parsed);
186
+ } catch (error) {
187
+ if (error.code === 'ENOENT') {
188
+ if (source.required) {
189
+ // Global config doesn't exist create it with defaults
190
+ const config = { ...DEFAULT_CONFIG };
191
+ await saveConfig(config);
192
+ logger.debug(`Created default config file: ${CONFIG_FILE}`);
193
+ isFirstRun = true;
162
194
  }
163
- }
164
- } catch (localError) {
165
- if (localError.code !== 'ENOENT') {
166
- if (localError instanceof SyntaxError) {
167
- logger.warn('Malformed local config at .pair-review/config.json, skipping');
168
- } else {
169
- throw localError;
195
+ // Optional files: skip silently
196
+ } else if (error instanceof SyntaxError) {
197
+ if (source.required) {
198
+ console.error(`Invalid configuration file at ~/.pair-review/config.json`);
199
+ process.exit(1);
170
200
  }
201
+ logger.warn(`Malformed config at ${source.label}, skipping`);
202
+ } else {
203
+ throw error;
171
204
  }
172
205
  }
206
+ }
173
207
 
174
- // Validate port
175
- if (!validatePort(mergedConfig.port)) {
176
- console.error(`Invalid port number ${mergedConfig.port}`);
177
- process.exit(1);
178
- }
179
-
180
- return { config: mergedConfig, isFirstRun: false };
181
- } catch (error) {
182
- if (error.code === 'ENOENT') {
183
- // Config file doesn't exist, create it with defaults
184
- const config = { ...DEFAULT_CONFIG };
185
- await saveConfig(config);
186
- logger.debug(`Created default config file: ${CONFIG_FILE}`);
187
- return { config, isFirstRun: true };
188
- } else if (error instanceof SyntaxError) {
189
- console.error(`Invalid configuration file at ~/.pair-review/config.json`);
190
- process.exit(1);
191
- } else {
192
- throw error;
193
- }
208
+ // Validate port
209
+ if (!validatePort(mergedConfig.port)) {
210
+ console.error(`Invalid port number ${mergedConfig.port}`);
211
+ process.exit(1);
194
212
  }
213
+
214
+ return { config: mergedConfig, isFirstRun };
195
215
  }
196
216
 
197
217
  /**
@@ -424,6 +444,7 @@ function warnIfDevModeWithoutDbName(config) {
424
444
  }
425
445
 
426
446
  module.exports = {
447
+ deepMerge,
427
448
  loadConfig,
428
449
  saveConfig,
429
450
  getConfigDir,
package/src/database.js CHANGED
@@ -20,7 +20,7 @@ function getDbPath() {
20
20
  /**
21
21
  * Current schema version - increment this when adding new migrations
22
22
  */
23
- const CURRENT_SCHEMA_VERSION = 24;
23
+ const CURRENT_SCHEMA_VERSION = 25;
24
24
 
25
25
  /**
26
26
  * Database schema SQL statements
@@ -80,6 +80,7 @@ const SCHEMA_SQL = {
80
80
  type TEXT,
81
81
  title TEXT,
82
82
  body TEXT,
83
+ suggestion_text TEXT,
83
84
  reasoning TEXT,
84
85
 
85
86
  status TEXT DEFAULT 'active' CHECK(status IN ('active', 'dismissed', 'adopted', 'submitted', 'draft', 'inactive')),
@@ -1135,6 +1136,28 @@ const MIGRATIONS = {
1135
1136
  }
1136
1137
 
1137
1138
  console.log('Migration to schema version 24 complete');
1139
+ },
1140
+
1141
+ // Migration to version 25: adds suggestion_text column to comments for structured suggestion storage
1142
+ 25: (db) => {
1143
+ console.log('Migrating to schema version 25: Add suggestion_text column to comments');
1144
+
1145
+ const columns = db.prepare('PRAGMA table_info(comments)').all();
1146
+ if (!columns.some(c => c.name === 'suggestion_text')) {
1147
+ try {
1148
+ db.prepare('ALTER TABLE comments ADD COLUMN suggestion_text TEXT').run();
1149
+ console.log(' Added suggestion_text column to comments');
1150
+ } catch (error) {
1151
+ if (!error.message.includes('duplicate column name')) {
1152
+ throw error;
1153
+ }
1154
+ console.log(' Column suggestion_text already exists (race condition)');
1155
+ }
1156
+ } else {
1157
+ console.log(' Column suggestion_text already exists');
1158
+ }
1159
+
1160
+ console.log('Migration to schema version 25 complete');
1138
1161
  }
1139
1162
  };
1140
1163
 
@@ -2193,12 +2216,8 @@ class CommentRepository {
2193
2216
  }
2194
2217
 
2195
2218
  for (const suggestion of normalized) {
2196
- const suggestionText = suggestion.suggestion;
2197
- const hasSuggestionBlock = suggestionText?.trimStart().startsWith('```suggestion');
2198
- const body = suggestion.description +
2199
- (suggestionText
2200
- ? (hasSuggestionBlock ? '\n\n' + suggestionText : '\n\n**Suggestion:** ' + suggestionText)
2201
- : '');
2219
+ const body = suggestion.description;
2220
+ const suggestionText = suggestion.suggestion || null;
2202
2221
 
2203
2222
  // File-level suggestions have is_file_level=true or have null line_start
2204
2223
  const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
@@ -2209,8 +2228,8 @@ class CommentRepository {
2209
2228
  await run(this.db, `
2210
2229
  INSERT INTO comments (
2211
2230
  review_id, source, author, ai_run_id, ai_level, ai_confidence,
2212
- file, line_start, line_end, side, type, title, body, reasoning, status, is_file_level
2213
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2231
+ file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level
2232
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2214
2233
  `, [
2215
2234
  reviewId,
2216
2235
  'ai',
@@ -2225,6 +2244,7 @@ class CommentRepository {
2225
2244
  suggestion.type,
2226
2245
  suggestion.title,
2227
2246
  body,
2247
+ suggestionText,
2228
2248
  suggestion.reasoning ? JSON.stringify(suggestion.reasoning) : null,
2229
2249
  'active',
2230
2250
  isFileLevel