/ src / main.ts
main.ts
   1  import { app, autoUpdater, BrowserWindow, dialog, ipcMain, Notification, safeStorage, shell } from 'electron';
   2  import path from 'path';
   3  import fs from 'fs';
   4  import os from 'os';
   5  import http from 'http';
   6  import crypto from 'crypto';
   7  import { Octokit } from '@octokit/rest';
   8  import {
   9    parsePrUrl,
  10    getPrMetadata,
  11    getPrDiff,
  12    getChangedFiles,
  13    getFileContent,
  14    getNeighborFiles,
  15    searchPullRequests,
  16    getCiStatus,
  17    getReviewStatus,
  18  } from '../lib/github';
  19  import type { CiCheck, PrStatus } from '../lib/types';
  20  import { buildContextPackage } from '../lib/context-builder';
  21  import { generateReviewGuide } from '../lib/agent';
  22  import { checkForUpdate } from '../lib/updater';
  23  import { renderDiffHunk, reRenderAllHunks } from '../lib/highlight';
  24  import { parseDiffLines, parsePatchValidLines } from '../lib/diff-lines';
  25  import { setBinaryOverride, detectBinaryPath, resolveBinaryPath } from '../lib/providers/shared';
  26  import { getProvider } from '../lib/provider';
  27  import { buildSlideChatSystemPrompt, buildSlideChatUserMessage } from '../lib/chat-agent';
  28  import { buildIndexedHunks, expandFullDiff, formatHunkIndexForPrompt, sortDiffHunks } from '../lib/diff-parse';
  29  import { classifyFiles, filterDiff, buildExcludedFilesSummary } from '../lib/file-filter';
  30  import { writeMcpConfig, cleanupMcpConfig } from '../lib/mcp-config';
  31  import type {
  32    ChangedFile,
  33    DiffHunk,
  34    GenerateReviewRequest,
  35    ModelId,
  36    Preferences,
  37    ReviewGuide,
  38    ReviewHistoryEntry,
  39    Slide,
  40    SendSlideChatRequest,
  41    StartReviewResult,
  42    SubmitReviewRequest,
  43    FreshnessResult,
  44  } from '../lib/types';
  45  
  46  // Injected by Electron Forge Vite plugin
  47  declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
  48  declare const MAIN_WINDOW_VITE_NAME: string;
  49  
  50  // Injected by Vite define
  51  declare const __GH_CLIENT_SECRET__: string;
  52  
  53  const GITHUB_CLIENT_ID = 'Ov23lifGr1yrXtcZD5Og';
  54  const GITHUB_CLIENT_SECRET: string = typeof __GH_CLIENT_SECRET__ !== 'undefined' ? __GH_CLIENT_SECRET__ : '';
  55  
  56  // ── In-memory cache ─────────────────────────────────────────────
  57  
  58  let cachedToken: string | null = null;
  59  let cachedLogin: string | null = null;
  60  
  61  // ── Token storage helpers ────────────────────────────────────────
  62  
  63  function getTokenPath() {
  64    return path.join(app.getPath('userData'), 'token.enc');
  65  }
  66  
  67  function getPlainTokenPath() {
  68    return getTokenPath() + '.plain';
  69  }
  70  
  71  function loadStoredToken(): string | null {
  72    // Avoid calling safeStorage.isEncryptionAvailable() — on macOS it can trigger its
  73    // own Keychain prompt before decryptString() does, causing two prompts on startup.
  74    // Instead, try decryptString() directly and fall back to plaintext on any error.
  75    try {
  76      if (fs.existsSync(getTokenPath())) {
  77        return safeStorage.decryptString(fs.readFileSync(getTokenPath()));
  78      }
  79    } catch {
  80      // Encryption unavailable or data corrupted — fall through to plaintext
  81    }
  82    try {
  83      if (fs.existsSync(getPlainTokenPath())) {
  84        return fs.readFileSync(getPlainTokenPath(), 'utf-8').trim();
  85      }
  86    } catch {
  87      // ignore
  88    }
  89    return null;
  90  }
  91  
  92  function persistToken(token: string) {
  93    if (safeStorage.isEncryptionAvailable()) {
  94      fs.writeFileSync(getTokenPath(), safeStorage.encryptString(token));
  95    } else {
  96      fs.writeFileSync(getPlainTokenPath(), token, { encoding: 'utf-8', mode: 0o600 });
  97    }
  98  }
  99  
 100  function deleteStoredToken() {
 101    const p = getTokenPath();
 102    if (fs.existsSync(p)) fs.unlinkSync(p);
 103    const plain = getPlainTokenPath();
 104    if (fs.existsSync(plain)) fs.unlinkSync(plain);
 105  }
 106  
 107  function getResolvedToken(): string | null {
 108    if (cachedToken) return cachedToken;
 109    const token = loadStoredToken();
 110    if (token) cachedToken = token; // cache so keychain is only unlocked once per session
 111    return token;
 112  }
 113  
 114  // ── OAuth flow ──────────────────────────────────────────────────
 115  
 116  async function exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise<string> {
 117    const body = new URLSearchParams({
 118      client_id: GITHUB_CLIENT_ID,
 119      client_secret: GITHUB_CLIENT_SECRET,
 120      code,
 121      code_verifier: codeVerifier,
 122      redirect_uri: redirectUri,
 123    });
 124  
 125    const res = await fetch('https://github.com/login/oauth/access_token', {
 126      method: 'POST',
 127      headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
 128      body: body.toString(),
 129    });
 130  
 131    const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string };
 132    if (!data.access_token) {
 133      throw new Error(data.error_description ?? data.error ?? 'OAuth token exchange failed');
 134    }
 135    return data.access_token;
 136  }
 137  
 138  async function fetchGitHubLogin(token: string): Promise<string> {
 139    const res = await fetch('https://api.github.com/user', {
 140      headers: {
 141        Authorization: `token ${token}`,
 142        'User-Agent': 'Gnosis-App',
 143      },
 144    });
 145    const data = (await res.json()) as { login?: string };
 146    return data.login ?? 'unknown';
 147  }
 148  
 149  async function validateAndFetchLogin(token: string): Promise<string> {
 150    const res = await fetch('https://api.github.com/user', {
 151      headers: { Authorization: `token ${token}`, 'User-Agent': 'Gnosis-App' },
 152    });
 153    if (!res.ok) throw new Error(`Invalid token (GitHub returned ${res.status})`);
 154    const data = (await res.json()) as { login?: string };
 155    if (!data.login) throw new Error('Token validated but could not retrieve GitHub username');
 156    return data.login;
 157  }
 158  
 159  function generatePkce(): { verifier: string; challenge: string } {
 160    const verifier = crypto.randomBytes(32).toString('base64url');
 161    const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
 162    return { verifier, challenge };
 163  }
 164  
 165  function runOAuthFlow(): Promise<void> {
 166    return new Promise((resolve, reject) => {
 167      const state = crypto.randomBytes(20).toString('hex');
 168      const { verifier, challenge } = generatePkce();
 169  
 170      // eslint-disable-next-line @typescript-eslint/no-misused-promises -- async HTTP handler
 171      const server = http.createServer(async (req, res) => {
 172        const url = new URL(req.url ?? '/', `http://localhost`);
 173        if (url.pathname !== '/callback') {
 174          res.writeHead(404);
 175          res.end();
 176          return;
 177        }
 178  
 179        const returnedState = url.searchParams.get('state');
 180        const code = url.searchParams.get('code');
 181        const errorParam = url.searchParams.get('error');
 182  
 183        if (returnedState !== state) {
 184          res.writeHead(400, { 'Content-Type': 'text/html' });
 185          res.end('<html><body><p>Invalid state parameter. Please try again.</p></body></html>');
 186          server.close();
 187          reject(new Error('OAuth state mismatch'));
 188          return;
 189        }
 190  
 191        if (errorParam || !code) {
 192          const desc = url.searchParams.get('error_description') ?? errorParam ?? 'Unknown error';
 193          res.writeHead(400, { 'Content-Type': 'text/html' });
 194          res.end(`<html><body><p>Sign-in failed: ${desc}</p></body></html>`);
 195          server.close();
 196          reject(new Error(desc));
 197          return;
 198        }
 199  
 200        const addr = server.address();
 201        const port = typeof addr === 'object' && addr ? addr.port : 0;
 202        const redirectUri = `http://127.0.0.1:${port}/callback`;
 203  
 204        try {
 205          const token = await exchangeCodeForToken(code, verifier, redirectUri);
 206          const login = await fetchGitHubLogin(token);
 207          persistToken(token);
 208          cachedToken = token;
 209          cachedLogin = login;
 210  
 211          res.writeHead(200, { 'Content-Type': 'text/html' });
 212          res.end('<html><body><p>You are now signed in to Gnosis. You can close this tab.</p></body></html>');
 213          server.close();
 214          resolve();
 215        } catch (err) {
 216          res.writeHead(500, { 'Content-Type': 'text/html' });
 217          res.end('<html><body><p>Authentication failed. Please try again.</p></body></html>');
 218          server.close();
 219          reject(err instanceof Error ? err : new Error(String(err)));
 220        }
 221      });
 222  
 223      server.listen(0, '127.0.0.1', () => {
 224        const addr = server.address();
 225        if (!addr || typeof addr === 'string') {
 226          server.close();
 227          reject(new Error('Failed to start OAuth callback server'));
 228          return;
 229        }
 230  
 231        const port = addr.port;
 232        const redirectUri = `http://127.0.0.1:${port}/callback`;
 233        const params = new URLSearchParams({
 234          client_id: GITHUB_CLIENT_ID,
 235          redirect_uri: redirectUri,
 236          scope: 'repo',
 237          state,
 238          code_challenge: challenge,
 239          code_challenge_method: 'S256',
 240        });
 241        void shell.openExternal(`https://github.com/login/oauth/authorize?${params}`);
 242      });
 243  
 244      const timeout = setTimeout(
 245        () => {
 246          server.close();
 247          reject(new Error('OAuth sign-in timed out after 5 minutes'));
 248        },
 249        5 * 60 * 1000
 250      );
 251  
 252      server.on('close', () => clearTimeout(timeout));
 253    });
 254  }
 255  
 256  // ── Persistent logging ───────────────────────────────────────────
 257  
 258  function getLogsDir() {
 259    return path.join(app.getPath('userData'), 'logs');
 260  }
 261  
 262  function setupLogging() {
 263    const logsDir = getLogsDir();
 264    if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
 265  
 266    const logPath = path.join(logsDir, 'main.log');
 267    const prevPath = path.join(logsDir, 'main.log.1');
 268  
 269    // Rotate previous log
 270    if (fs.existsSync(logPath)) {
 271      try {
 272        fs.renameSync(logPath, prevPath);
 273      } catch {
 274        // Best-effort rotation
 275      }
 276    }
 277  
 278    const stream = fs.createWriteStream(logPath, { flags: 'a' });
 279    const origLog = console.log.bind(console);
 280    const origWarn = console.warn.bind(console);
 281    const origError = console.error.bind(console);
 282  
 283    function write(level: string, args: unknown[]) {
 284      const ts = new Date().toISOString();
 285      const msg = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
 286      stream.write(`${ts} [${level}] ${msg}\n`);
 287    }
 288  
 289    console.log = (...args: unknown[]) => {
 290      origLog(...args);
 291      write('info', args);
 292    };
 293    console.warn = (...args: unknown[]) => {
 294      origWarn(...args);
 295      write('warn', args);
 296    };
 297    console.error = (...args: unknown[]) => {
 298      origError(...args);
 299      write('error', args);
 300    };
 301  }
 302  
 303  // ── Window ───────────────────────────────────────────────────────
 304  
 305  let quitConfirmed = false;
 306  
 307  function createWindow() {
 308    const mainWindow = new BrowserWindow({
 309      width: 1400,
 310      height: 900,
 311      webPreferences: {
 312        preload: path.join(__dirname, 'preload.js'),
 313      },
 314    });
 315  
 316    mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => {
 317      console.error('[main] Renderer failed to load:', errorCode, errorDescription);
 318    });
 319  
 320    // Open external links in the user's default browser instead of a new Electron window
 321    mainWindow.webContents.setWindowOpenHandler(({ url }) => {
 322      void shell.openExternal(url);
 323      return { action: 'deny' };
 324    });
 325  
 326    // On non-macOS, closing the window quits the app — confirm if a review is in progress.
 327    // On macOS, closing the window leaves the app running so reviews complete in the background.
 328    if (process.platform !== 'darwin') {
 329      mainWindow.on('close', (event) => {
 330        if (quitConfirmed || activeGenerations.size === 0) return;
 331        event.preventDefault();
 332        const count = activeGenerations.size;
 333        const response = dialog.showMessageBoxSync(mainWindow, {
 334          type: 'question',
 335          buttons: ['Cancel', 'Quit Anyway'],
 336          defaultId: 0,
 337          cancelId: 0,
 338          message: `Quit while ${count === 1 ? 'a review is' : `${count} reviews are`} generating?`,
 339          detail: `${count === 1 ? 'It' : 'They'} will be cancelled if you quit now.`,
 340        });
 341        if (response === 1) {
 342          quitConfirmed = true;
 343          mainWindow.destroy();
 344        }
 345      });
 346    }
 347  
 348    if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
 349      void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
 350    } else {
 351      void mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
 352    }
 353  }
 354  
 355  // ── Update check helpers ─────────────────────────────────────
 356  
 357  let dismissedUpdateVersion: string | null = null;
 358  
 359  async function runUpdateCheck() {
 360    const update = await checkForUpdate(app.getVersion(), getResolvedToken() ?? undefined);
 361    if (!update) return;
 362    if (dismissedUpdateVersion === update.version) return;
 363  
 364    const windows = BrowserWindow.getAllWindows();
 365    for (const win of windows) {
 366      win.webContents.send('update-available', update);
 367    }
 368  }
 369  
 370  let updateInterval: ReturnType<typeof setInterval> | null = null;
 371  
 372  function startUpdateChecks() {
 373    setTimeout(() => void runUpdateCheck(), 5_000);
 374    updateInterval = setInterval(() => void runUpdateCheck(), 4 * 60 * 60 * 1_000);
 375  }
 376  
 377  // ── Auto-review on reviewer assignment ──────────────────────────
 378  
 379  const AUTO_REVIEW_POLL_INTERVAL_MS = 5 * 60 * 1_000; // 5 minutes
 380  const AUTO_REVIEW_MAX_AGE_MS = 24 * 60 * 60 * 1_000; // ignore PRs not updated within 24 h
 381  
 382  function getSeenReviewRequestsPath() {
 383    return path.join(app.getPath('userData'), 'seen-review-requests.json');
 384  }
 385  
 386  function loadSeenReviewRequests(): Set<string> {
 387    try {
 388      const data = JSON.parse(fs.readFileSync(getSeenReviewRequestsPath(), 'utf-8')) as string[];
 389      return new Set(data);
 390    } catch {
 391      return new Set();
 392    }
 393  }
 394  
 395  function saveSeenReviewRequests(seen: Set<string>) {
 396    fs.writeFileSync(getSeenReviewRequestsPath(), JSON.stringify([...seen], null, 2));
 397  }
 398  
 399  // Loaded once into memory; updated as reviews are triggered
 400  let seenReviewRequests = new Set<string>();
 401  
 402  async function triggerBackgroundReview(prUrl: string, prefs: Preferences): Promise<void> {
 403    // cachedToken is guaranteed non-null by the caller (runAutoReviewCheck).
 404    console.log(`[auto-review] Starting background review for ${prUrl}`);
 405    const reviewId = crypto.randomUUID();
 406  
 407    try {
 408      const octokit = new Octokit({ auth: cachedToken ?? undefined });
 409      const { owner, repo, pullNumber } = parsePrUrl(prUrl);
 410      const prData = await getPrMetadata(octokit, owner, repo, pullNumber);
 411  
 412      const abortController = new AbortController();
 413      createPendingHistoryEntry(reviewId, prData.title, prUrl, prData.author, prefs.model, 'open', prData.headSha, true);
 414      activeGenerations.set(reviewId, { abortController });
 415  
 416      const request: GenerateReviewRequest = {
 417        prUrl,
 418        provider: prefs.provider,
 419        model: prefs.model,
 420        instructions: prefs.instructions,
 421        thinking: prefs.thinking,
 422        signalBoost: prefs.signalBoost,
 423        smartImports: prefs.smartImports,
 424        reviewSuggestions: prefs.reviewSuggestions,
 425      };
 426  
 427      await runBackgroundGeneration(reviewId, request, prData, abortController.signal);
 428  
 429      // Refresh the history list in all open windows
 430      broadcastToAllWindows('new-review-in-history');
 431      console.log(`[auto-review] Completed review for ${prUrl}`);
 432    } catch (err) {
 433      console.error(`[auto-review] Failed to start background review for ${prUrl}:`, err);
 434    }
 435  }
 436  
 437  async function runAutoReviewCheck() {
 438    // Use only in-memory state — never touch the keychain from a background timer.
 439    // cachedToken and cachedLogin are populated when the user authenticates via IPC.
 440    if (!cachedToken || !cachedLogin) return;
 441    const prefs = loadPreferences();
 442    if (!prefs.autoReviewOnRequest) return;
 443  
 444    try {
 445      const octokit = new Octokit({ auth: cachedToken });
 446      const prs = await searchPullRequests(octokit, cachedLogin);
 447      // searchPullRequests already queries `is:open`, so merged/closed PRs are excluded
 448      const reviewRequested = prs.filter((pr) => pr.role === 'review-requested');
 449      const now = Date.now();
 450  
 451      for (const pr of reviewRequested) {
 452        if (!seenReviewRequests.has(pr.url)) {
 453          // Always mark as seen so we never trigger the same PR twice
 454          seenReviewRequests.add(pr.url);
 455          saveSeenReviewRequests(seenReviewRequests);
 456  
 457          // Only review PRs updated in the last 24 h — avoids flooding on first enable
 458          const updatedAt = new Date(pr.updatedAt).getTime();
 459          if (now - updatedAt <= AUTO_REVIEW_MAX_AGE_MS) {
 460            void triggerBackgroundReview(pr.url, prefs);
 461          } else {
 462            console.log(`[auto-review] Skipping stale PR (${Math.round((now - updatedAt) / 3_600_000)}h old): ${pr.url}`);
 463          }
 464        }
 465      }
 466    } catch (err) {
 467      console.error('[auto-review] Poll check failed:', err);
 468    }
 469  }
 470  
 471  let autoReviewInterval: ReturnType<typeof setInterval> | null = null;
 472  
 473  function startAutoReviewPolling() {
 474    if (autoReviewInterval) return;
 475    seenReviewRequests = loadSeenReviewRequests();
 476    // Initial check after 10 seconds to let the app fully initialize
 477    setTimeout(() => void runAutoReviewCheck(), 10_000);
 478    autoReviewInterval = setInterval(() => void runAutoReviewCheck(), AUTO_REVIEW_POLL_INTERVAL_MS);
 479  }
 480  
 481  function stopAutoReviewPolling() {
 482    if (autoReviewInterval) {
 483      clearInterval(autoReviewInterval);
 484      autoReviewInterval = null;
 485    }
 486  }
 487  
 488  // ── Auto-updater (Squirrel) ─────────────────────────────────────
 489  function setupAutoUpdater() {
 490    if (!app.isPackaged) return;
 491    if (process.platform === 'linux') return;
 492  
 493    const feedURL = `https://update.electronjs.org/oddur/gnosis/${process.platform}-${process.arch}/${app.getVersion()}`;
 494    try {
 495      autoUpdater.setFeedURL({ url: feedURL });
 496    } catch (err) {
 497      console.warn('[main] Failed to set autoUpdater feed URL:', err);
 498      return;
 499    }
 500  
 501    autoUpdater.on('update-downloaded', (_event, _releaseNotes, releaseName) => {
 502      const version = releaseName.replace(/^v/, '');
 503      const label = version ? ` ${version}` : '';
 504      console.log(`[main] Update${label} downloaded, will install on exit`);
 505      if (loadPreferences().notifications) {
 506        const notif = new Notification({
 507          title: 'A new update is ready to install',
 508          body: `Gnosis${label} has been downloaded and will be automatically installed on exit`,
 509          silent: true,
 510        });
 511        notif.show();
 512      }
 513      for (const win of BrowserWindow.getAllWindows()) {
 514        win.webContents.send('update-ready', version);
 515      }
 516    });
 517  
 518    autoUpdater.on('error', (err: Error) => {
 519      console.warn('[main] Auto-updater error:', err.message);
 520    });
 521  
 522    // Check for updates automatically — download happens silently
 523    autoUpdater.checkForUpdates();
 524    setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1_000);
 525  }
 526  
 527  void app.whenReady().then(() => {
 528    setupLogging();
 529  
 530    // Expose packaged state to preload via env var (before creating windows)
 531    process.env.APP_IS_PACKAGED = app.isPackaged ? '1' : '0';
 532  
 533    // Mark any stale "generating" entries from a previous crash as failed
 534    cleanupStaleGeneratingEntries();
 535    applyBinaryOverrides(loadPreferences());
 536    createWindow();
 537    setupAutoUpdater();
 538  
 539    // GitHub release polling only needed on Linux (no native auto-update)
 540    if (process.platform === 'linux') {
 541      startUpdateChecks();
 542    }
 543  
 544    startAutoReviewPolling();
 545  
 546    app.on('activate', () => {
 547      if (BrowserWindow.getAllWindows().length === 0) createWindow();
 548      if (!updateInterval && process.platform === 'linux') startUpdateChecks();
 549      if (!autoReviewInterval) startAutoReviewPolling();
 550    });
 551  });
 552  
 553  app.on('before-quit', (event) => {
 554    if (!quitConfirmed && activeGenerations.size > 0) {
 555      event.preventDefault();
 556      const count = activeGenerations.size;
 557      const win = BrowserWindow.getAllWindows()[0];
 558      const response = dialog.showMessageBoxSync(win, {
 559        type: 'question',
 560        buttons: ['Cancel', 'Quit Anyway'],
 561        defaultId: 0,
 562        cancelId: 0,
 563        message: `Quit while ${count === 1 ? 'a review is' : `${count} reviews are`} generating?`,
 564        detail: `${count === 1 ? 'It' : 'They'} will be cancelled if you quit now.`,
 565      });
 566      if (response === 1) {
 567        quitConfirmed = true;
 568        app.quit();
 569      }
 570      return;
 571    }
 572    // Mark any in-flight generations as failed
 573    for (const [id] of activeGenerations) {
 574      updateHistoryEntry(id, { status: 'failed', error: 'App quit during generation' });
 575    }
 576    activeGenerations.clear();
 577  });
 578  
 579  app.on('window-all-closed', () => {
 580    if (updateInterval) {
 581      clearInterval(updateInterval);
 582      updateInterval = null;
 583    }
 584    stopAutoReviewPolling();
 585    if (process.platform !== 'darwin') app.quit();
 586  });
 587  
 588  // ── Review history helpers ───────────────────────────────────────
 589  
 590  function getReviewsDir() {
 591    return path.join(app.getPath('userData'), 'reviews');
 592  }
 593  
 594  function getReviewsIndexPath() {
 595    return path.join(app.getPath('userData'), 'reviews-index.json');
 596  }
 597  
 598  function ensureReviewsDir() {
 599    const dir = getReviewsDir();
 600    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
 601  }
 602  
 603  function readReviewsIndex(): ReviewHistoryEntry[] {
 604    try {
 605      const entries = JSON.parse(fs.readFileSync(getReviewsIndexPath(), 'utf-8')) as ReviewHistoryEntry[];
 606      // Backward compat: entries without status are completed
 607      return entries.map((e) => ({ ...e, status: e.status ?? 'completed' }));
 608    } catch {
 609      return [];
 610    }
 611  }
 612  
 613  // ── Background generation tracking ──────────────────────────────
 614  
 615  const activeGenerations = new Map<string, { abortController?: AbortController }>();
 616  
 617  function createPendingHistoryEntry(
 618    id: string,
 619    prTitle: string,
 620    prUrl: string,
 621    author: string,
 622    model?: ModelId,
 623    prState?: 'open' | 'merged' | 'closed',
 624    prHeadSha?: string,
 625    unread?: boolean
 626  ): void {
 627    ensureReviewsDir();
 628    const entry: ReviewHistoryEntry = {
 629      id,
 630      prTitle,
 631      prUrl,
 632      author,
 633      riskLevel: 'low', // placeholder until generation completes
 634      status: 'generating',
 635      model,
 636      savedAt: new Date().toISOString(),
 637      prState,
 638      prHeadSha,
 639      ...(unread ? { unread: true } : {}),
 640    };
 641    const index = readReviewsIndex();
 642    index.unshift(entry);
 643    fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2));
 644  }
 645  
 646  function updateHistoryEntry(id: string, updates: Partial<ReviewHistoryEntry>): void {
 647    const index = readReviewsIndex();
 648    const idx = index.findIndex((e) => e.id === id);
 649    if (idx === -1) return;
 650    index[idx] = { ...index[idx], ...updates };
 651    fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2));
 652  }
 653  
 654  function broadcastToAllWindows(channel: string, ...args: unknown[]): void {
 655    for (const win of BrowserWindow.getAllWindows()) {
 656      win.webContents.send(channel, ...args);
 657    }
 658  }
 659  
 660  function cleanupStaleGeneratingEntries(): void {
 661    const index = readReviewsIndex();
 662    let changed = false;
 663    for (const entry of index) {
 664      if (entry.status === 'generating') {
 665        entry.status = 'failed';
 666        entry.error = 'Generation was interrupted';
 667        changed = true;
 668      }
 669    }
 670    if (changed) {
 671      fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2));
 672    }
 673  }
 674  
 675  // ── Preferences helpers ─────────────────────────────────────────
 676  
 677  function getPreferencesPath() {
 678    return path.join(app.getPath('userData'), 'preferences.json');
 679  }
 680  
 681  const DEFAULT_PREFERENCES: Preferences = {
 682    instructions: '',
 683    provider: 'claude',
 684    model: 'claude-opus-4-6',
 685    thinking: true,
 686    signalBoost: true,
 687    smartImports: true,
 688    reviewSuggestions: true,
 689    enableTools: false,
 690    enableWebResearch: false,
 691    autoReviewOnRequest: false,
 692    codeTheme: 'aurora-x',
 693    codeFont: 'jetbrains-mono',
 694    claudePath: '',
 695    geminiPath: '',
 696    notifications: true,
 697    diffLayout: 'unified',
 698    includeAllFiles: true,
 699    reviewSignature: true,
 700  };
 701  
 702  function applyBinaryOverrides(prefs: Preferences): void {
 703    setBinaryOverride('claude', prefs.claudePath);
 704    setBinaryOverride('gemini', prefs.geminiPath);
 705  }
 706  
 707  function loadPreferences(): Preferences {
 708    try {
 709      const stored = JSON.parse(fs.readFileSync(getPreferencesPath(), 'utf-8')) as Partial<Preferences>;
 710      return { ...DEFAULT_PREFERENCES, ...stored };
 711    } catch {
 712      return { ...DEFAULT_PREFERENCES };
 713    }
 714  }
 715  
 716  function savePreferences(prefs: Preferences): void {
 717    fs.writeFileSync(getPreferencesPath(), JSON.stringify(prefs, null, 2));
 718  }
 719  
 720  // ── MCP tools constants ─────────────────────────────────────────
 721  
 722  const ALLOWED_TOOLS = [
 723    'WebFetch',
 724    'WebSearch',
 725    'mcp__github__get_file_contents',
 726    'mcp__github__get_issue',
 727    'mcp__github__list_issues',
 728    'mcp__github__get_pull_request',
 729    'mcp__github__get_pull_request_files',
 730    'mcp__github__get_pull_request_comments',
 731    'mcp__github__get_pull_request_reviews',
 732    'mcp__github__list_commits',
 733    'mcp__github__search_code',
 734    'mcp__github__search_issues',
 735  ];
 736  
 737  const WEB_ONLY_TOOLS = ['WebFetch', 'WebSearch'];
 738  
 739  // ── IPC handlers ────────────────────────────────────────────────
 740  
 741  ipcMain.handle('dismiss-update', (_event, version: string) => {
 742    dismissedUpdateVersion = version;
 743  });
 744  
 745  ipcMain.handle('open-external', (_event, url: string) => {
 746    try {
 747      if (new URL(url).protocol === 'https:') {
 748        void shell.openExternal(url);
 749      }
 750    } catch {
 751      // invalid URL — ignore
 752    }
 753  });
 754  
 755  ipcMain.handle('open-logs-directory', () => {
 756    const logsDir = getLogsDir();
 757    if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
 758    void shell.openPath(logsDir);
 759  });
 760  
 761  ipcMain.handle('open-review-prompt', (_event, id: string) => {
 762    const promptPath = path.join(getReviewsDir(), `${id}-prompt.md`);
 763    if (fs.existsSync(promptPath)) {
 764      void shell.openPath(promptPath);
 765    }
 766  });
 767  
 768  // Backward-compat shim — renderer still calls getConfig to check if signed in
 769  ipcMain.handle('get-config', () => {
 770    const token = getResolvedToken();
 771    return { githubToken: token };
 772  });
 773  
 774  ipcMain.handle('start-oauth', async () => {
 775    await runOAuthFlow();
 776  });
 777  
 778  ipcMain.handle('save-pat', async (_event, token: string) => {
 779    const trimmed = token.trim();
 780    if (!trimmed) throw new Error('Token must not be empty');
 781    const login = await validateAndFetchLogin(trimmed);
 782    persistToken(trimmed);
 783    cachedToken = trimmed;
 784    cachedLogin = login;
 785    return login;
 786  });
 787  
 788  ipcMain.handle('get-auth-state', async () => {
 789    const token = getResolvedToken();
 790    if (!token) return { authenticated: false, login: null };
 791  
 792    if (!cachedLogin) {
 793      try {
 794        cachedLogin = await fetchGitHubLogin(token);
 795      } catch {
 796        // token may be invalid
 797        return { authenticated: false, login: null };
 798      }
 799    }
 800  
 801    return { authenticated: true, login: cachedLogin };
 802  });
 803  
 804  ipcMain.handle('sign-out', () => {
 805    cachedToken = null;
 806    cachedLogin = null;
 807    deleteStoredToken();
 808  });
 809  
 810  ipcMain.handle('search-pull-requests', async () => {
 811    const token = getResolvedToken();
 812    if (!token || !cachedLogin) throw new Error('Not authenticated');
 813    const octokit = new Octokit({ auth: token });
 814    return searchPullRequests(octokit, cachedLogin);
 815  });
 816  
 817  ipcMain.handle('load-preferences', () => {
 818    return loadPreferences();
 819  });
 820  
 821  ipcMain.handle('save-preferences', (_event, prefs: Preferences) => {
 822    savePreferences(prefs);
 823    applyBinaryOverrides(prefs);
 824    // Restart polling so the new autoReviewOnRequest value takes effect immediately
 825    stopAutoReviewPolling();
 826    if (prefs.autoReviewOnRequest) startAutoReviewPolling();
 827  });
 828  
 829  ipcMain.handle('detect-binary-path', (_event, name: string) => {
 830    const extra = name === 'claude' ? [`${os.homedir()}/.volta/bin/claude`, `${os.homedir()}/.local/bin/claude`] : [];
 831    return detectBinaryPath(name, extra);
 832  });
 833  
 834  ipcMain.handle('check-cli-installed', (_event, provider: string) => {
 835    const extra = provider === 'claude' ? [`${os.homedir()}/.volta/bin/claude`, `${os.homedir()}/.local/bin/claude`] : [];
 836    const resolved = resolveBinaryPath(provider, extra);
 837    const installed = path.isAbsolute(resolved) && fs.existsSync(resolved);
 838    return { installed, resolvedPath: resolved };
 839  });
 840  
 841  ipcMain.handle('list-reviews', () => {
 842    return readReviewsIndex();
 843  });
 844  
 845  ipcMain.handle('load-review', async (_event, id: string) => {
 846    const reviewPath = path.join(getReviewsDir(), `${id}.json`);
 847    const review = JSON.parse(fs.readFileSync(reviewPath, 'utf-8')) as ReviewGuide;
 848    const prefs = loadPreferences();
 849    await reRenderAllHunks(review, prefs.codeTheme);
 850    return review;
 851  });
 852  
 853  ipcMain.handle('re-render-hunks', async (_event, review: ReviewGuide) => {
 854    const prefs = loadPreferences();
 855    await reRenderAllHunks(review, prefs.codeTheme);
 856    return review;
 857  });
 858  
 859  ipcMain.handle('mark-review-read', (_event, id: string) => {
 860    const index = readReviewsIndex().map((e) => (e.id === id ? { ...e, unread: false } : e));
 861    fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2));
 862  });
 863  
 864  ipcMain.handle('delete-review', (_event, id: string) => {
 865    const reviewPath = path.join(getReviewsDir(), `${id}.json`);
 866    const promptPath = path.join(getReviewsDir(), `${id}-prompt.md`);
 867    if (fs.existsSync(reviewPath)) fs.unlinkSync(reviewPath);
 868    if (fs.existsSync(promptPath)) fs.unlinkSync(promptPath);
 869    const index = readReviewsIndex().filter((e) => e.id !== id);
 870    fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2));
 871  });
 872  
 873  ipcMain.handle('delete-all-reviews', () => {
 874    const dir = getReviewsDir();
 875    if (fs.existsSync(dir)) {
 876      for (const file of fs.readdirSync(dir)) {
 877        if (file.endsWith('.json') || file.endsWith('-prompt.md')) fs.unlinkSync(path.join(dir, file));
 878      }
 879    }
 880    fs.writeFileSync(getReviewsIndexPath(), JSON.stringify([], null, 2));
 881  });
 882  
 883  ipcMain.handle(
 884    'check-pr-freshness',
 885    async (_event, prUrl: string, headSha: string | undefined): Promise<FreshnessResult> => {
 886      if (!headSha) {
 887        return { status: 'unknown', reason: 'Review has no stored head SHA' };
 888      }
 889  
 890      const token = getResolvedToken();
 891      if (!token) return { status: 'unknown', reason: 'Not signed in' };
 892      const octokit = new Octokit({ auth: token });
 893      const { owner, repo, pullNumber } = parsePrUrl(prUrl);
 894  
 895      try {
 896        const prData = await getPrMetadata(octokit, owner, repo, pullNumber);
 897        const currentSha = prData.headSha;
 898  
 899        if (currentSha === headSha) {
 900          return { status: 'current' };
 901        }
 902  
 903        try {
 904          const { data } = await octokit.repos.compareCommits({
 905            owner,
 906            repo,
 907            base: headSha,
 908            head: currentSha,
 909          });
 910  
 911          // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive
 912          const commits = (data.commits ?? []).slice(0, 50).map((c) => ({
 913            sha: c.sha,
 914            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive
 915            message: (c.commit.message ?? '').split('\n')[0],
 916            authorLogin: c.author?.login ?? c.commit.author?.name ?? 'unknown',
 917            authorDate: c.commit.author?.date ?? '',
 918          }));
 919  
 920          return {
 921            status: 'stale',
 922            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive
 923            aheadBy: data.ahead_by ?? commits.length,
 924            commits,
 925          };
 926        } catch (compareErr: unknown) {
 927          const status = (compareErr as { status?: number }).status;
 928          if (status === 404) {
 929            return { status: 'force-pushed' };
 930          }
 931          throw compareErr;
 932        }
 933      } catch (err: unknown) {
 934        const message = err instanceof Error ? err.message : 'Unknown error';
 935        return { status: 'unknown', reason: message };
 936      }
 937    }
 938  );
 939  
 940  ipcMain.handle('get-pr-status', async (_event, prUrl: string): Promise<PrStatus> => {
 941    const token = getResolvedToken();
 942    const octokit = new Octokit({ auth: token ?? undefined });
 943    const { owner, repo, pullNumber } = parsePrUrl(prUrl);
 944  
 945    const [prData, reviewSummary] = await Promise.all([
 946      getPrMetadata(octokit, owner, repo, pullNumber),
 947      getReviewStatus(octokit, owner, repo, pullNumber),
 948    ]);
 949  
 950    const ciStatus = await getCiStatus(octokit, owner, repo, prData.headSha).catch(() => ({
 951      checks: [] as CiCheck[],
 952      conclusion: 'neutral' as const,
 953    }));
 954  
 955    return {
 956      labels: prData.labels,
 957      mergeable: prData.mergeable,
 958      isDraft: prData.isDraft,
 959      ciChecks: ciStatus.checks,
 960      ciConclusion: ciStatus.conclusion,
 961      reviewSummary,
 962      baseBranch: prData.baseBranch,
 963      commitCount: prData.commitCount,
 964      requestedReviewers: prData.requestedReviewers,
 965      requestedTeams: prData.requestedTeams,
 966      mergeableState: prData.mergeableState,
 967      autoMerge: prData.autoMerge,
 968      milestone: prData.milestone,
 969    };
 970  });
 971  
 972  ipcMain.handle(
 973    'get-pr-state',
 974    async (_event, prUrl: string): Promise<{ prState: 'open' | 'merged' | 'closed'; headSha: string }> => {
 975      const token = getResolvedToken();
 976      const octokit = new Octokit({ auth: token ?? undefined });
 977      const { owner, repo, pullNumber } = parsePrUrl(prUrl);
 978      const prData = await getPrMetadata(octokit, owner, repo, pullNumber);
 979      const prState = prData.merged ? 'merged' : prData.state === 'open' ? 'open' : 'closed';
 980      return { prState, headSha: prData.headSha };
 981    }
 982  );
 983  
 984  ipcMain.handle('get-pr-files', async (_event, prUrl: string): Promise<ChangedFile[]> => {
 985    const token = getResolvedToken();
 986    const octokit = new Octokit({ auth: token ?? undefined });
 987    const { owner, repo, pullNumber } = parsePrUrl(prUrl);
 988    return getChangedFiles(octokit, owner, repo, pullNumber);
 989  });
 990  
 991  // ── Background review generation ────────────────────────────────
 992  
 993  async function runBackgroundGeneration(
 994    reviewId: string,
 995    request: GenerateReviewRequest,
 996    prData: Awaited<ReturnType<typeof getPrMetadata>>,
 997    signal: AbortSignal
 998  ): Promise<void> {
 999    const { prUrl, provider, model, instructions, thinking, signalBoost, smartImports, reviewSuggestions, webResearch } =
1000      request;
1001  
1002    try {
1003      const token = getResolvedToken();
1004      const octokit = new Octokit({ auth: token ?? undefined });
1005      const { owner, repo, pullNumber } = parsePrUrl(prUrl);
1006  
1007      broadcastToAllWindows('review-phase', { reviewId, phase: 'Fetching PR data' });
1008  
1009      const [diff, allChangedFiles] = await Promise.all([
1010        getPrDiff(octokit, owner, repo, pullNumber),
1011        getChangedFiles(octokit, owner, repo, pullNumber),
1012      ]);
1013  
1014      // Apply user exclusions before any other processing
1015      const userExcludedSet = new Set(request.excludedFiles ?? []);
1016      const changedFiles =
1017        userExcludedSet.size > 0 ? allChangedFiles.filter((f) => !userExcludedSet.has(f.filename)) : allChangedFiles;
1018      const userFilteredDiff = userExcludedSet.size > 0 ? filterDiff(diff, userExcludedSet) : diff;
1019  
1020      if (changedFiles.length === 0) {
1021        throw new Error('PR has no changed files');
1022      }
1023  
1024      // Filter out generated/lock files early to avoid token budget blowup
1025      const { normalFiles, generatedFiles } = classifyFiles(changedFiles);
1026      const generatedFilenames = new Set(generatedFiles.map((f) => f.filename));
1027      const filteredDiff = filterDiff(userFilteredDiff, generatedFilenames);
1028      const excludedFilesSummary = buildExcludedFilesSummary(generatedFiles);
1029  
1030      if (generatedFiles.length > 0) {
1031        console.log(
1032          `[main] Filtered ${generatedFiles.length} generated file(s):`,
1033          generatedFiles.map((f) => f.filename)
1034        );
1035      }
1036  
1037      const baseRef = prData.baseBranch;
1038      const headRef = prData.headSha;
1039  
1040      broadcastToAllWindows('review-phase', { reviewId, phase: 'Fetching file contents' });
1041  
1042      const fileContents: Record<string, string> = {};
1043      const headFileContents: Record<string, string> = {};
1044      const concurrency = 5;
1045      const filesToFetch = normalFiles.filter((f) => f.status !== 'deleted');
1046      const filesToFetchBase = normalFiles.filter((f) => f.status !== 'added');
1047  
1048      for (let i = 0; i < Math.max(filesToFetch.length, filesToFetchBase.length); i += concurrency) {
1049        const headBatch = filesToFetch.slice(i, i + concurrency);
1050        const baseBatch = filesToFetchBase.slice(i, i + concurrency);
1051        await Promise.all([
1052          ...headBatch.map(async (f) => {
1053            const content = await getFileContent(octokit, owner, repo, f.filename, headRef);
1054            if (content !== null) headFileContents[f.filename] = content;
1055          }),
1056          ...baseBatch.map(async (f) => {
1057            const content = await getFileContent(octokit, owner, repo, f.filename, baseRef);
1058            if (content !== null) fileContents[f.filename] = content;
1059          }),
1060        ]);
1061      }
1062  
1063      broadcastToAllWindows('review-phase', { reviewId, phase: 'Resolving imports' });
1064  
1065      const allFileContents = { ...fileContents, ...headFileContents };
1066      const neighborFiles = await getNeighborFiles(
1067        octokit,
1068        owner,
1069        repo,
1070        normalFiles.map((f) => f.filename),
1071        allFileContents,
1072        baseRef,
1073        smartImports ? provider : undefined
1074      );
1075  
1076      broadcastToAllWindows('review-phase', { reviewId, phase: 'Building context' });
1077  
1078      // Parse diff into indexed hunks and build expanded diff (using filtered diff)
1079      const indexedHunks = buildIndexedHunks(filteredDiff, fileContents, headFileContents);
1080      const expandedDiff = expandFullDiff(filteredDiff, fileContents, headFileContents);
1081      const hunkIndex = formatHunkIndexForPrompt(indexedHunks);
1082  
1083      const contextPackage = buildContextPackage(
1084        prData,
1085        expandedDiff,
1086        changedFiles,
1087        fileContents,
1088        headFileContents,
1089        neighborFiles,
1090        hunkIndex,
1091        excludedFilesSummary
1092      );
1093  
1094      broadcastToAllWindows('review-phase', { reviewId, phase: 'Generating review' });
1095  
1096      console.log(`[main] Generating review guide (${reviewId})...`);
1097      const generationStart = Date.now();
1098  
1099      const prefs = loadPreferences();
1100      let mcpConfigPath: string | undefined;
1101      let allowedTools: string[] | undefined;
1102  
1103      if (prefs.enableTools && provider === 'claude') {
1104        if (token) {
1105          mcpConfigPath = writeMcpConfig(token);
1106          allowedTools = ALLOWED_TOOLS;
1107        } else {
1108          allowedTools = WEB_ONLY_TOOLS;
1109        }
1110      } else if (webResearch && provider === 'claude') {
1111        allowedTools = WEB_ONLY_TOOLS;
1112      }
1113  
1114      let aiResult;
1115      let lastStreamPhase: string | null = null;
1116      try {
1117        aiResult = await generateReviewGuide(
1118          contextPackage,
1119          prUrl,
1120          provider,
1121          model,
1122          instructions,
1123          (chunk, isThinking) => {
1124            const phase = isThinking ? 'Thinking' : 'Generating review';
1125            if (phase !== lastStreamPhase) {
1126              lastStreamPhase = phase;
1127              broadcastToAllWindows('review-phase', { reviewId, phase });
1128            }
1129            broadcastToAllWindows('review-progress', { reviewId, chunk, isThinking });
1130          },
1131          thinking ?? false,
1132          signalBoost ?? false,
1133          mcpConfigPath,
1134          allowedTools,
1135          reviewSuggestions ?? true,
1136          webResearch ?? false,
1137          (toolName) => broadcastToAllWindows('review-tool-use', { reviewId, toolName }),
1138          (system, userMessage) => {
1139            broadcastToAllWindows('review-stats', {
1140              reviewId,
1141              inputBytes: system.length + userMessage.length,
1142            });
1143            try {
1144              ensureReviewsDir();
1145              fs.writeFileSync(
1146                path.join(getReviewsDir(), `${reviewId}-prompt.md`),
1147                `# System Prompt\n\n${system}\n\n# User Message\n\n${userMessage}\n`
1148              );
1149            } catch {
1150              // Best-effort — don't fail the review if prompt save fails
1151            }
1152          },
1153          signal
1154        );
1155      } finally {
1156        if (mcpConfigPath) cleanupMcpConfig(mcpConfigPath);
1157      }
1158  
1159      const generationDurationMs = Date.now() - generationStart;
1160  
1161      // Append raw model response to prompt file (best-effort)
1162      try {
1163        const promptPath = path.join(getReviewsDir(), `${reviewId}-prompt.md`);
1164        if (fs.existsSync(promptPath)) {
1165          fs.appendFileSync(
1166            promptPath,
1167            `\n---\n\n# Model Response\n\n\`\`\`json\n${JSON.stringify(aiResult, null, 2)}\n\`\`\`\n`
1168          );
1169        }
1170      } catch {
1171        // Best-effort
1172      }
1173  
1174      // Resolve hunk IDs → real DiffHunk objects
1175      const hunkMap = new Map(indexedHunks.map((h) => [h.id, h]));
1176      const assignedIds = new Set<string>();
1177  
1178      const resolvedSlides: Slide[] = aiResult.slides.map((aiSlide) => {
1179        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields
1180        const ids = aiSlide.diffHunkIds ?? [];
1181        const diffHunks: DiffHunk[] = ids
1182          .filter((id: string) => hunkMap.has(id) && !assignedIds.has(id))
1183          .map((id: string) => {
1184            assignedIds.add(id);
1185            const h = hunkMap.get(id);
1186            if (!h) throw new Error(`Hunk ${id} not found in index`);
1187            return {
1188              filePath: h.filePath,
1189              hunkHeader: h.expandedHunkHeader,
1190              content: h.expandedContent,
1191              language: h.language,
1192              renderedHtml: '',
1193            };
1194          });
1195  
1196        return {
1197          id: aiSlide.id,
1198          slideNumber: aiSlide.slideNumber,
1199          title: aiSlide.title,
1200          slideType: aiSlide.slideType,
1201          narrative: aiSlide.narrative,
1202          reviewFocus: aiSlide.reviewFocus,
1203          diffHunks: sortDiffHunks(diffHunks),
1204          // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields
1205          contextSnippets: aiSlide.contextSnippets ?? [],
1206          // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields
1207          affectedFiles: aiSlide.affectedFiles ?? [],
1208          // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields
1209          dependsOn: aiSlide.dependsOn ?? [],
1210          mermaidDiagram: aiSlide.mermaidDiagram,
1211          reviewChecks: aiSlide.reviewChecks,
1212        };
1213      });
1214  
1215      // Sanitize reviewChecks — clear invalid file/line refs so they render as non-clickable
1216      for (const slide of resolvedSlides) {
1217        if (!Array.isArray(slide.reviewChecks)) continue;
1218        const slideFilePaths = new Set(slide.diffHunks.map((h) => h.filePath));
1219  
1220        for (const check of slide.reviewChecks) {
1221          if (!check.filePath || !slideFilePaths.has(check.filePath)) {
1222            delete check.filePath;
1223            delete check.startLine;
1224            continue;
1225          }
1226          if (check.startLine == null || check.startLine <= 0) {
1227            delete check.filePath;
1228            delete check.startLine;
1229            continue;
1230          }
1231          // Verify startLine falls within one of the file's hunk ranges
1232          const fileHunks = slide.diffHunks.filter((h) => h.filePath === check.filePath);
1233          const lineExists = fileHunks.some((hunk) => {
1234            const lines = parseDiffLines(hunk.hunkHeader, hunk.content);
1235            return lines.some((l) => l.lineNumber === check.startLine);
1236          });
1237          if (!lineExists) {
1238            delete check.filePath;
1239            delete check.startLine;
1240          }
1241        }
1242      }
1243  
1244      // Catch-all slide for unassigned hunks
1245      const unassigned = indexedHunks.filter((h) => !assignedIds.has(h.id));
1246      if (unassigned.length > 0) {
1247        const otherHunks: DiffHunk[] = unassigned.map((h) => ({
1248          filePath: h.filePath,
1249          hunkHeader: h.expandedHunkHeader,
1250          content: h.expandedContent,
1251          language: h.language,
1252          renderedHtml: '',
1253        }));
1254  
1255        resolvedSlides.push({
1256          id: 'other-changes',
1257          slideNumber: resolvedSlides.length + 1,
1258          title: 'Other changes',
1259          slideType: 'refactor',
1260          narrative: 'Additional changes not covered in previous slides.',
1261          reviewFocus: null,
1262          diffHunks: sortDiffHunks(otherHunks),
1263          contextSnippets: [],
1264          affectedFiles: [...new Set(unassigned.map((h) => h.filePath))],
1265          dependsOn: [],
1266          mermaidDiagram: null,
1267        });
1268      }
1269  
1270      const reviewGuide: ReviewGuide = {
1271        prTitle: aiResult.prTitle || prData.title,
1272        prDescription: aiResult.prDescription || prData.description,
1273        prUrl,
1274        author: aiResult.author || prData.author,
1275        summary: aiResult.summary,
1276        riskLevel: aiResult.riskLevel,
1277        riskRationale: aiResult.riskRationale,
1278        totalFilesChanged: changedFiles.length,
1279        totalLinesChanged: changedFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0),
1280        neighborFileCount: Object.keys(neighborFiles).length,
1281        excludedFiles: generatedFiles.length > 0 ? generatedFiles.map((f) => f.filename) : undefined,
1282        generationDurationMs,
1283        slides: resolvedSlides,
1284        headSha: prData.headSha,
1285        webSources: aiResult.webSources,
1286      };
1287  
1288      broadcastToAllWindows('review-phase', { reviewId, phase: 'Rendering' });
1289  
1290      // Render syntax-highlighted HTML for each hunk
1291      const codeTheme = loadPreferences().codeTheme;
1292      for (const slide of reviewGuide.slides) {
1293        for (const hunk of slide.diffHunks) {
1294          try {
1295            hunk.renderedHtml = await renderDiffHunk(hunk.content, hunk.language, codeTheme, hunk.hunkHeader);
1296          } catch (err) {
1297            console.warn(`[main] Failed to render hunk for ${hunk.filePath}:`, err);
1298            hunk.renderedHtml = `<pre class="diff-block">${hunk.content}</pre>`;
1299          }
1300        }
1301      }
1302  
1303      // Save review JSON and update history entry to completed
1304      fs.writeFileSync(path.join(getReviewsDir(), `${reviewId}.json`), JSON.stringify(reviewGuide));
1305      updateHistoryEntry(reviewId, {
1306        status: 'completed',
1307        riskLevel: reviewGuide.riskLevel,
1308        generationDurationMs,
1309      });
1310  
1311      broadcastToAllWindows('review-completed', { reviewId });
1312  
1313      // Desktop notification
1314      if (loadPreferences().notifications) {
1315        const notif = new Notification({
1316          title: 'Review ready',
1317          body: prData.title,
1318          silent: true,
1319        });
1320        notif.on('click', () => {
1321          broadcastToAllWindows('review-navigate', { reviewId });
1322          const windows = BrowserWindow.getAllWindows();
1323          if (windows.length > 0) {
1324            const win = windows[0];
1325            if (win.isMinimized()) win.restore();
1326            win.focus();
1327          }
1328        });
1329        notif.show();
1330      }
1331  
1332      console.log(`[main] Review ${reviewId} completed in ${formatMs(generationDurationMs)}`);
1333    } catch (err) {
1334      const isCancelled = err instanceof Error && err.message === 'GNOSIS_CANCELLED';
1335      const errorMessage = isCancelled ? 'Cancelled' : err instanceof Error ? err.message : 'Unknown error';
1336      if (!isCancelled) console.error(`[main] Review ${reviewId} failed:`, errorMessage);
1337  
1338      updateHistoryEntry(reviewId, {
1339        status: 'failed',
1340        error: errorMessage,
1341      });
1342  
1343      broadcastToAllWindows('review-failed', { reviewId, error: errorMessage });
1344    } finally {
1345      activeGenerations.delete(reviewId);
1346    }
1347  }
1348  
1349  function formatMs(ms: number): string {
1350    const s = Math.round(ms / 1000);
1351    return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
1352  }
1353  
1354  ipcMain.handle('start-review', async (_event, request: GenerateReviewRequest): Promise<StartReviewResult> => {
1355    const token = getResolvedToken();
1356    const octokit = new Octokit({ auth: token ?? undefined });
1357    const { owner, repo, pullNumber } = parsePrUrl(request.prUrl);
1358  
1359    // Fetch PR metadata (fast, single API call)
1360    const prData = await getPrMetadata(octokit, owner, repo, pullNumber);
1361  
1362    const reviewId = crypto.randomUUID();
1363    const prState = prData.merged ? 'merged' : prData.state === 'open' ? 'open' : 'closed';
1364  
1365    // Create pending history entry
1366    createPendingHistoryEntry(
1367      reviewId,
1368      prData.title,
1369      request.prUrl,
1370      prData.author,
1371      request.model,
1372      prState,
1373      prData.headSha
1374    );
1375  
1376    // Track and fire off background generation (no await)
1377    const abortController = new AbortController();
1378    activeGenerations.set(reviewId, { abortController });
1379    void runBackgroundGeneration(reviewId, request, prData, abortController.signal);
1380  
1381    return {
1382      reviewId,
1383      prTitle: prData.title,
1384      prUrl: request.prUrl,
1385      author: prData.author,
1386    };
1387  });
1388  
1389  ipcMain.handle('cancel-review', (_event, reviewId: string) => {
1390    const gen = activeGenerations.get(reviewId);
1391    if (gen?.abortController) {
1392      gen.abortController.abort('User cancelled');
1393    }
1394  });
1395  
1396  ipcMain.handle('send-slide-chat', async (_event, req: SendSlideChatRequest) => {
1397    const chatProvider = getProvider(req.provider);
1398    const systemPrompt = buildSlideChatSystemPrompt();
1399    const userMessage = buildSlideChatUserMessage(req);
1400  
1401    const prefs = loadPreferences();
1402    let mcpConfigPath: string | undefined;
1403    let allowedTools: string[] | undefined;
1404  
1405    if (prefs.enableTools && req.provider === 'claude') {
1406      const token = getResolvedToken();
1407      if (token) {
1408        mcpConfigPath = writeMcpConfig(token);
1409        allowedTools = ALLOWED_TOOLS;
1410      } else {
1411        allowedTools = WEB_ONLY_TOOLS;
1412      }
1413    } else if (prefs.enableWebResearch && req.provider === 'claude') {
1414      allowedTools = WEB_ONLY_TOOLS;
1415    }
1416  
1417    try {
1418      const result = await chatProvider.generate({
1419        content: userMessage,
1420        systemPrompt,
1421        model: req.model,
1422        thinking: false,
1423        onChunk: (chunk, isThinking) => {
1424          if (!isThinking) {
1425            _event.sender.send('chat-progress', { chunk });
1426          }
1427        },
1428        onToolUse: (toolName) => _event.sender.send('chat-tool-use', { toolName }),
1429        mcpConfigPath,
1430        allowedTools,
1431      });
1432      return result;
1433    } finally {
1434      if (mcpConfigPath) cleanupMcpConfig(mcpConfigPath);
1435    }
1436  });
1437  
1438  ipcMain.handle('submit-review', async (_event, req: SubmitReviewRequest) => {
1439    const token = getResolvedToken();
1440    const octokit = new Octokit({ auth: token ?? undefined });
1441    const { owner, repo, pullNumber } = parsePrUrl(req.prUrl);
1442  
1443    // Fetch actual PR file patches to validate line numbers.
1444    // The AI-generated hunks have expanded context (10 lines vs GitHub's 3),
1445    // so some lines may not be in the real diff.
1446    const prFiles = await octokit.paginate(octokit.pulls.listFiles, {
1447      owner,
1448      repo,
1449      pull_number: pullNumber,
1450      per_page: 100,
1451    });
1452  
1453    const validLinesByFile = new Map<string, Set<string>>();
1454    for (const file of prFiles) {
1455      if (file.patch) {
1456        validLinesByFile.set(file.filename, parsePatchValidLines(file.patch));
1457      }
1458    }
1459  
1460    // Partition comments into valid (can be posted as line comments) and
1461    // dropped (line not in GitHub's diff — folded into review body instead)
1462    const validComments: typeof req.comments = [];
1463    const droppedComments: typeof req.comments = [];
1464  
1465    for (const c of req.comments) {
1466      const validLines = validLinesByFile.get(c.path);
1467      const key = `${c.line}:${c.side}`;
1468      if (validLines?.has(key)) {
1469        validComments.push(c);
1470      } else {
1471        droppedComments.push(c);
1472      }
1473    }
1474  
1475    // If some comments can't be posted inline, append them to the review body
1476    let reviewBody = req.body;
1477    if (droppedComments.length > 0) {
1478      const droppedText = droppedComments.map((c) => `**${c.path}:${c.line}** — ${c.body}`).join('\n\n');
1479      const suffix = `\n\n---\n_${droppedComments.length} comment(s) could not be posted inline (lines outside the diff range):_\n\n${droppedText}`;
1480      reviewBody = (reviewBody || '') + suffix;
1481    }
1482  
1483    const prefs = loadPreferences();
1484    if (prefs.reviewSignature) {
1485      reviewBody = (reviewBody || '') + '\n\n---\n_Reviewed using [gnosis.to](https://gnosis.to)_';
1486    }
1487  
1488    const { data } = await octokit.pulls.createReview({
1489      owner,
1490      repo,
1491      pull_number: pullNumber,
1492      commit_id: req.headSha,
1493      body: reviewBody,
1494      event: req.event,
1495      comments: validComments.map((c) => ({
1496        path: c.path,
1497        line: c.line,
1498        side: c.side,
1499        body: c.body,
1500      })),
1501    });
1502  
1503    return { reviewUrl: data.html_url, droppedCommentCount: droppedComments.length };
1504  });