newpr 0.4.0 → 0.5.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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import type { NewprConfig } from "../../types/config.ts";
2
2
  import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
3
3
  import { DEFAULT_CONFIG } from "../../types/config.ts";
4
- import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
4
+ import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar, saveSlidesSidecar, loadSlidesSidecar } from "../../history/store.ts";
5
5
  import type { DiffComment } from "../../types/output.ts";
6
6
  import { fetchPrDiff } from "../../github/fetch-diff.ts";
7
7
  import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
@@ -10,6 +10,7 @@ import { parsePrInput } from "../../github/parse-pr.ts";
10
10
  import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
11
11
  import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
+ import { generateSlides } from "../../llm/slides.ts";
13
14
  import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
14
15
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
15
16
  import { randomBytes } from "node:crypto";
@@ -70,6 +71,16 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
70
71
  return { login: "anonymous" };
71
72
  }
72
73
 
74
+ interface SlideJob {
75
+ status: "running" | "done" | "error";
76
+ message: string;
77
+ current: number;
78
+ total: number;
79
+ plan?: { stylePrompt: string; slides: Array<{ index: number; title: string; contentPrompt: string }> };
80
+ imagePrompts?: Array<{ index: number; prompt: string }>;
81
+ }
82
+ const slideJobs = new Map<string, SlideJob>();
83
+
73
84
  function buildChatSystemPrompt(data: NewprOutput): string {
74
85
  const fileSummaries = data.files
75
86
  .map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
@@ -223,7 +234,7 @@ $$
223
234
 
224
235
  return {
225
236
  "POST /api/analysis": async (req: Request) => {
226
- const body = await req.json() as { pr: string };
237
+ const body = await req.json() as { pr: string; reuseSessionId?: string };
227
238
  if (!body.pr) return json({ error: "Missing 'pr' field" }, 400);
228
239
 
229
240
  const result = startAnalysis(body.pr, token, config);
@@ -231,6 +242,7 @@ $$
231
242
 
232
243
  return json({
233
244
  sessionId: result.sessionId,
245
+ reuseSessionId: body.reuseSessionId,
234
246
  eventsUrl: `/api/analysis/${result.sessionId}/events`,
235
247
  });
236
248
  },
@@ -726,6 +738,107 @@ $$
726
738
  return json({ ok: true });
727
739
  },
728
740
 
741
+ "POST /api/sessions/:id/ask-inline": async (req: Request) => {
742
+ const url = new URL(req.url);
743
+ const segments = url.pathname.split("/");
744
+ const sessionId = segments[3]!;
745
+
746
+ if (!config.openrouter_api_key) {
747
+ return json({ error: "OpenRouter API key required" }, 400);
748
+ }
749
+
750
+ const body = await req.json() as { message: string };
751
+ if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
752
+
753
+ const sessionData = await loadSession(sessionId);
754
+ if (!sessionData) return json({ error: "Session not found" }, 404);
755
+
756
+ const systemPrompt = buildChatSystemPrompt(sessionData);
757
+ const apiMessages = [
758
+ { role: "system" as const, content: systemPrompt },
759
+ { role: "user" as const, content: body.message.trim() },
760
+ ];
761
+
762
+ const encoder = new TextEncoder();
763
+ const stream = new ReadableStream({
764
+ async start(controller) {
765
+ const send = (eventType: string, data: string) => {
766
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
767
+ };
768
+ try {
769
+ await chatWithTools(
770
+ { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
771
+ apiMessages as Parameters<typeof chatWithTools>[1],
772
+ buildChatTools(),
773
+ async (name: string, args: Record<string, unknown>): Promise<string> => {
774
+ if (name === "get_file_diff") {
775
+ const filePath = args.path as string;
776
+ if (!filePath) return "Error: path required";
777
+ const patches = await loadPatchesSidecar(sessionId);
778
+ if (patches?.[filePath]) return patches[filePath];
779
+ const patch = await loadSinglePatch(sessionId, filePath);
780
+ if (patch) return patch;
781
+ return `File "${filePath}" not found`;
782
+ }
783
+ if (name === "list_files") {
784
+ return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
785
+ }
786
+ return `Tool ${name} not available in inline mode`;
787
+ },
788
+ (event: ChatStreamEvent) => {
789
+ if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
790
+ else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
791
+ else if (event.type === "done") send("done", JSON.stringify({}));
792
+ },
793
+ );
794
+ send("done", JSON.stringify({}));
795
+ } catch (err) {
796
+ send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
797
+ } finally {
798
+ controller.close();
799
+ }
800
+ },
801
+ });
802
+
803
+ return new Response(stream, {
804
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
805
+ });
806
+ },
807
+
808
+ "GET /api/sessions/:id/outdated": async (req: Request) => {
809
+ const url = new URL(req.url);
810
+ const segments = url.pathname.split("/");
811
+ const id = segments[3]!;
812
+ const sessionData = await loadSession(id);
813
+ if (!sessionData) return json({ error: "Session not found" }, 404);
814
+
815
+ const prUrl = sessionData.meta.pr_url;
816
+ const analyzedUpdatedAt = sessionData.meta.pr_updated_at;
817
+ if (!analyzedUpdatedAt) return json({ outdated: false, reason: "no_baseline" });
818
+
819
+ try {
820
+ const pr = parsePrInput(prUrl);
821
+ const res = await fetch(
822
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
823
+ { headers: ghHeaders },
824
+ );
825
+ if (!res.ok) return json({ outdated: false, reason: "api_error" });
826
+ const data = await res.json() as { updated_at?: string; title?: string; state?: string; merged?: boolean; draft?: boolean };
827
+ const currentUpdatedAt = data.updated_at ?? "";
828
+ const outdated = currentUpdatedAt !== analyzedUpdatedAt;
829
+ return json({
830
+ outdated,
831
+ analyzed_at: sessionData.meta.analyzed_at,
832
+ analyzed_updated_at: analyzedUpdatedAt,
833
+ current_updated_at: currentUpdatedAt,
834
+ current_title: data.title,
835
+ current_state: data.draft ? "draft" : data.merged ? "merged" : data.state === "closed" ? "closed" : "open",
836
+ });
837
+ } catch {
838
+ return json({ outdated: false, reason: "fetch_error" });
839
+ }
840
+ },
841
+
729
842
  "GET /api/sessions/:id/chat": async (req: Request) => {
730
843
  const url = new URL(req.url);
731
844
  const segments = url.pathname.split("/");
@@ -1127,5 +1240,75 @@ $$
1127
1240
  return json({ error: msg }, 500);
1128
1241
  }
1129
1242
  },
1243
+ "GET /api/sessions/:id/slides": async (req: Request) => {
1244
+ const url = new URL(req.url);
1245
+ const segments = url.pathname.split("/");
1246
+ const id = segments[3]!;
1247
+ const deck = await loadSlidesSidecar(id);
1248
+ if (!deck) return json(null);
1249
+ return json(deck);
1250
+ },
1251
+
1252
+ "POST /api/slides": async (req: Request) => {
1253
+ if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required" }, 400);
1254
+
1255
+ const body = await req.json() as { sessionId?: string; language?: string; resume?: boolean };
1256
+ const sessionId = body.sessionId;
1257
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1258
+
1259
+ const data = await loadSession(sessionId);
1260
+ if (!data) return json({ error: "Session not found" }, 404);
1261
+
1262
+ if (slideJobs.has(sessionId) && slideJobs.get(sessionId)!.status === "running") {
1263
+ return json({ status: "already_running" });
1264
+ }
1265
+
1266
+ const existingDeck = body.resume ? await loadSlidesSidecar(sessionId) : null;
1267
+ const job: SlideJob = { status: "running", message: "Planning slide deck...", current: 0, total: 0 };
1268
+ slideJobs.set(sessionId, job);
1269
+
1270
+ (async () => {
1271
+ try {
1272
+ const deck = await generateSlides(
1273
+ config.openrouter_api_key,
1274
+ data,
1275
+ config.model,
1276
+ body.language ?? config.language,
1277
+ (msg, current, total) => {
1278
+ job.message = msg;
1279
+ job.current = current;
1280
+ job.total = total;
1281
+ },
1282
+ existingDeck,
1283
+ (plan, prompts) => {
1284
+ job.plan = plan;
1285
+ job.imagePrompts = prompts;
1286
+ },
1287
+ (partialDeck) => {
1288
+ saveSlidesSidecar(sessionId, partialDeck).catch(() => {});
1289
+ },
1290
+ );
1291
+ await saveSlidesSidecar(sessionId, deck);
1292
+ job.status = "done";
1293
+ job.message = `Generated ${deck.slides.length} slides`;
1294
+ job.total = deck.slides.length;
1295
+ job.current = deck.slides.length;
1296
+ } catch (err) {
1297
+ job.status = "error";
1298
+ job.message = err instanceof Error ? err.message : String(err);
1299
+ }
1300
+ })();
1301
+
1302
+ return json({ status: "started" });
1303
+ },
1304
+
1305
+ "GET /api/slides/status": async (req: Request) => {
1306
+ const url = new URL(req.url);
1307
+ const sessionId = url.searchParams.get("sessionId");
1308
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1309
+ const job = slideJobs.get(sessionId);
1310
+ if (!job) return json({ status: "idle" });
1311
+ return json(job);
1312
+ },
1130
1313
  };
1131
1314
  }
package/src/web/server.ts CHANGED
@@ -102,6 +102,12 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
102
102
  if (path.match(/^\/api\/sessions\/[^/]+\/comments$/) && req.method === "POST") {
103
103
  return routes["POST /api/sessions/:id/comments"](req);
104
104
  }
105
+ if (path.match(/^\/api\/sessions\/[^/]+\/ask-inline$/) && req.method === "POST") {
106
+ return routes["POST /api/sessions/:id/ask-inline"](req);
107
+ }
108
+ if (path.match(/^\/api\/sessions\/[^/]+\/outdated$/) && req.method === "GET") {
109
+ return routes["GET /api/sessions/:id/outdated"](req);
110
+ }
105
111
  if (path.match(/^\/api\/sessions\/[^/]+\/chat\/undo$/) && req.method === "POST") {
106
112
  return routes["POST /api/sessions/:id/chat/undo"](req);
107
113
  }
@@ -132,6 +138,15 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
132
138
  if (path === "/api/cartoon" && req.method === "POST") {
133
139
  return routes["POST /api/cartoon"](req);
134
140
  }
141
+ if (path.match(/^\/api\/sessions\/[^/]+\/slides$/) && req.method === "GET") {
142
+ return routes["GET /api/sessions/:id/slides"](req);
143
+ }
144
+ if (path === "/api/slides" && req.method === "POST") {
145
+ return routes["POST /api/slides"](req);
146
+ }
147
+ if (path === "/api/slides/status" && req.method === "GET") {
148
+ return routes["GET /api/slides/status"](req);
149
+ }
135
150
  if (path === "/api/review" && req.method === "POST") {
136
151
  return routes["POST /api/review"](req);
137
152
  }