newpr 1.0.11 → 1.0.13
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/stack/pr-title.ts +35 -5
- 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 +1 -1
- package/src/web/server/session-manager.ts +13 -1
package/package.json
CHANGED
package/src/stack/pr-title.ts
CHANGED
|
@@ -2,6 +2,24 @@ import type { LlmClient } from "../llm/client.ts";
|
|
|
2
2
|
import type { StackGroup } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const MAX_TITLE_LENGTH = 72;
|
|
5
|
+
const TYPE_PREFIX: Record<string, string> = {
|
|
6
|
+
feature: "feat",
|
|
7
|
+
feat: "feat",
|
|
8
|
+
bugfix: "fix",
|
|
9
|
+
fix: "fix",
|
|
10
|
+
refactor: "refactor",
|
|
11
|
+
chore: "chore",
|
|
12
|
+
docs: "docs",
|
|
13
|
+
test: "test",
|
|
14
|
+
config: "chore",
|
|
15
|
+
perf: "perf",
|
|
16
|
+
style: "style",
|
|
17
|
+
ci: "ci",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function normalizeTypePrefix(type: string): string {
|
|
21
|
+
return TYPE_PREFIX[type] ?? "chore";
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
function sanitizeTitle(raw: string): string {
|
|
7
25
|
let title = raw.trim().replace(/\.+$/, "");
|
|
@@ -13,13 +31,13 @@ function sanitizeTitle(raw: string): string {
|
|
|
13
31
|
|
|
14
32
|
function fallbackTitle(g: StackGroup): string {
|
|
15
33
|
const desc = g.description || g.name;
|
|
16
|
-
const cleaned = desc.replace(/[^\
|
|
17
|
-
const prefix = `${g.type}: `;
|
|
34
|
+
const cleaned = desc.replace(/[^\p{L}\p{N}\s\-/.,()]/gu, " ").replace(/\s+/g, " ").trim();
|
|
35
|
+
const prefix = `${normalizeTypePrefix(g.type)}: `;
|
|
18
36
|
const maxDesc = MAX_TITLE_LENGTH - prefix.length;
|
|
19
37
|
const truncated = cleaned.length > maxDesc
|
|
20
38
|
? cleaned.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd()
|
|
21
39
|
: cleaned;
|
|
22
|
-
return truncated ? `${prefix}${truncated}` : `${
|
|
40
|
+
return truncated ? `${prefix}${truncated}` : `${prefix}${g.name}`;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
export async function generatePrTitles(
|
|
@@ -37,7 +55,12 @@ export async function generatePrTitles(
|
|
|
37
55
|
].join("\n"))
|
|
38
56
|
.join("\n\n");
|
|
39
57
|
|
|
40
|
-
const
|
|
58
|
+
const hasKoreanContext = /[가-힣]/.test(prTitle) || groups.some((g) => /[가-힣]/.test(`${g.name} ${g.description}`));
|
|
59
|
+
const lang = language && language !== "English" && language !== "auto"
|
|
60
|
+
? language
|
|
61
|
+
: hasKoreanContext
|
|
62
|
+
? "Korean"
|
|
63
|
+
: null;
|
|
41
64
|
const langRule = lang
|
|
42
65
|
? `- Write the description part in ${lang}. Keep the type prefix (feat/fix/etc.) in English.`
|
|
43
66
|
: "- Write the description in English.";
|
|
@@ -85,7 +108,14 @@ Generate a descriptive PR title (40-72 chars) for each group. Return JSON array:
|
|
|
85
108
|
|
|
86
109
|
try {
|
|
87
110
|
const cleaned = response.content.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "").trim();
|
|
88
|
-
|
|
111
|
+
let parsed: Array<{ group_id: string; title: string }> = [];
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(cleaned) as Array<{ group_id: string; title: string }>;
|
|
114
|
+
} catch {
|
|
115
|
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
116
|
+
if (!arrayMatch) throw new Error("No JSON array found in response");
|
|
117
|
+
parsed = JSON.parse(arrayMatch[0]) as Array<{ group_id: string; title: string }>;
|
|
118
|
+
}
|
|
89
119
|
for (const item of parsed) {
|
|
90
120
|
if (item.group_id && item.title?.trim()) {
|
|
91
121
|
titles.set(item.group_id, sanitizeTitle(item.title));
|
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({
|
|
@@ -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);
|