newpr 0.3.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.
Files changed (35) hide show
  1. package/README.md +135 -103
  2. package/package.json +2 -2
  3. package/src/analyzer/pipeline.ts +1 -4
  4. package/src/cli/args.ts +1 -1
  5. package/src/cli/index.ts +2 -1
  6. package/src/github/fetch-pr.ts +1 -0
  7. package/src/history/store.ts +25 -1
  8. package/src/llm/prompts.ts +82 -27
  9. package/src/llm/slides.ts +381 -0
  10. package/src/types/config.ts +1 -1
  11. package/src/types/github.ts +1 -0
  12. package/src/types/output.ts +26 -0
  13. package/src/version.ts +23 -0
  14. package/src/web/client/App.tsx +51 -1
  15. package/src/web/client/components/AppShell.tsx +173 -45
  16. package/src/web/client/components/ChatSection.tsx +76 -185
  17. package/src/web/client/components/DetailPane.tsx +1 -0
  18. package/src/web/client/components/DiffViewer.tsx +200 -4
  19. package/src/web/client/components/InputScreen.tsx +3 -0
  20. package/src/web/client/components/Markdown.tsx +66 -16
  21. package/src/web/client/components/ResultsScreen.tsx +32 -2
  22. package/src/web/client/components/SettingsPanel.tsx +1 -1
  23. package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
  24. package/src/web/client/hooks/useChatStore.ts +247 -0
  25. package/src/web/client/hooks/useFeatures.ts +2 -1
  26. package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
  27. package/src/web/client/lib/notify.ts +21 -0
  28. package/src/web/client/panels/SlidesPanel.tsx +316 -0
  29. package/src/web/index.html +1 -0
  30. package/src/web/server/routes.ts +226 -4
  31. package/src/web/server/session-manager.ts +34 -0
  32. package/src/web/server.ts +20 -1
  33. package/src/web/styles/built.css +1 -1
  34. package/src/workspace/explore.ts +39 -6
  35. package/src/workspace/types.ts +1 -0
@@ -1,9 +1,10 @@
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 } from "lucide-react";
3
+ import { MessageSquare, 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";
7
+ import { Markdown } from "./Markdown.tsx";
7
8
 
8
9
  interface DiffLine {
9
10
  type: "header" | "hunk" | "added" | "removed" | "context" | "binary";
@@ -375,6 +376,135 @@ function CommentForm({
375
376
  );
376
377
  }
377
378
 
379
+ function AskAiPanel({
380
+ sessionId,
381
+ filePath,
382
+ startLine,
383
+ endLine,
384
+ codeSnippet,
385
+ onClose,
386
+ }: {
387
+ sessionId: string;
388
+ filePath: string;
389
+ startLine: number;
390
+ endLine: number;
391
+ codeSnippet: string;
392
+ onClose: () => void;
393
+ }) {
394
+ const [question, setQuestion] = useState("");
395
+ const [response, setResponse] = useState("");
396
+ const [loading, setLoading] = useState(false);
397
+ const [autoStarted, setAutoStarted] = useState(false);
398
+
399
+ const ask = useCallback(async (customQuestion?: string) => {
400
+ setLoading(true);
401
+ setResponse("");
402
+ const q = customQuestion ?? question.trim();
403
+ const prompt = q
404
+ ? `Regarding this code in ${filePath} (lines ${startLine}-${endLine}):\n\`\`\`\n${codeSnippet}\n\`\`\`\n\nQuestion: ${q}`
405
+ : `Analyze this code in ${filePath} (lines ${startLine}-${endLine}). Explain what it does, identify any issues (bugs, performance, security, style), and suggest improvements:\n\`\`\`\n${codeSnippet}\n\`\`\``;
406
+
407
+ try {
408
+ const res = await fetch(`/api/sessions/${sessionId}/ask-inline`, {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify({ message: prompt }),
412
+ });
413
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
414
+
415
+ const reader = res.body!.getReader();
416
+ const decoder = new TextDecoder();
417
+ let buffer = "";
418
+ let text = "";
419
+ let pendingEvent = "";
420
+
421
+ while (true) {
422
+ const { done, value } = await reader.read();
423
+ if (done) break;
424
+ buffer += decoder.decode(value, { stream: true });
425
+ const lines = buffer.split("\n");
426
+ buffer = lines.pop() ?? "";
427
+ for (const line of lines) {
428
+ const trimmed = line.trim();
429
+ if (!trimmed) { pendingEvent = ""; continue; }
430
+ if (trimmed.startsWith("event: ")) { pendingEvent = trimmed.slice(7); continue; }
431
+ if (!trimmed.startsWith("data: ")) continue;
432
+ try {
433
+ const data = JSON.parse(trimmed.slice(6));
434
+ if (pendingEvent === "text") {
435
+ text += data.content ?? "";
436
+ setResponse(text);
437
+ }
438
+ } catch {}
439
+ pendingEvent = "";
440
+ }
441
+ }
442
+ } catch (err) {
443
+ setResponse(`Error: ${err instanceof Error ? err.message : String(err)}`);
444
+ } finally {
445
+ setLoading(false);
446
+ }
447
+ }, [sessionId, filePath, startLine, endLine, codeSnippet, question]);
448
+
449
+ useEffect(() => {
450
+ if (!autoStarted) {
451
+ setAutoStarted(true);
452
+ ask();
453
+ }
454
+ }, [autoStarted, ask]);
455
+
456
+ return (
457
+ <div className="px-3 py-2.5 font-sans">
458
+ <div className="space-y-2.5">
459
+ <div className="flex items-center justify-between">
460
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
461
+ <Sparkles className="h-3 w-3" />
462
+ <span>AI Analysis</span>
463
+ <span className="text-muted-foreground/30">L{startLine}{endLine !== startLine ? `-L${endLine}` : ""}</span>
464
+ </div>
465
+ <button type="button" onClick={onClose} className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-muted-foreground/60 transition-colors">
466
+ <X className="h-3 w-3" />
467
+ </button>
468
+ </div>
469
+
470
+ {loading && !response && (
471
+ <div className="flex items-center gap-1.5 py-2">
472
+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />
473
+ <span className="text-[11px] text-muted-foreground/40">Analyzing...</span>
474
+ </div>
475
+ )}
476
+
477
+ {response && (
478
+ <div className="text-xs leading-relaxed">
479
+ <Markdown>{response}</Markdown>
480
+ </div>
481
+ )}
482
+
483
+ {!loading && (
484
+ <div className="flex items-center gap-1.5">
485
+ <input
486
+ type="text"
487
+ value={question}
488
+ onChange={(e) => setQuestion(e.target.value)}
489
+ onKeyDown={(e) => { if (e.key === "Enter" && question.trim()) ask(); }}
490
+ placeholder="Ask a follow-up..."
491
+ className="flex-1 h-7 rounded-md border bg-background px-2.5 text-[11px] placeholder:text-muted-foreground/30 focus:outline-none focus:border-foreground/20"
492
+ />
493
+ <button
494
+ type="button"
495
+ onClick={() => ask()}
496
+ disabled={loading}
497
+ className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-30 hover:opacity-80 transition-opacity"
498
+ >
499
+ Ask
500
+ </button>
501
+ </div>
502
+ )}
503
+ </div>
504
+ </div>
505
+ );
506
+ }
507
+
378
508
  function InlineComments({
379
509
  comments,
380
510
  currentUser,
@@ -383,6 +513,11 @@ function InlineComments({
383
513
  formTarget,
384
514
  onSubmit,
385
515
  onCancel,
516
+ sessionId,
517
+ filePath,
518
+ startLine,
519
+ endLine,
520
+ codeSnippet,
386
521
  }: {
387
522
  comments: DiffComment[];
388
523
  currentUser: { login: string; avatar_url: string } | null;
@@ -391,16 +526,48 @@ function InlineComments({
391
526
  formTarget: boolean;
392
527
  onSubmit: (body: string) => Promise<void>;
393
528
  onCancel: () => void;
529
+ sessionId?: string | null;
530
+ filePath: string;
531
+ startLine?: number;
532
+ endLine?: number;
533
+ codeSnippet?: string;
394
534
  }) {
535
+ const [showAi, setShowAi] = useState(false);
395
536
  const hasComments = comments.length > 0;
396
- if (!hasComments && !formTarget) return null;
537
+ if (!hasComments && !formTarget && !showAi) return null;
397
538
 
398
539
  return (
399
540
  <div className="border-y border-border/30 bg-card/80 font-sans divide-y divide-border/20">
400
541
  {comments.map((c) => (
401
542
  <CommentCard key={c.id} comment={c} currentLogin={currentUser?.login ?? null} onEdit={onEdit} onDelete={onDelete} />
402
543
  ))}
403
- {formTarget && <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />}
544
+ {formTarget && (
545
+ <div>
546
+ <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />
547
+ {sessionId && startLine && endLine && codeSnippet && !showAi && (
548
+ <div className="px-3 pb-2">
549
+ <button
550
+ type="button"
551
+ onClick={() => setShowAi(true)}
552
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
553
+ >
554
+ <Sparkles className="h-3 w-3" />
555
+ Ask AI about this code
556
+ </button>
557
+ </div>
558
+ )}
559
+ </div>
560
+ )}
561
+ {showAi && sessionId && startLine && endLine && codeSnippet && (
562
+ <AskAiPanel
563
+ sessionId={sessionId}
564
+ filePath={filePath}
565
+ startLine={startLine}
566
+ endLine={endLine}
567
+ codeSnippet={codeSnippet}
568
+ onClose={() => setShowAi(false)}
569
+ />
570
+ )}
404
571
  </div>
405
572
  );
406
573
  }
@@ -456,7 +623,19 @@ export function DiffViewer({
456
623
  if (!scrollTarget) scrollTarget = el;
457
624
  }
458
625
  }
459
- scrollTarget?.scrollIntoView({ behavior: "instant", block: "center" });
626
+ 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;
632
+ }
633
+ if (scrollParent) {
634
+ const parentRect = scrollParent.getBoundingClientRect();
635
+ const targetRect = scrollTarget.getBoundingClientRect();
636
+ scrollParent.scrollTop += targetRect.top - parentRect.top - parentRect.height / 2;
637
+ }
638
+ }
460
639
  }, 100);
461
640
  return () => clearTimeout(timer);
462
641
  }, [scrollToLine, scrollToLineEnd, patch]);
@@ -583,6 +762,18 @@ export function DiffViewer({
583
762
  return () => document.removeEventListener("mouseup", handleUp);
584
763
  }, []);
585
764
 
765
+ const codeSnippetForRange = useMemo(() => {
766
+ if (!formRange) return "";
767
+ return lines
768
+ .filter((l) => {
769
+ const lk = lineKey(l);
770
+ if (!lk || lk.side !== formRange.side) return false;
771
+ return lk.num >= formRange.startLine && lk.num <= formRange.endLine;
772
+ })
773
+ .map((l) => l.content)
774
+ .join("\n");
775
+ }, [formRange, lines]);
776
+
586
777
  const commentCount = comments.length;
587
778
 
588
779
  return (
@@ -679,6 +870,11 @@ export function DiffViewer({
679
870
  formTarget={!!isFormAnchor}
680
871
  onSubmit={handleAddComment}
681
872
  onCancel={() => setFormRange(null)}
873
+ sessionId={sessionId}
874
+ filePath={filePath}
875
+ startLine={formRange?.startLine}
876
+ endLine={formRange?.endLine}
877
+ codeSnippet={codeSnippetForRange}
682
878
  />
683
879
  </div>
684
880
  )}
@@ -82,10 +82,12 @@ export function InputScreen({
82
82
  onSubmit,
83
83
  sessions,
84
84
  onSessionSelect,
85
+ version,
85
86
  }: {
86
87
  onSubmit: (pr: string) => void;
87
88
  sessions?: SessionRecord[];
88
89
  onSessionSelect?: (id: string) => void;
90
+ version?: string;
89
91
  }) {
90
92
  const [value, setValue] = useState("");
91
93
  const [focused, setFocused] = useState(false);
@@ -112,6 +114,7 @@ export function InputScreen({
112
114
  <div className="space-y-2">
113
115
  <div className="flex items-baseline gap-2">
114
116
  <h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
117
+ {version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
115
118
  <span className="text-[10px] text-muted-foreground/40">AI code review</span>
116
119
  </div>
117
120
  <p className="text-xs text-muted-foreground">
@@ -4,7 +4,6 @@ import remarkGfm from "remark-gfm";
4
4
  import remarkMath from "remark-math";
5
5
  import rehypeRaw from "rehype-raw";
6
6
  import rehypeKatex from "rehype-katex";
7
- import "katex/dist/katex.min.css";
8
7
  import type { Components } from "react-markdown";
9
8
  import type { Highlighter } from "shiki";
10
9
  import { ensureHighlighter, getHighlighterSync, langFromClassName } from "../lib/shiki.ts";
@@ -96,8 +95,7 @@ function MediaEmbed({ src }: { src: string }) {
96
95
  }
97
96
 
98
97
  const ANCHOR_RE = /\[\[(group|file):([^\]]+)\]\]/g;
99
- const LINE_ANCHOR_WITH_TEXT_RE = /\[\[line:([^\]]+)\]\]\(([^)]+)\)/g;
100
- const LINE_ANCHOR_BARE_RE = /\[\[line:([^\]]+)\]\]/g;
98
+
101
99
  const BOLD_CJK_RE = /\*\*(.+?)\*\*/g;
102
100
 
103
101
  function hasCJK(text: string): boolean {
@@ -120,17 +118,65 @@ function inlineMarkdownToHtml(text: string): string {
120
118
  .replace(/`([^`]+)`/g, "<code>$1</code>");
121
119
  }
122
120
 
121
+ function replaceLineAnchors(text: string): string {
122
+ const OPEN = "[[line:";
123
+ let result = "";
124
+ let i = 0;
125
+ while (i < text.length) {
126
+ const start = text.indexOf(OPEN, i);
127
+ if (start === -1) {
128
+ result += text.slice(i);
129
+ break;
130
+ }
131
+ result += text.slice(i, start);
132
+
133
+ const idStart = start + OPEN.length;
134
+ const closeBracket = text.indexOf("]", idStart);
135
+ if (closeBracket === -1) {
136
+ result += text.slice(start);
137
+ break;
138
+ }
139
+
140
+ const id = text.slice(idStart, closeBracket);
141
+ let afterClose = closeBracket + 1;
142
+
143
+ if (text[afterClose] === "]") afterClose++;
144
+
145
+ let label: string | null = null;
146
+ if (text[afterClose] === "(") {
147
+ let depth = 1;
148
+ let end = afterClose + 1;
149
+ while (end < text.length && depth > 0) {
150
+ if (text[end] === "(") depth++;
151
+ else if (text[end] === ")") depth--;
152
+ end++;
153
+ }
154
+ if (depth === 0) {
155
+ label = text.slice(afterClose + 1, end - 1);
156
+ afterClose = end;
157
+ }
158
+ }
159
+
160
+ if (text[afterClose] === "]") afterClose++;
161
+
162
+ const encoded = encodeURIComponent(id);
163
+ if (label) {
164
+ result += `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
165
+ } else {
166
+ result += `<span data-line-ref="${encoded}">${formatLineLabel(id)}</span>`;
167
+ }
168
+ i = afterClose;
169
+ }
170
+ return result;
171
+ }
172
+
123
173
  function preprocess(text: string): string {
124
- return text
125
- .replace(LINE_ANCHOR_WITH_TEXT_RE, (_, id, label) => {
126
- const encoded = encodeURIComponent(id);
127
- return `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
128
- })
129
- .replace(LINE_ANCHOR_BARE_RE, (_, id) => {
130
- const encoded = encodeURIComponent(id);
131
- const label = formatLineLabel(id);
132
- return `<span data-line-ref="${encoded}">${label}</span>`;
133
- })
174
+ const mathBlocks: string[] = [];
175
+ const preserved = text
176
+ .replace(/\$\$[\s\S]+?\$\$/g, (m) => { mathBlocks.push(m); return `\x00MATH_BLOCK_${mathBlocks.length - 1}\x00`; })
177
+ .replace(/\$(?!\$)(.+?)\$/g, (m) => { mathBlocks.push(m); return `\x00MATH_BLOCK_${mathBlocks.length - 1}\x00`; });
178
+
179
+ const processed = replaceLineAnchors(preserved)
134
180
  .replace(ANCHOR_RE, (_, kind, id) => {
135
181
  const encoded = encodeURIComponent(id);
136
182
  return `![${kind}:${encoded}](newpr)`;
@@ -139,6 +185,8 @@ function preprocess(text: string): string {
139
185
  if (hasCJK(inner)) return `<strong>${inner}</strong>`;
140
186
  return match;
141
187
  });
188
+
189
+ return processed.replace(/\x00MATH_BLOCK_(\d+)\x00/g, (_, idx) => mathBlocks[Number(idx)]!);
142
190
  }
143
191
 
144
192
  export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
@@ -178,7 +226,8 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
178
226
  <pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3 whitespace-pre text-xs font-mono [&>span>pre]:!bg-transparent [&>span>pre]:!p-0 [&>span>pre]:!m-0">{children}</pre>
179
227
  ),
180
228
  span: ({ children, ...props }) => {
181
- const lineRef = (props as Record<string, unknown>)["data-line-ref"] as string | undefined;
229
+ const allProps = props as Record<string, unknown>;
230
+ const lineRef = allProps["data-line-ref"] as string | undefined;
182
231
  if (lineRef && onAnchorClick) {
183
232
  const id = decodeURIComponent(lineRef);
184
233
  const isActive = activeId === `line:${id}`;
@@ -201,7 +250,8 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
201
250
  if (lineRef) {
202
251
  return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
203
252
  }
204
- return <span>{children}</span>;
253
+ const { node, ...rest } = allProps as Record<string, unknown> & { node?: unknown };
254
+ return <span {...rest as React.HTMLAttributes<HTMLSpanElement>}>{children}</span>;
205
255
  },
206
256
  a: ({ href, children }) => {
207
257
  if (href && isMediaUrl(href)) {
@@ -284,5 +334,5 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
284
334
  td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
285
335
  };
286
336
 
287
- return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }], rehypeRaw]} components={components}>{processed}</ReactMarkdown>;
337
+ return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeRaw, { passThrough: ["math", "inlineMath"] }], [rehypeKatex, { throwOnError: false, strict: false, output: "html" }]]} components={components}>{processed}</ReactMarkdown>;
288
338
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect, useRef } from "react";
2
- import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown } from "lucide-react";
2
+ import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation } from "lucide-react";
3
3
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
4
4
  import type { NewprOutput } from "../../../types/output.ts";
5
5
  import { GroupsPanel } from "../panels/GroupsPanel.tsx";
@@ -7,9 +7,11 @@ import { FilesPanel } from "../panels/FilesPanel.tsx";
7
7
  import { StoryPanel } from "../panels/StoryPanel.tsx";
8
8
  import { DiscussionPanel } from "../panels/DiscussionPanel.tsx";
9
9
  import { CartoonPanel } from "../panels/CartoonPanel.tsx";
10
+ import { SlidesPanel } from "../panels/SlidesPanel.tsx";
10
11
  import { ReviewModal } from "./ReviewModal.tsx";
12
+ import { useOutdatedCheck } from "../hooks/useOutdatedCheck.ts";
11
13
 
12
- const VALID_TABS = ["story", "discussion", "groups", "files", "cartoon"] as const;
14
+ const VALID_TABS = ["story", "discussion", "groups", "files", "slides", "cartoon"] as const;
13
15
  type TabValue = typeof VALID_TABS[number];
14
16
 
15
17
  function getInitialTab(): TabValue {
@@ -46,6 +48,7 @@ export function ResultsScreen({
46
48
  cartoonEnabled,
47
49
  sessionId,
48
50
  onTabChange,
51
+ onReanalyze,
49
52
  }: {
50
53
  data: NewprOutput;
51
54
  onBack: () => void;
@@ -54,10 +57,12 @@ export function ResultsScreen({
54
57
  cartoonEnabled?: boolean;
55
58
  sessionId?: string | null;
56
59
  onTabChange?: (tab: string) => void;
60
+ onReanalyze?: (prUrl: string) => void;
57
61
  }) {
58
62
  const { meta, summary } = data;
59
63
  const [tab, setTab] = useState<TabValue>(getInitialTab);
60
64
  const [reviewOpen, setReviewOpen] = useState(false);
65
+ const outdated = useOutdatedCheck(sessionId);
61
66
 
62
67
  const stickyRef = useRef<HTMLDivElement>(null);
63
68
  const collapsibleRef = useRef<HTMLDivElement>(null);
@@ -173,6 +178,24 @@ export function ResultsScreen({
173
178
  </div>
174
179
  </div>
175
180
  </div>
181
+ {outdated?.outdated && (
182
+ <div className="flex items-center gap-2 mt-3 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
183
+ <AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
184
+ <span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
185
+ This PR has been updated since this analysis was created.
186
+ </span>
187
+ {onReanalyze && (
188
+ <button
189
+ type="button"
190
+ onClick={() => onReanalyze(meta.pr_url)}
191
+ className="flex items-center gap-1 text-[11px] font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 shrink-0 transition-colors"
192
+ >
193
+ <RefreshCw className="h-3 w-3" />
194
+ Re-analyze
195
+ </button>
196
+ )}
197
+ </div>
198
+ )}
176
199
  </div>
177
200
 
178
201
  <div ref={compactRef} className="overflow-hidden transition-[max-height,opacity] duration-200" style={{ maxHeight: 0, opacity: 0 }}>
@@ -211,6 +234,10 @@ export function ResultsScreen({
211
234
  <FolderTree className="h-3 w-3 shrink-0" />
212
235
  Files
213
236
  </TabsTrigger>
237
+ <TabsTrigger value="slides">
238
+ <Presentation className="h-3 w-3 shrink-0" />
239
+ Slides
240
+ </TabsTrigger>
214
241
  {cartoonEnabled && (
215
242
  <TabsTrigger value="cartoon">
216
243
  <Sparkles className="h-3 w-3 shrink-0" />
@@ -237,6 +264,9 @@ export function ResultsScreen({
237
264
  onFileSelect={(path: string) => onAnchorClick("file", path)}
238
265
  />
239
266
  </TabsContent>
267
+ <TabsContent value="slides">
268
+ <SlidesPanel data={data} sessionId={sessionId} />
269
+ </TabsContent>
240
270
  {cartoonEnabled && (
241
271
  <TabsContent value="cartoon">
242
272
  <CartoonPanel data={data} sessionId={sessionId} />
@@ -20,7 +20,7 @@ interface ConfigData {
20
20
  }
21
21
 
22
22
  const MODELS = [
23
- "anthropic/claude-sonnet-4.5",
23
+ "anthropic/claude-sonnet-4.6",
24
24
  "anthropic/claude-sonnet-4-20250514",
25
25
  "openai/gpt-4.1",
26
26
  "openai/o3",
@@ -0,0 +1,152 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
+ import type { NewprOutput } from "../../../types/output.ts";
4
+ import { sendNotification } from "../lib/notify.ts";
5
+
6
+ export type BgStatus = "running" | "done" | "error";
7
+
8
+ export interface BackgroundAnalysis {
9
+ sessionId: string;
10
+ prInput: string;
11
+ prTitle?: string;
12
+ prNumber?: number;
13
+ status: BgStatus;
14
+ startedAt: number;
15
+ lastStage?: string;
16
+ lastMessage?: string;
17
+ result?: NewprOutput;
18
+ historyId?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export function useBackgroundAnalyses() {
23
+ const [analyses, setAnalyses] = useState<BackgroundAnalysis[]>([]);
24
+ const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
25
+ const restoredRef = useRef(false);
26
+
27
+ useEffect(() => {
28
+ if (restoredRef.current) return;
29
+ restoredRef.current = true;
30
+ fetch("/api/active-analyses")
31
+ .then((r) => r.json())
32
+ .then((data) => {
33
+ const active = data as Array<{
34
+ id: string;
35
+ prInput: string;
36
+ status: string;
37
+ startedAt: number;
38
+ prTitle?: string;
39
+ prNumber?: number;
40
+ lastStage?: string;
41
+ lastMessage?: string;
42
+ }>;
43
+ for (const a of active) {
44
+ if (!eventSourcesRef.current.has(a.id)) {
45
+ trackInternal(a.id, a.prInput, a.prTitle, a.prNumber, a.lastMessage);
46
+ }
47
+ }
48
+ })
49
+ .catch(() => {});
50
+ }, []);
51
+
52
+ const trackInternal = useCallback((sessionId: string, prInput: string, initTitle?: string, initNumber?: number, initMessage?: string) => {
53
+ if (eventSourcesRef.current.has(sessionId)) return;
54
+
55
+ const entry: BackgroundAnalysis = {
56
+ sessionId,
57
+ prInput,
58
+ status: "running",
59
+ startedAt: Date.now(),
60
+ prTitle: initTitle,
61
+ prNumber: initNumber,
62
+ lastMessage: initMessage,
63
+ };
64
+
65
+ setAnalyses((prev) => [...prev.filter((a) => a.sessionId !== sessionId), entry]);
66
+
67
+ const es = new EventSource(`/api/analysis/${sessionId}/events`);
68
+ eventSourcesRef.current.set(sessionId, es);
69
+
70
+ es.addEventListener("progress", (e) => {
71
+ const event = JSON.parse(e.data) as ProgressEvent;
72
+ setAnalyses((prev) =>
73
+ prev.map((a) =>
74
+ a.sessionId === sessionId
75
+ ? {
76
+ ...a,
77
+ lastStage: event.stage,
78
+ lastMessage: event.message,
79
+ prTitle: event.pr_title ?? a.prTitle,
80
+ prNumber: event.pr_number ?? a.prNumber,
81
+ }
82
+ : a,
83
+ ),
84
+ );
85
+ });
86
+
87
+ es.addEventListener("done", async () => {
88
+ es.close();
89
+ eventSourcesRef.current.delete(sessionId);
90
+ try {
91
+ const res = await fetch(`/api/analysis/${sessionId}`);
92
+ const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
93
+ setAnalyses((prev) => {
94
+ const a = prev.find((x) => x.sessionId === sessionId);
95
+ sendNotification("Analysis complete", a?.prTitle ?? prInput);
96
+ return prev.map((x) =>
97
+ x.sessionId === sessionId
98
+ ? { ...x, status: "done" as const, result: data.result, historyId: data.historyId }
99
+ : x,
100
+ );
101
+ });
102
+ } catch {
103
+ setAnalyses((prev) => {
104
+ sendNotification("Analysis complete", prInput);
105
+ return prev.map((a) =>
106
+ a.sessionId === sessionId ? { ...a, status: "done" as const } : a,
107
+ );
108
+ });
109
+ }
110
+ });
111
+
112
+ es.addEventListener("analysis_error", (e) => {
113
+ es.close();
114
+ eventSourcesRef.current.delete(sessionId);
115
+ let msg = "Analysis failed";
116
+ try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
117
+ sendNotification("Analysis failed", msg);
118
+ setAnalyses((prev) =>
119
+ prev.map((a) =>
120
+ a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
121
+ ),
122
+ );
123
+ });
124
+
125
+ es.onerror = () => {
126
+ if (es.readyState === EventSource.CLOSED) {
127
+ eventSourcesRef.current.delete(sessionId);
128
+ }
129
+ };
130
+ }, []);
131
+
132
+ const dismiss = useCallback((sessionId: string) => {
133
+ const es = eventSourcesRef.current.get(sessionId);
134
+ if (es) {
135
+ es.close();
136
+ eventSourcesRef.current.delete(sessionId);
137
+ }
138
+ setAnalyses((prev) => prev.filter((a) => a.sessionId !== sessionId));
139
+ }, []);
140
+
141
+ useEffect(() => {
142
+ return () => {
143
+ for (const es of eventSourcesRef.current.values()) es.close();
144
+ };
145
+ }, []);
146
+
147
+ const track = useCallback((sessionId: string, prInput: string) => {
148
+ trackInternal(sessionId, prInput);
149
+ }, [trackInternal]);
150
+
151
+ return { analyses, track, dismiss };
152
+ }