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.
Files changed (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,9 +1,37 @@
1
1
  import { useState } from "react";
2
- import { ArrowRight } from "lucide-react";
3
- import { Button } from "../../components/ui/button.tsx";
2
+ import { CornerDownLeft, Clock, GitPullRequest } from "lucide-react";
3
+ import type { SessionRecord } from "../../../history/types.ts";
4
4
 
5
- export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
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
- return (
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
- <form onSubmit={handleSubmit} className="w-full max-w-xl">
24
- <div className="flex gap-2">
25
- <input
26
- type="text"
27
- value={value}
28
- onChange={(e) => setValue(e.target.value)}
29
- placeholder="https://github.com/owner/repo/pull/123"
30
- className="flex-1 h-11 rounded-lg border bg-background px-4 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
31
- autoFocus
32
- />
33
- <Button type="submit" size="lg" disabled={!value.trim()}>
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
- </form>
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.replace(ANCHOR_RE, (_, kind, id) => {
15
- const encoded = encodeURIComponent(id);
16
- return `![${kind}:${encoded}](newpr)`;
17
- });
106
+ return text
107
+ .replace(ANCHOR_RE, (_, kind, id) => {
108
+ const encoded = encodeURIComponent(id);
109
+ return `![${kind}:${encoded}](newpr)`;
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 }) => <>{children}</>,
45
- a: ({ href, children }) => (
46
- <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{children}</a>
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
- if (!onAnchorClick) {
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("file", id); }}
84
- onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("file", id); }}
85
- className={`inline px-1.5 py-0.5 rounded text-xs font-mono transition-colors cursor-pointer ${
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-muted ring-1 ring-blue-500/40 text-blue-500 dark:text-blue-300"
88
- : "bg-muted text-blue-600 dark:text-blue-400 hover:bg-blue-500/10"
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.split("/").pop()}
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, FileText, Layers, FolderTree, BookOpen, LayoutList, GitBranch, User, Files, Bot, Sparkles } from "lucide-react";
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", "summary", "groups", "files", "narrative", "cartoon"] as const;
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 RISK_COLORS: Record<string, string> = {
29
- low: "bg-green-500/10 text-green-600 dark:text-green-400",
30
- medium: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
31
- high: "bg-red-500/10 text-red-600 dark:text-red-400",
32
- critical: "bg-red-500/20 text-red-700 dark:text-red-300",
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 pb-2 -mx-10 px-10">
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-3 pt-1">
96
- <div className="flex items-center gap-3 mb-3">
97
- <Button variant="ghost" size="icon" className="shrink-0 -ml-2" onClick={onBack}>
98
- <ArrowLeft className="h-4 w-4" />
99
- </Button>
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 text-sm hover:text-foreground transition-colors"
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={`text-xs font-medium px-2 py-0.5 rounded-full ${RISK_COLORS[summary.risk_level] ?? RISK_COLORS.medium}`}>
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-lg font-bold tracking-tight mb-2 line-clamp-2">{meta.pr_title}</h1>
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-4 gap-y-1">
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 text-xs text-muted-foreground hover:text-foreground transition-colors"
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-3.5 w-3.5 rounded-full" />
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
- <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
130
- <GitBranch className="h-3 w-3" />
131
- <span className="font-mono">{meta.base_branch}</span>
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
- <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
136
- <Files className="h-3 w-3" />
137
- <span className="text-green-500">+{meta.total_additions}</span>
138
- <span className="text-red-500">−{meta.total_deletions}</span>
139
- <span className="text-muted-foreground/50">·</span>
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-3 min-w-0 pb-2">
152
- <Button variant="ghost" size="icon" className="shrink-0 -ml-2 h-7 w-7" onClick={onBack}>
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
- </Button>
155
- <span className="text-sm font-semibold truncate flex-1">{meta.pr_title}</span>
156
- <span className="text-xs text-muted-foreground font-mono shrink-0">{repoSlug}</span>
157
- <span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 ${RISK_COLORS[summary.risk_level] ?? RISK_COLORS.medium}`}>
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 overflow-x-auto">
164
- <TabsTrigger value="story" className="gap-1.5">
165
- <BookOpen className="h-3.5 w-3.5 shrink-0" />
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="summary" className="gap-1.5">
169
- <LayoutList className="h-3.5 w-3.5 shrink-0" />
170
- Summary
165
+ <TabsTrigger value="discussion">
166
+ <MessageSquare className="h-3 w-3 shrink-0" />
167
+ Discussion
171
168
  </TabsTrigger>
172
- <TabsTrigger value="groups" className="gap-1.5">
173
- <Layers className="h-3.5 w-3.5 shrink-0" />
169
+ <TabsTrigger value="groups">
170
+ <Layers className="h-3 w-3 shrink-0" />
174
171
  Groups
175
172
  </TabsTrigger>
176
- <TabsTrigger value="files" className="gap-1.5">
177
- <FolderTree className="h-3.5 w-3.5 shrink-0" />
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" className="gap-1.5">
186
- <Sparkles className="h-3.5 w-3.5 shrink-0" />
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="summary">
197
- <SummaryPanel summary={data.summary} />
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 files={data.files} />
204
- </TabsContent>
205
- <TabsContent value="narrative">
206
- <NarrativePanel narrative={data.narrative} />
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">