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.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +37 -15
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +53 -1
- package/src/history/store.ts +107 -1
- package/src/history/types.ts +1 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +80 -19
- package/src/llm/response-parser.ts +13 -1
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +14 -0
- package/src/types/output.ts +50 -0
- package/src/web/client/App.tsx +33 -5
- package/src/web/client/components/AppShell.tsx +107 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +217 -77
- package/src/web/client/components/DiffViewer.tsx +713 -0
- package/src/web/client/components/InputScreen.tsx +178 -27
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +220 -41
- package/src/web/client/components/ResultsScreen.tsx +109 -73
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- 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 +62 -40
- package/src/web/client/panels/StoryPanel.tsx +43 -23
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +856 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +66 -4
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
|
@@ -7,6 +7,7 @@ type Phase = "idle" | "loading" | "done" | "error";
|
|
|
7
7
|
interface AnalysisState {
|
|
8
8
|
phase: Phase;
|
|
9
9
|
sessionId: string | null;
|
|
10
|
+
historyId: string | null;
|
|
10
11
|
events: ProgressEvent[];
|
|
11
12
|
result: NewprOutput | null;
|
|
12
13
|
error: string | null;
|
|
@@ -18,6 +19,7 @@ export function useAnalysis() {
|
|
|
18
19
|
const [state, setState] = useState<AnalysisState>({
|
|
19
20
|
phase: "idle",
|
|
20
21
|
sessionId: null,
|
|
22
|
+
historyId: null,
|
|
21
23
|
events: [],
|
|
22
24
|
result: null,
|
|
23
25
|
error: null,
|
|
@@ -30,6 +32,7 @@ export function useAnalysis() {
|
|
|
30
32
|
setState({
|
|
31
33
|
phase: "loading",
|
|
32
34
|
sessionId: null,
|
|
35
|
+
historyId: null,
|
|
33
36
|
events: [],
|
|
34
37
|
result: null,
|
|
35
38
|
error: null,
|
|
@@ -75,11 +78,12 @@ export function useAnalysis() {
|
|
|
75
78
|
es.close();
|
|
76
79
|
eventSourceRef.current = null;
|
|
77
80
|
const resultRes = await fetch(`/api/analysis/${sessionId}`);
|
|
78
|
-
const data = await resultRes.json();
|
|
81
|
+
const data = await resultRes.json() as { result?: NewprOutput; historyId?: string };
|
|
79
82
|
setState((s) => ({
|
|
80
83
|
...s,
|
|
81
84
|
phase: "done",
|
|
82
85
|
result: data.result ?? null,
|
|
86
|
+
historyId: data.historyId ?? null,
|
|
83
87
|
}));
|
|
84
88
|
});
|
|
85
89
|
|
|
@@ -109,6 +113,7 @@ export function useAnalysis() {
|
|
|
109
113
|
setState((s) => ({
|
|
110
114
|
...s,
|
|
111
115
|
phase: "loading",
|
|
116
|
+
historyId: null,
|
|
112
117
|
events: [],
|
|
113
118
|
result: null,
|
|
114
119
|
error: null,
|
|
@@ -125,6 +130,7 @@ export function useAnalysis() {
|
|
|
125
130
|
phase: "done",
|
|
126
131
|
result: data,
|
|
127
132
|
sessionId,
|
|
133
|
+
historyId: sessionId,
|
|
128
134
|
}));
|
|
129
135
|
} catch (err) {
|
|
130
136
|
setState((s) => ({
|
|
@@ -141,6 +147,7 @@ export function useAnalysis() {
|
|
|
141
147
|
setState({
|
|
142
148
|
phase: "idle",
|
|
143
149
|
sessionId: null,
|
|
150
|
+
historyId: null,
|
|
144
151
|
events: [],
|
|
145
152
|
result: null,
|
|
146
153
|
error: null,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHighlighter, type Highlighter } from "shiki";
|
|
2
|
+
|
|
3
|
+
export const SHIKI_LANGS = [
|
|
4
|
+
"typescript", "tsx", "javascript", "jsx",
|
|
5
|
+
"python", "go", "rust", "css", "json",
|
|
6
|
+
"yaml", "html", "bash", "java", "c",
|
|
7
|
+
"cpp", "ruby", "php", "swift", "kotlin",
|
|
8
|
+
"sql", "markdown", "toml", "xml",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type ShikiLang = (typeof SHIKI_LANGS)[number];
|
|
12
|
+
|
|
13
|
+
let hlInstance: Highlighter | null = null;
|
|
14
|
+
let hlLoading: Promise<Highlighter> | null = null;
|
|
15
|
+
|
|
16
|
+
export function ensureHighlighter(): Promise<Highlighter> {
|
|
17
|
+
if (hlInstance) return Promise.resolve(hlInstance);
|
|
18
|
+
if (!hlLoading) {
|
|
19
|
+
hlLoading = createHighlighter({
|
|
20
|
+
themes: ["github-light", "github-dark"],
|
|
21
|
+
langs: [...SHIKI_LANGS],
|
|
22
|
+
}).then((hl) => { hlInstance = hl; return hl; });
|
|
23
|
+
}
|
|
24
|
+
return hlLoading;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ensureHighlighter().catch(() => {});
|
|
28
|
+
|
|
29
|
+
export function getHighlighterSync(): Highlighter | null {
|
|
30
|
+
return hlInstance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EXT_TO_LANG: Record<string, string> = {
|
|
34
|
+
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
35
|
+
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
36
|
+
py: "python", pyi: "python",
|
|
37
|
+
go: "go", rs: "rust",
|
|
38
|
+
css: "css", scss: "css", less: "css",
|
|
39
|
+
json: "json", jsonc: "json",
|
|
40
|
+
yaml: "yaml", yml: "yaml",
|
|
41
|
+
html: "html", htm: "html", vue: "html", svelte: "html",
|
|
42
|
+
sh: "bash", bash: "bash", zsh: "bash",
|
|
43
|
+
java: "java", kt: "kotlin",
|
|
44
|
+
c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp",
|
|
45
|
+
rb: "ruby", php: "php", swift: "swift",
|
|
46
|
+
sql: "sql", md: "markdown", mdx: "markdown",
|
|
47
|
+
toml: "toml",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function detectShikiLang(filePath: string): ShikiLang | null {
|
|
51
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
52
|
+
return (EXT_TO_LANG[ext] as ShikiLang | undefined) ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function langFromClassName(className: string | undefined): ShikiLang | null {
|
|
56
|
+
if (!className) return null;
|
|
57
|
+
const match = className.match(/language-(\w+)/);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
const raw = match[1]!.toLowerCase();
|
|
60
|
+
const mapped = EXT_TO_LANG[raw] ?? raw;
|
|
61
|
+
if ((SHIKI_LANGS as readonly string[]).includes(mapped)) return mapped as ShikiLang;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
2
|
-
import { Loader2, Sparkles, RefreshCw } from "lucide-react";
|
|
3
|
-
import { Button } from "../../components/ui/button.tsx";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Loader2, Sparkles, RefreshCw, Download, AlertCircle } from "lucide-react";
|
|
4
3
|
import type { NewprOutput } from "../../../types/output.ts";
|
|
5
4
|
|
|
6
5
|
export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
|
|
@@ -12,10 +11,21 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
|
|
|
12
11
|
if (data.cartoon) {
|
|
13
12
|
setImageUrl(`data:${data.cartoon.mimeType};base64,${data.cartoon.imageBase64}`);
|
|
14
13
|
setState("done");
|
|
14
|
+
return;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
if (!sessionId) return;
|
|
17
|
+
fetch(`/api/sessions/${sessionId}/cartoon`)
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((cartoon) => {
|
|
20
|
+
if (cartoon?.imageBase64) {
|
|
21
|
+
setImageUrl(`data:${cartoon.mimeType};base64,${cartoon.imageBase64}`);
|
|
22
|
+
setState("done");
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {});
|
|
26
|
+
}, [data.cartoon, sessionId]);
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
const generate = useCallback(async () => {
|
|
19
29
|
setState("loading");
|
|
20
30
|
setError(null);
|
|
21
31
|
try {
|
|
@@ -36,61 +46,108 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
|
|
|
36
46
|
setError(err instanceof Error ? err.message : String(err));
|
|
37
47
|
setState("error");
|
|
38
48
|
}
|
|
39
|
-
}
|
|
49
|
+
}, [data, sessionId]);
|
|
50
|
+
|
|
51
|
+
const download = useCallback(() => {
|
|
52
|
+
if (!imageUrl) return;
|
|
53
|
+
const a = document.createElement("a");
|
|
54
|
+
a.href = imageUrl;
|
|
55
|
+
a.download = `newpr-comic-${data.meta.pr_number}.png`;
|
|
56
|
+
a.click();
|
|
57
|
+
}, [imageUrl, data.meta.pr_number]);
|
|
40
58
|
|
|
41
59
|
if (state === "idle") {
|
|
42
60
|
return (
|
|
43
|
-
<div className="pt-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
<div className="pt-8 flex flex-col items-center">
|
|
62
|
+
<div className="w-full max-w-sm space-y-6">
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<h3 className="text-xs font-medium">Comic Strip</h3>
|
|
65
|
+
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
|
66
|
+
Generate a 4-panel comic strip that visualizes the key changes in this PR. Powered by Gemini.
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={generate}
|
|
72
|
+
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-foreground text-background text-xs font-medium hover:opacity-90 transition-opacity"
|
|
73
|
+
>
|
|
74
|
+
<Sparkles className="h-3.5 w-3.5" />
|
|
75
|
+
Generate
|
|
76
|
+
</button>
|
|
50
77
|
</div>
|
|
51
|
-
<Button onClick={generate} size="lg">
|
|
52
|
-
<Sparkles className="mr-2 h-4 w-4" />
|
|
53
|
-
Generate Comic
|
|
54
|
-
</Button>
|
|
55
78
|
</div>
|
|
56
79
|
);
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
if (state === "loading") {
|
|
60
83
|
return (
|
|
61
|
-
<div className="pt-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
<div className="pt-8 flex flex-col items-center">
|
|
85
|
+
<div className="w-full max-w-sm space-y-4">
|
|
86
|
+
<div className="aspect-[4/3] rounded-lg border border-dashed border-border/60 flex flex-col items-center justify-center gap-3">
|
|
87
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/40" />
|
|
88
|
+
<div className="text-center space-y-1">
|
|
89
|
+
<p className="text-xs text-muted-foreground/60">Generating comic...</p>
|
|
90
|
+
<p className="text-[10px] text-muted-foreground/30">This may take 10-30 seconds</p>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
65
94
|
</div>
|
|
66
95
|
);
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
if (state === "error") {
|
|
70
99
|
return (
|
|
71
|
-
<div className="pt-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
<div className="pt-8 flex flex-col items-center">
|
|
101
|
+
<div className="w-full max-w-sm space-y-4">
|
|
102
|
+
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 flex items-start gap-2.5">
|
|
103
|
+
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
|
104
|
+
<div className="space-y-1 min-w-0">
|
|
105
|
+
<p className="text-xs text-destructive font-medium">Generation failed</p>
|
|
106
|
+
<p className="text-[11px] text-destructive/70 break-words">{error}</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={generate}
|
|
112
|
+
className="w-full flex items-center justify-center gap-2 h-9 rounded-lg border text-xs text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
|
113
|
+
>
|
|
114
|
+
<RefreshCw className="h-3 w-3" />
|
|
115
|
+
Try again
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
77
118
|
</div>
|
|
78
119
|
);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
return (
|
|
82
|
-
<div className="pt-6
|
|
123
|
+
<div className="pt-6 space-y-3">
|
|
83
124
|
{imageUrl && (
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
125
|
+
<div className="rounded-lg border overflow-hidden">
|
|
126
|
+
<img
|
|
127
|
+
src={imageUrl}
|
|
128
|
+
alt="PR 4-panel comic"
|
|
129
|
+
className="w-full"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
89
132
|
)}
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
133
|
+
<div className="flex items-center justify-end gap-1.5">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={download}
|
|
137
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
138
|
+
>
|
|
139
|
+
<Download className="h-3 w-3" />
|
|
140
|
+
Download
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={generate}
|
|
145
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
146
|
+
>
|
|
147
|
+
<RefreshCw className="h-3 w-3" />
|
|
148
|
+
Regenerate
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
94
151
|
</div>
|
|
95
152
|
);
|
|
96
153
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Markdown } from "../components/Markdown.tsx";
|
|
3
|
+
import { RefreshCw, ExternalLink, AlertCircle, Loader2 } from "lucide-react";
|
|
4
|
+
import type { PrComment } from "../../../types/github.ts";
|
|
5
|
+
|
|
6
|
+
interface DiscussionData {
|
|
7
|
+
body: string;
|
|
8
|
+
comments: PrComment[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function timeAgo(dateStr: string): string {
|
|
12
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
13
|
+
const mins = Math.floor(diff / 60_000);
|
|
14
|
+
if (mins < 1) return "just now";
|
|
15
|
+
if (mins < 60) return `${mins}m ago`;
|
|
16
|
+
const hours = Math.floor(mins / 60);
|
|
17
|
+
if (hours < 24) return `${hours}h ago`;
|
|
18
|
+
const days = Math.floor(hours / 24);
|
|
19
|
+
if (days < 30) return `${days}d ago`;
|
|
20
|
+
return new Date(dateStr).toLocaleDateString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
|
|
24
|
+
const [data, setData] = useState<DiscussionData | null>(null);
|
|
25
|
+
const [loading, setLoading] = useState(false);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
const fetchDiscussion = useCallback(async () => {
|
|
29
|
+
if (!sessionId) return;
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`/api/sessions/${sessionId}/discussion`);
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.json().catch(() => ({})) as { error?: string };
|
|
36
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
37
|
+
}
|
|
38
|
+
const json = await res.json() as DiscussionData;
|
|
39
|
+
setData(json);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [sessionId]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setData(null);
|
|
49
|
+
setError(null);
|
|
50
|
+
fetchDiscussion();
|
|
51
|
+
}, [fetchDiscussion]);
|
|
52
|
+
|
|
53
|
+
if (!sessionId) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col items-center justify-center py-20">
|
|
56
|
+
<p className="text-xs text-muted-foreground/50">No session available</p>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (loading) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex items-center justify-center py-20 gap-2">
|
|
64
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
|
|
65
|
+
<span className="text-xs text-muted-foreground/50">Loading discussion</span>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (error) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
|
73
|
+
<div className="flex items-center gap-2 text-destructive">
|
|
74
|
+
<AlertCircle className="h-3.5 w-3.5" />
|
|
75
|
+
<p className="text-xs">{error}</p>
|
|
76
|
+
</div>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={fetchDiscussion}
|
|
80
|
+
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
81
|
+
>
|
|
82
|
+
<RefreshCw className="h-3 w-3" />
|
|
83
|
+
Retry
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!data) return null;
|
|
90
|
+
|
|
91
|
+
const hasBody = data.body.trim().length > 0;
|
|
92
|
+
const hasComments = data.comments.length > 0;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="pt-5 space-y-6">
|
|
96
|
+
{hasBody && (
|
|
97
|
+
<div>
|
|
98
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
|
|
99
|
+
Description
|
|
100
|
+
</div>
|
|
101
|
+
<div className="text-xs">
|
|
102
|
+
<Markdown>{data.body}</Markdown>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{!hasBody && !hasComments && (
|
|
108
|
+
<div className="text-center py-12">
|
|
109
|
+
<p className="text-xs text-muted-foreground/40">No description or comments</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{hasComments && (
|
|
114
|
+
<div>
|
|
115
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
|
|
116
|
+
Comments
|
|
117
|
+
<span className="ml-1.5 text-muted-foreground/25">{data.comments.length}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="space-y-0">
|
|
120
|
+
{data.comments.map((comment, i) => (
|
|
121
|
+
<div
|
|
122
|
+
key={comment.id}
|
|
123
|
+
className={`py-4 ${i > 0 ? "border-t border-border/50" : ""}`}
|
|
124
|
+
>
|
|
125
|
+
<div className="flex items-center gap-2 mb-2.5">
|
|
126
|
+
{comment.author_avatar ? (
|
|
127
|
+
<img
|
|
128
|
+
src={comment.author_avatar}
|
|
129
|
+
alt={comment.author}
|
|
130
|
+
className="h-5 w-5 rounded-full"
|
|
131
|
+
/>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="h-5 w-5 rounded-full bg-muted" />
|
|
134
|
+
)}
|
|
135
|
+
<span className="text-xs font-medium">{comment.author}</span>
|
|
136
|
+
<span className="text-[10px] text-muted-foreground/40">
|
|
137
|
+
{timeAgo(comment.created_at)}
|
|
138
|
+
</span>
|
|
139
|
+
<a
|
|
140
|
+
href={comment.html_url}
|
|
141
|
+
target="_blank"
|
|
142
|
+
rel="noopener noreferrer"
|
|
143
|
+
className="ml-auto text-muted-foreground/20 hover:text-muted-foreground/60 transition-colors"
|
|
144
|
+
>
|
|
145
|
+
<ExternalLink className="h-3 w-3" />
|
|
146
|
+
</a>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="pl-7 text-xs">
|
|
149
|
+
<Markdown>{comment.body}</Markdown>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|