newpr 0.1.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 (82) hide show
  1. package/README.md +189 -0
  2. package/package.json +78 -0
  3. package/src/analyzer/errors.ts +22 -0
  4. package/src/analyzer/pipeline.ts +299 -0
  5. package/src/analyzer/progress.ts +69 -0
  6. package/src/cli/args.ts +192 -0
  7. package/src/cli/auth.ts +82 -0
  8. package/src/cli/history-cmd.ts +64 -0
  9. package/src/cli/index.ts +115 -0
  10. package/src/cli/pretty.ts +79 -0
  11. package/src/config/index.ts +103 -0
  12. package/src/config/store.ts +50 -0
  13. package/src/diff/chunker.ts +30 -0
  14. package/src/diff/parser.ts +116 -0
  15. package/src/diff/stats.ts +37 -0
  16. package/src/github/auth.ts +16 -0
  17. package/src/github/fetch-diff.ts +24 -0
  18. package/src/github/fetch-pr.ts +90 -0
  19. package/src/github/parse-pr.ts +39 -0
  20. package/src/history/store.ts +96 -0
  21. package/src/history/types.ts +15 -0
  22. package/src/llm/claude-code-client.ts +134 -0
  23. package/src/llm/client.ts +240 -0
  24. package/src/llm/prompts.ts +176 -0
  25. package/src/llm/response-parser.ts +71 -0
  26. package/src/tui/App.tsx +97 -0
  27. package/src/tui/Footer.tsx +34 -0
  28. package/src/tui/Header.tsx +27 -0
  29. package/src/tui/HelpOverlay.tsx +46 -0
  30. package/src/tui/InputBar.tsx +65 -0
  31. package/src/tui/Loading.tsx +192 -0
  32. package/src/tui/Shell.tsx +384 -0
  33. package/src/tui/TabBar.tsx +31 -0
  34. package/src/tui/commands.ts +75 -0
  35. package/src/tui/narrative-parser.ts +143 -0
  36. package/src/tui/panels/FilesPanel.tsx +134 -0
  37. package/src/tui/panels/GroupsPanel.tsx +140 -0
  38. package/src/tui/panels/NarrativePanel.tsx +102 -0
  39. package/src/tui/panels/StoryPanel.tsx +296 -0
  40. package/src/tui/panels/SummaryPanel.tsx +59 -0
  41. package/src/tui/panels/WalkthroughPanel.tsx +149 -0
  42. package/src/tui/render.tsx +62 -0
  43. package/src/tui/theme.ts +44 -0
  44. package/src/types/config.ts +19 -0
  45. package/src/types/diff.ts +36 -0
  46. package/src/types/github.ts +28 -0
  47. package/src/types/output.ts +59 -0
  48. package/src/web/client/App.tsx +121 -0
  49. package/src/web/client/components/AppShell.tsx +203 -0
  50. package/src/web/client/components/DetailPane.tsx +141 -0
  51. package/src/web/client/components/ErrorScreen.tsx +119 -0
  52. package/src/web/client/components/InputScreen.tsx +41 -0
  53. package/src/web/client/components/LoadingTimeline.tsx +179 -0
  54. package/src/web/client/components/Markdown.tsx +109 -0
  55. package/src/web/client/components/ResizeHandle.tsx +45 -0
  56. package/src/web/client/components/ResultsScreen.tsx +185 -0
  57. package/src/web/client/components/SettingsPanel.tsx +299 -0
  58. package/src/web/client/hooks/useAnalysis.ts +153 -0
  59. package/src/web/client/hooks/useGithubUser.ts +24 -0
  60. package/src/web/client/hooks/useSessions.ts +17 -0
  61. package/src/web/client/hooks/useTheme.ts +34 -0
  62. package/src/web/client/main.tsx +12 -0
  63. package/src/web/client/panels/FilesPanel.tsx +85 -0
  64. package/src/web/client/panels/GroupsPanel.tsx +62 -0
  65. package/src/web/client/panels/NarrativePanel.tsx +9 -0
  66. package/src/web/client/panels/StoryPanel.tsx +54 -0
  67. package/src/web/client/panels/SummaryPanel.tsx +20 -0
  68. package/src/web/components/ui/button.tsx +46 -0
  69. package/src/web/components/ui/card.tsx +37 -0
  70. package/src/web/components/ui/scroll-area.tsx +39 -0
  71. package/src/web/components/ui/tabs.tsx +52 -0
  72. package/src/web/index.html +14 -0
  73. package/src/web/lib/utils.ts +6 -0
  74. package/src/web/server/routes.ts +202 -0
  75. package/src/web/server/session-manager.ts +147 -0
  76. package/src/web/server.ts +96 -0
  77. package/src/web/styles/globals.css +91 -0
  78. package/src/workspace/agent.ts +317 -0
  79. package/src/workspace/explore.ts +82 -0
  80. package/src/workspace/repo-cache.ts +69 -0
  81. package/src/workspace/types.ts +30 -0
  82. package/src/workspace/worktree.ts +129 -0
@@ -0,0 +1,179 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { CheckCircle2, Circle, Loader2 } from "lucide-react";
3
+ import type { ProgressEvent, ProgressStage } from "../../../analyzer/progress.ts";
4
+ import { stageIndex, allStages } from "../../../analyzer/progress.ts";
5
+
6
+ const STAGE_LABELS: Record<ProgressStage, string> = {
7
+ fetching: "Fetch PR data",
8
+ parsing: "Parse diff",
9
+ cloning: "Clone repository",
10
+ checkout: "Checkout branches",
11
+ exploring: "Explore codebase",
12
+ analyzing: "Analyze files",
13
+ grouping: "Group changes",
14
+ summarizing: "Generate summary",
15
+ narrating: "Write narrative",
16
+ done: "Complete",
17
+ };
18
+
19
+ const MAX_LOG_LINES = 8;
20
+
21
+ interface StepInfo {
22
+ stage: ProgressStage;
23
+ message: string;
24
+ done: boolean;
25
+ active: boolean;
26
+ durationMs?: number;
27
+ current?: number;
28
+ total?: number;
29
+ log: string[];
30
+ }
31
+
32
+ function buildSteps(events: ProgressEvent[]): StepInfo[] {
33
+ const stages = allStages();
34
+ const lastByStage = new Map<ProgressStage, ProgressEvent>();
35
+ const firstTs = new Map<ProgressStage, number>();
36
+ const logByStage = new Map<ProgressStage, string[]>();
37
+ let maxIdx = -1;
38
+
39
+ for (const e of events) {
40
+ lastByStage.set(e.stage, e);
41
+ if (e.timestamp && !firstTs.has(e.stage)) firstTs.set(e.stage, e.timestamp);
42
+ const idx = stageIndex(e.stage);
43
+ if (idx > maxIdx) maxIdx = idx;
44
+
45
+ if (e.message && !e.partial_content) {
46
+ const existing = logByStage.get(e.stage) ?? [];
47
+ const last = existing[existing.length - 1];
48
+ if (e.message !== last) {
49
+ existing.push(e.message);
50
+ logByStage.set(e.stage, existing);
51
+ }
52
+ }
53
+ }
54
+
55
+ return stages
56
+ .filter((_, i) => i <= maxIdx + 1 && i < stages.length - 1)
57
+ .map((stage) => {
58
+ const event = lastByStage.get(stage);
59
+ const idx = stageIndex(stage);
60
+ const done = idx < maxIdx;
61
+ const active = idx === maxIdx;
62
+ const log = logByStage.get(stage) ?? [];
63
+
64
+ let durationMs: number | undefined;
65
+ if (done) {
66
+ const start = firstTs.get(stage);
67
+ const nextStart = stages
68
+ .filter((_, i) => i > idx)
69
+ .reduce<number | undefined>((f, s) => f ?? firstTs.get(s), undefined);
70
+ if (start && nextStart) durationMs = nextStart - start;
71
+ }
72
+
73
+ return {
74
+ stage,
75
+ message: event?.message ?? STAGE_LABELS[stage],
76
+ done,
77
+ active,
78
+ durationMs,
79
+ current: event?.current,
80
+ total: event?.total,
81
+ log,
82
+ };
83
+ });
84
+ }
85
+
86
+ function formatDuration(ms: number): string {
87
+ if (ms < 1000) return `${ms}ms`;
88
+ return `${(ms / 1000).toFixed(1)}s`;
89
+ }
90
+
91
+ export function LoadingTimeline({
92
+ events,
93
+ startedAt,
94
+ }: {
95
+ events: ProgressEvent[];
96
+ startedAt: number;
97
+ }) {
98
+ const [elapsed, setElapsed] = useState(0);
99
+ const logEndRef = useRef<HTMLDivElement>(null);
100
+
101
+ useEffect(() => {
102
+ const timer = setInterval(() => setElapsed(Date.now() - startedAt), 500);
103
+ return () => clearInterval(timer);
104
+ }, [startedAt]);
105
+
106
+ useEffect(() => {
107
+ logEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
108
+ }, [events.length]);
109
+
110
+ const steps = buildSteps(events);
111
+ const seconds = Math.floor(elapsed / 1000);
112
+
113
+ return (
114
+ <div className="flex flex-col items-center py-16">
115
+ <div className="w-full max-w-lg">
116
+ <div className="flex items-center gap-2 mb-8">
117
+ <span className="text-lg font-bold">newpr</span>
118
+ <span className="text-muted-foreground">·</span>
119
+ <span className="text-sm text-muted-foreground">
120
+ {seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`}
121
+ </span>
122
+ </div>
123
+
124
+ <div className="space-y-3">
125
+ {steps.map((step) => {
126
+ const completionDetail = step.message !== STAGE_LABELS[step.stage] ? step.message : "";
127
+ const progress = step.current !== undefined && step.total !== undefined
128
+ ? ` (${step.current}/${step.total})`
129
+ : "";
130
+ const recentLog = step.log.slice(-MAX_LOG_LINES);
131
+
132
+ return (
133
+ <div key={step.stage} className="flex items-start gap-3">
134
+ {step.done ? (
135
+ <CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
136
+ ) : step.active ? (
137
+ <Loader2 className="h-5 w-5 text-primary animate-spin mt-0.5 shrink-0" />
138
+ ) : (
139
+ <Circle className="h-5 w-5 text-muted-foreground/30 mt-0.5 shrink-0" />
140
+ )}
141
+ <div className="min-w-0 flex-1">
142
+ <div className="flex items-center gap-2">
143
+ <span className={`text-sm font-medium ${step.done ? "text-muted-foreground" : step.active ? "text-foreground" : "text-muted-foreground/50"}`}>
144
+ {STAGE_LABELS[step.stage]}{progress}
145
+ </span>
146
+ {step.done && step.durationMs !== undefined && (
147
+ <span className="text-xs text-muted-foreground/60">
148
+ {formatDuration(step.durationMs)}
149
+ </span>
150
+ )}
151
+ </div>
152
+ {step.done && completionDetail && (
153
+ <p className="text-xs text-muted-foreground/60 mt-0.5 truncate">{completionDetail}</p>
154
+ )}
155
+ {step.active && recentLog.length > 0 && (
156
+ <div className="mt-1.5 space-y-0.5 max-h-40 overflow-y-auto">
157
+ {recentLog.map((line, j) => {
158
+ const isLast = j === recentLog.length - 1;
159
+ return (
160
+ <p
161
+ key={`${step.stage}-${j}`}
162
+ className={`text-xs font-mono truncate ${isLast ? "text-muted-foreground" : "text-muted-foreground/40"}`}
163
+ >
164
+ {line}
165
+ </p>
166
+ );
167
+ })}
168
+ <div ref={logEndRef} />
169
+ </div>
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ })}
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,109 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import type { Components } from "react-markdown";
4
+
5
+ interface MarkdownProps {
6
+ children: string;
7
+ onAnchorClick?: (kind: "group" | "file", id: string) => void;
8
+ activeId?: string | null;
9
+ }
10
+
11
+ const ANCHOR_RE = /\[\[(group|file):([^\]]+)\]\]/g;
12
+
13
+ function preprocess(text: string): string {
14
+ return text.replace(ANCHOR_RE, (_, kind, id) => {
15
+ const encoded = encodeURIComponent(id);
16
+ return `![${kind}:${encoded}](newpr)`;
17
+ });
18
+ }
19
+
20
+ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
21
+ const processed = preprocess(children);
22
+
23
+ const components: Components = {
24
+ h1: ({ children }) => <h1 className="text-xl font-bold mt-6 mb-3 break-words">{children}</h1>,
25
+ h2: ({ children }) => <h2 className="text-lg font-semibold mt-6 mb-2 break-words">{children}</h2>,
26
+ h3: ({ children }) => <h3 className="text-base font-medium mt-4 mb-1 break-words">{children}</h3>,
27
+ h4: ({ children }) => <h4 className="text-sm font-medium mt-3 mb-1 break-words">{children}</h4>,
28
+ p: ({ children }) => <p className="text-sm leading-relaxed text-foreground/90 break-words mb-3">{children}</p>,
29
+ ul: ({ children }) => <ul className="space-y-1 mb-3">{children}</ul>,
30
+ ol: ({ children }) => <ol className="list-decimal pl-5 space-y-1 mb-3 text-sm text-foreground/90">{children}</ol>,
31
+ li: ({ children }) => <li className="text-sm text-muted-foreground ml-4 break-words leading-relaxed">{children}</li>,
32
+ strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
33
+ em: ({ children }) => <em className="italic">{children}</em>,
34
+ code: ({ children, className }) => {
35
+ 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
+ );
41
+ }
42
+ return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
43
+ },
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>
47
+ ),
48
+ img: ({ alt }) => {
49
+ if (!alt?.includes(":")) return null;
50
+ const colonIdx = alt.indexOf(":");
51
+ const kind = alt.slice(0, colonIdx) as "group" | "file";
52
+ const id = decodeURIComponent(alt.slice(colonIdx + 1));
53
+
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}`;
62
+ 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
+ );
78
+ }
79
+ return (
80
+ <span
81
+ role="button"
82
+ 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 ${
86
+ 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"
89
+ }`}
90
+ >
91
+ {id.split("/").pop()}
92
+ </span>
93
+ );
94
+ },
95
+ blockquote: ({ children }) => (
96
+ <blockquote className="border-l-2 border-muted-foreground/30 pl-4 text-sm text-muted-foreground italic mb-3">{children}</blockquote>
97
+ ),
98
+ hr: () => <hr className="border-border my-4" />,
99
+ table: ({ children }) => (
100
+ <div className="overflow-x-auto mb-3">
101
+ <table className="text-sm w-full border-collapse">{children}</table>
102
+ </div>
103
+ ),
104
+ th: ({ children }) => <th className="border-b border-border px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">{children}</th>,
105
+ td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
106
+ };
107
+
108
+ return <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>{processed}</ReactMarkdown>;
109
+ }
@@ -0,0 +1,45 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ export function ResizeHandle({
4
+ onResize,
5
+ side = "right",
6
+ }: {
7
+ onResize: (delta: number) => void;
8
+ side?: "left" | "right";
9
+ }) {
10
+ const dragging = useRef(false);
11
+ const startX = useRef(0);
12
+
13
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
14
+ e.preventDefault();
15
+ dragging.current = true;
16
+ startX.current = e.clientX;
17
+ document.body.style.cursor = "col-resize";
18
+ document.body.style.userSelect = "none";
19
+
20
+ const onMouseMove = (ev: MouseEvent) => {
21
+ if (!dragging.current) return;
22
+ const delta = ev.clientX - startX.current;
23
+ startX.current = ev.clientX;
24
+ onResize(side === "right" ? delta : -delta);
25
+ };
26
+
27
+ const onMouseUp = () => {
28
+ dragging.current = false;
29
+ document.body.style.cursor = "";
30
+ document.body.style.userSelect = "";
31
+ document.removeEventListener("mousemove", onMouseMove);
32
+ document.removeEventListener("mouseup", onMouseUp);
33
+ };
34
+
35
+ document.addEventListener("mousemove", onMouseMove);
36
+ document.addEventListener("mouseup", onMouseUp);
37
+ }, [onResize, side]);
38
+
39
+ return (
40
+ <div
41
+ onMouseDown={onMouseDown}
42
+ className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors"
43
+ />
44
+ );
45
+ }
@@ -0,0 +1,185 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { ArrowLeft, FileText, Layers, FolderTree, BookOpen, LayoutList, GitBranch, User, Files, Bot } from "lucide-react";
3
+ import { Button } from "../../components/ui/button.tsx";
4
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
5
+ import type { NewprOutput } from "../../../types/output.ts";
6
+ import { SummaryPanel } from "../panels/SummaryPanel.tsx";
7
+ import { GroupsPanel } from "../panels/GroupsPanel.tsx";
8
+ import { FilesPanel } from "../panels/FilesPanel.tsx";
9
+ import { NarrativePanel } from "../panels/NarrativePanel.tsx";
10
+ import { StoryPanel } from "../panels/StoryPanel.tsx";
11
+
12
+ const VALID_TABS = ["story", "summary", "groups", "files", "narrative"] as const;
13
+ type TabValue = typeof VALID_TABS[number];
14
+
15
+ function getInitialTab(): TabValue {
16
+ const param = new URLSearchParams(window.location.search).get("tab");
17
+ if (param && VALID_TABS.includes(param as TabValue)) return param as TabValue;
18
+ return "story";
19
+ }
20
+
21
+ function setTabParam(tab: string) {
22
+ const url = new URL(window.location.href);
23
+ url.searchParams.set("tab", tab);
24
+ window.history.replaceState(null, "", url.toString());
25
+ }
26
+
27
+ const RISK_COLORS: Record<string, string> = {
28
+ low: "bg-green-500/10 text-green-600 dark:text-green-400",
29
+ medium: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
30
+ high: "bg-red-500/10 text-red-600 dark:text-red-400",
31
+ critical: "bg-red-500/20 text-red-700 dark:text-red-300",
32
+ };
33
+
34
+ export function ResultsScreen({
35
+ data,
36
+ onBack,
37
+ activeId,
38
+ onAnchorClick,
39
+ }: {
40
+ data: NewprOutput;
41
+ onBack: () => void;
42
+ activeId: string | null;
43
+ onAnchorClick: (kind: "group" | "file", id: string) => void;
44
+ }) {
45
+ const { meta, summary } = data;
46
+ const [tab, setTab] = useState<TabValue>(getInitialTab);
47
+ const [scrolled, setScrolled] = useState(false);
48
+ const sentinelRef = useRef<HTMLDivElement>(null);
49
+
50
+ useEffect(() => {
51
+ const sentinel = sentinelRef.current;
52
+ if (!sentinel) return;
53
+ const observer = new IntersectionObserver(
54
+ ([entry]) => setScrolled(!entry!.isIntersecting),
55
+ { threshold: 0 },
56
+ );
57
+ observer.observe(sentinel);
58
+ return () => observer.disconnect();
59
+ }, []);
60
+
61
+ function handleTabChange(value: string) {
62
+ setTab(value as TabValue);
63
+ setTabParam(value);
64
+ }
65
+
66
+ const repoSlug = meta.pr_url.replace(/^https?:\/\/github\.com\//, "").replace(/\/pull\//, "#");
67
+
68
+ return (
69
+ <div className="flex flex-col">
70
+ <div ref={sentinelRef} />
71
+
72
+ <div className={`sticky top-0 z-10 bg-background transition-all ${scrolled ? "pb-3 pt-1 border-b" : "pb-6 pt-0"}`}>
73
+ {!scrolled && (
74
+ <>
75
+ <div className="flex items-center gap-3 mb-4">
76
+ <Button variant="ghost" size="icon" className="shrink-0 -ml-2" onClick={onBack}>
77
+ <ArrowLeft className="h-4 w-4" />
78
+ </Button>
79
+ <a
80
+ href={meta.pr_url}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="text-muted-foreground font-mono text-sm hover:text-foreground transition-colors"
84
+ >
85
+ {repoSlug}
86
+ </a>
87
+ <span className={`text-xs font-medium px-2 py-0.5 rounded-full ${RISK_COLORS[summary.risk_level] ?? RISK_COLORS.medium}`}>
88
+ {summary.risk_level}
89
+ </span>
90
+ </div>
91
+
92
+ <h1 className="text-2xl font-bold tracking-tight mb-5" style={{ textWrap: "balance" }}>{meta.pr_title}</h1>
93
+
94
+ <div className="flex flex-wrap gap-x-5 gap-y-2 mb-6">
95
+ <a
96
+ href={meta.author_url ?? `https://github.com/${meta.author}`}
97
+ target="_blank"
98
+ rel="noopener noreferrer"
99
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
100
+ >
101
+ {meta.author_avatar ? (
102
+ <img src={meta.author_avatar} alt={meta.author} className="h-4 w-4 rounded-full" />
103
+ ) : (
104
+ <User className="h-3.5 w-3.5" />
105
+ )}
106
+ <span>{meta.author}</span>
107
+ </a>
108
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
109
+ <GitBranch className="h-3.5 w-3.5" />
110
+ <span className="font-mono text-xs">{meta.base_branch}</span>
111
+ <span className="text-muted-foreground/50">←</span>
112
+ <span className="font-mono text-xs">{meta.head_branch}</span>
113
+ </div>
114
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
115
+ <Files className="h-3.5 w-3.5" />
116
+ <span className="text-green-500">+{meta.total_additions}</span>
117
+ <span className="text-red-500">−{meta.total_deletions}</span>
118
+ <span className="text-muted-foreground/50">·</span>
119
+ <span>{meta.total_files_changed} files</span>
120
+ </div>
121
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
122
+ <Bot className="h-3.5 w-3.5" />
123
+ <span>{meta.model_used.split("/").pop()}</span>
124
+ </div>
125
+ </div>
126
+ </>
127
+ )}
128
+
129
+ {scrolled && (
130
+ <div className="flex items-center gap-3 min-w-0">
131
+ <Button variant="ghost" size="icon" className="shrink-0 -ml-2 h-7 w-7" onClick={onBack}>
132
+ <ArrowLeft className="h-3.5 w-3.5" />
133
+ </Button>
134
+ <span className="text-sm font-semibold truncate flex-1">{meta.pr_title}</span>
135
+ <span className="text-xs text-muted-foreground font-mono shrink-0">{repoSlug}</span>
136
+ <span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 ${RISK_COLORS[summary.risk_level] ?? RISK_COLORS.medium}`}>
137
+ {summary.risk_level}
138
+ </span>
139
+ </div>
140
+ )}
141
+
142
+ <Tabs value={tab} onValueChange={handleTabChange} className="w-full">
143
+ <TabsList className="w-full justify-start overflow-x-auto">
144
+ <TabsTrigger value="story" className="gap-1.5">
145
+ <BookOpen className="h-3.5 w-3.5 shrink-0" />
146
+ Story
147
+ </TabsTrigger>
148
+ <TabsTrigger value="summary" className="gap-1.5">
149
+ <LayoutList className="h-3.5 w-3.5 shrink-0" />
150
+ Summary
151
+ </TabsTrigger>
152
+ <TabsTrigger value="groups" className="gap-1.5">
153
+ <Layers className="h-3.5 w-3.5 shrink-0" />
154
+ Groups
155
+ </TabsTrigger>
156
+ <TabsTrigger value="files" className="gap-1.5">
157
+ <FolderTree className="h-3.5 w-3.5 shrink-0" />
158
+ Files
159
+ </TabsTrigger>
160
+ <TabsTrigger value="narrative" className="gap-1.5">
161
+ <FileText className="h-3.5 w-3.5 shrink-0" />
162
+ Narrative
163
+ </TabsTrigger>
164
+ </TabsList>
165
+
166
+ <TabsContent value="story">
167
+ <StoryPanel data={data} activeId={activeId} onAnchorClick={onAnchorClick} />
168
+ </TabsContent>
169
+ <TabsContent value="summary">
170
+ <SummaryPanel summary={data.summary} />
171
+ </TabsContent>
172
+ <TabsContent value="groups">
173
+ <GroupsPanel groups={data.groups} />
174
+ </TabsContent>
175
+ <TabsContent value="files">
176
+ <FilesPanel files={data.files} />
177
+ </TabsContent>
178
+ <TabsContent value="narrative">
179
+ <NarrativePanel narrative={data.narrative} />
180
+ </TabsContent>
181
+ </Tabs>
182
+ </div>
183
+ </div>
184
+ );
185
+ }