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.
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +42 -1
- package/src/config/store.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +37 -17
- package/src/llm/slides.ts +381 -0
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +7 -1
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +241 -37
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +37 -3
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +407 -5
- package/src/web/server.ts +30 -0
- package/src/web/styles/built.css +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
110
|
-
const
|
|
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 === "
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
134
|
+
const map: TokenMap = new Map();
|
|
135
|
+
const theme = dark ? "github-dark" : "github-light";
|
|
120
136
|
|
|
121
137
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 &&
|
|
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 =
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
},
|
|
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
|
|
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="
|
|
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/
|
|
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/
|
|
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
|
-
{
|
|
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} />
|