newpr 0.4.0 → 0.5.1
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 +2 -2
- package/src/analyzer/pipeline.ts +42 -1
- package/src/config/store.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +37 -17
- package/src/llm/slides.ts +381 -0
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +7 -1
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +241 -37
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +37 -3
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +407 -5
- package/src/web/server.ts +30 -0
- package/src/web/styles/built.css +1 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { Loader2, Presentation, RefreshCw, Download, AlertCircle, ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import type { NewprOutput, SlideDeck } from "../../../types/output.ts";
|
|
4
|
+
import { sendNotification } from "../lib/notify.ts";
|
|
5
|
+
|
|
6
|
+
export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
|
|
7
|
+
const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
|
|
8
|
+
const [deck, setDeck] = useState<SlideDeck | null>(null);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [progress, setProgress] = useState("");
|
|
11
|
+
const [progressDetail, setProgressDetail] = useState<{ current: number; total: number } | null>(null);
|
|
12
|
+
const [currentSlide, setCurrentSlide] = useState(0);
|
|
13
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
14
|
+
|
|
15
|
+
const stopPolling = useCallback(() => {
|
|
16
|
+
if (pollRef.current) {
|
|
17
|
+
clearInterval(pollRef.current);
|
|
18
|
+
pollRef.current = null;
|
|
19
|
+
}
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!sessionId) return;
|
|
24
|
+
fetch(`/api/sessions/${sessionId}/slides`)
|
|
25
|
+
.then((r) => r.json())
|
|
26
|
+
.then((d) => {
|
|
27
|
+
if (d?.slides?.length > 0) {
|
|
28
|
+
setDeck(d as SlideDeck);
|
|
29
|
+
setState("done");
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {});
|
|
33
|
+
return stopPolling;
|
|
34
|
+
}, [sessionId, stopPolling]);
|
|
35
|
+
|
|
36
|
+
const startPolling = useCallback(() => {
|
|
37
|
+
if (!sessionId || pollRef.current) return;
|
|
38
|
+
pollRef.current = setInterval(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const [statusRes, slidesRes] = await Promise.all([
|
|
41
|
+
fetch(`/api/slides/status?sessionId=${sessionId}`),
|
|
42
|
+
fetch(`/api/sessions/${sessionId}/slides`),
|
|
43
|
+
]);
|
|
44
|
+
const job = await statusRes.json() as { status: string; message?: string; current?: number; total?: number };
|
|
45
|
+
if (job.message) setProgress(job.message);
|
|
46
|
+
if (job.total && job.total > 0) setProgressDetail({ current: job.current ?? 0, total: job.total });
|
|
47
|
+
|
|
48
|
+
const partial = slidesRes.ok ? await slidesRes.json() as SlideDeck | null : null;
|
|
49
|
+
if (partial?.slides?.length) setDeck(partial);
|
|
50
|
+
|
|
51
|
+
if (job.status === "done") {
|
|
52
|
+
stopPolling();
|
|
53
|
+
if (partial?.slides?.length) {
|
|
54
|
+
setDeck(partial);
|
|
55
|
+
setState("done");
|
|
56
|
+
sendNotification("Slides ready", `${partial.slides.length} slides generated`);
|
|
57
|
+
}
|
|
58
|
+
} else if (job.status === "error") {
|
|
59
|
+
stopPolling();
|
|
60
|
+
setError(job.message ?? "Generation failed");
|
|
61
|
+
if (partial?.slides?.length) {
|
|
62
|
+
setDeck(partial);
|
|
63
|
+
setState("done");
|
|
64
|
+
} else {
|
|
65
|
+
setState("error");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
}, 1000);
|
|
70
|
+
}, [sessionId, stopPolling]);
|
|
71
|
+
|
|
72
|
+
const generate = useCallback(async (resume = false) => {
|
|
73
|
+
if (!sessionId) return;
|
|
74
|
+
setState("loading");
|
|
75
|
+
setError(null);
|
|
76
|
+
setProgress(resume ? "Resuming failed slides..." : "Starting...");
|
|
77
|
+
setProgressDetail(null);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch("/api/slides", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ sessionId, resume }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.json() as { error?: string };
|
|
87
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
startPolling();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
92
|
+
setState("error");
|
|
93
|
+
}
|
|
94
|
+
}, [sessionId, startPolling]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!sessionId || state !== "idle") return;
|
|
98
|
+
fetch(`/api/slides/status?sessionId=${sessionId}`)
|
|
99
|
+
.then((r) => r.json())
|
|
100
|
+
.then((job) => {
|
|
101
|
+
if ((job as { status: string }).status === "running") {
|
|
102
|
+
setState("loading");
|
|
103
|
+
setProgress((job as { message?: string }).message ?? "");
|
|
104
|
+
startPolling();
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
}, [sessionId, state, startPolling]);
|
|
109
|
+
|
|
110
|
+
const downloadAll = useCallback(() => {
|
|
111
|
+
if (!deck) return;
|
|
112
|
+
for (const slide of deck.slides) {
|
|
113
|
+
const a = document.createElement("a");
|
|
114
|
+
a.href = `data:${slide.mimeType};base64,${slide.imageBase64}`;
|
|
115
|
+
a.download = `newpr-slide-${slide.index + 1}-${data.meta.pr_number}.png`;
|
|
116
|
+
a.click();
|
|
117
|
+
}
|
|
118
|
+
}, [deck, data.meta.pr_number]);
|
|
119
|
+
|
|
120
|
+
if (state === "idle") {
|
|
121
|
+
return (
|
|
122
|
+
<div className="pt-8 flex flex-col items-center">
|
|
123
|
+
<div className="w-full max-w-sm space-y-6">
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<h3 className="text-xs font-medium">Slide Deck</h3>
|
|
126
|
+
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
|
127
|
+
Generate a presentation that explains this PR to your team. The number of slides is automatically determined based on PR complexity.
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => generate()}
|
|
133
|
+
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"
|
|
134
|
+
>
|
|
135
|
+
<Presentation className="h-3.5 w-3.5" />
|
|
136
|
+
Generate Slides
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (state === "loading") {
|
|
144
|
+
const pct = progressDetail && progressDetail.total > 0 ? Math.round((progressDetail.current / progressDetail.total) * 100) : 0;
|
|
145
|
+
const partialSlides = deck?.slides ?? [];
|
|
146
|
+
return (
|
|
147
|
+
<div className="pt-5 space-y-4">
|
|
148
|
+
<div className="flex items-center gap-3">
|
|
149
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40 shrink-0" />
|
|
150
|
+
<p className="text-xs text-muted-foreground/60 line-clamp-1 flex-1">{progress}</p>
|
|
151
|
+
{progressDetail && progressDetail.total > 0 && (
|
|
152
|
+
<span className="text-[10px] text-muted-foreground/30 tabular-nums shrink-0">{progressDetail.current}/{progressDetail.total}</span>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
{progressDetail && progressDetail.total > 0 && (
|
|
156
|
+
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
|
157
|
+
<div className="h-full bg-foreground/40 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{partialSlides.length > 0 && (
|
|
161
|
+
<div className="space-y-3">
|
|
162
|
+
<div className="rounded-lg border overflow-hidden bg-black">
|
|
163
|
+
<img
|
|
164
|
+
src={`data:${partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.mimeType};base64,${partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.imageBase64}`}
|
|
165
|
+
alt={partialSlides[currentSlide >= partialSlides.length ? 0 : currentSlide]!.title}
|
|
166
|
+
className="w-full aspect-video object-contain"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
170
|
+
{partialSlides.map((s, i) => (
|
|
171
|
+
<button
|
|
172
|
+
key={s.index}
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => setCurrentSlide(i)}
|
|
175
|
+
className={`shrink-0 rounded-md overflow-hidden border-2 transition-colors ${
|
|
176
|
+
i === (currentSlide >= partialSlides.length ? 0 : currentSlide) ? "border-foreground" : "border-transparent hover:border-border"
|
|
177
|
+
}`}
|
|
178
|
+
>
|
|
179
|
+
<img src={`data:${s.mimeType};base64,${s.imageBase64}`} alt={s.title} className="h-12 aspect-video object-cover" />
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
{partialSlides.length === 0 && !progressDetail && (
|
|
186
|
+
<p className="text-[10px] text-muted-foreground/30 text-center">This may take a few minutes</p>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (state === "error") {
|
|
193
|
+
return (
|
|
194
|
+
<div className="pt-8 flex flex-col items-center">
|
|
195
|
+
<div className="w-full max-w-sm space-y-4">
|
|
196
|
+
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 flex items-start gap-2.5">
|
|
197
|
+
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
|
198
|
+
<div className="space-y-1 min-w-0">
|
|
199
|
+
<p className="text-xs text-destructive font-medium">Generation failed</p>
|
|
200
|
+
<p className="text-[11px] text-destructive/70 break-words">{error}</p>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={() => generate(true)}
|
|
206
|
+
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"
|
|
207
|
+
>
|
|
208
|
+
<RefreshCw className="h-3 w-3" />
|
|
209
|
+
Try again
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!deck || deck.slides.length === 0) return null;
|
|
217
|
+
const slide = deck.slides[currentSlide];
|
|
218
|
+
if (!slide) return null;
|
|
219
|
+
const total = deck.slides.length;
|
|
220
|
+
const hasFailed = deck.failedIndices && deck.failedIndices.length > 0;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="pt-5 space-y-3">
|
|
224
|
+
{hasFailed && (
|
|
225
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
|
|
226
|
+
<AlertCircle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
|
227
|
+
<span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
|
|
228
|
+
{deck.failedIndices!.length} slide{deck.failedIndices!.length > 1 ? "s" : ""} failed to generate
|
|
229
|
+
</span>
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={() => generate(true)}
|
|
233
|
+
className="flex items-center gap-1 text-[11px] font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 shrink-0 transition-colors"
|
|
234
|
+
>
|
|
235
|
+
<RefreshCw className="h-3 w-3" />
|
|
236
|
+
Retry failed
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
<div className="rounded-lg border overflow-hidden bg-black">
|
|
242
|
+
<img
|
|
243
|
+
src={`data:${slide.mimeType};base64,${slide.imageBase64}`}
|
|
244
|
+
alt={slide.title}
|
|
245
|
+
className="w-full aspect-video object-contain"
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="flex items-center justify-between">
|
|
250
|
+
<div className="flex items-center gap-2">
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => setCurrentSlide((i) => Math.max(0, i - 1))}
|
|
254
|
+
disabled={currentSlide === 0}
|
|
255
|
+
className="flex h-7 w-7 items-center justify-center rounded-md border text-muted-foreground/60 hover:text-foreground hover:border-foreground/20 disabled:opacity-20 transition-colors"
|
|
256
|
+
>
|
|
257
|
+
<ChevronLeft className="h-3.5 w-3.5" />
|
|
258
|
+
</button>
|
|
259
|
+
<span className="text-[11px] text-muted-foreground/50 tabular-nums min-w-[60px] text-center">
|
|
260
|
+
{currentSlide + 1} / {total}
|
|
261
|
+
</span>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={() => setCurrentSlide((i) => Math.min(total - 1, i + 1))}
|
|
265
|
+
disabled={currentSlide === total - 1}
|
|
266
|
+
className="flex h-7 w-7 items-center justify-center rounded-md border text-muted-foreground/60 hover:text-foreground hover:border-foreground/20 disabled:opacity-20 transition-colors"
|
|
267
|
+
>
|
|
268
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="text-[11px] text-muted-foreground/40 truncate max-w-[50%]">
|
|
273
|
+
{slide.title}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className="flex items-center gap-1.5">
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={downloadAll}
|
|
280
|
+
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"
|
|
281
|
+
>
|
|
282
|
+
<Download className="h-3 w-3" />
|
|
283
|
+
Download
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={() => generate()}
|
|
288
|
+
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"
|
|
289
|
+
>
|
|
290
|
+
<RefreshCw className="h-3 w-3" />
|
|
291
|
+
Regenerate
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
297
|
+
{deck.slides.map((s, i) => (
|
|
298
|
+
<button
|
|
299
|
+
key={s.index}
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={() => setCurrentSlide(i)}
|
|
302
|
+
className={`shrink-0 rounded-md overflow-hidden border-2 transition-colors ${
|
|
303
|
+
i === currentSlide ? "border-foreground" : "border-transparent hover:border-border"
|
|
304
|
+
}`}
|
|
305
|
+
>
|
|
306
|
+
<img
|
|
307
|
+
src={`data:${s.mimeType};base64,${s.imageBase64}`}
|
|
308
|
+
alt={s.title}
|
|
309
|
+
className="h-12 aspect-video object-cover"
|
|
310
|
+
/>
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|