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.
- package/README.md +135 -103
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +1 -4
- package/src/cli/args.ts +1 -1
- package/src/cli/index.ts +2 -1
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +82 -27
- package/src/llm/slides.ts +381 -0
- package/src/types/config.ts +1 -1
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/version.ts +23 -0
- package/src/web/client/App.tsx +51 -1
- package/src/web/client/components/AppShell.tsx +173 -45
- package/src/web/client/components/ChatSection.tsx +76 -185
- package/src/web/client/components/DetailPane.tsx +1 -0
- package/src/web/client/components/DiffViewer.tsx +200 -4
- package/src/web/client/components/InputScreen.tsx +3 -0
- package/src/web/client/components/Markdown.tsx +66 -16
- package/src/web/client/components/ResultsScreen.tsx +32 -2
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
- package/src/web/client/hooks/useChatStore.ts +247 -0
- package/src/web/client/hooks/useFeatures.ts +2 -1
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/index.html +1 -0
- package/src/web/server/routes.ts +226 -4
- package/src/web/server/session-manager.ts +34 -0
- package/src/web/server.ts +20 -1
- package/src/web/styles/built.css +1 -1
- package/src/workspace/explore.ts +39 -6
- 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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 ``;
|
|
@@ -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
|
|
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
|
-
|
|
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 }]
|
|
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} />
|
|
@@ -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
|
+
}
|