newpr 0.1.1 → 0.2.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 (37) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/cli/args.ts +6 -1
  4. package/src/cli/index.ts +2 -2
  5. package/src/github/fetch-pr.ts +43 -1
  6. package/src/history/store.ts +106 -1
  7. package/src/llm/cartoon.ts +128 -0
  8. package/src/llm/client.ts +197 -0
  9. package/src/llm/prompts.ts +33 -8
  10. package/src/tui/Shell.tsx +7 -2
  11. package/src/types/github.ts +11 -0
  12. package/src/types/output.ts +51 -0
  13. package/src/web/client/App.tsx +32 -2
  14. package/src/web/client/components/AppShell.tsx +94 -47
  15. package/src/web/client/components/ChatSection.tsx +427 -0
  16. package/src/web/client/components/DetailPane.tsx +163 -75
  17. package/src/web/client/components/DiffViewer.tsx +679 -0
  18. package/src/web/client/components/InputScreen.tsx +110 -26
  19. package/src/web/client/components/Markdown.tsx +169 -43
  20. package/src/web/client/components/ResultsScreen.tsx +135 -110
  21. package/src/web/client/components/TipTapEditor.tsx +405 -0
  22. package/src/web/client/hooks/useAnalysis.ts +8 -1
  23. package/src/web/client/hooks/useFeatures.ts +18 -0
  24. package/src/web/client/lib/shiki.ts +63 -0
  25. package/src/web/client/panels/CartoonPanel.tsx +153 -0
  26. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  27. package/src/web/client/panels/FilesPanel.tsx +435 -54
  28. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  29. package/src/web/client/panels/StoryPanel.tsx +42 -22
  30. package/src/web/components/ui/tabs.tsx +3 -3
  31. package/src/web/server/routes.ts +752 -2
  32. package/src/web/server/session-manager.ts +11 -2
  33. package/src/web/server.ts +42 -2
  34. package/src/web/styles/built.css +1 -1
  35. package/src/web/styles/globals.css +117 -1
  36. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  37. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -0,0 +1,405 @@
1
+ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
2
+ import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import Placeholder from "@tiptap/extension-placeholder";
5
+ import Mention from "@tiptap/extension-mention";
6
+ import { PluginKey } from "@tiptap/pm/state";
7
+ import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
8
+
9
+ export interface AnchorItem {
10
+ kind: "group" | "file";
11
+ id: string;
12
+ label: string;
13
+ }
14
+
15
+ export interface CommandItem {
16
+ id: string;
17
+ label: string;
18
+ description: string;
19
+ }
20
+
21
+ interface SuggestionEntry {
22
+ id: string;
23
+ label: string;
24
+ badge?: string;
25
+ badgeClass?: string;
26
+ description?: string;
27
+ mono?: boolean;
28
+ }
29
+
30
+ interface TipTapEditorProps {
31
+ content?: string;
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ autoFocus?: boolean;
35
+ className?: string;
36
+ onSubmit?: () => void;
37
+ onChange?: (text: string) => void;
38
+ submitOnEnter?: boolean;
39
+ submitOnModEnter?: boolean;
40
+ onEscape?: () => void;
41
+ editorRef?: React.MutableRefObject<ReturnType<typeof useEditor> | null>;
42
+ anchorItems?: AnchorItem[];
43
+ commands?: CommandItem[];
44
+ }
45
+
46
+ function SuggestionList({
47
+ items,
48
+ command,
49
+ selectedIndex,
50
+ setSelectedIndex,
51
+ }: {
52
+ items: SuggestionEntry[];
53
+ command: (item: SuggestionEntry) => void;
54
+ selectedIndex: number;
55
+ setSelectedIndex: (i: number) => void;
56
+ }) {
57
+ const listRef = useRef<HTMLDivElement>(null);
58
+
59
+ useEffect(() => {
60
+ const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
61
+ el?.scrollIntoView({ block: "nearest" });
62
+ }, [selectedIndex]);
63
+
64
+ if (items.length === 0) return null;
65
+
66
+ return (
67
+ <div
68
+ ref={listRef}
69
+ className="z-50 min-w-[200px] max-w-[340px] max-h-[200px] overflow-y-auto rounded-lg border bg-background shadow-lg py-1"
70
+ >
71
+ {items.map((item, i) => (
72
+ <button
73
+ key={item.id}
74
+ type="button"
75
+ onClick={() => command(item)}
76
+ onMouseEnter={() => setSelectedIndex(i)}
77
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors ${
78
+ i === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
79
+ }`}
80
+ >
81
+ {item.badge && (
82
+ <span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded ${item.badgeClass ?? "bg-muted text-muted-foreground"}`}>
83
+ {item.badge}
84
+ </span>
85
+ )}
86
+ <span className={`truncate text-xs ${item.mono ? "font-mono" : ""}`}>
87
+ {item.label}
88
+ </span>
89
+ {item.description && (
90
+ <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/50">
91
+ {item.description}
92
+ </span>
93
+ )}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ interface SuggestionListRef {
101
+ onKeyDown: (props: SuggestionKeyDownProps) => boolean;
102
+ }
103
+
104
+ const SuggestionListWrapper = forwardRef<SuggestionListRef, SuggestionProps<SuggestionEntry>>(
105
+ (props, ref) => {
106
+ const [selectedIndex, setSelectedIndex] = useState(0);
107
+
108
+ useEffect(() => {
109
+ setSelectedIndex(0);
110
+ }, [props.items]);
111
+
112
+ useImperativeHandle(ref, () => ({
113
+ onKeyDown: ({ event }: SuggestionKeyDownProps) => {
114
+ if (event.key === "ArrowUp") {
115
+ setSelectedIndex((i) => (i + props.items.length - 1) % props.items.length);
116
+ return true;
117
+ }
118
+ if (event.key === "ArrowDown") {
119
+ setSelectedIndex((i) => (i + 1) % props.items.length);
120
+ return true;
121
+ }
122
+ if (event.key === "Enter") {
123
+ const item = props.items[selectedIndex];
124
+ if (item) props.command(item);
125
+ return true;
126
+ }
127
+ if (event.key === "Escape") {
128
+ return true;
129
+ }
130
+ return false;
131
+ },
132
+ }));
133
+
134
+ return (
135
+ <SuggestionList
136
+ items={props.items}
137
+ command={props.command}
138
+ selectedIndex={selectedIndex}
139
+ setSelectedIndex={setSelectedIndex}
140
+ />
141
+ );
142
+ },
143
+ );
144
+
145
+ function createSuggestionRender(suggestionOpenRef: React.MutableRefObject<boolean>) {
146
+ return () => {
147
+ let renderer: ReactRenderer<SuggestionListRef> | null = null;
148
+ let popup: HTMLDivElement | null = null;
149
+
150
+ const positionPopup = (rect: DOMRect | null) => {
151
+ if (!rect || !popup) return;
152
+ const menuHeight = popup.offsetHeight || 200;
153
+ const spaceBelow = window.innerHeight - rect.bottom;
154
+ const fitsBelow = spaceBelow > menuHeight + 8;
155
+ popup.style.left = `${rect.left}px`;
156
+ if (fitsBelow) {
157
+ popup.style.top = `${rect.bottom + 4}px`;
158
+ popup.style.bottom = "";
159
+ } else {
160
+ popup.style.top = "";
161
+ popup.style.bottom = `${window.innerHeight - rect.top + 4}px`;
162
+ }
163
+ };
164
+
165
+ return {
166
+ onStart: (props: SuggestionProps<SuggestionEntry>) => {
167
+ suggestionOpenRef.current = true;
168
+ popup = document.createElement("div");
169
+ popup.style.position = "fixed";
170
+ popup.style.zIndex = "50";
171
+ document.body.appendChild(popup);
172
+
173
+ renderer = new ReactRenderer(SuggestionListWrapper, {
174
+ props,
175
+ editor: props.editor,
176
+ });
177
+ popup.appendChild(renderer.element);
178
+ positionPopup(props.clientRect?.() ?? null);
179
+ },
180
+ onUpdate: (props: SuggestionProps<SuggestionEntry>) => {
181
+ renderer?.updateProps(props);
182
+ positionPopup(props.clientRect?.() ?? null);
183
+ },
184
+ onKeyDown: (props: SuggestionKeyDownProps) => {
185
+ if (props.event.key === "Escape") {
186
+ popup?.remove();
187
+ renderer?.destroy();
188
+ popup = null;
189
+ renderer = null;
190
+ suggestionOpenRef.current = false;
191
+ return true;
192
+ }
193
+ return renderer?.ref?.onKeyDown(props) ?? false;
194
+ },
195
+ onExit: () => {
196
+ popup?.remove();
197
+ renderer?.destroy();
198
+ popup = null;
199
+ renderer = null;
200
+ suggestionOpenRef.current = false;
201
+ },
202
+ };
203
+ };
204
+ }
205
+
206
+ function getTextWithAnchors(editor: ReturnType<typeof useEditor>): string {
207
+ if (!editor) return "";
208
+ const doc = editor.state.doc;
209
+ const parts: string[] = [];
210
+
211
+ doc.descendants((node) => {
212
+ if (node.type.name === "anchorMention") {
213
+ const kind = node.attrs.kind ?? "file";
214
+ const id = node.attrs.id ?? node.attrs.label ?? "";
215
+ parts.push(`[[${kind}:${id}]]`);
216
+ return false;
217
+ }
218
+ if (node.type.name === "slashCommand") {
219
+ parts.push(`/${node.attrs.id ?? node.attrs.label ?? ""}`);
220
+ return false;
221
+ }
222
+ if (node.isText) {
223
+ parts.push(node.text ?? "");
224
+ }
225
+ if (node.type.name === "paragraph" && parts.length > 0) {
226
+ const last = parts[parts.length - 1];
227
+ if (last !== "\n") parts.push("\n");
228
+ }
229
+ return true;
230
+ });
231
+
232
+ return parts.join("").trim();
233
+ }
234
+
235
+ const AnchorMention = Mention.extend({ name: "anchorMention" });
236
+ const SlashCommand = Mention.extend({ name: "slashCommand" });
237
+
238
+ export function TipTapEditor({
239
+ content,
240
+ placeholder: placeholderText = "",
241
+ disabled = false,
242
+ autoFocus = false,
243
+ className = "",
244
+ onSubmit,
245
+ onChange,
246
+ submitOnEnter = false,
247
+ submitOnModEnter = false,
248
+ onEscape,
249
+ editorRef,
250
+ anchorItems,
251
+ commands,
252
+ }: TipTapEditorProps) {
253
+ const callbacksRef = useRef({ onSubmit, onChange, onEscape });
254
+ callbacksRef.current = { onSubmit, onChange, onEscape };
255
+ const anchorItemsRef = useRef(anchorItems);
256
+ anchorItemsRef.current = anchorItems;
257
+ const commandsRef = useRef(commands);
258
+ commandsRef.current = commands;
259
+ const suggestionOpenRef = useRef(false);
260
+
261
+ const editor = useEditor({
262
+ extensions: [
263
+ StarterKit.configure({
264
+ heading: false,
265
+ horizontalRule: false,
266
+ blockquote: false,
267
+ }),
268
+ Placeholder.configure({ placeholder: placeholderText }),
269
+ ...(anchorItems
270
+ ? [
271
+ AnchorMention.configure({
272
+ HTMLAttributes: { class: "mention-anchor" },
273
+ suggestion: {
274
+ char: "@",
275
+ pluginKey: new PluginKey("anchorMention"),
276
+ allowSpaces: true,
277
+ items: ({ query }: { query: string }) => {
278
+ const all = (anchorItemsRef.current ?? []).map((a) => ({
279
+ id: a.id,
280
+ label: a.label,
281
+ badge: a.kind === "group" ? "group" : "file",
282
+ badgeClass: a.kind === "group"
283
+ ? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
284
+ : "bg-muted text-muted-foreground",
285
+ mono: a.kind === "file",
286
+ kind: a.kind,
287
+ }));
288
+ if (!query) return all.slice(0, 12);
289
+ const q = query.toLowerCase();
290
+ return all.filter((item) => item.label.toLowerCase().includes(q)).slice(0, 12);
291
+ },
292
+ render: createSuggestionRender(suggestionOpenRef),
293
+ command: ({ editor: ed, range, props: attrs }) => {
294
+ const item = attrs as unknown as AnchorItem & SuggestionEntry;
295
+ ed.chain()
296
+ .focus()
297
+ .insertContentAt(range, [
298
+ {
299
+ type: "anchorMention",
300
+ attrs: { id: item.id, label: item.label, kind: item.kind ?? "file" },
301
+ },
302
+ { type: "text", text: " " },
303
+ ])
304
+ .run();
305
+ },
306
+ },
307
+ }).extend({
308
+ addAttributes() {
309
+ return {
310
+ ...this.parent?.(),
311
+ kind: { default: "file" },
312
+ };
313
+ },
314
+ }),
315
+ ]
316
+ : []),
317
+ ...(commands && commands.length > 0
318
+ ? [
319
+ SlashCommand.configure({
320
+ HTMLAttributes: { class: "mention-command" },
321
+ suggestion: {
322
+ char: "/",
323
+ pluginKey: new PluginKey("slashCommand"),
324
+ startOfLine: true,
325
+ items: ({ query }: { query: string }) => {
326
+ const all = (commandsRef.current ?? []).map((c) => ({
327
+ id: c.id,
328
+ label: `/${c.id}`,
329
+ description: c.description,
330
+ }));
331
+ if (!query) return all;
332
+ const q = query.toLowerCase();
333
+ return all.filter((item) => item.id.toLowerCase().includes(q));
334
+ },
335
+ render: createSuggestionRender(suggestionOpenRef),
336
+ command: ({ editor: ed, range, props: attrs }) => {
337
+ ed.chain()
338
+ .focus()
339
+ .deleteRange(range)
340
+ .insertContent(`/${attrs.id} `)
341
+ .run();
342
+ },
343
+ },
344
+ }),
345
+ ]
346
+ : []),
347
+ ],
348
+ editorProps: {
349
+ attributes: {
350
+ class: `outline-none min-h-[20px] ${className}`,
351
+ },
352
+ handleKeyDown: (_view, event) => {
353
+ if (suggestionOpenRef.current) return false;
354
+ if (event.key === "Escape" && callbacksRef.current.onEscape) {
355
+ event.preventDefault();
356
+ callbacksRef.current.onEscape();
357
+ return true;
358
+ }
359
+ if (submitOnEnter && event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
360
+ event.preventDefault();
361
+ callbacksRef.current.onSubmit?.();
362
+ return true;
363
+ }
364
+ if (submitOnModEnter && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
365
+ event.preventDefault();
366
+ callbacksRef.current.onSubmit?.();
367
+ return true;
368
+ }
369
+ return false;
370
+ },
371
+ },
372
+ content: content ?? "",
373
+ editable: !disabled,
374
+ onUpdate: ({ editor: ed }) => {
375
+ callbacksRef.current.onChange?.(ed.getText());
376
+ },
377
+ });
378
+
379
+ useEffect(() => {
380
+ if (editorRef) editorRef.current = editor;
381
+ }, [editor, editorRef]);
382
+
383
+ useEffect(() => {
384
+ if (!editor) return;
385
+ editor.setEditable(!disabled);
386
+ }, [editor, disabled]);
387
+
388
+ useEffect(() => {
389
+ if (!editor || content === undefined) return;
390
+ const current = editor.getText();
391
+ if (content !== current) {
392
+ editor.commands.setContent(content ? `<p>${content.replace(/\n/g, "<br>")}</p>` : "");
393
+ }
394
+ }, [editor, content]);
395
+
396
+ useEffect(() => {
397
+ if (autoFocus && editor) {
398
+ setTimeout(() => editor.commands.focus("end"), 0);
399
+ }
400
+ }, [autoFocus, editor]);
401
+
402
+ return <EditorContent editor={editor} />;
403
+ }
404
+
405
+ export { getTextWithAnchors };
@@ -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,18 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ interface Features {
4
+ cartoon: boolean;
5
+ }
6
+
7
+ export function useFeatures(): Features {
8
+ const [features, setFeatures] = useState<Features>({ cartoon: false });
9
+
10
+ useEffect(() => {
11
+ fetch("/api/features")
12
+ .then((r) => r.json())
13
+ .then((data) => setFeatures(data as Features))
14
+ .catch(() => {});
15
+ }, []);
16
+
17
+ return features;
18
+ }
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Loader2, Sparkles, RefreshCw, Download, AlertCircle } from "lucide-react";
3
+ import type { NewprOutput } from "../../../types/output.ts";
4
+
5
+ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
6
+ const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
7
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ if (data.cartoon) {
12
+ setImageUrl(`data:${data.cartoon.mimeType};base64,${data.cartoon.imageBase64}`);
13
+ setState("done");
14
+ return;
15
+ }
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]);
27
+
28
+ const generate = useCallback(async () => {
29
+ setState("loading");
30
+ setError(null);
31
+ try {
32
+ const body: Record<string, unknown> = { data };
33
+ if (sessionId) body.sessionId = sessionId;
34
+
35
+ const res = await fetch("/api/cartoon", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ const result = await res.json() as { imageBase64?: string; mimeType?: string; error?: string };
41
+ if (result.error) throw new Error(result.error);
42
+ if (!result.imageBase64) throw new Error("No image returned");
43
+ setImageUrl(`data:${result.mimeType};base64,${result.imageBase64}`);
44
+ setState("done");
45
+ } catch (err) {
46
+ setError(err instanceof Error ? err.message : String(err));
47
+ setState("error");
48
+ }
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]);
58
+
59
+ if (state === "idle") {
60
+ return (
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>
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ if (state === "loading") {
83
+ return (
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>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ if (state === "error") {
99
+ return (
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>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div className="pt-6 space-y-3">
124
+ {imageUrl && (
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>
132
+ )}
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>
151
+ </div>
152
+ );
153
+ }