newpr 0.1.3 → 0.3.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 (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,9 +1,102 @@
1
- import { useState } from "react";
2
- import { ArrowRight } from "lucide-react";
3
- import { Button } from "../../components/ui/button.tsx";
1
+ import { useState, useEffect } from "react";
2
+ import { CornerDownLeft, Clock, GitPullRequest, Check, X, Minus } from "lucide-react";
3
+ import type { SessionRecord } from "../../../history/types.ts";
4
4
 
5
- export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
5
+ interface ToolStatus {
6
+ name: string;
7
+ installed: boolean;
8
+ version?: string;
9
+ detail?: string;
10
+ }
11
+
12
+ interface PreflightData {
13
+ github: ToolStatus & { authenticated: boolean; user?: string };
14
+ agents: ToolStatus[];
15
+ openrouterKey: boolean;
16
+ }
17
+
18
+ const RISK_DOT: Record<string, string> = {
19
+ low: "bg-green-500",
20
+ medium: "bg-yellow-500",
21
+ high: "bg-red-500",
22
+ critical: "bg-red-600",
23
+ };
24
+
25
+ function timeAgo(date: string): string {
26
+ const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
27
+ if (s < 60) return "just now";
28
+ const m = Math.floor(s / 60);
29
+ if (m < 60) return `${m}m ago`;
30
+ const h = Math.floor(m / 60);
31
+ if (h < 24) return `${h}h ago`;
32
+ const d = Math.floor(h / 24);
33
+ if (d < 30) return `${d}d ago`;
34
+ return `${Math.floor(d / 30)}mo ago`;
35
+ }
36
+
37
+ function StatusIcon({ ok, optional }: { ok: boolean; optional?: boolean }) {
38
+ if (ok) return <Check className="h-3 w-3 text-green-500" />;
39
+ if (optional) return <Minus className="h-3 w-3 text-muted-foreground/30" />;
40
+ return <X className="h-3 w-3 text-red-500" />;
41
+ }
42
+
43
+ function PreflightStatus({ data }: { data: PreflightData }) {
44
+ const gh = data.github;
45
+ return (
46
+ <div className="space-y-1.5">
47
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">Status</div>
48
+ <div className="flex items-center gap-2">
49
+ <StatusIcon ok={gh.installed && gh.authenticated} />
50
+ <span className="text-[11px] font-mono">gh</span>
51
+ {gh.installed && gh.authenticated && gh.user && (
52
+ <span className="text-[10px] text-muted-foreground/40">{gh.user}</span>
53
+ )}
54
+ {gh.installed && !gh.authenticated && (
55
+ <span className="text-[10px] text-red-500/70">not authenticated</span>
56
+ )}
57
+ {!gh.installed && (
58
+ <span className="text-[10px] text-red-500/70">not installed</span>
59
+ )}
60
+ </div>
61
+ {data.agents.map((agent) => (
62
+ <div key={agent.name} className="flex items-center gap-2">
63
+ <StatusIcon ok={agent.installed} optional />
64
+ <span className={`text-[11px] font-mono ${agent.installed ? "" : "text-muted-foreground/30"}`}>{agent.name}</span>
65
+ {agent.installed && agent.version && (
66
+ <span className="text-[10px] text-muted-foreground/30">{agent.version}</span>
67
+ )}
68
+ </div>
69
+ ))}
70
+ <div className="flex items-center gap-2">
71
+ <StatusIcon ok={data.openrouterKey} />
72
+ <span className="text-[11px]">OpenRouter</span>
73
+ {!data.openrouterKey && (
74
+ <span className="text-[10px] text-red-500/70">run newpr auth</span>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export function InputScreen({
82
+ onSubmit,
83
+ sessions,
84
+ onSessionSelect,
85
+ }: {
86
+ onSubmit: (pr: string) => void;
87
+ sessions?: SessionRecord[];
88
+ onSessionSelect?: (id: string) => void;
89
+ }) {
6
90
  const [value, setValue] = useState("");
91
+ const [focused, setFocused] = useState(false);
92
+ const [preflight, setPreflight] = useState<PreflightData | null>(null);
93
+
94
+ useEffect(() => {
95
+ fetch("/api/preflight")
96
+ .then((r) => r.json())
97
+ .then((data) => { if (data) setPreflight(data as PreflightData); })
98
+ .catch(() => {});
99
+ }, []);
7
100
 
8
101
  function handleSubmit(e: React.FormEvent) {
9
102
  e.preventDefault();
@@ -11,31 +104,89 @@ export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
11
104
  if (trimmed) onSubmit(trimmed);
12
105
  }
13
106
 
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>
107
+ const recents = sessions?.slice(0, 5) ?? [];
22
108
 
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>
109
+ return (
110
+ <div className="flex flex-col items-center justify-center min-h-[60vh]">
111
+ <div className="w-full max-w-lg space-y-8">
112
+ <div className="space-y-2">
113
+ <div className="flex items-baseline gap-2">
114
+ <h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
115
+ <span className="text-[10px] text-muted-foreground/40">AI code review</span>
116
+ </div>
117
+ <p className="text-xs text-muted-foreground">
118
+ Paste a GitHub PR URL to start analysis
119
+ </p>
37
120
  </div>
38
- </form>
121
+
122
+ <form onSubmit={handleSubmit}>
123
+ <div className={`flex items-center rounded-xl border bg-background transition-all ${
124
+ focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
125
+ }`}>
126
+ <GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
127
+ <input
128
+ type="text"
129
+ value={value}
130
+ onChange={(e) => setValue(e.target.value)}
131
+ onFocus={() => setFocused(true)}
132
+ onBlur={() => setFocused(false)}
133
+ placeholder="https://github.com/owner/repo/pull/123"
134
+ className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
135
+ autoFocus
136
+ />
137
+ <button
138
+ type="submit"
139
+ disabled={!value.trim()}
140
+ 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"
141
+ >
142
+ <CornerDownLeft className="h-3.5 w-3.5" />
143
+ </button>
144
+ </div>
145
+ <div className="flex justify-end mt-2 pr-1">
146
+ <span className="text-[10px] text-muted-foreground/30">
147
+ Enter to analyze
148
+ </span>
149
+ </div>
150
+ </form>
151
+
152
+ {recents.length > 0 && (
153
+ <div className="space-y-2 pt-2">
154
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
155
+ Recent
156
+ </div>
157
+ <div className="space-y-px">
158
+ {recents.map((s) => (
159
+ <button
160
+ key={s.id}
161
+ type="button"
162
+ onClick={() => onSessionSelect?.(s.id)}
163
+ className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
164
+ >
165
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
166
+ <div className="flex-1 min-w-0">
167
+ <div className="text-xs truncate group-hover:text-foreground transition-colors">
168
+ {s.pr_title}
169
+ </div>
170
+ <div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
171
+ <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
172
+ <span className="font-mono">#{s.pr_number}</span>
173
+ <span className="text-muted-foreground/20">·</span>
174
+ <span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
175
+ <span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
176
+ </div>
177
+ </div>
178
+ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
179
+ <Clock className="h-2.5 w-2.5" />
180
+ <span>{timeAgo(s.analyzed_at)}</span>
181
+ </div>
182
+ </button>
183
+ ))}
184
+ </div>
185
+ </div>
186
+ )}
187
+
188
+ {preflight && <PreflightStatus data={preflight} />}
189
+ </div>
39
190
  </div>
40
191
  );
41
192
  }
@@ -110,15 +110,28 @@ export function LoadingTimeline({
110
110
  const steps = buildSteps(events);
111
111
  const seconds = Math.floor(elapsed / 1000);
112
112
 
113
+ const prInfo = events.find((e) => e.pr_title);
114
+ const title = prInfo?.pr_title;
115
+ const prNum = prInfo?.pr_number;
116
+
113
117
  return (
114
118
  <div className="flex flex-col items-center py-16">
115
119
  <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>
120
+ <div className="mb-8">
121
+ <div className="flex items-center gap-2">
122
+ {title ? (
123
+ <span className="text-sm font-semibold truncate">{title}</span>
124
+ ) : (
125
+ <span className="text-sm font-semibold font-mono">newpr</span>
126
+ )}
127
+ <span className="text-muted-foreground/30">·</span>
128
+ <span className="text-xs text-muted-foreground/50 tabular-nums shrink-0">
129
+ {seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`}
130
+ </span>
131
+ </div>
132
+ {prNum && (
133
+ <span className="text-[11px] text-muted-foreground/40 font-mono">#{prNum}</span>
134
+ )}
122
135
  </div>
123
136
 
124
137
  <div className="space-y-3">
@@ -1,24 +1,150 @@
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;
7
- onAnchorClick?: (kind: "group" | "file", id: string) => void;
14
+ onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
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 LINE_ANCHOR_WITH_TEXT_RE = /\[\[line:([^\]]+)\]\]\(([^)]+)\)/g;
100
+ const LINE_ANCHOR_BARE_RE = /\[\[line:([^\]]+)\]\]/g;
101
+ const BOLD_CJK_RE = /\*\*(.+?)\*\*/g;
102
+
103
+ function hasCJK(text: string): boolean {
104
+ return /[가-힣ぁ-ヿ一-鿿]/.test(text);
105
+ }
106
+
107
+ function formatLineLabel(id: string): string {
108
+ const hashIdx = id.indexOf("#");
109
+ if (hashIdx < 0) return id;
110
+ const file = id.slice(0, hashIdx);
111
+ const lineRef = id.slice(hashIdx + 1);
112
+ const fileName = file.split("/").pop() ?? file;
113
+ return `${fileName}:${lineRef}`;
114
+ }
115
+
116
+ function inlineMarkdownToHtml(text: string): string {
117
+ return text
118
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
119
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
120
+ .replace(/`([^`]+)`/g, "<code>$1</code>");
121
+ }
12
122
 
13
123
  function preprocess(text: string): string {
14
- return text.replace(ANCHOR_RE, (_, kind, id) => {
15
- const encoded = encodeURIComponent(id);
16
- return `![${kind}:${encoded}](newpr)`;
17
- });
124
+ return text
125
+ .replace(LINE_ANCHOR_WITH_TEXT_RE, (_, id, label) => {
126
+ const encoded = encodeURIComponent(id);
127
+ return `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
128
+ })
129
+ .replace(LINE_ANCHOR_BARE_RE, (_, id) => {
130
+ const encoded = encodeURIComponent(id);
131
+ const label = formatLineLabel(id);
132
+ return `<span data-line-ref="${encoded}">${label}</span>`;
133
+ })
134
+ .replace(ANCHOR_RE, (_, kind, id) => {
135
+ const encoded = encodeURIComponent(id);
136
+ return `![${kind}:${encoded}](newpr)`;
137
+ })
138
+ .replace(BOLD_CJK_RE, (match, inner) => {
139
+ if (hasCJK(inner)) return `<strong>${inner}</strong>`;
140
+ return match;
141
+ });
18
142
  }
19
143
 
20
144
  export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
21
145
  const processed = preprocess(children);
146
+ const hl = useHighlighter();
147
+ const dark = useDarkMode();
22
148
 
23
149
  const components: Components = {
24
150
  h1: ({ children }) => <h1 className="text-xl font-bold mt-6 mb-3 break-words">{children}</h1>,
@@ -32,69 +158,122 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
32
158
  strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
33
159
  em: ({ children }) => <em className="italic">{children}</em>,
34
160
  code: ({ children, className }) => {
161
+ const lang = langFromClassName(className);
162
+ if (lang && hl) {
163
+ const code = String(children).replace(/\n$/, "");
164
+ const theme = dark ? "github-dark" : "github-light";
165
+ try {
166
+ const html = hl.codeToHtml(code, { lang, theme });
167
+ return <span dangerouslySetInnerHTML={{ __html: html }} />;
168
+ } catch {
169
+ return <code className="text-xs font-mono">{children}</code>;
170
+ }
171
+ }
35
172
  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
- );
173
+ return <code className="text-xs font-mono">{children}</code>;
41
174
  }
42
175
  return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
43
176
  },
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>
177
+ pre: ({ children }) => (
178
+ <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>
47
179
  ),
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") {
180
+ span: ({ children, ...props }) => {
181
+ const lineRef = (props as Record<string, unknown>)["data-line-ref"] as string | undefined;
182
+ if (lineRef && onAnchorClick) {
183
+ const id = decodeURIComponent(lineRef);
184
+ const isActive = activeId === `line:${id}`;
63
185
  return (
64
186
  <span
65
187
  role="button"
66
188
  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 ${
189
+ onClick={(e) => { e.stopPropagation(); onAnchorClick("line", id); }}
190
+ onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("line", id); }}
191
+ className={`underline decoration-1 underline-offset-[3px] cursor-pointer transition-colors ${
70
192
  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"
193
+ ? "decoration-blue-500 dark:decoration-blue-400 bg-blue-500/5 rounded-sm"
194
+ : "decoration-foreground/15 hover:decoration-foreground/40"
73
195
  }`}
74
196
  >
75
- {id}
197
+ {children}
76
198
  </span>
77
199
  );
78
200
  }
201
+ if (lineRef) {
202
+ return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
203
+ }
204
+ return <span>{children}</span>;
205
+ },
206
+ a: ({ href, children }) => {
207
+ if (href && isMediaUrl(href)) {
208
+ const textContent = String(children ?? "");
209
+ if (textContent === href || !textContent.trim()) {
210
+ return <MediaEmbed src={href} />;
211
+ }
212
+ }
213
+ return <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{children}</a>;
214
+ },
215
+ video: ({ src, ...rest }: React.ComponentProps<"video">) => (
216
+ src ? <video src={src} controls playsInline className="max-w-full rounded-lg my-2" {...rest} /> : null
217
+ ),
218
+ img: ({ alt, src, ...rest }) => {
219
+ if (src !== "newpr") {
220
+ return <img alt={alt} src={src ? proxied(src) : src} {...rest} className="max-w-full rounded-lg my-2" />;
221
+ }
222
+ if (!alt?.includes(":")) return null;
223
+ const colonIdx = alt.indexOf(":");
224
+ const kind = alt.slice(0, colonIdx) as "group" | "file";
225
+ const id = decodeURIComponent(alt.slice(colonIdx + 1));
226
+
227
+ if (!onAnchorClick) {
228
+ if (kind === "group") {
229
+ 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>;
230
+ }
231
+ return <code className="px-1.5 py-0.5 rounded-md bg-accent text-[11px] font-mono text-foreground/70">{id.split("/").pop()}</code>;
232
+ }
233
+
234
+ const isActive = activeId === `${kind}:${id}`;
235
+ if (kind === "group") {
79
236
  return (
80
237
  <span
81
238
  role="button"
82
239
  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 ${
240
+ onClick={(e) => { e.stopPropagation(); onAnchorClick("group", id); }}
241
+ onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("group", id); }}
242
+ 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
243
  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"
244
+ ? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
245
+ : "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
246
  }`}
90
247
  >
91
- {id.split("/").pop()}
248
+ {id}
92
249
  </span>
93
250
  );
251
+ }
252
+ return (
253
+ <span
254
+ role="button"
255
+ tabIndex={0}
256
+ onClick={(e) => { e.stopPropagation(); onAnchorClick("file", id); }}
257
+ onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("file", id); }}
258
+ className={`inline px-1.5 py-0.5 rounded-md text-[11px] font-mono transition-colors cursor-pointer border ${
259
+ isActive
260
+ ? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
261
+ : "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"
262
+ }`}
263
+ >
264
+ {id.split("/").pop()}
265
+ </span>
266
+ );
94
267
  },
95
268
  blockquote: ({ children }) => (
96
269
  <blockquote className="border-l-2 border-muted-foreground/30 pl-4 text-sm text-muted-foreground italic mb-3">{children}</blockquote>
97
270
  ),
271
+ details: ({ children, ...rest }) => (
272
+ <details className="rounded-lg border border-border mb-3 open:pb-2" {...rest}>{children}</details>
273
+ ),
274
+ summary: ({ children }) => (
275
+ <summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none hover:bg-muted/50 rounded-lg">{children}</summary>
276
+ ),
98
277
  hr: () => <hr className="border-border my-4" />,
99
278
  table: ({ children }) => (
100
279
  <div className="overflow-x-auto mb-3">
@@ -105,5 +284,5 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
105
284
  td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
106
285
  };
107
286
 
108
- return <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>{processed}</ReactMarkdown>;
287
+ return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }], rehypeRaw]} components={components}>{processed}</ReactMarkdown>;
109
288
  }