newpr 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -5,6 +5,46 @@ import type { FileChange, FileGroup, NewprOutput, PrSummary } from "../types/out
5
5
  import type { ExplorationResult } from "../workspace/types.ts";
6
6
  import type { AgentToolName } from "../workspace/types.ts";
7
7
  import { parseDiff } from "../diff/parser.ts";
8
+
9
+ function annotateDiffWithLineNumbers(rawDiff: string): string {
10
+ const lines = rawDiff.split("\n");
11
+ const result: string[] = [];
12
+ let oldNum = 0;
13
+ let newNum = 0;
14
+
15
+ for (const line of lines) {
16
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
17
+ if (hunkMatch) {
18
+ oldNum = Number(hunkMatch[1]);
19
+ newNum = Number(hunkMatch[2]);
20
+ result.push(line);
21
+ continue;
22
+ }
23
+
24
+ if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("\\")) {
25
+ result.push(line);
26
+ continue;
27
+ }
28
+
29
+ if (line.startsWith("+")) {
30
+ result.push(`L${newNum} + ${line.slice(1)}`);
31
+ newNum++;
32
+ } else if (line.startsWith("-")) {
33
+ result.push(` - ${line.slice(1)}`);
34
+ oldNum++;
35
+ } else {
36
+ const text = line.startsWith(" ") ? line.slice(1) : line;
37
+ if (oldNum > 0 || newNum > 0) {
38
+ result.push(`L${newNum} ${text}`);
39
+ oldNum++;
40
+ newNum++;
41
+ } else {
42
+ result.push(line);
43
+ }
44
+ }
45
+ }
46
+ return result.join("\n");
47
+ }
8
48
  import { chunkDiff } from "../diff/chunker.ts";
9
49
  import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
10
50
  import { fetchPrDiff } from "../github/fetch-diff.ts";
@@ -257,7 +297,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
257
297
  progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
258
298
  const fileDiffs = chunks.slice(0, 30).map((c) => ({
259
299
  path: c.file_path,
260
- diff: c.diff_content.length > 3000 ? `${c.diff_content.slice(0, 3000)}\n... (truncated)` : c.diff_content,
300
+ diff: annotateDiffWithLineNumbers(c.diff_content.length > 3000 ? c.diff_content.slice(0, 3000) : c.diff_content),
261
301
  }));
262
302
  const narrativePrompt = exploration
263
303
  ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
@@ -1,5 +1,4 @@
1
1
  const PACKAGE_NAME = "newpr";
2
- const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
3
2
 
4
3
  interface UpdateInfo {
5
4
  current: string;
@@ -7,27 +6,32 @@ interface UpdateInfo {
7
6
  needsUpdate: boolean;
8
7
  }
9
8
 
10
- async function getLastCheckTime(): Promise<number> {
9
+ interface CachedCheck {
10
+ latest: string;
11
+ checkedAt: number;
12
+ }
13
+
14
+ async function readCache(): Promise<CachedCheck | null> {
11
15
  try {
12
- const file = Bun.file(`${process.env.HOME}/.newpr/last-update-check`);
13
- const text = await file.text();
14
- return Number.parseInt(text.trim(), 10) || 0;
16
+ const file = Bun.file(`${process.env.HOME}/.newpr/update-cache.json`);
17
+ if (!(await file.exists())) return null;
18
+ return JSON.parse(await file.text()) as CachedCheck;
15
19
  } catch {
16
- return 0;
20
+ return null;
17
21
  }
18
22
  }
19
23
 
20
- async function setLastCheckTime(): Promise<void> {
24
+ async function writeCache(latest: string): Promise<void> {
21
25
  const dir = `${process.env.HOME}/.newpr`;
22
26
  const { mkdirSync } = await import("node:fs");
23
27
  try { mkdirSync(dir, { recursive: true }); } catch {}
24
- await Bun.write(`${dir}/last-update-check`, String(Date.now()));
28
+ await Bun.write(`${dir}/update-cache.json`, JSON.stringify({ latest, checkedAt: Date.now() }));
25
29
  }
26
30
 
27
31
  async function fetchLatestVersion(): Promise<string | null> {
28
32
  try {
29
33
  const controller = new AbortController();
30
- const timeout = setTimeout(() => controller.abort(), 3000);
34
+ const timeout = setTimeout(() => controller.abort(), 5000);
31
35
  const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
32
36
  signal: controller.signal,
33
37
  headers: { Accept: "application/json" },
@@ -53,23 +57,20 @@ function compareVersions(current: string, latest: string): boolean {
53
57
  }
54
58
 
55
59
  export async function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
56
- const lastCheck = await getLastCheckTime();
57
- if (Date.now() - lastCheck < CHECK_INTERVAL_MS) return null;
58
-
59
60
  const latest = await fetchLatestVersion();
60
- await setLastCheckTime();
61
-
62
- if (!latest) return null;
63
- if (!compareVersions(currentVersion, latest)) return null;
61
+ if (latest) await writeCache(latest);
64
62
 
65
- return { current: currentVersion, latest, needsUpdate: true };
63
+ const version = latest ?? (await readCache())?.latest;
64
+ if (!version) return null;
65
+ if (!compareVersions(currentVersion, version)) return null;
66
+ return { current: currentVersion, latest: version, needsUpdate: true };
66
67
  }
67
68
 
68
69
  export function printUpdateNotice(info: UpdateInfo): void {
69
70
  const msg = [
70
71
  "",
71
- ` Update available: ${info.current} → \x1b[32m${info.latest}\x1b[0m`,
72
- ` Run \x1b[36mbun add -g ${PACKAGE_NAME}\x1b[0m to update`,
72
+ ` \x1b[33m⚡\x1b[0m Update available: \x1b[2m${info.current}\x1b[0m → \x1b[32m${info.latest}\x1b[0m`,
73
+ ` Run \x1b[36mbun add -g ${PACKAGE_NAME}\x1b[0m to update`,
73
74
  "",
74
75
  ].join("\n");
75
76
  process.stderr.write(msg);
@@ -13,6 +13,7 @@ export interface StoredConfig {
13
13
  concurrency?: number;
14
14
  language?: string;
15
15
  agent?: string;
16
+ enabled_plugins?: string[];
16
17
  }
17
18
 
18
19
  function ensureDir(): void {
@@ -145,7 +145,12 @@ export function buildNarrativePrompt(
145
145
  .join("\n\n");
146
146
 
147
147
  const diffContext = fileDiffs && fileDiffs.length > 0
148
- ? `\n\n--- FILE DIFFS (use these line numbers for [[line:...]] anchors) ---\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
148
+ ? `\n\n--- FILE DIFFS (LINE NUMBERS ARE PRE-COMPUTED — use them directly for [[line:...]] anchors) ---
149
+ Each line is prefixed with its new-file line number:
150
+ "L42 + code" = added line at L42
151
+ " - code" = removed line (no new line number)
152
+ "L42 code" = unchanged context at L42
153
+ Use the L-numbers EXACTLY as shown. Do NOT compute line numbers yourself.\n\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
149
154
  : "";
150
155
 
151
156
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
@@ -178,6 +183,7 @@ There are THREE anchor types. You MUST use ALL of them.
178
183
  - Format: [[group:Exact Group Name]]
179
184
  - Renders as a clickable blue chip.
180
185
  - You MUST reference EVERY group from the Change Groups list at least once. No exceptions.
186
+ - Use the EXACT group name ONLY — do NOT append the type in parentheses. Write [[group:Auth Flow]], NOT [[group:Auth Flow (refactor)]].
181
187
  - Use group anchors when introducing a topic area or explaining what a set of changes accomplishes together.
182
188
  - Example: "The [[group:Auth Flow]] group introduces session management."
183
189
 
@@ -196,24 +202,37 @@ There are THREE anchor types. You MUST use ALL of them.
196
202
  ### Usage Rules:
197
203
  - ALWAYS use [[line:path#Lstart-Lend]](text) with BOTH start and end lines. Single lines: [[line:path#L42-L42]](text).
198
204
  - The (text) must describe WHAT the code does, not WHERE it is. Bad: "lines 42-50". Good: "the new rate limiter middleware".
199
- - Wrap EVERY specific code mention in a line anchor. If you mention a function, class, type, constant, config change, or import — anchor it.
200
- - Interleave anchors naturally within sentences. They should feel like hyperlinks in a wiki article.
201
205
  - Do NOT pair [[file:...]] with [[line:...]] for the same file. The line anchor already opens the file.
202
206
  - Use the diff context provided to find accurate line numbers. If unsure of exact lines, use [[file:...]] instead.
203
207
 
204
- ### Anchor DensityTWO levels:
205
- When describing a function or class, use anchors at TWO granularity levels:
208
+ ### ANCHOR DENSITYTHIS IS THE MOST IMPORTANT RULE
206
209
 
207
- **Level 1 Declaration**: Anchor the function/class name itself to its full definition range.
208
- **Level 2 — Implementation details**: When explaining what the code does, anchor EACH distinct piece of logic to its specific lines within the function.
210
+ Every sentence that describes code MUST contain at least one [[line:...]](...) anchor. A sentence without an anchor is a FAILURE.
209
211
 
210
- Example with two levels:
212
+ Think of this like writing a Wikipedia article: almost every claim links to its source. In your narrative, the "source" is the specific line range in the diff.
213
+
214
+ **What MUST be anchored:**
215
+ - Every function, method, or class mentioned → anchor its declaration
216
+ - Every implementation detail (what a function does) → anchor the specific lines
217
+ - Every type, interface, or schema → anchor its definition
218
+ - Every config change, constant, or environment variable → anchor it
219
+ - Every import, export, or wiring between modules → anchor it
220
+ - Every conditional logic, error handling, or edge case → anchor the specific branch
221
+ - Every before/after comparison → anchor both the old and new code
222
+
223
+ **Two-level anchoring for functions:**
224
+ - Level 1: Anchor the function/class NAME to its full range (e.g., L15-L50)
225
+ - Level 2: Inside the same paragraph, anchor EACH logical step to its sub-range (e.g., L18-L22, L24-L30, L32-L40)
226
+ - A function description without Level 2 sub-anchors is TOO SPARSE
227
+
228
+ **Target density: 3-6 line anchors per paragraph.** If a paragraph has fewer than 2 line anchors, you are not anchoring enough.
229
+
230
+ Example (CORRECT density — 5 anchors in one paragraph):
211
231
  "[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles the full JWT lifecycle. It [[line:src/auth/session.ts#L18-L22]](extracts the token from the Authorization header), [[line:src/auth/session.ts#L24-L30]](verifies the signature against the configured secret), and [[line:src/auth/session.ts#L32-L40]](checks the expiration timestamp). If validation fails, [[line:src/auth/session.ts#L42-L48]](it throws a typed AuthError with a specific error code)."
212
232
 
213
- Key principles:
214
- - The first anchor covers the entire function (L15-L50). Subsequent anchors zoom into specific parts within it.
215
- - Each sub-anchor should cover 2-10 lines one logical step.
216
- - Descriptive text for sub-anchors should explain the step, not name the function again.
233
+ Example (TOO SPARSE — only 1 anchor, rest is unlinked prose):
234
+ "[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles JWT parsing. It extracts the token, verifies the signature, and checks expiration. If validation fails, it throws an error."
235
+ This is BAD because "extracts the token", "verifies the signature", "checks expiration", and "throws an error" should ALL be separate line anchors.
217
236
 
218
237
  ### Line Anchor Granularity:
219
238
  - Anchor individual functions, not entire files: [[line:auth.ts#L15-L30]](validateToken) not [[line:auth.ts#L1-L200]](auth module)
@@ -222,14 +241,15 @@ Key principles:
222
241
  - Anchor imports and exports that wire things together: [[line:index.ts#L3-L3]](re-exported from the barrel file)
223
242
  - For multi-part changes, anchor each part separately
224
243
 
225
- GOOD example (uses all 3 anchor types + two-level density):
244
+ GOOD example (all 3 anchor types + high density):
226
245
  "The [[group:Auth Flow]] group introduces session management. [[line:src/auth/session.ts#L15-L50]](The new validateToken function) handles JWT parsing: [[line:src/auth/session.ts#L18-L22]](it extracts the token from the header), then [[line:src/auth/session.ts#L24-L35]](verifies the signature and checks expiration). [[line:src/auth/middleware.ts#L8-L20]](The auth middleware) invokes it on every request, [[line:src/auth/middleware.ts#L15-L18]](rejecting invalid tokens with a 401). Supporting configuration lives in [[file:src/auth/constants.ts]]."
227
246
 
228
247
  BAD examples:
229
- - No group anchors: "The auth changes introduce session management." (MUST use [[group:Auth Flow]])
230
- - No anchors in implementation details: "The validateToken function extracts the token, verifies the signature, and checks expiration." (MUST anchor each step separately)
231
- - One big anchor for everything: "[[line:session.ts#L15-L50]](The function extracts tokens, verifies signatures, and checks expiration)" (MUST split into sub-anchors)
232
- - Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" (MUST have (text) after it)
248
+ - Unanchored prose: "The function extracts the token, verifies the signature, and checks expiration." MUST anchor EACH action
249
+ - No group anchors: "The auth changes introduce session management." MUST use [[group:Auth Flow]]
250
+ - One big anchor: "[[line:session.ts#L15-L50]](The function extracts tokens, verifies signatures, and checks expiration)" MUST split into sub-anchors
251
+ - Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" MUST have (text) after it
252
+ - Low density paragraph: A paragraph with only 1 line anchor and 4+ sentences of plain text → MUST add more anchors
233
253
 
234
254
  ${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and anchor tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}`,
235
255
  user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
@@ -0,0 +1,34 @@
1
+ import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
2
+ import type { CartoonImage } from "../types/output.ts";
3
+ import { generateCartoon } from "../llm/cartoon.ts";
4
+ import { saveCartoonSidecar, loadCartoonSidecar } from "../history/store.ts";
5
+
6
+ export const cartoonPlugin: GeneratorPlugin = {
7
+ id: "cartoon",
8
+ name: "Comic Strip",
9
+ description: "Generate a 4-panel comic strip that visualizes the key changes in this PR.",
10
+ icon: "Sparkles",
11
+ tabLabel: "Comic",
12
+
13
+ isAvailable: (ctx) => !!ctx.apiKey,
14
+
15
+ async generate(ctx: PluginContext, onProgress?: PluginProgressCallback): Promise<PluginResult> {
16
+ onProgress?.({ message: "Generating comic strip...", current: 0, total: 1 });
17
+ const result = await generateCartoon(ctx.apiKey, ctx.data, ctx.language);
18
+ const cartoon: CartoonImage = {
19
+ imageBase64: result.imageBase64,
20
+ mimeType: result.mimeType,
21
+ generatedAt: new Date().toISOString(),
22
+ };
23
+ onProgress?.({ message: "Comic strip done", current: 1, total: 1 });
24
+ return { type: "cartoon", data: cartoon };
25
+ },
26
+
27
+ async save(sessionId: string, data: unknown): Promise<void> {
28
+ await saveCartoonSidecar(sessionId, data as CartoonImage);
29
+ },
30
+
31
+ async load(sessionId: string): Promise<CartoonImage | null> {
32
+ return loadCartoonSidecar(sessionId);
33
+ },
34
+ };
@@ -0,0 +1,20 @@
1
+ import type { GeneratorPlugin } from "./types.ts";
2
+ import { cartoonPlugin } from "./cartoon.ts";
3
+ import { slidesPlugin } from "./slides.ts";
4
+
5
+ const plugins: GeneratorPlugin[] = [
6
+ slidesPlugin,
7
+ cartoonPlugin,
8
+ ];
9
+
10
+ export function getPlugin(id: string): GeneratorPlugin | undefined {
11
+ return plugins.find((p) => p.id === id);
12
+ }
13
+
14
+ export function getAllPlugins(): GeneratorPlugin[] {
15
+ return plugins;
16
+ }
17
+
18
+ export function getPluginIds(): string[] {
19
+ return plugins.map((p) => p.id);
20
+ }
@@ -0,0 +1,39 @@
1
+ import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
2
+ import type { SlideDeck } from "../types/output.ts";
3
+ import { generateSlides } from "../llm/slides.ts";
4
+ import { saveSlidesSidecar, loadSlidesSidecar } from "../history/store.ts";
5
+
6
+ export const slidesPlugin: GeneratorPlugin = {
7
+ id: "slides",
8
+ name: "Slide Deck",
9
+ description: "Generate a presentation that explains this PR to your team.",
10
+ icon: "Presentation",
11
+ tabLabel: "Slides",
12
+
13
+ isAvailable: (ctx) => !!ctx.apiKey,
14
+
15
+ async generate(ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown): Promise<PluginResult> {
16
+ const existing = existingData as SlideDeck | null | undefined;
17
+ const deck = await generateSlides(
18
+ ctx.apiKey,
19
+ ctx.data,
20
+ undefined,
21
+ ctx.language,
22
+ (msg, current, total) => onProgress?.({ message: msg, current, total }),
23
+ existing,
24
+ undefined,
25
+ (partialDeck) => {
26
+ saveSlidesSidecar(ctx.sessionId, partialDeck).catch(() => {});
27
+ },
28
+ );
29
+ return { type: "slides", data: deck };
30
+ },
31
+
32
+ async save(sessionId: string, data: unknown): Promise<void> {
33
+ await saveSlidesSidecar(sessionId, data as SlideDeck);
34
+ },
35
+
36
+ async load(sessionId: string): Promise<SlideDeck | null> {
37
+ return loadSlidesSidecar(sessionId);
38
+ },
39
+ };
@@ -0,0 +1,33 @@
1
+ import type { NewprOutput } from "../types/output.ts";
2
+
3
+ export interface PluginContext {
4
+ apiKey: string;
5
+ sessionId: string;
6
+ data: NewprOutput;
7
+ language: string;
8
+ }
9
+
10
+ export interface PluginProgressEvent {
11
+ message: string;
12
+ current: number;
13
+ total: number;
14
+ }
15
+
16
+ export type PluginProgressCallback = (event: PluginProgressEvent) => void;
17
+
18
+ export interface PluginResult {
19
+ type: string;
20
+ data: unknown;
21
+ }
22
+
23
+ export interface GeneratorPlugin {
24
+ id: string;
25
+ name: string;
26
+ description: string;
27
+ icon: string;
28
+ tabLabel: string;
29
+ isAvailable: (ctx: PluginContext) => boolean;
30
+ generate: (ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown) => Promise<PluginResult>;
31
+ save: (sessionId: string, data: unknown) => Promise<void>;
32
+ load: (sessionId: string) => Promise<unknown | null>;
33
+ }
@@ -163,6 +163,7 @@ export function App() {
163
163
  bgAnalyses={bgAnalyses.analyses}
164
164
  onBgClick={handleBgClick}
165
165
  onBgDismiss={bgAnalyses.dismiss}
166
+ onFeaturesChange={features.refresh}
166
167
  >
167
168
  {analysis.phase === "idle" && (
168
169
  <InputScreen
@@ -188,6 +189,7 @@ export function App() {
188
189
  sessionId={diffSessionId}
189
190
  onTabChange={setActiveTab}
190
191
  onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
192
+ enabledPlugins={features.enabledPlugins}
191
193
  />
192
194
  )}
193
195
  {analysis.phase === "error" && (
@@ -166,6 +166,7 @@ export function AppShell({
166
166
  bgAnalyses,
167
167
  onBgClick,
168
168
  onBgDismiss,
169
+ onFeaturesChange,
169
170
  children,
170
171
  }: {
171
172
  theme: Theme;
@@ -181,6 +182,7 @@ export function AppShell({
181
182
  bgAnalyses?: BackgroundAnalysis[];
182
183
  onBgClick?: (sessionId: string) => void;
183
184
  onBgDismiss?: (sessionId: string) => void;
185
+ onFeaturesChange?: () => void;
184
186
  children: React.ReactNode;
185
187
  }) {
186
188
  const [settingsOpen, setSettingsOpen] = useState(false);
@@ -382,7 +384,7 @@ export function AppShell({
382
384
  onKeyDown={(e) => { if (e.key === "Escape") setSettingsOpen(false); }}
383
385
  />
384
386
  <div className="relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border bg-background p-6 shadow-lg">
385
- <SettingsPanel onClose={() => setSettingsOpen(false)} />
387
+ <SettingsPanel onClose={() => setSettingsOpen(false)} onFeaturesChange={onFeaturesChange} />
386
388
  </div>
387
389
  </div>
388
390
  )}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { Plus, Pencil, Trash2, ArrowRight, X, Loader2, AlertCircle } from "lucide-react";
3
3
  import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
4
4
  import { DiffViewer } from "./DiffViewer.tsx";
@@ -43,7 +43,8 @@ export function resolveDetail(
43
43
  files: FileChange[],
44
44
  ): DetailTarget | null {
45
45
  if (kind === "group") {
46
- const group = groups.find((g) => g.name === id);
46
+ const cleanId = id.replace(/\s*\([^)]*\)\s*$/, "").trim();
47
+ const group = groups.find((g) => g.name === id || g.name === cleanId || g.name.toLowerCase() === cleanId.toLowerCase());
47
48
  if (!group) return null;
48
49
  const groupFiles = files.filter((f) => group.files.includes(f.path));
49
50
  return { kind: "group", group, files: groupFiles };
@@ -115,6 +116,7 @@ function FileDetail({
115
116
  }) {
116
117
  const Icon = STATUS_ICON[file.status];
117
118
  const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
119
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
118
120
 
119
121
  useEffect(() => {
120
122
  if (sessionId && !patch && !loading && !error) {
@@ -140,7 +142,7 @@ function FileDetail({
140
142
  </div>
141
143
  </div>
142
144
 
143
- <div className="flex-1 overflow-y-auto">
145
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
144
146
  {loading && (
145
147
  <div className="flex items-center justify-center py-16 gap-2">
146
148
  <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
@@ -164,13 +166,13 @@ function FileDetail({
164
166
  )}
165
167
  {patch && (
166
168
  <DiffViewer
167
- key={`${file.path}-${scrollToLine ?? 0}-${scrollToLineEnd ?? 0}`}
168
169
  patch={patch}
169
170
  filePath={file.path}
170
171
  sessionId={sessionId}
171
172
  githubUrl={prUrl ? `${prUrl}/files` : undefined}
172
173
  scrollToLine={scrollToLine}
173
174
  scrollToLineEnd={scrollToLineEnd}
175
+ scrollContainerRef={scrollContainerRef}
174
176
  />
175
177
  )}
176
178
  </div>
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from "react";
2
2
  import { type Highlighter, type ThemedToken } from "shiki";
3
- import { MessageSquare, Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X, Sparkles, Loader2 } from "lucide-react";
3
+ import { Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X, Sparkles, Loader2 } from "lucide-react";
4
4
  import { ensureHighlighter, getHighlighterSync, detectShikiLang, type ShikiLang } from "../lib/shiki.ts";
5
5
  import type { DiffComment } from "../../../types/output.ts";
6
6
  import { TipTapEditor } from "./TipTapEditor.tsx";
@@ -18,7 +18,8 @@ const RENDER_CAP = 2000;
18
18
  const TOTAL_CAP = 3000;
19
19
 
20
20
  function parseLines(patch: string): DiffLine[] {
21
- const raw = patch.split("\n");
21
+ let raw = patch.split("\n");
22
+ while (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
22
23
  const lines: DiffLine[] = [];
23
24
  let oldNum = 0;
24
25
  let newNum = 0;
@@ -61,6 +62,8 @@ function parseLines(patch: string): DiffLine[] {
61
62
  oldNum++;
62
63
  } else if (line.startsWith("\\")) {
63
64
  lines.push({ type: "context", content: line, oldNum: null, newNum: null });
65
+ } else if (line === "" && (oldNum === 0 && newNum === 0)) {
66
+ continue;
64
67
  } else {
65
68
  const text = line.startsWith(" ") ? line.slice(1) : line;
66
69
  if (oldNum > 0 || newNum > 0) {
@@ -107,27 +110,48 @@ function useTokenizedLines(
107
110
  return useMemo(() => {
108
111
  if (!hl || !lang) return null;
109
112
 
110
- const codeIndices: number[] = [];
111
- const codeLines: string[] = [];
113
+ const newIndices: number[] = [];
114
+ const newLines: string[] = [];
115
+ const oldIndices: number[] = [];
116
+ const oldLines: string[] = [];
117
+
112
118
  for (let i = 0; i < lines.length; i++) {
113
119
  const t = lines[i]!.type;
114
- if (t === "added" || t === "removed" || t === "context") {
115
- codeIndices.push(i);
116
- codeLines.push(lines[i]!.content);
120
+ if (t === "added" || t === "context") {
121
+ newIndices.push(i);
122
+ newLines.push(lines[i]!.content);
123
+ }
124
+ if (t === "removed") {
125
+ oldIndices.push(i);
126
+ oldLines.push(lines[i]!.content);
127
+ }
128
+ if (t === "context") {
129
+ oldIndices.push(i);
130
+ oldLines.push(lines[i]!.content);
117
131
  }
118
132
  }
119
133
 
120
- if (codeLines.length === 0) return null;
134
+ const map: TokenMap = new Map();
135
+ const theme = dark ? "github-dark" : "github-light";
121
136
 
122
137
  try {
123
- const theme = dark ? "github-dark" : "github-light";
124
- const result = hl.codeToTokens(codeLines.join("\n"), { lang, theme });
125
- const map: TokenMap = new Map();
126
- for (let j = 0; j < codeIndices.length; j++) {
127
- const tokens = result.tokens[j];
128
- if (tokens) map.set(codeIndices[j]!, tokens);
138
+ if (newLines.length > 0) {
139
+ const result = hl.codeToTokens(newLines.join("\n"), { lang, theme });
140
+ for (let j = 0; j < newIndices.length; j++) {
141
+ const tokens = result.tokens[j];
142
+ if (tokens) map.set(newIndices[j]!, tokens);
143
+ }
129
144
  }
130
- return map;
145
+ if (oldLines.length > 0) {
146
+ const result = hl.codeToTokens(oldLines.join("\n"), { lang, theme });
147
+ for (let j = 0; j < oldIndices.length; j++) {
148
+ if (!map.has(oldIndices[j]!)) {
149
+ const tokens = result.tokens[j];
150
+ if (tokens) map.set(oldIndices[j]!, tokens);
151
+ }
152
+ }
153
+ }
154
+ return map.size > 0 ? map : null;
131
155
  } catch {
132
156
  return null;
133
157
  }
@@ -579,6 +603,7 @@ export function DiffViewer({
579
603
  githubUrl,
580
604
  scrollToLine,
581
605
  scrollToLineEnd,
606
+ scrollContainerRef,
582
607
  }: {
583
608
  patch: string;
584
609
  filePath: string;
@@ -586,6 +611,7 @@ export function DiffViewer({
586
611
  githubUrl?: string;
587
612
  scrollToLine?: number;
588
613
  scrollToLineEnd?: number;
614
+ scrollContainerRef?: React.RefObject<HTMLElement | null>;
589
615
  }) {
590
616
  const [showAll, setShowAll] = useState(false);
591
617
  const hl = useHighlighter();
@@ -595,15 +621,18 @@ export function DiffViewer({
595
621
  const tokenMap = useTokenizedLines(hl, allLines, lang, dark);
596
622
  const isCapped = !showAll && allLines.length > TOTAL_CAP;
597
623
  const lines = isCapped ? allLines.slice(0, RENDER_CAP) : allLines;
598
- const fileName = filePath.split("/").pop() ?? filePath;
599
624
 
600
625
  const scrollRef = useRef<HTMLDivElement>(null);
601
626
  const containerRef = useRef<HTMLDivElement>(null);
602
627
  const [visibleWidth, setVisibleWidth] = useState(0);
603
628
 
604
629
  const highlightedRef = useRef<HTMLElement[]>([]);
630
+ const scrollKeyRef = useRef(0);
605
631
 
606
632
  useEffect(() => {
633
+ scrollKeyRef.current++;
634
+ const currentKey = scrollKeyRef.current;
635
+
607
636
  for (const el of highlightedRef.current) {
608
637
  el.style.boxShadow = "";
609
638
  }
@@ -612,6 +641,7 @@ export function DiffViewer({
612
641
  if (!scrollToLine || !containerRef.current) return;
613
642
  const endLine = scrollToLineEnd ?? scrollToLine;
614
643
  const timer = setTimeout(() => {
644
+ if (scrollKeyRef.current !== currentKey) return;
615
645
  const container = containerRef.current;
616
646
  if (!container) return;
617
647
  let scrollTarget: HTMLElement | null = null;
@@ -624,11 +654,14 @@ export function DiffViewer({
624
654
  }
625
655
  }
626
656
  if (scrollTarget) {
627
- let scrollParent = scrollTarget.parentElement;
628
- while (scrollParent) {
629
- const style = getComputedStyle(scrollParent);
630
- if (style.overflowY === "auto" || style.overflowY === "scroll") break;
631
- scrollParent = scrollParent.parentElement;
657
+ let scrollParent: HTMLElement | null = scrollContainerRef?.current ?? null;
658
+ if (!scrollParent) {
659
+ scrollParent = scrollTarget.parentElement;
660
+ while (scrollParent) {
661
+ const style = getComputedStyle(scrollParent);
662
+ if (style.overflowY === "auto" || style.overflowY === "scroll") break;
663
+ scrollParent = scrollParent.parentElement;
664
+ }
632
665
  }
633
666
  if (scrollParent) {
634
667
  const parentRect = scrollParent.getBoundingClientRect();
@@ -636,7 +669,7 @@ export function DiffViewer({
636
669
  scrollParent.scrollTop += targetRect.top - parentRect.top - parentRect.height / 2;
637
670
  }
638
671
  }
639
- }, 100);
672
+ }, 50);
640
673
  return () => clearTimeout(timer);
641
674
  }, [scrollToLine, scrollToLineEnd, patch]);
642
675
  const [comments, setComments] = useState<DiffComment[]>([]);
@@ -774,21 +807,8 @@ export function DiffViewer({
774
807
  .join("\n");
775
808
  }, [formRange, lines]);
776
809
 
777
- const commentCount = comments.length;
778
-
779
810
  return (
780
- <div ref={containerRef} className="rounded-lg border overflow-hidden">
781
- <div className="sticky top-0 z-10 bg-muted px-3 py-1.5 border-b flex items-center gap-2">
782
- <span className="text-xs font-mono font-medium truncate flex-1" title={filePath}>
783
- {fileName}
784
- </span>
785
- {commentCount > 0 && (
786
- <span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
787
- <MessageSquare className="h-3 w-3" />
788
- {commentCount}
789
- </span>
790
- )}
791
- </div>
811
+ <div ref={containerRef} className="overflow-hidden">
792
812
  <div ref={scrollRef} className="overflow-x-auto">
793
813
  <div className="min-w-max font-mono text-xs leading-5 select-text">
794
814
  {lines.map((line, i) => {