newpr 0.1.3 → 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 (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. 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,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
- }, [data.cartoon]);
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
- async function generate() {
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-6 flex flex-col items-center gap-6 py-20">
44
- <Sparkles className="h-12 w-12 text-yellow-500/60" />
45
- <div className="text-center space-y-2">
46
- <h3 className="text-lg font-semibold">PR 4-Panel Comic</h3>
47
- <p className="text-sm text-muted-foreground max-w-sm">
48
- Turn this PR into a fun 4-panel comic strip. Powered by Gemini.
49
- </p>
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-6 flex flex-col items-center gap-4 py-20">
62
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
63
- <p className="text-sm text-muted-foreground">Drawing your PR comic...</p>
64
- <p className="text-xs text-muted-foreground/60">This may take 10-30 seconds</p>
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-6 flex flex-col items-center gap-4 py-20">
72
- <p className="text-sm text-destructive">{error}</p>
73
- <Button variant="ghost" onClick={generate}>
74
- <RefreshCw className="mr-2 h-3.5 w-3.5" />
75
- Try again
76
- </Button>
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 flex flex-col items-center gap-4">
123
+ <div className="pt-6 space-y-3">
83
124
  {imageUrl && (
84
- <img
85
- src={imageUrl}
86
- alt="PR 4-panel comic"
87
- className="max-w-full rounded-lg border shadow-sm"
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
- <Button variant="ghost" size="sm" onClick={generate}>
91
- <RefreshCw className="mr-2 h-3.5 w-3.5" />
92
- Regenerate
93
- </Button>
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
  }