newpr 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +44 -0
- package/src/web/client/App.tsx +29 -3
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +66 -71
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +716 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +33 -0
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
|
@@ -1,9 +1,37 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { CornerDownLeft, Clock, GitPullRequest } from "lucide-react";
|
|
3
|
+
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const RISK_DOT: Record<string, string> = {
|
|
6
|
+
low: "bg-green-500",
|
|
7
|
+
medium: "bg-yellow-500",
|
|
8
|
+
high: "bg-red-500",
|
|
9
|
+
critical: "bg-red-600",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function timeAgo(date: string): string {
|
|
13
|
+
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
|
14
|
+
if (s < 60) return "just now";
|
|
15
|
+
const m = Math.floor(s / 60);
|
|
16
|
+
if (m < 60) return `${m}m ago`;
|
|
17
|
+
const h = Math.floor(m / 60);
|
|
18
|
+
if (h < 24) return `${h}h ago`;
|
|
19
|
+
const d = Math.floor(h / 24);
|
|
20
|
+
if (d < 30) return `${d}d ago`;
|
|
21
|
+
return `${Math.floor(d / 30)}mo ago`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function InputScreen({
|
|
25
|
+
onSubmit,
|
|
26
|
+
sessions,
|
|
27
|
+
onSessionSelect,
|
|
28
|
+
}: {
|
|
29
|
+
onSubmit: (pr: string) => void;
|
|
30
|
+
sessions?: SessionRecord[];
|
|
31
|
+
onSessionSelect?: (id: string) => void;
|
|
32
|
+
}) {
|
|
6
33
|
const [value, setValue] = useState("");
|
|
34
|
+
const [focused, setFocused] = useState(false);
|
|
7
35
|
|
|
8
36
|
function handleSubmit(e: React.FormEvent) {
|
|
9
37
|
e.preventDefault();
|
|
@@ -11,31 +39,87 @@ export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
|
|
|
11
39
|
if (trimmed) onSubmit(trimmed);
|
|
12
40
|
}
|
|
13
41
|
|
|
14
|
-
|
|
15
|
-
<div className="flex flex-col items-center gap-12 py-24">
|
|
16
|
-
<div className="flex flex-col items-center gap-3">
|
|
17
|
-
<h1 className="text-4xl font-bold tracking-tight">newpr</h1>
|
|
18
|
-
<p className="text-muted-foreground text-center max-w-md">
|
|
19
|
-
AI-powered PR review tool. Paste a PR URL to get a comprehensive analysis.
|
|
20
|
-
</p>
|
|
21
|
-
</div>
|
|
42
|
+
const recents = sessions?.slice(0, 5) ?? [];
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Analyze
|
|
35
|
-
<ArrowRight className="ml-2 h-4 w-4" />
|
|
36
|
-
</Button>
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
|
46
|
+
<div className="w-full max-w-lg space-y-8">
|
|
47
|
+
<div className="space-y-2">
|
|
48
|
+
<div className="flex items-baseline gap-2">
|
|
49
|
+
<h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
|
|
50
|
+
<span className="text-[10px] text-muted-foreground/40">AI code review</span>
|
|
51
|
+
</div>
|
|
52
|
+
<p className="text-xs text-muted-foreground">
|
|
53
|
+
Paste a GitHub PR URL to start analysis
|
|
54
|
+
</p>
|
|
37
55
|
</div>
|
|
38
|
-
|
|
56
|
+
|
|
57
|
+
<form onSubmit={handleSubmit}>
|
|
58
|
+
<div className={`flex items-center rounded-xl border bg-background transition-all ${
|
|
59
|
+
focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
|
|
60
|
+
}`}>
|
|
61
|
+
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
|
|
62
|
+
<input
|
|
63
|
+
type="text"
|
|
64
|
+
value={value}
|
|
65
|
+
onChange={(e) => setValue(e.target.value)}
|
|
66
|
+
onFocus={() => setFocused(true)}
|
|
67
|
+
onBlur={() => setFocused(false)}
|
|
68
|
+
placeholder="https://github.com/owner/repo/pull/123"
|
|
69
|
+
className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
|
|
70
|
+
autoFocus
|
|
71
|
+
/>
|
|
72
|
+
<button
|
|
73
|
+
type="submit"
|
|
74
|
+
disabled={!value.trim()}
|
|
75
|
+
className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-20 hover:opacity-80"
|
|
76
|
+
>
|
|
77
|
+
<CornerDownLeft className="h-3.5 w-3.5" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex justify-end mt-2 pr-1">
|
|
81
|
+
<span className="text-[10px] text-muted-foreground/30">
|
|
82
|
+
Enter to analyze
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
</form>
|
|
86
|
+
|
|
87
|
+
{recents.length > 0 && (
|
|
88
|
+
<div className="space-y-2 pt-2">
|
|
89
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
|
|
90
|
+
Recent
|
|
91
|
+
</div>
|
|
92
|
+
<div className="space-y-px">
|
|
93
|
+
{recents.map((s) => (
|
|
94
|
+
<button
|
|
95
|
+
key={s.id}
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => onSessionSelect?.(s.id)}
|
|
98
|
+
className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
|
|
99
|
+
>
|
|
100
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
101
|
+
<div className="flex-1 min-w-0">
|
|
102
|
+
<div className="text-xs truncate group-hover:text-foreground transition-colors">
|
|
103
|
+
{s.pr_title}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
|
|
106
|
+
<span className="font-mono truncate">{s.repo.split("/").pop()}</span>
|
|
107
|
+
<span className="font-mono">#{s.pr_number}</span>
|
|
108
|
+
<span className="text-muted-foreground/20">·</span>
|
|
109
|
+
<span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
|
|
110
|
+
<span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
|
|
114
|
+
<Clock className="h-2.5 w-2.5" />
|
|
115
|
+
<span>{timeAgo(s.analyzed_at)}</span>
|
|
116
|
+
</div>
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
39
123
|
</div>
|
|
40
124
|
);
|
|
41
125
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
1
2
|
import ReactMarkdown from "react-markdown";
|
|
2
3
|
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkMath from "remark-math";
|
|
5
|
+
import rehypeRaw from "rehype-raw";
|
|
6
|
+
import rehypeKatex from "rehype-katex";
|
|
7
|
+
import "katex/dist/katex.min.css";
|
|
3
8
|
import type { Components } from "react-markdown";
|
|
9
|
+
import type { Highlighter } from "shiki";
|
|
10
|
+
import { ensureHighlighter, getHighlighterSync, langFromClassName } from "../lib/shiki.ts";
|
|
4
11
|
|
|
5
12
|
interface MarkdownProps {
|
|
6
13
|
children: string;
|
|
@@ -8,17 +15,109 @@ interface MarkdownProps {
|
|
|
8
15
|
activeId?: string | null;
|
|
9
16
|
}
|
|
10
17
|
|
|
18
|
+
function useHighlighter(): Highlighter | null {
|
|
19
|
+
const [hl, setHl] = useState<Highlighter | null>(getHighlighterSync());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!hl) ensureHighlighter().then(setHl).catch(() => {});
|
|
22
|
+
}, [hl]);
|
|
23
|
+
return hl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function useDarkMode(): boolean {
|
|
27
|
+
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"));
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const observer = new MutationObserver(() => {
|
|
30
|
+
setDark(document.documentElement.classList.contains("dark"));
|
|
31
|
+
});
|
|
32
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
33
|
+
return () => observer.disconnect();
|
|
34
|
+
}, []);
|
|
35
|
+
return dark;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GITHUB_ATTACHMENT_RE = /^https:\/\/github\.com\/user-attachments\/assets\//;
|
|
39
|
+
const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?|$)/i;
|
|
40
|
+
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?|$)/i;
|
|
41
|
+
|
|
42
|
+
function isMediaUrl(href: string): boolean {
|
|
43
|
+
return GITHUB_ATTACHMENT_RE.test(href) || VIDEO_EXT_RE.test(href) || IMAGE_EXT_RE.test(href);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLikelyVideo(href: string): boolean {
|
|
47
|
+
return VIDEO_EXT_RE.test(href);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function proxied(url: string): string {
|
|
51
|
+
if (GITHUB_ATTACHMENT_RE.test(url) || url.startsWith("https://user-images.githubusercontent.com/")) {
|
|
52
|
+
return `/api/proxy?url=${encodeURIComponent(url)}`;
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function MediaEmbed({ src }: { src: string }) {
|
|
58
|
+
const url = proxied(src);
|
|
59
|
+
const [mode, setMode] = useState<"img" | "video" | "link">(isLikelyVideo(src) ? "video" : "img");
|
|
60
|
+
const triedRef = useState(() => new Set<string>())[0];
|
|
61
|
+
|
|
62
|
+
function fallback(from: "img" | "video") {
|
|
63
|
+
triedRef.add(from);
|
|
64
|
+
const next = from === "img" ? "video" : "img";
|
|
65
|
+
if (triedRef.has(next)) {
|
|
66
|
+
setMode("link");
|
|
67
|
+
} else {
|
|
68
|
+
setMode(next);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mode === "link") {
|
|
73
|
+
return <a href={src} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline break-all text-sm">{src}</a>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mode === "video") {
|
|
77
|
+
return (
|
|
78
|
+
<video
|
|
79
|
+
src={url}
|
|
80
|
+
controls
|
|
81
|
+
playsInline
|
|
82
|
+
className="max-w-full rounded-lg my-2"
|
|
83
|
+
onError={() => fallback("video")}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<img
|
|
90
|
+
src={url}
|
|
91
|
+
alt=""
|
|
92
|
+
className="max-w-full rounded-lg my-2"
|
|
93
|
+
onError={() => fallback("img")}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
11
98
|
const ANCHOR_RE = /\[\[(group|file):([^\]]+)\]\]/g;
|
|
99
|
+
const BOLD_CJK_RE = /\*\*(.+?)\*\*/g;
|
|
100
|
+
|
|
101
|
+
function hasCJK(text: string): boolean {
|
|
102
|
+
return /[가-힣ぁ-ヿ一-鿿]/.test(text);
|
|
103
|
+
}
|
|
12
104
|
|
|
13
105
|
function preprocess(text: string): string {
|
|
14
|
-
return text
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
106
|
+
return text
|
|
107
|
+
.replace(ANCHOR_RE, (_, kind, id) => {
|
|
108
|
+
const encoded = encodeURIComponent(id);
|
|
109
|
+
return ``;
|
|
110
|
+
})
|
|
111
|
+
.replace(BOLD_CJK_RE, (match, inner) => {
|
|
112
|
+
if (hasCJK(inner)) return `<strong>${inner}</strong>`;
|
|
113
|
+
return match;
|
|
114
|
+
});
|
|
18
115
|
}
|
|
19
116
|
|
|
20
117
|
export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
21
118
|
const processed = preprocess(children);
|
|
119
|
+
const hl = useHighlighter();
|
|
120
|
+
const dark = useDarkMode();
|
|
22
121
|
|
|
23
122
|
const components: Components = {
|
|
24
123
|
h1: ({ children }) => <h1 className="text-xl font-bold mt-6 mb-3 break-words">{children}</h1>,
|
|
@@ -32,69 +131,96 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
32
131
|
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
|
33
132
|
em: ({ children }) => <em className="italic">{children}</em>,
|
|
34
133
|
code: ({ children, className }) => {
|
|
134
|
+
const lang = langFromClassName(className);
|
|
135
|
+
if (lang && hl) {
|
|
136
|
+
const code = String(children).replace(/\n$/, "");
|
|
137
|
+
const theme = dark ? "github-dark" : "github-light";
|
|
138
|
+
try {
|
|
139
|
+
const html = hl.codeToHtml(code, { lang, theme });
|
|
140
|
+
return <span dangerouslySetInnerHTML={{ __html: html }} />;
|
|
141
|
+
} catch {
|
|
142
|
+
return <code className="text-xs font-mono">{children}</code>;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
35
145
|
if (className?.includes("language-")) {
|
|
36
|
-
return
|
|
37
|
-
<pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3">
|
|
38
|
-
<code className="text-xs font-mono">{children}</code>
|
|
39
|
-
</pre>
|
|
40
|
-
);
|
|
146
|
+
return <code className="text-xs font-mono">{children}</code>;
|
|
41
147
|
}
|
|
42
148
|
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
|
|
43
149
|
},
|
|
44
|
-
pre: ({ children }) =>
|
|
45
|
-
|
|
46
|
-
|
|
150
|
+
pre: ({ children }) => (
|
|
151
|
+
<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>
|
|
152
|
+
),
|
|
153
|
+
a: ({ href, children }) => {
|
|
154
|
+
if (href && isMediaUrl(href)) {
|
|
155
|
+
const textContent = String(children ?? "");
|
|
156
|
+
if (textContent === href || !textContent.trim()) {
|
|
157
|
+
return <MediaEmbed src={href} />;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{children}</a>;
|
|
161
|
+
},
|
|
162
|
+
video: ({ src, ...rest }: React.ComponentProps<"video">) => (
|
|
163
|
+
src ? <video src={src} controls playsInline className="max-w-full rounded-lg my-2" {...rest} /> : null
|
|
47
164
|
),
|
|
48
|
-
img: ({ alt }) => {
|
|
165
|
+
img: ({ alt, src, ...rest }) => {
|
|
166
|
+
if (src !== "newpr") {
|
|
167
|
+
return <img alt={alt} src={src ? proxied(src) : src} {...rest} className="max-w-full rounded-lg my-2" />;
|
|
168
|
+
}
|
|
49
169
|
if (!alt?.includes(":")) return null;
|
|
50
170
|
const colonIdx = alt.indexOf(":");
|
|
51
171
|
const kind = alt.slice(0, colonIdx) as "group" | "file";
|
|
52
172
|
const id = decodeURIComponent(alt.slice(colonIdx + 1));
|
|
53
173
|
|
|
54
|
-
|
|
55
|
-
if (kind === "group") {
|
|
56
|
-
return <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium">{id}</span>;
|
|
57
|
-
}
|
|
58
|
-
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono text-blue-600 dark:text-blue-400">{id.split("/").pop()}</code>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const isActive = activeId === `${kind}:${id}`;
|
|
174
|
+
if (!onAnchorClick) {
|
|
62
175
|
if (kind === "group") {
|
|
63
|
-
return
|
|
64
|
-
<span
|
|
65
|
-
role="button"
|
|
66
|
-
tabIndex={0}
|
|
67
|
-
onClick={(e) => { e.stopPropagation(); onAnchorClick("group", id); }}
|
|
68
|
-
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("group", id); }}
|
|
69
|
-
className={`inline px-1.5 py-0.5 rounded text-xs font-medium transition-colors cursor-pointer ${
|
|
70
|
-
isActive
|
|
71
|
-
? "bg-blue-500/20 text-blue-500 dark:text-blue-300 ring-1 ring-blue-500/40"
|
|
72
|
-
: "bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20"
|
|
73
|
-
}`}
|
|
74
|
-
>
|
|
75
|
-
{id}
|
|
76
|
-
</span>
|
|
77
|
-
);
|
|
176
|
+
return <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-accent text-[11px] font-medium text-foreground/80">{id}</span>;
|
|
78
177
|
}
|
|
178
|
+
return <code className="px-1.5 py-0.5 rounded-md bg-accent text-[11px] font-mono text-foreground/70">{id.split("/").pop()}</code>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const isActive = activeId === `${kind}:${id}`;
|
|
182
|
+
if (kind === "group") {
|
|
79
183
|
return (
|
|
80
184
|
<span
|
|
81
185
|
role="button"
|
|
82
186
|
tabIndex={0}
|
|
83
|
-
onClick={(e) => { e.stopPropagation(); onAnchorClick("
|
|
84
|
-
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("
|
|
85
|
-
className={`inline px-1.5 py-0.5 rounded text-
|
|
187
|
+
onClick={(e) => { e.stopPropagation(); onAnchorClick("group", id); }}
|
|
188
|
+
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("group", id); }}
|
|
189
|
+
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[11px] font-medium transition-colors cursor-pointer border ${
|
|
86
190
|
isActive
|
|
87
|
-
? "bg-
|
|
88
|
-
: "bg-
|
|
191
|
+
? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
|
|
192
|
+
: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 hover:border-blue-300 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/30 dark:hover:bg-blue-500/20 dark:hover:border-blue-500/40"
|
|
89
193
|
}`}
|
|
90
194
|
>
|
|
91
|
-
{id
|
|
195
|
+
{id}
|
|
92
196
|
</span>
|
|
93
197
|
);
|
|
198
|
+
}
|
|
199
|
+
return (
|
|
200
|
+
<span
|
|
201
|
+
role="button"
|
|
202
|
+
tabIndex={0}
|
|
203
|
+
onClick={(e) => { e.stopPropagation(); onAnchorClick("file", id); }}
|
|
204
|
+
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("file", id); }}
|
|
205
|
+
className={`inline px-1.5 py-0.5 rounded-md text-[11px] font-mono transition-colors cursor-pointer border ${
|
|
206
|
+
isActive
|
|
207
|
+
? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
|
|
208
|
+
: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 hover:border-blue-300 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/30 dark:hover:bg-blue-500/20 dark:hover:border-blue-500/40"
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
{id.split("/").pop()}
|
|
212
|
+
</span>
|
|
213
|
+
);
|
|
94
214
|
},
|
|
95
215
|
blockquote: ({ children }) => (
|
|
96
216
|
<blockquote className="border-l-2 border-muted-foreground/30 pl-4 text-sm text-muted-foreground italic mb-3">{children}</blockquote>
|
|
97
217
|
),
|
|
218
|
+
details: ({ children, ...rest }) => (
|
|
219
|
+
<details className="rounded-lg border border-border mb-3 open:pb-2" {...rest}>{children}</details>
|
|
220
|
+
),
|
|
221
|
+
summary: ({ children }) => (
|
|
222
|
+
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none hover:bg-muted/50 rounded-lg">{children}</summary>
|
|
223
|
+
),
|
|
98
224
|
hr: () => <hr className="border-border my-4" />,
|
|
99
225
|
table: ({ children }) => (
|
|
100
226
|
<div className="overflow-x-auto mb-3">
|
|
@@ -105,5 +231,5 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
105
231
|
td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
|
|
106
232
|
};
|
|
107
233
|
|
|
108
|
-
return <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>{processed}</ReactMarkdown>;
|
|
234
|
+
return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }], rehypeRaw]} components={components}>{processed}</ReactMarkdown>;
|
|
109
235
|
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { ArrowLeft,
|
|
3
|
-
import { Button } from "../../components/ui/button.tsx";
|
|
2
|
+
import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles } from "lucide-react";
|
|
4
3
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
|
|
5
4
|
import type { NewprOutput } from "../../../types/output.ts";
|
|
6
|
-
import { SummaryPanel } from "../panels/SummaryPanel.tsx";
|
|
7
5
|
import { GroupsPanel } from "../panels/GroupsPanel.tsx";
|
|
8
6
|
import { FilesPanel } from "../panels/FilesPanel.tsx";
|
|
9
|
-
import { NarrativePanel } from "../panels/NarrativePanel.tsx";
|
|
10
7
|
import { StoryPanel } from "../panels/StoryPanel.tsx";
|
|
8
|
+
import { DiscussionPanel } from "../panels/DiscussionPanel.tsx";
|
|
11
9
|
import { CartoonPanel } from "../panels/CartoonPanel.tsx";
|
|
12
10
|
|
|
13
|
-
const VALID_TABS = ["story", "
|
|
11
|
+
const VALID_TABS = ["story", "discussion", "groups", "files", "cartoon"] as const;
|
|
14
12
|
type TabValue = typeof VALID_TABS[number];
|
|
15
13
|
|
|
16
14
|
function getInitialTab(): TabValue {
|
|
@@ -25,11 +23,11 @@ function setTabParam(tab: string) {
|
|
|
25
23
|
window.history.replaceState(null, "", url.toString());
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
const
|
|
29
|
-
low: "bg-green-500
|
|
30
|
-
medium: "bg-yellow-500
|
|
31
|
-
high: "bg-red-500
|
|
32
|
-
critical: "bg-red-
|
|
26
|
+
const RISK_DOT: Record<string, string> = {
|
|
27
|
+
low: "bg-green-500",
|
|
28
|
+
medium: "bg-yellow-500",
|
|
29
|
+
high: "bg-red-500",
|
|
30
|
+
critical: "bg-red-600",
|
|
33
31
|
};
|
|
34
32
|
|
|
35
33
|
export function ResultsScreen({
|
|
@@ -90,100 +88,95 @@ export function ResultsScreen({
|
|
|
90
88
|
|
|
91
89
|
return (
|
|
92
90
|
<Tabs value={tab} onValueChange={handleTabChange} className="flex flex-col">
|
|
93
|
-
<div ref={stickyRef} className="sticky top-0 z-10 bg-background
|
|
91
|
+
<div ref={stickyRef} className="sticky top-0 z-10 bg-background -mx-10 px-10">
|
|
94
92
|
<div ref={collapsibleRef} className="overflow-hidden transition-[max-height,opacity] duration-200">
|
|
95
|
-
<div className="pb-
|
|
96
|
-
<div className="flex items-center gap-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
<div className="pb-4 pt-1">
|
|
94
|
+
<div className="flex items-center gap-2 mb-3">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onBack}
|
|
98
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/40 transition-colors shrink-0 -ml-1"
|
|
99
|
+
>
|
|
100
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
101
|
+
</button>
|
|
100
102
|
<a
|
|
101
103
|
href={meta.pr_url}
|
|
102
104
|
target="_blank"
|
|
103
105
|
rel="noopener noreferrer"
|
|
104
|
-
className="text-muted-foreground font-mono
|
|
106
|
+
className="text-[11px] text-muted-foreground/50 font-mono hover:text-foreground transition-colors"
|
|
105
107
|
>
|
|
106
108
|
{repoSlug}
|
|
107
109
|
</a>
|
|
108
|
-
<span className={`
|
|
109
|
-
{summary.risk_level}
|
|
110
|
-
</span>
|
|
110
|
+
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${RISK_DOT[summary.risk_level] ?? RISK_DOT.medium}`} />
|
|
111
111
|
</div>
|
|
112
112
|
|
|
113
|
-
<h1 className="text-
|
|
113
|
+
<h1 className="text-sm font-semibold tracking-tight mb-3 line-clamp-2">{meta.pr_title}</h1>
|
|
114
114
|
|
|
115
|
-
<div className="flex flex-wrap gap-x-
|
|
115
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px] text-muted-foreground/50">
|
|
116
116
|
<a
|
|
117
117
|
href={meta.author_url ?? `https://github.com/${meta.author}`}
|
|
118
118
|
target="_blank"
|
|
119
119
|
rel="noopener noreferrer"
|
|
120
|
-
className="flex items-center gap-1.5
|
|
120
|
+
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
|
121
121
|
>
|
|
122
|
-
{meta.author_avatar
|
|
123
|
-
<img src={meta.author_avatar} alt={meta.author} className="h-
|
|
124
|
-
) : (
|
|
125
|
-
<User className="h-3 w-3" />
|
|
122
|
+
{meta.author_avatar && (
|
|
123
|
+
<img src={meta.author_avatar} alt={meta.author} className="h-4 w-4 rounded-full" />
|
|
126
124
|
)}
|
|
127
125
|
<span>{meta.author}</span>
|
|
128
126
|
</a>
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
<span className="text-muted-foreground/50">←</span>
|
|
127
|
+
<span className="text-muted-foreground/15">|</span>
|
|
128
|
+
<div className="flex items-center gap-1">
|
|
129
|
+
<GitBranch className="h-3 w-3 text-muted-foreground/30" />
|
|
133
130
|
<span className="font-mono">{meta.head_branch}</span>
|
|
131
|
+
<span className="text-muted-foreground/25">→</span>
|
|
132
|
+
<span className="font-mono">{meta.base_branch}</span>
|
|
134
133
|
</div>
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
<span className="text-green-
|
|
138
|
-
<span className="text-red-
|
|
139
|
-
<span className="text-muted-foreground/
|
|
140
|
-
<span>{meta.total_files_changed} files</span>
|
|
141
|
-
</div>
|
|
142
|
-
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
143
|
-
<Bot className="h-3 w-3" />
|
|
144
|
-
<span>{meta.model_used.split("/").pop()}</span>
|
|
134
|
+
<span className="text-muted-foreground/15">|</span>
|
|
135
|
+
<div className="flex items-center gap-1.5">
|
|
136
|
+
<span className="text-green-600 dark:text-green-400 tabular-nums">+{meta.total_additions}</span>
|
|
137
|
+
<span className="text-red-600 dark:text-red-400 tabular-nums">-{meta.total_deletions}</span>
|
|
138
|
+
<span className="text-muted-foreground/25">·</span>
|
|
139
|
+
<span className="tabular-nums">{meta.total_files_changed} files</span>
|
|
145
140
|
</div>
|
|
146
141
|
</div>
|
|
147
142
|
</div>
|
|
148
143
|
</div>
|
|
149
144
|
|
|
150
145
|
<div ref={compactRef} className="overflow-hidden transition-[max-height,opacity] duration-200" style={{ maxHeight: 0, opacity: 0 }}>
|
|
151
|
-
<div className="flex items-center gap-
|
|
152
|
-
<
|
|
146
|
+
<div className="flex items-center gap-2.5 min-w-0 pb-2.5">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={onBack}
|
|
150
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/40 transition-colors shrink-0 -ml-1"
|
|
151
|
+
>
|
|
153
152
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
154
|
-
</
|
|
155
|
-
<span className=
|
|
156
|
-
<span className="text-xs
|
|
157
|
-
<span className=
|
|
158
|
-
{summary.risk_level}
|
|
159
|
-
</span>
|
|
153
|
+
</button>
|
|
154
|
+
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${RISK_DOT[summary.risk_level] ?? RISK_DOT.medium}`} />
|
|
155
|
+
<span className="text-xs font-medium truncate flex-1">{meta.pr_title}</span>
|
|
156
|
+
<span className="text-[10px] text-muted-foreground/30 font-mono shrink-0">{repoSlug}</span>
|
|
160
157
|
</div>
|
|
161
158
|
</div>
|
|
162
159
|
|
|
163
|
-
<TabsList className="w-full justify-start
|
|
164
|
-
<TabsTrigger value="story"
|
|
165
|
-
<BookOpen className="h-3
|
|
160
|
+
<TabsList className="w-full justify-start">
|
|
161
|
+
<TabsTrigger value="story">
|
|
162
|
+
<BookOpen className="h-3 w-3 shrink-0" />
|
|
166
163
|
Story
|
|
167
164
|
</TabsTrigger>
|
|
168
|
-
<TabsTrigger value="
|
|
169
|
-
<
|
|
170
|
-
|
|
165
|
+
<TabsTrigger value="discussion">
|
|
166
|
+
<MessageSquare className="h-3 w-3 shrink-0" />
|
|
167
|
+
Discussion
|
|
171
168
|
</TabsTrigger>
|
|
172
|
-
<TabsTrigger value="groups"
|
|
173
|
-
<Layers className="h-3
|
|
169
|
+
<TabsTrigger value="groups">
|
|
170
|
+
<Layers className="h-3 w-3 shrink-0" />
|
|
174
171
|
Groups
|
|
175
172
|
</TabsTrigger>
|
|
176
|
-
<TabsTrigger value="files"
|
|
177
|
-
<FolderTree className="h-3
|
|
173
|
+
<TabsTrigger value="files">
|
|
174
|
+
<FolderTree className="h-3 w-3 shrink-0" />
|
|
178
175
|
Files
|
|
179
176
|
</TabsTrigger>
|
|
180
|
-
<TabsTrigger value="narrative" className="gap-1.5">
|
|
181
|
-
<FileText className="h-3.5 w-3.5 shrink-0" />
|
|
182
|
-
Narrative
|
|
183
|
-
</TabsTrigger>
|
|
184
177
|
{cartoonEnabled && (
|
|
185
|
-
<TabsTrigger value="cartoon"
|
|
186
|
-
<Sparkles className="h-3
|
|
178
|
+
<TabsTrigger value="cartoon">
|
|
179
|
+
<Sparkles className="h-3 w-3 shrink-0" />
|
|
187
180
|
Comic
|
|
188
181
|
</TabsTrigger>
|
|
189
182
|
)}
|
|
@@ -193,17 +186,19 @@ export function ResultsScreen({
|
|
|
193
186
|
<TabsContent value="story">
|
|
194
187
|
<StoryPanel data={data} activeId={activeId} onAnchorClick={onAnchorClick} />
|
|
195
188
|
</TabsContent>
|
|
196
|
-
<TabsContent value="
|
|
197
|
-
<
|
|
189
|
+
<TabsContent value="discussion">
|
|
190
|
+
<DiscussionPanel sessionId={sessionId} />
|
|
198
191
|
</TabsContent>
|
|
199
192
|
<TabsContent value="groups">
|
|
200
193
|
<GroupsPanel groups={data.groups} />
|
|
201
194
|
</TabsContent>
|
|
202
195
|
<TabsContent value="files">
|
|
203
|
-
<FilesPanel
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
<FilesPanel
|
|
197
|
+
files={data.files}
|
|
198
|
+
groups={data.groups}
|
|
199
|
+
selectedPath={activeId?.startsWith("file:") ? activeId.slice(5) : null}
|
|
200
|
+
onFileSelect={(path: string) => onAnchorClick("file", path)}
|
|
201
|
+
/>
|
|
207
202
|
</TabsContent>
|
|
208
203
|
{cartoonEnabled && (
|
|
209
204
|
<TabsContent value="cartoon">
|