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 +1 -1
- package/src/web/client/App.tsx +1 -1
- package/src/web/client/components/ChatSection.tsx +4 -31
- package/src/web/client/components/Markdown.tsx +18 -5
- package/src/web/client/hooks/useAnalysis.ts +3 -2
- package/src/web/server/routes.ts +28 -3
- package/src/web/server/session-manager.ts +13 -1
package/package.json
CHANGED
package/src/web/client/App.tsx
CHANGED
|
@@ -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
|
-
{
|
|
143
|
-
<
|
|
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
|
|
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");
|
package/src/web/server/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|