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.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +44 -0
- package/src/web/client/App.tsx +29 -3
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +66 -71
- 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 +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +716 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +33 -0
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- 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
|
-
|
|
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
|
}
|