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,427 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
2
+ import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
3
+ import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
4
+ import { Markdown } from "./Markdown.tsx";
5
+ import { TipTapEditor, getTextWithAnchors, type AnchorItem, type CommandItem } from "./TipTapEditor.tsx";
6
+ import type { useEditor } from "@tiptap/react";
7
+
8
+ export interface ChatState {
9
+ messages: ChatMessage[];
10
+ input: string;
11
+ loading: boolean;
12
+ streaming: { segments: ChatSegment[]; activeToolName?: string } | null;
13
+ loaded: boolean;
14
+ setInput: (v: string) => void;
15
+ sendMessage: (text?: string) => void;
16
+ }
17
+
18
+ interface ChatContextValue {
19
+ state: ChatState;
20
+ anchorItems?: AnchorItem[];
21
+ }
22
+
23
+ const ChatContext = createContext<ChatContextValue | null>(null);
24
+
25
+ export function useChatState(sessionId?: string | null): ChatState {
26
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
27
+ const [input, setInput] = useState("");
28
+ const [loading, setLoading] = useState(false);
29
+ const [streaming, setStreaming] = useState<ChatState["streaming"]>(null);
30
+ const [loaded, setLoaded] = useState(false);
31
+ const prevSessionId = useRef(sessionId);
32
+
33
+ useEffect(() => {
34
+ if (prevSessionId.current !== sessionId) {
35
+ setMessages([]);
36
+ setInput("");
37
+ setLoading(false);
38
+ setStreaming(null);
39
+ setLoaded(false);
40
+ prevSessionId.current = sessionId;
41
+ }
42
+ if (!sessionId) return;
43
+ fetch(`/api/sessions/${sessionId}/chat`)
44
+ .then((r) => r.json())
45
+ .then((data) => {
46
+ setMessages(data as ChatMessage[]);
47
+ setLoaded(true);
48
+ })
49
+ .catch(() => setLoaded(true));
50
+ }, [sessionId]);
51
+
52
+ const undoLast = useCallback(async () => {
53
+ if (!sessionId) return;
54
+ setMessages((prev) => {
55
+ const lastAssistantIdx = prev.findLastIndex((m) => m.role === "assistant");
56
+ if (lastAssistantIdx === -1) return prev;
57
+ const lastUserIdx = prev.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
58
+ const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
59
+ return prev.slice(0, removeFrom);
60
+ });
61
+ await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
62
+ }, [sessionId]);
63
+
64
+ const sendMessage = useCallback(async (text?: string) => {
65
+ const messageText = text?.trim() || input.trim();
66
+ if (!sessionId || !messageText || loading) return;
67
+
68
+ if (messageText.replace(/\n/g, "").trim() === "/undo") {
69
+ setInput("");
70
+ await undoLast();
71
+ return;
72
+ }
73
+
74
+ const userMessage = messageText;
75
+ setInput("");
76
+ setLoading(true);
77
+ setStreaming({ segments: [] });
78
+
79
+ setMessages((prev) => [...prev, {
80
+ role: "user",
81
+ content: userMessage,
82
+ timestamp: new Date().toISOString(),
83
+ }]);
84
+
85
+ try {
86
+ const res = await fetch(`/api/sessions/${sessionId}/chat`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ message: userMessage }),
90
+ });
91
+
92
+ if (!res.ok) {
93
+ const err = await res.json() as { error?: string };
94
+ throw new Error(err.error ?? `HTTP ${res.status}`);
95
+ }
96
+
97
+ const reader = res.body!.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buffer = "";
100
+ let fullText = "";
101
+ const orderedSegments: ChatSegment[] = [];
102
+ const allToolCalls: ChatToolCall[] = [];
103
+ let pendingEvent = "";
104
+
105
+ while (true) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+
109
+ buffer += decoder.decode(value, { stream: true });
110
+ const lines = buffer.split("\n");
111
+ buffer = lines.pop() ?? "";
112
+
113
+ for (const line of lines) {
114
+ const trimmed = line.trim();
115
+ if (!trimmed) {
116
+ pendingEvent = "";
117
+ continue;
118
+ }
119
+
120
+ if (trimmed.startsWith("event: ")) {
121
+ pendingEvent = trimmed.slice(7);
122
+ continue;
123
+ }
124
+ if (!trimmed.startsWith("data: ")) continue;
125
+
126
+ try {
127
+ const data = JSON.parse(trimmed.slice(6));
128
+
129
+ switch (pendingEvent) {
130
+ case "text": {
131
+ fullText += data.content ?? "";
132
+ const lastSeg = orderedSegments[orderedSegments.length - 1];
133
+ if (lastSeg && lastSeg.type === "text") {
134
+ lastSeg.content += data.content ?? "";
135
+ } else {
136
+ orderedSegments.push({ type: "text", content: data.content ?? "" });
137
+ }
138
+ setStreaming({ segments: [...orderedSegments] });
139
+ break;
140
+ }
141
+ case "tool_call": {
142
+ const tc: ChatToolCall = {
143
+ id: data.id,
144
+ name: data.name,
145
+ arguments: data.arguments ?? {},
146
+ };
147
+ allToolCalls.push(tc);
148
+ orderedSegments.push({ type: "tool_call", toolCall: tc });
149
+ setStreaming({ segments: [...orderedSegments], activeToolName: data.name });
150
+ break;
151
+ }
152
+ case "tool_result": {
153
+ const tc = allToolCalls.find((c) => c.id === data.id);
154
+ if (tc) tc.result = data.result;
155
+ setStreaming({ segments: [...orderedSegments] });
156
+ break;
157
+ }
158
+ case "done":
159
+ break;
160
+ case "chat_error":
161
+ throw new Error(data.message ?? "Chat error");
162
+ }
163
+ } catch (parseErr) {
164
+ if (parseErr instanceof Error && parseErr.message === "Chat error") {
165
+ throw parseErr;
166
+ }
167
+ }
168
+ pendingEvent = "";
169
+ }
170
+ }
171
+
172
+ setMessages((prev) => [...prev, {
173
+ role: "assistant",
174
+ content: fullText,
175
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
176
+ segments: orderedSegments.length > 0 ? orderedSegments : undefined,
177
+ timestamp: new Date().toISOString(),
178
+ }]);
179
+ } catch (err) {
180
+ setMessages((prev) => [...prev, {
181
+ role: "assistant",
182
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
183
+ timestamp: new Date().toISOString(),
184
+ }]);
185
+ } finally {
186
+ setLoading(false);
187
+ setStreaming(null);
188
+ }
189
+ }, [sessionId, input, loading, undoLast]);
190
+
191
+ return { messages, input, loading, streaming, loaded, setInput, sendMessage };
192
+ }
193
+
194
+ export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
195
+ const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
196
+ return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
197
+ }
198
+
199
+ function ToolCallDisplay({ tc }: { tc: ChatToolCall }) {
200
+ const [open, setOpen] = useState(false);
201
+ const truncated = tc.result && tc.result.length > 200;
202
+ const displayResult = truncated && !open ? `${tc.result!.slice(0, 200)}…` : tc.result;
203
+
204
+ return (
205
+ <div className="text-[11px]">
206
+ <button
207
+ type="button"
208
+ onClick={() => setOpen(!open)}
209
+ className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-accent/60 text-muted-foreground/60 hover:text-foreground hover:bg-accent transition-colors text-left"
210
+ >
211
+ <ChevronRight className={`h-2.5 w-2.5 shrink-0 transition-transform ${open ? "rotate-90" : ""}`} />
212
+ <span className="font-mono">{tc.name}</span>
213
+ {Object.keys(tc.arguments).length > 0 && (
214
+ <span className="text-muted-foreground/30 truncate max-w-[200px]">
215
+ {Object.entries(tc.arguments).map(([k, v]) => `${k}: ${String(v)}`).join(", ")}
216
+ </span>
217
+ )}
218
+ </button>
219
+ {open && tc.result && (
220
+ <pre className="mt-1 ml-1 px-2.5 py-2 text-[10px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-md bg-accent/30">
221
+ {displayResult}
222
+ </pre>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
229
+ if (msg.segments && msg.segments.length > 0) return msg.segments;
230
+ const segs: ChatSegment[] = [];
231
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
232
+ for (const tc of msg.toolCalls) {
233
+ segs.push({ type: "tool_call", toolCall: tc });
234
+ }
235
+ }
236
+ if (msg.content) {
237
+ segs.push({ type: "text", content: msg.content });
238
+ }
239
+ return segs;
240
+ }
241
+
242
+ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
243
+ segments: ChatSegment[];
244
+ activeToolName?: string;
245
+ isStreaming?: boolean;
246
+ onAnchorClick?: (kind: "group" | "file", id: string) => void;
247
+ activeId?: string | null;
248
+ }) {
249
+ const hasContent = segments.some((s) => s.type === "text" && s.content);
250
+
251
+ return (
252
+ <div className="space-y-2">
253
+ {segments.map((seg, i) => {
254
+ if (seg.type === "tool_call") {
255
+ return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
256
+ }
257
+ return seg.content ? (
258
+ <div key={`text-${i}`} className="text-xs leading-relaxed">
259
+ <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
260
+ </div>
261
+ ) : null;
262
+ })}
263
+ {activeToolName && (
264
+ <div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
265
+ <Loader2 className="h-2.5 w-2.5 animate-spin" />
266
+ <span className="font-mono">{activeToolName}</span>
267
+ </div>
268
+ )}
269
+ {isStreaming && !hasContent && !activeToolName && segments.length === 0 && (
270
+ <div className="flex items-center gap-1.5">
271
+ <span className="flex gap-1">
272
+ <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse" />
273
+ <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:150ms]" />
274
+ <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:300ms]" />
275
+ </span>
276
+ </div>
277
+ )}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ export function ChatMessages({ onAnchorClick, activeId }: {
283
+ onAnchorClick?: (kind: "group" | "file", id: string) => void;
284
+ activeId?: string | null;
285
+ }) {
286
+ const ctx = useContext(ChatContext);
287
+ const containerRef = useRef<HTMLDivElement>(null);
288
+ const isNearBottomRef = useRef(true);
289
+ const mainElRef = useRef<HTMLElement | null>(null);
290
+ const scrollListenerRef = useRef<(() => void) | null>(null);
291
+
292
+ useEffect(() => {
293
+ if (scrollListenerRef.current) return;
294
+ const el = containerRef.current?.closest("main") as HTMLElement | null;
295
+ if (!el) return;
296
+ mainElRef.current = el;
297
+ const onScroll = () => {
298
+ isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
299
+ };
300
+ scrollListenerRef.current = onScroll;
301
+ el.addEventListener("scroll", onScroll, { passive: true });
302
+ return () => {
303
+ el.removeEventListener("scroll", onScroll);
304
+ scrollListenerRef.current = null;
305
+ };
306
+ });
307
+
308
+ useEffect(() => {
309
+ const el = mainElRef.current ?? containerRef.current?.closest("main") as HTMLElement | null;
310
+ if (el) {
311
+ mainElRef.current = el;
312
+ if (isNearBottomRef.current) {
313
+ el.scrollTop = el.scrollHeight;
314
+ }
315
+ }
316
+ }, [ctx?.state.messages, ctx?.state.streaming]);
317
+
318
+ if (!ctx) return null;
319
+ const { messages, streaming, loaded, loading } = ctx.state;
320
+ const hasMessages = messages.length > 0 || loading;
321
+
322
+ if (!hasMessages && loaded) {
323
+ return (
324
+ <div className="border-t mt-6 pt-6 text-center">
325
+ <p className="text-[11px] text-muted-foreground/40">Ask anything about this PR</p>
326
+ <p className="text-[10px] text-muted-foreground/20 mt-1">@ to reference files · / for commands</p>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ if (!hasMessages) return null;
332
+
333
+ return (
334
+ <div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
335
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
336
+ {messages.map((msg, i) => {
337
+ if (msg.role === "user") {
338
+ return (
339
+ <div key={`user-${i}`} className="flex justify-end">
340
+ <div className="max-w-[80%] rounded-xl rounded-br-sm bg-foreground text-background px-3.5 py-2 text-[11px] leading-relaxed">
341
+ {msg.content}
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+ return (
347
+ <AssistantMessage
348
+ key={`assistant-${i}`}
349
+ segments={segmentsFromMessage(msg)}
350
+ onAnchorClick={onAnchorClick}
351
+ activeId={activeId}
352
+ />
353
+ );
354
+ })}
355
+
356
+ {streaming && (
357
+ <AssistantMessage
358
+ segments={streaming.segments}
359
+ activeToolName={streaming.activeToolName}
360
+ onAnchorClick={onAnchorClick}
361
+ activeId={activeId}
362
+ isStreaming
363
+ />
364
+ )}
365
+ </div>
366
+ );
367
+ }
368
+
369
+ export function ChatInput() {
370
+ const ctx = useContext(ChatContext);
371
+ const editorRef = useRef<ReturnType<typeof useEditor>>(null);
372
+
373
+ const handleSubmit = useCallback(() => {
374
+ if (!ctx) return;
375
+ const text = editorRef.current ? getTextWithAnchors(editorRef.current) : "";
376
+ if (!text) return;
377
+ editorRef.current?.commands.clearContent();
378
+ ctx.state.sendMessage(text);
379
+ }, [ctx]);
380
+
381
+ const chatCommands = useMemo<CommandItem[]>(() => [
382
+ { id: "undo", label: "/undo", description: "Remove last exchange" },
383
+ ], []);
384
+
385
+ if (!ctx) return null;
386
+ const { loading } = ctx.state;
387
+ const { anchorItems } = ctx;
388
+
389
+ return (
390
+ <div className="px-10 pb-3 pt-2 border-t bg-background">
391
+ <div className="mx-auto max-w-5xl">
392
+ <div className="relative rounded-xl border bg-background px-4 py-2.5 pr-12 focus-within:border-foreground/15 focus-within:shadow-sm transition-all">
393
+ <TipTapEditor
394
+ editorRef={editorRef}
395
+ placeholder="Ask about this PR..."
396
+ disabled={loading}
397
+ submitOnEnter
398
+ onSubmit={handleSubmit}
399
+ className="max-h-[120px] overflow-y-auto"
400
+ anchorItems={anchorItems}
401
+ commands={chatCommands}
402
+ />
403
+ <button
404
+ type="button"
405
+ onClick={handleSubmit}
406
+ disabled={loading}
407
+ className="absolute right-2.5 bottom-2 flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background transition-opacity disabled:opacity-20 hover:opacity-80"
408
+ >
409
+ {loading ? (
410
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
411
+ ) : (
412
+ <CornerDownLeft className="h-3.5 w-3.5" />
413
+ )}
414
+ </button>
415
+ </div>
416
+ <div className="flex items-center justify-between mt-1.5 px-1">
417
+ <span className="text-[10px] text-muted-foreground/25">
418
+ @ to reference · / for commands
419
+ </span>
420
+ <span className="text-[10px] text-muted-foreground/25">
421
+ Enter to send
422
+ </span>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ );
427
+ }