newpr 1.0.11 → 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.11",
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({
@@ -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);