newpr 0.4.0 → 0.5.0

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.4.0",
3
+ "version": "0.5.0",
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",
@@ -53,7 +53,7 @@
53
53
  "@types/react-dom": "^19.2.3"
54
54
  },
55
55
  "peerDependencies": {
56
- "typescript": "^5"
56
+ "typescript": "^5.9.3"
57
57
  },
58
58
  "dependencies": {
59
59
  "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -298,6 +298,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
298
298
  pr_body: prData.body || undefined,
299
299
  pr_url: prData.url,
300
300
  pr_state: prData.state,
301
+ pr_updated_at: prData.updated_at,
301
302
  base_branch: prData.base_branch,
302
303
  head_branch: prData.head_branch,
303
304
  author: prData.author,
@@ -20,6 +20,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
20
20
  body: (json.body as string) ?? "",
21
21
  url: json.html_url as string,
22
22
  state,
23
+ updated_at: (json.updated_at as string) ?? new Date().toISOString(),
23
24
  base_branch: (base?.ref as string) ?? "unknown",
24
25
  head_branch: (head?.ref as string) ?? "unknown",
25
26
  author: (user?.login as string) ?? "unknown",
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { mkdirSync, rmSync, existsSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
- import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
5
+ import type { NewprOutput, DiffComment, ChatMessage, CartoonImage, SlideDeck } from "../types/output.ts";
6
6
  import type { SessionRecord } from "./types.ts";
7
7
 
8
8
  const HISTORY_DIR = join(homedir(), ".newpr", "history");
@@ -197,6 +197,30 @@ export async function loadCartoonSidecar(
197
197
  }
198
198
  }
199
199
 
200
+ export async function saveSlidesSidecar(
201
+ id: string,
202
+ deck: SlideDeck,
203
+ ): Promise<void> {
204
+ ensureDirs();
205
+ await Bun.write(
206
+ join(SESSIONS_DIR, `${id}.slides.json`),
207
+ JSON.stringify(deck),
208
+ );
209
+ }
210
+
211
+ export async function loadSlidesSidecar(
212
+ id: string,
213
+ ): Promise<SlideDeck | null> {
214
+ try {
215
+ const filePath = join(SESSIONS_DIR, `${id}.slides.json`);
216
+ const file = Bun.file(filePath);
217
+ if (!(await file.exists())) return null;
218
+ return JSON.parse(await file.text()) as SlideDeck;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
200
224
  export function getHistoryPath(): string {
201
225
  return HISTORY_DIR;
202
226
  }
@@ -0,0 +1,381 @@
1
+ import type { NewprOutput, SlideImage, SlideDeck, SlidePlan, SlideSpec } from "../types/output.ts";
2
+
3
+ function getSystemLanguage(): string {
4
+ const env = process.env.LANG ?? process.env.LANGUAGE ?? process.env.LC_ALL ?? "";
5
+ if (env.startsWith("ko")) return "Korean";
6
+ if (env.startsWith("ja")) return "Japanese";
7
+ if (env.startsWith("zh")) return "Chinese";
8
+ if (env.startsWith("es")) return "Spanish";
9
+ if (env.startsWith("fr")) return "French";
10
+ if (env.startsWith("de")) return "German";
11
+ return "English";
12
+ }
13
+
14
+ function buildPrContext(data: NewprOutput): string {
15
+ const groupDetails = data.groups.map((g) => `- ${g.name} (${g.type}): ${g.description}\n Files: ${g.files.join(", ")}${g.key_changes ? `\n Key changes: ${g.key_changes.join("; ")}` : ""}${g.risk ? `\n Risk: ${g.risk}` : ""}`).join("\n");
16
+ const fileSummaries = data.files.slice(0, 30).map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`).join("\n");
17
+
18
+ return `PR: #${data.meta.pr_number} "${data.meta.pr_title}"
19
+ Author: ${data.meta.author}
20
+ Repo: ${data.meta.pr_url.split("/pull/")[0]?.split("github.com/")[1] ?? ""}
21
+ Branches: ${data.meta.head_branch} → ${data.meta.base_branch}
22
+ Stats: ${data.meta.total_files_changed} files, +${data.meta.total_additions} -${data.meta.total_deletions}
23
+ Risk: ${data.summary.risk_level}
24
+ State: ${data.meta.pr_state ?? "open"}
25
+
26
+ Purpose: ${data.summary.purpose}
27
+ Scope: ${data.summary.scope}
28
+ Impact: ${data.summary.impact}
29
+
30
+ Change Groups:
31
+ ${groupDetails}
32
+
33
+ File Summaries:
34
+ ${fileSummaries}
35
+
36
+ Narrative:
37
+ ${data.narrative.slice(0, 3000)}`;
38
+ }
39
+
40
+ function buildStylePrompt(data: NewprOutput): { system: string; user: string } {
41
+ return {
42
+ system: `You are a world-class presentation designer specializing in technical presentations for software engineering teams.
43
+
44
+ Your task is to design a comprehensive visual style system for a slide deck about a Pull Request. This style will be used as a prompt for an image generation model (Gemini) to render each slide, so you must be EXTREMELY specific and detailed.
45
+
46
+ Output a single string — the style prompt. It must cover ALL of the following in exhaustive detail:
47
+
48
+ ## Color System
49
+ - Exact hex codes for: primary background, secondary background, text primary, text secondary, accent color, code block background, code text, highlight/emphasis, success (green), warning (yellow), danger (red)
50
+ - Gradient specifications if any (direction, stops)
51
+ - How colors interact (contrast ratios, when to use each)
52
+
53
+ ## Typography
54
+ - Primary font family (suggest a specific well-known font)
55
+ - Title: exact size in px, weight, letter-spacing, color
56
+ - Subtitle: exact size, weight, color
57
+ - Body text: exact size, line-height, color
58
+ - Code/monospace: font family, size, color, background
59
+ - Captions/labels: size, weight, color, text-transform
60
+ - Bullet points: style, indentation, spacing between items
61
+
62
+ ## Layout Grid
63
+ - Slide dimensions: 1920x1080 (16:9)
64
+ - Margins: top, bottom, left, right in px
65
+ - Title position: x, y coordinates and alignment
66
+ - Content area: bounds in px
67
+ - Column layouts: when to use 1-col, 2-col, 3-col, and exact widths
68
+ - Spacing between elements in px
69
+
70
+ ## Visual Elements
71
+ - Code blocks: border-radius, padding, syntax highlighting theme, line numbers yes/no
72
+ - Diagrams/flowcharts: node style, arrow style, colors
73
+ - Icons: style (outline/filled), size, color
74
+ - Dividers/separators: style, color, thickness
75
+ - Cards/boxes: background, border, border-radius, shadow, padding
76
+ - Badges/tags: shape, colors for different types (feature, bugfix, refactor, etc.)
77
+
78
+ ## Slide-Type Templates
79
+ - Title slide: layout, where logo/branding goes, title size, subtitle placement
80
+ - Content slide: title bar height, content area layout
81
+ - Code slide: how to display code with explanations
82
+ - Comparison slide: before/after or side-by-side layout
83
+ - Summary slide: key points layout, call-to-action area
84
+
85
+ ## Mood & Aesthetic
86
+ - Overall feel (e.g., "modern dark engineering dashboard", "clean Apple keynote", "Linear-inspired minimal")
87
+ - What to AVOID (e.g., no clip art, no heavy shadows, no busy backgrounds)
88
+ - Professional tone appropriate for engineering team review
89
+
90
+ The style prompt should be a single continuous text paragraph (not JSON, not markdown) that an image generation AI can follow. Be specific enough that any slide generated with this style will look like it belongs to the same deck.
91
+
92
+ Respond with ONLY the style prompt string. No JSON wrapping, no explanation.`,
93
+ user: buildPrContext(data),
94
+ };
95
+ }
96
+
97
+ function buildSlidesPrompt(data: NewprOutput, stylePrompt: string, language?: string): { system: string; user: string } {
98
+ const lang = language ?? getSystemLanguage();
99
+ return {
100
+ system: `You are a presentation content planner. You will receive a PR analysis and a visual style description. Your job is to plan the CONTENT of each slide.
101
+
102
+ Output a JSON object with:
103
+ - "slides": An array of slide specifications. Each slide has:
104
+ - "index": slide number (0-based)
105
+ - "title": slide title text (in ${lang})
106
+ - "contentPrompt": A VERY detailed description of what should be on this slide — exact text content, layout within the slide, visual elements, code snippets if any. All visible text must be in ${lang}. Be extremely specific about positioning, what goes where, and what text to show.
107
+
108
+ Decide the number of slides based on the PR complexity:
109
+ - Small PR (1-3 groups, <10 files): 4-6 slides
110
+ - Medium PR (3-6 groups, 10-30 files): 6-10 slides
111
+ - Large PR (6+ groups, 30+ files): 8-14 slides
112
+
113
+ Typical slide structure:
114
+ - Slide 0: Title slide with PR name, author, repo, key stats
115
+ - Slide 1: Overview/motivation — why this PR exists
116
+ - Middle slides: One or more slides per major change group, showing key changes with specific code examples
117
+ - Near-end: Architecture/dependency impact if relevant
118
+ - Final slide: Summary with risk assessment and review notes
119
+
120
+ Each contentPrompt should be detailed enough that an image generation model can render the slide given the style guide. Include exact text, numbers, file paths, and code snippets to display.
121
+
122
+ Respond ONLY with the JSON object. No markdown, no explanation.`,
123
+ user: `Visual Style:\n${stylePrompt}\n\n${buildPrContext(data)}`,
124
+ };
125
+ }
126
+
127
+ function buildImagePrompt(stylePrompt: string, slide: SlideSpec): string {
128
+ return `Generate a presentation slide image. 16:9 aspect ratio (1920x1080 pixels).
129
+
130
+ VISUAL STYLE (apply consistently):
131
+ ${stylePrompt}
132
+
133
+ THIS SLIDE (slide ${slide.index + 1}):
134
+ Title: "${slide.title}"
135
+
136
+ CONTENT AND LAYOUT:
137
+ ${slide.contentPrompt}
138
+
139
+ CRITICAL REQUIREMENTS:
140
+ - This is a real presentation slide, not an illustration or diagram
141
+ - All text must be clearly readable
142
+ - Use proper text hierarchy (title, subtitle, body, captions)
143
+ - Maintain consistent margins and alignment
144
+ - The slide should look professional and polished
145
+ - Render all text exactly as specified — do not paraphrase or translate`;
146
+ }
147
+
148
+ async function callOpenRouter(apiKey: string, model: string, system: string, user: string, maxTokens: number, timeoutMs: number): Promise<string> {
149
+ const controller = new AbortController();
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
151
+ try {
152
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
153
+ method: "POST",
154
+ signal: controller.signal,
155
+ headers: {
156
+ Authorization: `Bearer ${apiKey}`,
157
+ "Content-Type": "application/json",
158
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
159
+ "X-Title": "newpr",
160
+ },
161
+ body: JSON.stringify({
162
+ model,
163
+ messages: [
164
+ { role: "system", content: system },
165
+ { role: "user", content: user },
166
+ ],
167
+ temperature: 0.4,
168
+ max_tokens: maxTokens,
169
+ }),
170
+ });
171
+ if (!res.ok) {
172
+ const body = await res.text().catch(() => "");
173
+ throw new Error(`OpenRouter API ${res.status}: ${body.slice(0, 200)}`);
174
+ }
175
+ const json = await res.json() as { choices: Array<{ message: { content: string } }> };
176
+ return json.choices[0]?.message?.content ?? "";
177
+ } catch (err) {
178
+ if ((err as Error).name === "AbortError") throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
179
+ throw err;
180
+ } finally {
181
+ clearTimeout(timer);
182
+ }
183
+ }
184
+
185
+ async function renderSlides(
186
+ apiKey: string,
187
+ plan: SlidePlan,
188
+ specsToRender: SlideSpec[],
189
+ onProgress?: (msg: string, current: number, total: number) => void,
190
+ onSlideComplete?: (completed: SlideImage[]) => void,
191
+ ): Promise<{ completed: SlideImage[]; failed: number[] }> {
192
+ const totalPlan = plan.slides.length;
193
+ const completed: SlideImage[] = [];
194
+ const failed: number[] = [];
195
+
196
+ const conversationHistory: Array<{ role: string; content: string }> = [
197
+ {
198
+ role: "user",
199
+ content: `You are generating a series of ${totalPlan} presentation slides. All slides MUST follow this exact visual style consistently:\n\n${plan.stylePrompt}\n\nI will now ask you to generate each slide one by one. Maintain EXACTLY the same visual style, colors, fonts, and layout principles across every slide. Respond with the slide image for each request.`,
200
+ },
201
+ ];
202
+
203
+ for (const spec of specsToRender) {
204
+ onProgress?.(`Rendering slide ${spec.index + 1}/${totalPlan}: "${spec.title}"`, completed.length, specsToRender.length);
205
+
206
+ const slidePrompt = `Generate slide ${spec.index + 1} of ${totalPlan}. 16:9 aspect ratio (1920x1080).
207
+
208
+ Title: "${spec.title}"
209
+
210
+ CONTENT AND LAYOUT:
211
+ ${spec.contentPrompt}
212
+
213
+ IMPORTANT: Use the EXACT SAME visual style as all previous slides. Same colors, fonts, spacing, background.`;
214
+
215
+ conversationHistory.push({ role: "user", content: slidePrompt });
216
+
217
+ try {
218
+ const controller = new AbortController();
219
+ const timeout = setTimeout(() => controller.abort(), 120_000);
220
+
221
+ try {
222
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
223
+ method: "POST",
224
+ signal: controller.signal,
225
+ headers: {
226
+ Authorization: `Bearer ${apiKey}`,
227
+ "Content-Type": "application/json",
228
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
229
+ "X-Title": "newpr",
230
+ },
231
+ body: JSON.stringify({
232
+ model: "google/gemini-3-pro-image-preview",
233
+ messages: conversationHistory,
234
+ modalities: ["image", "text"],
235
+ }),
236
+ });
237
+
238
+ if (!res.ok) {
239
+ const body = await res.text();
240
+ throw new Error(`Gemini API ${res.status}: ${body.slice(0, 200)}`);
241
+ }
242
+
243
+ const json = await res.json() as {
244
+ choices?: Array<{
245
+ message?: {
246
+ content?: string | Array<{ type?: string; image_url?: { url?: string }; text?: string }>;
247
+ images?: Array<{ image_url?: { url?: string } }>;
248
+ };
249
+ }>;
250
+ };
251
+
252
+ const msg = json.choices?.[0]?.message;
253
+ let imageData: { base64: string; mimeType: string } | null = null;
254
+
255
+ if (msg?.images) {
256
+ for (const img of msg.images) {
257
+ if (img.image_url?.url) {
258
+ const match = img.image_url.url.match(/data:([^;]+);base64,(.+)/s);
259
+ if (match) { imageData = { mimeType: match[1]!, base64: match[2]! }; break; }
260
+ }
261
+ }
262
+ }
263
+
264
+ if (!imageData) {
265
+ const content = msg?.content;
266
+ if (typeof content === "string") {
267
+ const match = content.match(/data:([^;]+);base64,(.+)/s);
268
+ if (match) imageData = { mimeType: match[1]!, base64: match[2]! };
269
+ }
270
+ if (!imageData && Array.isArray(content)) {
271
+ for (const part of content) {
272
+ if (part.type === "image_url" && part.image_url?.url) {
273
+ const match = part.image_url.url.match(/data:([^;]+);base64,(.+)/s);
274
+ if (match) { imageData = { mimeType: match[1]!, base64: match[2]! }; break; }
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ if (!imageData) throw new Error(`No image in response for slide ${spec.index + 1}`);
281
+
282
+ conversationHistory.push({ role: "assistant", content: `[Slide ${spec.index + 1} image generated successfully]` });
283
+
284
+ completed.push({
285
+ index: spec.index,
286
+ imageBase64: imageData.base64,
287
+ mimeType: imageData.mimeType,
288
+ title: spec.title,
289
+ });
290
+ onProgress?.(`Slide ${spec.index + 1}/${totalPlan} done (${completed.length}/${specsToRender.length})`, completed.length, specsToRender.length);
291
+ onSlideComplete?.([...completed]);
292
+ } finally {
293
+ clearTimeout(timeout);
294
+ }
295
+ } catch (err) {
296
+ failed.push(spec.index);
297
+ conversationHistory.push({ role: "assistant", content: `[Slide ${spec.index + 1} failed]` });
298
+ onProgress?.(`Slide ${spec.index + 1}/${totalPlan} failed: ${err instanceof Error ? err.message : String(err)}`, completed.length + failed.length, specsToRender.length);
299
+ }
300
+ }
301
+
302
+ return { completed, failed };
303
+ }
304
+
305
+ export async function generateSlides(
306
+ apiKey: string,
307
+ data: NewprOutput,
308
+ _planModel?: string,
309
+ language?: string,
310
+ onProgress?: (msg: string, current: number, total: number) => void,
311
+ existingDeck?: SlideDeck | null,
312
+ onPlan?: (plan: SlidePlan, imagePrompts: Array<{ index: number; prompt: string }>) => void,
313
+ onSlideComplete?: (partialDeck: SlideDeck) => void,
314
+ ): Promise<SlideDeck> {
315
+ let plan: SlidePlan;
316
+
317
+ if (existingDeck?.plan && existingDeck.plan.slides.length > 0) {
318
+ plan = existingDeck.plan;
319
+ onProgress?.(`Resuming with existing plan (${plan.slides.length} slides)...`, 0, plan.slides.length);
320
+ } else {
321
+ const OPUS_MODEL = "anthropic/claude-opus-4.6";
322
+
323
+ onProgress?.("Step 1/2: Designing visual style (Opus)...", 0, 1);
324
+ const styleP = buildStylePrompt(data);
325
+ const styleContent = await callOpenRouter(apiKey, OPUS_MODEL, styleP.system, styleP.user, 4096, 120_000);
326
+ const stylePrompt = styleContent.trim();
327
+ onProgress?.(`Style designed (${stylePrompt.length} chars)`, 0, 1);
328
+
329
+ onProgress?.("Step 2/2: Planning slide content (Opus)...", 0, 1);
330
+ const slidesP = buildSlidesPrompt(data, stylePrompt, language);
331
+ const slidesContent = await callOpenRouter(apiKey, OPUS_MODEL, slidesP.system, slidesP.user, 8192, 120_000);
332
+
333
+ let rawContent = slidesContent.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
334
+ const jsonStart = rawContent.indexOf("{");
335
+ const jsonEnd = rawContent.lastIndexOf("}");
336
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
337
+ rawContent = rawContent.slice(jsonStart, jsonEnd + 1);
338
+ }
339
+ const parsed = JSON.parse(rawContent) as { slides: SlideSpec[] };
340
+ if (!parsed.slides || parsed.slides.length === 0) throw new Error("Empty slide plan");
341
+
342
+ plan = { stylePrompt, slides: parsed.slides };
343
+ onProgress?.(`Plan ready: ${plan.slides.length} slides`, 0, plan.slides.length);
344
+ }
345
+
346
+ const imagePrompts = plan.slides.map((spec) => ({
347
+ index: spec.index,
348
+ prompt: buildImagePrompt(plan.stylePrompt, spec),
349
+ }));
350
+ onPlan?.(plan, imagePrompts);
351
+
352
+ const existingSlides = existingDeck?.slides ?? [];
353
+ const existingIndices = new Set(existingSlides.map((s) => s.index));
354
+ const specsToRender = existingDeck?.failedIndices && existingDeck.failedIndices.length > 0
355
+ ? plan.slides.filter((s) => existingDeck.failedIndices!.includes(s.index))
356
+ : plan.slides.filter((s) => !existingIndices.has(s.index));
357
+
358
+ if (specsToRender.length === 0 && existingSlides.length > 0) {
359
+ onProgress?.("All slides already generated", existingSlides.length, existingSlides.length);
360
+ return { slides: existingSlides.sort((a, b) => a.index - b.index), plan, generatedAt: existingDeck?.generatedAt ?? new Date().toISOString() };
361
+ }
362
+
363
+ onProgress?.(`Generating ${specsToRender.length} slide${specsToRender.length > 1 ? "s" : ""}...`, 0, specsToRender.length);
364
+ const { completed, failed } = await renderSlides(apiKey, plan, specsToRender, onProgress, (partialCompleted) => {
365
+ const allSlides = [...existingSlides.filter((s) => !partialCompleted.some((c) => c.index === s.index)), ...partialCompleted].sort((a, b) => a.index - b.index);
366
+ const remaining = specsToRender.filter((s) => !partialCompleted.some((c) => c.index === s.index)).map((s) => s.index);
367
+ onSlideComplete?.({ slides: allSlides, plan, failedIndices: remaining.length > 0 ? remaining : undefined, generatedAt: new Date().toISOString() });
368
+ });
369
+
370
+ const allSlides = [...existingSlides.filter((s) => !completed.some((c) => c.index === s.index)), ...completed]
371
+ .sort((a, b) => a.index - b.index);
372
+
373
+ if (allSlides.length === 0) throw new Error("All slide generations failed");
374
+
375
+ return {
376
+ slides: allSlides,
377
+ plan,
378
+ failedIndices: failed.length > 0 ? failed : undefined,
379
+ generatedAt: new Date().toISOString(),
380
+ };
381
+ }
@@ -30,6 +30,7 @@ export interface GithubPrData {
30
30
  body: string;
31
31
  url: string;
32
32
  state: PrState;
33
+ updated_at: string;
33
34
  base_branch: string;
34
35
  head_branch: string;
35
36
  author: string;
@@ -19,6 +19,7 @@ export interface PrMeta {
19
19
  pr_body?: string;
20
20
  pr_url: string;
21
21
  pr_state?: PrStateLabel;
22
+ pr_updated_at?: string;
22
23
  base_branch: string;
23
24
  head_branch: string;
24
25
  author: string;
@@ -106,6 +107,31 @@ export interface ChatMessage {
106
107
  timestamp: string;
107
108
  }
108
109
 
110
+ export interface SlideImage {
111
+ index: number;
112
+ imageBase64: string;
113
+ mimeType: string;
114
+ title: string;
115
+ }
116
+
117
+ export interface SlideSpec {
118
+ index: number;
119
+ title: string;
120
+ contentPrompt: string;
121
+ }
122
+
123
+ export interface SlidePlan {
124
+ stylePrompt: string;
125
+ slides: SlideSpec[];
126
+ }
127
+
128
+ export interface SlideDeck {
129
+ slides: SlideImage[];
130
+ plan?: SlidePlan;
131
+ failedIndices?: number[];
132
+ generatedAt: string;
133
+ }
134
+
109
135
  export interface NewprOutput {
110
136
  meta: PrMeta;
111
137
  summary: PrSummary;
@@ -13,6 +13,7 @@ import { ErrorScreen } from "./components/ErrorScreen.tsx";
13
13
  import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
14
14
  import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
15
15
  import type { AnchorItem } from "./components/TipTapEditor.tsx";
16
+ import { requestNotificationPermission } from "./lib/notify.ts";
16
17
 
17
18
  function getUrlParam(key: string): string | null {
18
19
  return new URLSearchParams(window.location.search).get(key);
@@ -38,6 +39,8 @@ export function App() {
38
39
  const features = useFeatures();
39
40
  const bgAnalyses = useBackgroundAnalyses();
40
41
  const initialLoadDone = useRef(false);
42
+
43
+ useEffect(() => { requestNotificationPermission(); }, []);
41
44
  const [activeId, setActiveId] = useState<string | null>(null);
42
45
 
43
46
  useEffect(() => {
@@ -145,7 +148,7 @@ export function App() {
145
148
  }, [analysis.result]);
146
149
 
147
150
  return (
148
- <ChatProvider state={chatState} anchorItems={anchorItems}>
151
+ <ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
149
152
  <AppShell
150
153
  theme={themeCtx.theme}
151
154
  onThemeChange={themeCtx.setTheme}
@@ -184,6 +187,7 @@ export function App() {
184
187
  cartoonEnabled={features.cartoon}
185
188
  sessionId={diffSessionId}
186
189
  onTabChange={setActiveTab}
190
+ onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
187
191
  />
188
192
  )}
189
193
  {analysis.phase === "error" && (
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
2
2
  import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
3
3
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
4
4
  import { Markdown } from "./Markdown.tsx";
@@ -17,14 +17,15 @@ export interface ChatState {
17
17
  interface ChatContextValue {
18
18
  state: ChatState;
19
19
  anchorItems?: AnchorItem[];
20
+ analyzedAt?: string;
20
21
  }
21
22
 
22
23
  const ChatContext = createContext<ChatContextValue | null>(null);
23
24
 
24
25
  export { useChatStore as useChatState };
25
26
 
26
- export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
27
- const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
27
+ export function ChatProvider({ state, anchorItems, analyzedAt, children }: { state: ChatState; anchorItems?: AnchorItem[]; analyzedAt?: string; children: React.ReactNode }) {
28
+ const value = useMemo(() => ({ state, anchorItems, analyzedAt }), [state, anchorItems, analyzedAt]);
28
29
  return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
29
30
  }
30
31
 
@@ -71,6 +72,33 @@ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
71
72
  return segs;
72
73
  }
73
74
 
75
+ function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
76
+ content: string;
77
+ onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
78
+ activeId?: string | null;
79
+ }) {
80
+ const [rendered, setRendered] = useState(content);
81
+ const pendingRef = useRef(content);
82
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
83
+
84
+ useEffect(() => {
85
+ pendingRef.current = content;
86
+ if (!timerRef.current) {
87
+ timerRef.current = setTimeout(() => {
88
+ setRendered(pendingRef.current);
89
+ timerRef.current = null;
90
+ }, 150);
91
+ }
92
+ return () => {};
93
+ }, [content]);
94
+
95
+ useEffect(() => {
96
+ return () => { if (timerRef.current) clearTimeout(timerRef.current); };
97
+ }, []);
98
+
99
+ return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
100
+ }
101
+
74
102
  function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
75
103
  segments: ChatSegment[];
76
104
  activeToolName?: string;
@@ -86,11 +114,16 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
86
114
  if (seg.type === "tool_call") {
87
115
  return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
88
116
  }
89
- return seg.content ? (
117
+ if (!seg.content) return null;
118
+ return (
90
119
  <div key={`text-${i}`} className="text-xs leading-relaxed">
91
- <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
120
+ {isStreaming ? (
121
+ <ThrottledMarkdown content={seg.content} onAnchorClick={onAnchorClick} activeId={activeId} />
122
+ ) : (
123
+ <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
124
+ )}
92
125
  </div>
93
- ) : null;
126
+ );
94
127
  })}
95
128
  {activeToolName && (
96
129
  <div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
@@ -149,6 +182,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
149
182
 
150
183
  if (!ctx) return null;
151
184
  const { messages, streaming, loaded, loading } = ctx.state;
185
+ const { analyzedAt } = ctx;
152
186
  const hasMessages = messages.length > 0 || loading;
153
187
 
154
188
  if (!hasMessages && loaded) {
@@ -162,26 +196,51 @@ export function ChatMessages({ onAnchorClick, activeId }: {
162
196
 
163
197
  if (!hasMessages) return null;
164
198
 
199
+ let shownOutdatedDivider = false;
200
+
165
201
  return (
166
202
  <div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
167
203
  <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
168
204
  {messages.map((msg, i) => {
205
+ const isFromPreviousAnalysis = analyzedAt && msg.timestamp && msg.timestamp < analyzedAt;
206
+ let divider = null;
207
+ if (isFromPreviousAnalysis && !shownOutdatedDivider) {
208
+ shownOutdatedDivider = true;
209
+ divider = (
210
+ <div className="flex items-center gap-2 py-1">
211
+ <div className="flex-1 h-px bg-yellow-500/20" />
212
+ <span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">Previous analysis</span>
213
+ <div className="flex-1 h-px bg-yellow-500/20" />
214
+ </div>
215
+ );
216
+ }
169
217
  if (msg.role === "user") {
170
218
  return (
171
- <div key={`user-${i}`} className="flex justify-end">
172
- <div className="max-w-[80%] rounded-xl rounded-br-sm bg-foreground text-background px-3.5 py-2 text-[11px] leading-relaxed">
173
- {msg.content}
219
+ <div key={`user-${i}`}>
220
+ {divider}
221
+ <div className="flex justify-end">
222
+ <div className={`max-w-[80%] rounded-xl rounded-br-sm px-3.5 py-2 text-[11px] leading-relaxed ${
223
+ isFromPreviousAnalysis
224
+ ? "bg-foreground/60 text-background"
225
+ : "bg-foreground text-background"
226
+ }`}>
227
+ {msg.content}
228
+ </div>
174
229
  </div>
175
230
  </div>
176
231
  );
177
232
  }
178
233
  return (
179
- <AssistantMessage
180
- key={`assistant-${i}`}
181
- segments={segmentsFromMessage(msg)}
182
- onAnchorClick={onAnchorClick}
183
- activeId={activeId}
184
- />
234
+ <div key={`assistant-${i}`}>
235
+ {divider}
236
+ <div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
237
+ <AssistantMessage
238
+ segments={segmentsFromMessage(msg)}
239
+ onAnchorClick={onAnchorClick}
240
+ activeId={activeId}
241
+ />
242
+ </div>
243
+ </div>
185
244
  );
186
245
  })}
187
246