newpr 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { 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";
@@ -17,7 +18,8 @@ const RENDER_CAP = 2000;
17
18
  const TOTAL_CAP = 3000;
18
19
 
19
20
  function parseLines(patch: string): DiffLine[] {
20
- const raw = patch.split("\n");
21
+ let raw = patch.split("\n");
22
+ while (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
21
23
  const lines: DiffLine[] = [];
22
24
  let oldNum = 0;
23
25
  let newNum = 0;
@@ -60,6 +62,8 @@ function parseLines(patch: string): DiffLine[] {
60
62
  oldNum++;
61
63
  } else if (line.startsWith("\\")) {
62
64
  lines.push({ type: "context", content: line, oldNum: null, newNum: null });
65
+ } else if (line === "" && (oldNum === 0 && newNum === 0)) {
66
+ continue;
63
67
  } else {
64
68
  const text = line.startsWith(" ") ? line.slice(1) : line;
65
69
  if (oldNum > 0 || newNum > 0) {
@@ -106,27 +110,48 @@ function useTokenizedLines(
106
110
  return useMemo(() => {
107
111
  if (!hl || !lang) return null;
108
112
 
109
- const codeIndices: number[] = [];
110
- const codeLines: string[] = [];
113
+ const newIndices: number[] = [];
114
+ const newLines: string[] = [];
115
+ const oldIndices: number[] = [];
116
+ const oldLines: string[] = [];
117
+
111
118
  for (let i = 0; i < lines.length; i++) {
112
119
  const t = lines[i]!.type;
113
- if (t === "added" || t === "removed" || t === "context") {
114
- codeIndices.push(i);
115
- 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);
116
131
  }
117
132
  }
118
133
 
119
- if (codeLines.length === 0) return null;
134
+ const map: TokenMap = new Map();
135
+ const theme = dark ? "github-dark" : "github-light";
120
136
 
121
137
  try {
122
- const theme = dark ? "github-dark" : "github-light";
123
- const result = hl.codeToTokens(codeLines.join("\n"), { lang, theme });
124
- const map: TokenMap = new Map();
125
- for (let j = 0; j < codeIndices.length; j++) {
126
- const tokens = result.tokens[j];
127
- 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
+ }
144
+ }
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
+ }
128
153
  }
129
- return map;
154
+ return map.size > 0 ? map : null;
130
155
  } catch {
131
156
  return null;
132
157
  }
@@ -375,6 +400,135 @@ function CommentForm({
375
400
  );
376
401
  }
377
402
 
403
+ function AskAiPanel({
404
+ sessionId,
405
+ filePath,
406
+ startLine,
407
+ endLine,
408
+ codeSnippet,
409
+ onClose,
410
+ }: {
411
+ sessionId: string;
412
+ filePath: string;
413
+ startLine: number;
414
+ endLine: number;
415
+ codeSnippet: string;
416
+ onClose: () => void;
417
+ }) {
418
+ const [question, setQuestion] = useState("");
419
+ const [response, setResponse] = useState("");
420
+ const [loading, setLoading] = useState(false);
421
+ const [autoStarted, setAutoStarted] = useState(false);
422
+
423
+ const ask = useCallback(async (customQuestion?: string) => {
424
+ setLoading(true);
425
+ setResponse("");
426
+ const q = customQuestion ?? question.trim();
427
+ const prompt = q
428
+ ? `Regarding this code in ${filePath} (lines ${startLine}-${endLine}):\n\`\`\`\n${codeSnippet}\n\`\`\`\n\nQuestion: ${q}`
429
+ : `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\`\`\``;
430
+
431
+ try {
432
+ const res = await fetch(`/api/sessions/${sessionId}/ask-inline`, {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json" },
435
+ body: JSON.stringify({ message: prompt }),
436
+ });
437
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
438
+
439
+ const reader = res.body!.getReader();
440
+ const decoder = new TextDecoder();
441
+ let buffer = "";
442
+ let text = "";
443
+ let pendingEvent = "";
444
+
445
+ while (true) {
446
+ const { done, value } = await reader.read();
447
+ if (done) break;
448
+ buffer += decoder.decode(value, { stream: true });
449
+ const lines = buffer.split("\n");
450
+ buffer = lines.pop() ?? "";
451
+ for (const line of lines) {
452
+ const trimmed = line.trim();
453
+ if (!trimmed) { pendingEvent = ""; continue; }
454
+ if (trimmed.startsWith("event: ")) { pendingEvent = trimmed.slice(7); continue; }
455
+ if (!trimmed.startsWith("data: ")) continue;
456
+ try {
457
+ const data = JSON.parse(trimmed.slice(6));
458
+ if (pendingEvent === "text") {
459
+ text += data.content ?? "";
460
+ setResponse(text);
461
+ }
462
+ } catch {}
463
+ pendingEvent = "";
464
+ }
465
+ }
466
+ } catch (err) {
467
+ setResponse(`Error: ${err instanceof Error ? err.message : String(err)}`);
468
+ } finally {
469
+ setLoading(false);
470
+ }
471
+ }, [sessionId, filePath, startLine, endLine, codeSnippet, question]);
472
+
473
+ useEffect(() => {
474
+ if (!autoStarted) {
475
+ setAutoStarted(true);
476
+ ask();
477
+ }
478
+ }, [autoStarted, ask]);
479
+
480
+ return (
481
+ <div className="px-3 py-2.5 font-sans">
482
+ <div className="space-y-2.5">
483
+ <div className="flex items-center justify-between">
484
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
485
+ <Sparkles className="h-3 w-3" />
486
+ <span>AI Analysis</span>
487
+ <span className="text-muted-foreground/30">L{startLine}{endLine !== startLine ? `-L${endLine}` : ""}</span>
488
+ </div>
489
+ <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">
490
+ <X className="h-3 w-3" />
491
+ </button>
492
+ </div>
493
+
494
+ {loading && !response && (
495
+ <div className="flex items-center gap-1.5 py-2">
496
+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />
497
+ <span className="text-[11px] text-muted-foreground/40">Analyzing...</span>
498
+ </div>
499
+ )}
500
+
501
+ {response && (
502
+ <div className="text-xs leading-relaxed">
503
+ <Markdown>{response}</Markdown>
504
+ </div>
505
+ )}
506
+
507
+ {!loading && (
508
+ <div className="flex items-center gap-1.5">
509
+ <input
510
+ type="text"
511
+ value={question}
512
+ onChange={(e) => setQuestion(e.target.value)}
513
+ onKeyDown={(e) => { if (e.key === "Enter" && question.trim()) ask(); }}
514
+ placeholder="Ask a follow-up..."
515
+ 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"
516
+ />
517
+ <button
518
+ type="button"
519
+ onClick={() => ask()}
520
+ disabled={loading}
521
+ className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-30 hover:opacity-80 transition-opacity"
522
+ >
523
+ Ask
524
+ </button>
525
+ </div>
526
+ )}
527
+ </div>
528
+ </div>
529
+ );
530
+ }
531
+
378
532
  function InlineComments({
379
533
  comments,
380
534
  currentUser,
@@ -383,6 +537,11 @@ function InlineComments({
383
537
  formTarget,
384
538
  onSubmit,
385
539
  onCancel,
540
+ sessionId,
541
+ filePath,
542
+ startLine,
543
+ endLine,
544
+ codeSnippet,
386
545
  }: {
387
546
  comments: DiffComment[];
388
547
  currentUser: { login: string; avatar_url: string } | null;
@@ -391,16 +550,48 @@ function InlineComments({
391
550
  formTarget: boolean;
392
551
  onSubmit: (body: string) => Promise<void>;
393
552
  onCancel: () => void;
553
+ sessionId?: string | null;
554
+ filePath: string;
555
+ startLine?: number;
556
+ endLine?: number;
557
+ codeSnippet?: string;
394
558
  }) {
559
+ const [showAi, setShowAi] = useState(false);
395
560
  const hasComments = comments.length > 0;
396
- if (!hasComments && !formTarget) return null;
561
+ if (!hasComments && !formTarget && !showAi) return null;
397
562
 
398
563
  return (
399
564
  <div className="border-y border-border/30 bg-card/80 font-sans divide-y divide-border/20">
400
565
  {comments.map((c) => (
401
566
  <CommentCard key={c.id} comment={c} currentLogin={currentUser?.login ?? null} onEdit={onEdit} onDelete={onDelete} />
402
567
  ))}
403
- {formTarget && <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />}
568
+ {formTarget && (
569
+ <div>
570
+ <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />
571
+ {sessionId && startLine && endLine && codeSnippet && !showAi && (
572
+ <div className="px-3 pb-2">
573
+ <button
574
+ type="button"
575
+ onClick={() => setShowAi(true)}
576
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
577
+ >
578
+ <Sparkles className="h-3 w-3" />
579
+ Ask AI about this code
580
+ </button>
581
+ </div>
582
+ )}
583
+ </div>
584
+ )}
585
+ {showAi && sessionId && startLine && endLine && codeSnippet && (
586
+ <AskAiPanel
587
+ sessionId={sessionId}
588
+ filePath={filePath}
589
+ startLine={startLine}
590
+ endLine={endLine}
591
+ codeSnippet={codeSnippet}
592
+ onClose={() => setShowAi(false)}
593
+ />
594
+ )}
404
595
  </div>
405
596
  );
406
597
  }
@@ -412,6 +603,7 @@ export function DiffViewer({
412
603
  githubUrl,
413
604
  scrollToLine,
414
605
  scrollToLineEnd,
606
+ scrollContainerRef,
415
607
  }: {
416
608
  patch: string;
417
609
  filePath: string;
@@ -419,6 +611,7 @@ export function DiffViewer({
419
611
  githubUrl?: string;
420
612
  scrollToLine?: number;
421
613
  scrollToLineEnd?: number;
614
+ scrollContainerRef?: React.RefObject<HTMLElement | null>;
422
615
  }) {
423
616
  const [showAll, setShowAll] = useState(false);
424
617
  const hl = useHighlighter();
@@ -428,15 +621,18 @@ export function DiffViewer({
428
621
  const tokenMap = useTokenizedLines(hl, allLines, lang, dark);
429
622
  const isCapped = !showAll && allLines.length > TOTAL_CAP;
430
623
  const lines = isCapped ? allLines.slice(0, RENDER_CAP) : allLines;
431
- const fileName = filePath.split("/").pop() ?? filePath;
432
624
 
433
625
  const scrollRef = useRef<HTMLDivElement>(null);
434
626
  const containerRef = useRef<HTMLDivElement>(null);
435
627
  const [visibleWidth, setVisibleWidth] = useState(0);
436
628
 
437
629
  const highlightedRef = useRef<HTMLElement[]>([]);
630
+ const scrollKeyRef = useRef(0);
438
631
 
439
632
  useEffect(() => {
633
+ scrollKeyRef.current++;
634
+ const currentKey = scrollKeyRef.current;
635
+
440
636
  for (const el of highlightedRef.current) {
441
637
  el.style.boxShadow = "";
442
638
  }
@@ -445,6 +641,7 @@ export function DiffViewer({
445
641
  if (!scrollToLine || !containerRef.current) return;
446
642
  const endLine = scrollToLineEnd ?? scrollToLine;
447
643
  const timer = setTimeout(() => {
644
+ if (scrollKeyRef.current !== currentKey) return;
448
645
  const container = containerRef.current;
449
646
  if (!container) return;
450
647
  let scrollTarget: HTMLElement | null = null;
@@ -457,11 +654,14 @@ export function DiffViewer({
457
654
  }
458
655
  }
459
656
  if (scrollTarget) {
460
- let scrollParent = scrollTarget.parentElement;
461
- while (scrollParent) {
462
- const style = getComputedStyle(scrollParent);
463
- if (style.overflowY === "auto" || style.overflowY === "scroll") break;
464
- 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
+ }
465
665
  }
466
666
  if (scrollParent) {
467
667
  const parentRect = scrollParent.getBoundingClientRect();
@@ -469,7 +669,7 @@ export function DiffViewer({
469
669
  scrollParent.scrollTop += targetRect.top - parentRect.top - parentRect.height / 2;
470
670
  }
471
671
  }
472
- }, 100);
672
+ }, 50);
473
673
  return () => clearTimeout(timer);
474
674
  }, [scrollToLine, scrollToLineEnd, patch]);
475
675
  const [comments, setComments] = useState<DiffComment[]>([]);
@@ -595,21 +795,20 @@ export function DiffViewer({
595
795
  return () => document.removeEventListener("mouseup", handleUp);
596
796
  }, []);
597
797
 
598
- const commentCount = comments.length;
798
+ const codeSnippetForRange = useMemo(() => {
799
+ if (!formRange) return "";
800
+ return lines
801
+ .filter((l) => {
802
+ const lk = lineKey(l);
803
+ if (!lk || lk.side !== formRange.side) return false;
804
+ return lk.num >= formRange.startLine && lk.num <= formRange.endLine;
805
+ })
806
+ .map((l) => l.content)
807
+ .join("\n");
808
+ }, [formRange, lines]);
599
809
 
600
810
  return (
601
- <div ref={containerRef} className="rounded-lg border overflow-hidden">
602
- <div className="sticky top-0 z-10 bg-muted px-3 py-1.5 border-b flex items-center gap-2">
603
- <span className="text-xs font-mono font-medium truncate flex-1" title={filePath}>
604
- {fileName}
605
- </span>
606
- {commentCount > 0 && (
607
- <span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
608
- <MessageSquare className="h-3 w-3" />
609
- {commentCount}
610
- </span>
611
- )}
612
- </div>
811
+ <div ref={containerRef} className="overflow-hidden">
613
812
  <div ref={scrollRef} className="overflow-x-auto">
614
813
  <div className="min-w-max font-mono text-xs leading-5 select-text">
615
814
  {lines.map((line, i) => {
@@ -691,6 +890,11 @@ export function DiffViewer({
691
890
  formTarget={!!isFormAnchor}
692
891
  onSubmit={handleAddComment}
693
892
  onCancel={() => setFormRange(null)}
893
+ sessionId={sessionId}
894
+ filePath={filePath}
895
+ startLine={formRange?.startLine}
896
+ endLine={formRange?.endLine}
897
+ codeSnippet={codeSnippetForRange}
694
898
  />
695
899
  </div>
696
900
  )}
@@ -240,7 +240,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
240
240
  className={`underline decoration-1 underline-offset-[3px] cursor-pointer transition-colors ${
241
241
  isActive
242
242
  ? "decoration-blue-500 dark:decoration-blue-400 bg-blue-500/5 rounded-sm"
243
- : "decoration-foreground/15 hover:decoration-foreground/40"
243
+ : "decoration-foreground/30 hover:decoration-foreground/60"
244
244
  }`}
245
245
  >
246
246
  {children}
@@ -248,7 +248,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
248
248
  );
249
249
  }
250
250
  if (lineRef) {
251
- return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
251
+ return <span className="underline decoration-foreground/25 decoration-1 underline-offset-[3px]">{children}</span>;
252
252
  }
253
253
  const { node, ...rest } = allProps as Record<string, unknown> & { node?: unknown };
254
254
  return <span {...rest as React.HTMLAttributes<HTMLSpanElement>}>{children}</span>;
@@ -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,8 @@ export function ResultsScreen({
46
48
  cartoonEnabled,
47
49
  sessionId,
48
50
  onTabChange,
51
+ onReanalyze,
52
+ enabledPlugins,
49
53
  }: {
50
54
  data: NewprOutput;
51
55
  onBack: () => void;
@@ -54,10 +58,13 @@ export function ResultsScreen({
54
58
  cartoonEnabled?: boolean;
55
59
  sessionId?: string | null;
56
60
  onTabChange?: (tab: string) => void;
61
+ onReanalyze?: (prUrl: string) => void;
62
+ enabledPlugins?: string[];
57
63
  }) {
58
64
  const { meta, summary } = data;
59
65
  const [tab, setTab] = useState<TabValue>(getInitialTab);
60
66
  const [reviewOpen, setReviewOpen] = useState(false);
67
+ const outdated = useOutdatedCheck(sessionId);
61
68
 
62
69
  const stickyRef = useRef<HTMLDivElement>(null);
63
70
  const collapsibleRef = useRef<HTMLDivElement>(null);
@@ -173,6 +180,24 @@ export function ResultsScreen({
173
180
  </div>
174
181
  </div>
175
182
  </div>
183
+ {outdated?.outdated && (
184
+ <div className="flex items-center gap-2 mt-3 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
185
+ <AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
186
+ <span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
187
+ This PR has been updated since this analysis was created.
188
+ </span>
189
+ {onReanalyze && (
190
+ <button
191
+ type="button"
192
+ onClick={() => onReanalyze(meta.pr_url)}
193
+ 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"
194
+ >
195
+ <RefreshCw className="h-3 w-3" />
196
+ Re-analyze
197
+ </button>
198
+ )}
199
+ </div>
200
+ )}
176
201
  </div>
177
202
 
178
203
  <div ref={compactRef} className="overflow-hidden transition-[max-height,opacity] duration-200" style={{ maxHeight: 0, opacity: 0 }}>
@@ -211,7 +236,13 @@ export function ResultsScreen({
211
236
  <FolderTree className="h-3 w-3 shrink-0" />
212
237
  Files
213
238
  </TabsTrigger>
214
- {cartoonEnabled && (
239
+ {(!enabledPlugins || enabledPlugins.includes("slides")) && (
240
+ <TabsTrigger value="slides">
241
+ <Presentation className="h-3 w-3 shrink-0" />
242
+ Slides
243
+ </TabsTrigger>
244
+ )}
245
+ {(!enabledPlugins || enabledPlugins.includes("cartoon")) && (
215
246
  <TabsTrigger value="cartoon">
216
247
  <Sparkles className="h-3 w-3 shrink-0" />
217
248
  Comic
@@ -237,6 +268,9 @@ export function ResultsScreen({
237
268
  onFileSelect={(path: string) => onAnchorClick("file", path)}
238
269
  />
239
270
  </TabsContent>
271
+ <TabsContent value="slides">
272
+ <SlidesPanel data={data} sessionId={sessionId} />
273
+ </TabsContent>
240
274
  {cartoonEnabled && (
241
275
  <TabsContent value="cartoon">
242
276
  <CartoonPanel data={data} sessionId={sessionId} />