newpr 1.0.10 → 1.0.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -200,7 +200,7 @@ export function App() {
200
200
  cartoonEnabled={features.cartoon}
201
201
  sessionId={diffSessionId}
202
202
  onTabChange={handleTabChange}
203
- onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
203
+ onReanalyze={(prUrl: string) => { analysis.start(prUrl, { reuseSessionId: diffSessionId }); }}
204
204
  enabledPlugins={features.enabledPlugins}
205
205
  onTrackAnalysis={bgAnalyses.track}
206
206
  />
@@ -92,35 +92,6 @@ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
92
92
  return segs;
93
93
  }
94
94
 
95
- function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
96
- content: string;
97
- onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
98
- activeId?: string | null;
99
- }) {
100
- const [rendered, setRendered] = useState(content);
101
- const pendingRef = useRef(content);
102
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
103
-
104
- useEffect(() => {
105
- pendingRef.current = content;
106
- if (!timerRef.current) {
107
- timerRef.current = setTimeout(() => {
108
- setRendered(pendingRef.current);
109
- timerRef.current = null;
110
- }, 150);
111
- }
112
- }, [content]);
113
-
114
- useEffect(() => {
115
- return () => {
116
- if (timerRef.current) clearTimeout(timerRef.current);
117
- setRendered(pendingRef.current);
118
- };
119
- }, []);
120
-
121
- return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
122
- }
123
-
124
95
  function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
125
96
  segments: ChatSegment[];
126
97
  activeToolName?: string;
@@ -137,10 +108,12 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
137
108
  return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
138
109
  }
139
110
  if (!seg.content) return null;
111
+ const isTrailingSegment = i === segments.length - 1;
112
+ const isStreamingTail = isStreaming && isTrailingSegment;
140
113
  return (
141
114
  <div key={`text-${i}`} className="text-xs leading-relaxed">
142
- {isStreaming ? (
143
- <ThrottledMarkdown content={seg.content} onAnchorClick={onAnchorClick} activeId={activeId} />
115
+ {isStreamingTail ? (
116
+ <Markdown streaming onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
144
117
  ) : (
145
118
  <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
146
119
  )}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { memo, useState, useEffect, useMemo } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import remarkMath from "remark-math";
@@ -12,6 +12,7 @@ interface MarkdownProps {
12
12
  children: string;
13
13
  onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
14
14
  activeId?: string | null;
15
+ streaming?: boolean;
15
16
  }
16
17
 
17
18
  function useHighlighter(): Highlighter | null {
@@ -185,8 +186,8 @@ function preprocess(text: string): string {
185
186
  return processed.replace(/\x00MATH_BLOCK_(\d+)\x00/g, (_, idx) => mathBlocks[Number(idx)]!);
186
187
  }
187
188
 
188
- export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
189
- const processed = preprocess(children);
189
+ export const Markdown = memo(function Markdown({ children, onAnchorClick, activeId, streaming = false }: MarkdownProps) {
190
+ const processed = useMemo(() => preprocess(children), [children]);
190
191
  const hl = useHighlighter();
191
192
  const dark = useDarkMode();
192
193
 
@@ -202,6 +203,12 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
202
203
  strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
203
204
  em: ({ children }) => <em className="italic">{children}</em>,
204
205
  code: ({ children, className }) => {
206
+ if (streaming) {
207
+ if (className?.includes("language-")) {
208
+ return <code className="text-xs font-mono">{children}</code>;
209
+ }
210
+ return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
211
+ }
205
212
  const lang = langFromClassName(className);
206
213
  if (lang && hl) {
207
214
  const code = String(children).replace(/\n$/, "");
@@ -330,5 +337,11 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
330
337
  td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
331
338
  };
332
339
 
333
- return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeRaw, { passThrough: ["math", "inlineMath"] }], [rehypeKatex, { throwOnError: false, strict: false, output: "html" }]]} components={components}>{processed}</ReactMarkdown>;
334
- }
340
+ return <ReactMarkdown
341
+ remarkPlugins={streaming ? [remarkGfm] : [[remarkMath, { singleDollarTextMath: true }], remarkGfm]}
342
+ rehypePlugins={streaming
343
+ ? [[rehypeRaw, { passThrough: ["math", "inlineMath"] }]]
344
+ : [[rehypeRaw, { passThrough: ["math", "inlineMath"] }], [rehypeKatex, { throwOnError: false, strict: false, output: "html" }]]}
345
+ components={components}
346
+ >{processed}</ReactMarkdown>;
347
+ });
@@ -29,7 +29,7 @@ export function useAnalysis() {
29
29
  });
30
30
  const eventSourceRef = useRef<EventSource | null>(null);
31
31
 
32
- const start = useCallback(async (prInput: string) => {
32
+ const start = useCallback(async (prInput: string, options?: { reuseSessionId?: string | null }) => {
33
33
  analytics.analysisStarted(0);
34
34
  setState({
35
35
  phase: "loading",
@@ -43,10 +43,11 @@ export function useAnalysis() {
43
43
  });
44
44
 
45
45
  try {
46
+ const reuseSessionId = options?.reuseSessionId ?? undefined;
46
47
  const res = await fetch("/api/analysis", {
47
48
  method: "POST",
48
49
  headers: { "Content-Type": "application/json" },
49
- body: JSON.stringify({ pr: prInput }),
50
+ body: JSON.stringify({ pr: prInput, reuseSessionId }),
50
51
  });
51
52
  const body = await res.json();
52
53
  if (!res.ok) throw new Error(body.error ?? "Failed to start analysis");
@@ -332,7 +332,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
332
332
  const body = await req.json() as { pr: string; reuseSessionId?: string };
333
333
  if (!body.pr) return json({ error: "Missing 'pr' field" }, 400);
334
334
 
335
- const result = startAnalysis(body.pr, token, config);
335
+ const result = startAnalysis(body.pr, token, config, body.reuseSessionId);
336
336
  if ("error" in result) return json({ error: result.error }, result.status);
337
337
 
338
338
  return json({
@@ -953,9 +953,22 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
953
953
  const encoder = new TextEncoder();
954
954
  const stream = new ReadableStream({
955
955
  async start(controller) {
956
+ let closed = false;
956
957
  const send = (eventType: string, data: string) => {
958
+ if (closed) return;
957
959
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
958
960
  };
961
+ const safeClose = () => {
962
+ if (closed) return;
963
+ closed = true;
964
+ clearInterval(heartbeat);
965
+ setTimeout(() => { try { controller.close(); } catch {} }, 50);
966
+ };
967
+ const heartbeat = setInterval(() => {
968
+ if (closed) return;
969
+ try { controller.enqueue(encoder.encode(":keepalive\n\n")); } catch { safeClose(); }
970
+ }, 15_000);
971
+
959
972
  try {
960
973
  if (config.openrouter_api_key) {
961
974
  await chatWithTools(
@@ -999,7 +1012,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
999
1012
  } catch (err) {
1000
1013
  send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
1001
1014
  } finally {
1002
- controller.close();
1015
+ safeClose();
1003
1016
  }
1004
1017
  },
1005
1018
  });
@@ -1424,9 +1437,21 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1424
1437
  const encoder = new TextEncoder();
1425
1438
  const stream = new ReadableStream({
1426
1439
  async start(controller) {
1440
+ let closed = false;
1427
1441
  const send = (eventType: string, data: string) => {
1442
+ if (closed) return;
1428
1443
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
1429
1444
  };
1445
+ const safeClose = () => {
1446
+ if (closed) return;
1447
+ closed = true;
1448
+ clearInterval(heartbeat);
1449
+ setTimeout(() => { try { controller.close(); } catch {} }, 50);
1450
+ };
1451
+ const heartbeat = setInterval(() => {
1452
+ if (closed) return;
1453
+ try { controller.enqueue(encoder.encode(":keepalive\n\n")); } catch { safeClose(); }
1454
+ }, 15_000);
1430
1455
 
1431
1456
  let fullText = "";
1432
1457
  const collectedToolCalls: ChatToolCall[] = [];
@@ -1526,7 +1551,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1526
1551
  } catch (err) {
1527
1552
  send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
1528
1553
  } finally {
1529
- controller.close();
1554
+ safeClose();
1530
1555
  }
1531
1556
  },
1532
1557
  });
@@ -3,7 +3,7 @@ import type { NewprOutput } from "../../types/output.ts";
3
3
  import type { ProgressEvent } from "../../analyzer/progress.ts";
4
4
  import { analyzePr } from "../../analyzer/pipeline.ts";
5
5
  import { parsePrInput } from "../../github/parse-pr.ts";
6
- import { saveSession, savePatchesSidecar } from "../../history/store.ts";
6
+ import { saveSession, savePatchesSidecar, loadSession, loadChatSidecar, saveChatSidecar } from "../../history/store.ts";
7
7
  import { telemetry } from "../../telemetry/index.ts";
8
8
 
9
9
  type SessionStatus = "running" | "done" | "error" | "canceled";
@@ -15,6 +15,7 @@ interface AnalysisSession {
15
15
  events: ProgressEvent[];
16
16
  result?: NewprOutput;
17
17
  historyId?: string;
18
+ reuseSessionId?: string;
18
19
  error?: string;
19
20
  startedAt: number;
20
21
  finishedAt?: number;
@@ -47,6 +48,7 @@ export function startAnalysis(
47
48
  prInput: string,
48
49
  token: string,
49
50
  config: NewprConfig,
51
+ reuseSessionId?: string,
50
52
  ): { sessionId: string } | { error: string; status: number } {
51
53
  if (runningCount() >= MAX_CONCURRENT) {
52
54
  return { error: "Too many concurrent analyses. Try again later.", status: 429 };
@@ -58,6 +60,7 @@ export function startAnalysis(
58
60
  const session: AnalysisSession = {
59
61
  id,
60
62
  prInput,
63
+ reuseSessionId,
61
64
  status: "running",
62
65
  events: [],
63
66
  startedAt: Date.now(),
@@ -116,6 +119,15 @@ async function runPipeline(
116
119
  if (Object.keys(capturedPatches).length > 0) {
117
120
  await savePatchesSidecar(record.id, capturedPatches).catch(() => {});
118
121
  }
122
+ if (session.reuseSessionId) {
123
+ const prior = await loadSession(session.reuseSessionId).catch(() => null);
124
+ if (prior?.meta.pr_url === result.meta.pr_url) {
125
+ const priorChat = await loadChatSidecar(session.reuseSessionId).catch(() => null);
126
+ if (priorChat && priorChat.length > 0) {
127
+ await saveChatSidecar(record.id, priorChat).catch(() => {});
128
+ }
129
+ }
130
+ }
119
131
  }
120
132
  } catch (err) {
121
133
  const msg = err instanceof Error ? err.message : String(err);