newpr 0.1.3 → 0.3.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.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +37 -15
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +53 -1
- package/src/history/store.ts +107 -1
- package/src/history/types.ts +1 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +80 -19
- package/src/llm/response-parser.ts +13 -1
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +14 -0
- package/src/types/output.ts +50 -0
- package/src/web/client/App.tsx +33 -5
- package/src/web/client/components/AppShell.tsx +107 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +217 -77
- package/src/web/client/components/DiffViewer.tsx +713 -0
- package/src/web/client/components/InputScreen.tsx +178 -27
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +220 -41
- package/src/web/client/components/ResultsScreen.tsx +109 -73
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +62 -40
- package/src/web/client/panels/StoryPanel.tsx +43 -23
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +856 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +66 -4
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/src/llm/client.ts
CHANGED
|
@@ -238,3 +238,200 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
|
|
|
238
238
|
const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
|
|
239
239
|
return create(options.timeout);
|
|
240
240
|
}
|
|
241
|
+
|
|
242
|
+
export interface ChatTool {
|
|
243
|
+
type: "function";
|
|
244
|
+
function: {
|
|
245
|
+
name: string;
|
|
246
|
+
description: string;
|
|
247
|
+
parameters: Record<string, unknown>;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ChatToolCallDelta {
|
|
252
|
+
id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
arguments: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ChatStreamEvent {
|
|
258
|
+
type: "text" | "tool_call" | "tool_result" | "done" | "error";
|
|
259
|
+
content?: string;
|
|
260
|
+
toolCall?: ChatToolCallDelta;
|
|
261
|
+
toolResult?: { id: string; result: string };
|
|
262
|
+
error?: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type ChatStreamCallback = (event: ChatStreamEvent) => void;
|
|
266
|
+
|
|
267
|
+
interface OpenRouterChatMessage {
|
|
268
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
269
|
+
content?: string | null;
|
|
270
|
+
tool_calls?: Array<{
|
|
271
|
+
id: string;
|
|
272
|
+
type: "function";
|
|
273
|
+
function: { name: string; arguments: string };
|
|
274
|
+
}>;
|
|
275
|
+
tool_call_id?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface OpenRouterStreamToolCallDelta {
|
|
279
|
+
index?: number;
|
|
280
|
+
id?: string;
|
|
281
|
+
type?: string;
|
|
282
|
+
function?: { name?: string; arguments?: string };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface OpenRouterStreamChunkWithTools {
|
|
286
|
+
choices: Array<{
|
|
287
|
+
delta: {
|
|
288
|
+
content?: string;
|
|
289
|
+
tool_calls?: OpenRouterStreamToolCallDelta[];
|
|
290
|
+
};
|
|
291
|
+
finish_reason?: string | null;
|
|
292
|
+
}>;
|
|
293
|
+
model?: string;
|
|
294
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function chatWithTools(
|
|
298
|
+
options: LlmClientOptions,
|
|
299
|
+
messages: OpenRouterChatMessage[],
|
|
300
|
+
tools: ChatTool[],
|
|
301
|
+
executeTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
302
|
+
onEvent: ChatStreamCallback,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const MAX_TOOL_ROUNDS = 10;
|
|
305
|
+
|
|
306
|
+
let currentMessages = [...messages];
|
|
307
|
+
|
|
308
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
|
|
311
|
+
|
|
312
|
+
let response: Response;
|
|
313
|
+
try {
|
|
314
|
+
response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
headers: {
|
|
318
|
+
Authorization: `Bearer ${options.api_key}`,
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
"HTTP-Referer": "https://github.com/sionic/newpr",
|
|
321
|
+
"X-Title": "newpr",
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
model: options.model,
|
|
325
|
+
messages: currentMessages,
|
|
326
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
327
|
+
temperature: 0.3,
|
|
328
|
+
stream: true,
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
clearTimeout(timeoutId);
|
|
333
|
+
onEvent({ type: "error", error: err instanceof Error ? err.message : String(err) });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const body = await response.text();
|
|
341
|
+
onEvent({ type: "error", error: `OpenRouter API error ${response.status}: ${body}` });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const reader = response.body!.getReader();
|
|
346
|
+
const decoder = new TextDecoder();
|
|
347
|
+
let buffer = "";
|
|
348
|
+
let textContent = "";
|
|
349
|
+
const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
while (true) {
|
|
353
|
+
const { done, value } = await reader.read();
|
|
354
|
+
if (done) break;
|
|
355
|
+
|
|
356
|
+
buffer += decoder.decode(value, { stream: true });
|
|
357
|
+
const lines = buffer.split("\n");
|
|
358
|
+
buffer = lines.pop() ?? "";
|
|
359
|
+
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
const trimmed = line.trim();
|
|
362
|
+
if (!trimmed || trimmed === "data: [DONE]") continue;
|
|
363
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunkWithTools;
|
|
367
|
+
const delta = chunk.choices[0]?.delta;
|
|
368
|
+
if (!delta) continue;
|
|
369
|
+
|
|
370
|
+
if (delta.content) {
|
|
371
|
+
textContent += delta.content;
|
|
372
|
+
onEvent({ type: "text", content: delta.content });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (delta.tool_calls) {
|
|
376
|
+
for (const tc of delta.tool_calls) {
|
|
377
|
+
const idx = tc.index ?? 0;
|
|
378
|
+
if (!toolCalls.has(idx)) {
|
|
379
|
+
toolCalls.set(idx, { id: tc.id ?? "", name: "", arguments: "" });
|
|
380
|
+
}
|
|
381
|
+
const entry = toolCalls.get(idx)!;
|
|
382
|
+
if (tc.id) entry.id = tc.id;
|
|
383
|
+
if (tc.function?.name) entry.name += tc.function.name;
|
|
384
|
+
if (tc.function?.arguments) entry.arguments += tc.function.arguments;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
reader.releaseLock();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (toolCalls.size === 0) {
|
|
395
|
+
onEvent({ type: "done" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const assistantMsg: OpenRouterChatMessage = {
|
|
400
|
+
role: "assistant",
|
|
401
|
+
content: textContent || null,
|
|
402
|
+
tool_calls: [...toolCalls.values()].map((tc) => ({
|
|
403
|
+
id: tc.id,
|
|
404
|
+
type: "function" as const,
|
|
405
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
406
|
+
})),
|
|
407
|
+
};
|
|
408
|
+
currentMessages.push(assistantMsg);
|
|
409
|
+
|
|
410
|
+
for (const tc of toolCalls.values()) {
|
|
411
|
+
onEvent({
|
|
412
|
+
type: "tool_call",
|
|
413
|
+
toolCall: { id: tc.id, name: tc.name, arguments: tc.arguments },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
let args: Record<string, unknown> = {};
|
|
417
|
+
try { args = JSON.parse(tc.arguments); } catch {}
|
|
418
|
+
|
|
419
|
+
let result: string;
|
|
420
|
+
try {
|
|
421
|
+
result = await executeTool(tc.name, args);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
onEvent({ type: "tool_result", toolResult: { id: tc.id, result } });
|
|
427
|
+
|
|
428
|
+
currentMessages.push({
|
|
429
|
+
role: "tool",
|
|
430
|
+
content: result,
|
|
431
|
+
tool_call_id: tc.id,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
onEvent({ type: "done" });
|
|
437
|
+
}
|
package/src/llm/prompts.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface FileSummaryInput {
|
|
|
17
17
|
export interface PromptContext {
|
|
18
18
|
commits?: PrCommit[];
|
|
19
19
|
language?: string;
|
|
20
|
+
prBody?: string;
|
|
21
|
+
discussion?: Array<{ author: string; body: string }>;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function langDirective(lang?: string): string {
|
|
@@ -24,6 +26,21 @@ function langDirective(lang?: string): string {
|
|
|
24
26
|
return `\nCRITICAL LANGUAGE RULE: ALL text values in your response MUST be written in ${lang}. This includes every summary, description, name, purpose, scope, and impact field. JSON keys stay in English, but ALL string values MUST be in ${lang}. Do NOT use English for any descriptive text.`;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function formatDiscussion(ctx?: PromptContext): string {
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
if (ctx?.prBody?.trim()) {
|
|
32
|
+
parts.push(`PR Description:\n${ctx.prBody.trim()}`);
|
|
33
|
+
}
|
|
34
|
+
if (ctx?.discussion && ctx.discussion.length > 0) {
|
|
35
|
+
const comments = ctx.discussion
|
|
36
|
+
.map((c) => `@${c.author}: ${c.body.length > 500 ? `${c.body.slice(0, 500)}…` : c.body}`)
|
|
37
|
+
.join("\n\n");
|
|
38
|
+
parts.push(`Discussion (${ctx.discussion.length} comments):\n${comments}`);
|
|
39
|
+
}
|
|
40
|
+
if (parts.length === 0) return "";
|
|
41
|
+
return `\n\n--- PR DISCUSSION ---\n${parts.join("\n\n")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
function formatCommitHistory(commits: PrCommit[]): string {
|
|
28
45
|
if (commits.length === 0) return "";
|
|
29
46
|
const lines = commits.map((c) => {
|
|
@@ -44,13 +61,15 @@ export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext)
|
|
|
44
61
|
|
|
45
62
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
46
63
|
|
|
64
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
65
|
+
|
|
47
66
|
return {
|
|
48
67
|
system: `You are an expert code reviewer. Analyze the given diff and provide a 1-line summary for each changed file.
|
|
49
|
-
Use the commit history to understand the intent behind each change — why the change was made, not just what changed.
|
|
68
|
+
Use the commit history and PR discussion to understand the intent behind each change — why the change was made, not just what changed.
|
|
50
69
|
Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
|
|
51
70
|
The "path" value must be the exact file path. The "summary" value is a human-readable description.
|
|
52
71
|
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
53
|
-
user: `${fileList}${commitCtx}`,
|
|
72
|
+
user: `${fileList}${commitCtx}${discussionCtx}`,
|
|
54
73
|
};
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -61,15 +80,25 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
|
|
|
61
80
|
|
|
62
81
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
63
82
|
|
|
83
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
84
|
+
|
|
64
85
|
return {
|
|
65
|
-
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
|
|
66
|
-
Each group should have
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose and provide detailed analysis for each group.
|
|
87
|
+
Each group should have:
|
|
88
|
+
- "name": descriptive group name
|
|
89
|
+
- "type": one of: feature, refactor, bugfix, chore, docs, test, config
|
|
90
|
+
- "description": what this group of changes does (1-2 sentences)
|
|
91
|
+
- "files": list of file paths
|
|
92
|
+
- "key_changes": 2-5 bullet points describing the most important specific changes (e.g. "Add JWT token validation middleware", "Replace REST calls with GraphQL queries")
|
|
93
|
+
- "risk": a brief risk assessment for this group (e.g. "Low - cosmetic changes only", "Medium - modifies auth flow, needs careful review", "High - changes database schema")
|
|
94
|
+
- "dependencies": list of other group names that this group depends on or interacts with (empty array if none)
|
|
95
|
+
|
|
96
|
+
A file MAY appear in multiple groups if it serves multiple purposes.
|
|
97
|
+
Use the commit history and PR discussion to understand which changes belong together logically.
|
|
98
|
+
Respond ONLY with a JSON array. Each element: {"name": "...", "type": "...", "description": "...", "files": [...], "key_changes": [...], "risk": "...", "dependencies": [...]}.
|
|
99
|
+
The "type" value must be one of the English keywords listed above. File paths stay as-is.
|
|
71
100
|
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
72
|
-
user: `Changed files:\n${fileList}${commitCtx}`,
|
|
101
|
+
user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
73
102
|
};
|
|
74
103
|
}
|
|
75
104
|
|
|
@@ -86,13 +115,15 @@ export function buildOverallSummaryPrompt(
|
|
|
86
115
|
const fileList = fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n");
|
|
87
116
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
88
117
|
|
|
118
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
119
|
+
|
|
89
120
|
return {
|
|
90
121
|
system: `You are an expert code reviewer. Provide an overall summary of this Pull Request.
|
|
91
|
-
Use the commit history to understand the development progression and intent.
|
|
122
|
+
Use the commit history and PR discussion to understand the development progression and intent. The PR description and reviewer comments provide essential context about why changes were made.
|
|
92
123
|
Respond ONLY with a JSON object: {"purpose": "why this PR exists (1-2 sentences)", "scope": "what areas of code are affected", "impact": "what is the impact of these changes", "risk_level": "low|medium|high"}.
|
|
93
124
|
The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
|
|
94
125
|
No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}`,
|
|
95
|
-
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}`,
|
|
126
|
+
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
96
127
|
};
|
|
97
128
|
}
|
|
98
129
|
|
|
@@ -101,25 +132,54 @@ export function buildNarrativePrompt(
|
|
|
101
132
|
summary: PrSummary,
|
|
102
133
|
groups: FileGroup[],
|
|
103
134
|
ctx?: PromptContext,
|
|
135
|
+
fileDiffs?: Array<{ path: string; diff: string }>,
|
|
104
136
|
): PromptPair {
|
|
105
137
|
const groupDetails = groups
|
|
106
|
-
.map((g) =>
|
|
138
|
+
.map((g) => {
|
|
139
|
+
let detail = `### ${g.name} (${g.type})\n${g.description}\nFiles: ${g.files.join(", ")}`;
|
|
140
|
+
if (g.key_changes && g.key_changes.length > 0) {
|
|
141
|
+
detail += `\nKey changes:\n${g.key_changes.map((c) => `- ${c}`).join("\n")}`;
|
|
142
|
+
}
|
|
143
|
+
return detail;
|
|
144
|
+
})
|
|
107
145
|
.join("\n\n");
|
|
108
146
|
|
|
147
|
+
const diffContext = fileDiffs && fileDiffs.length > 0
|
|
148
|
+
? `\n\n--- FILE DIFFS (use these line numbers for [[line:...]] anchors) ---\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
|
|
149
|
+
: "";
|
|
150
|
+
|
|
109
151
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
110
152
|
const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
|
|
111
153
|
|
|
154
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
155
|
+
|
|
112
156
|
return {
|
|
113
157
|
system: `You are an expert code reviewer writing a review walkthrough for other developers.
|
|
114
158
|
Write a clear, concise narrative that tells the "story" of this PR — what changes were made and in what logical order.
|
|
115
|
-
Use the commit history to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step.
|
|
159
|
+
Use the commit history and PR discussion to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step. The PR description often explains the author's motivation and approach.
|
|
116
160
|
Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
|
|
117
161
|
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and [[group:...]]/[[file:...]] tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}
|
|
118
162
|
|
|
119
|
-
IMPORTANT:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
IMPORTANT: Use these anchor formats — they become clickable links in the UI:
|
|
164
|
+
|
|
165
|
+
1. Group: [[group:Group Name]] — renders as a clickable chip.
|
|
166
|
+
2. File: [[file:path/to/file.ts]] — renders as a clickable chip.
|
|
167
|
+
3. Line reference: [[line:path/to/file.ts#L42-L50]](descriptive text here) — the "descriptive text" becomes an underlined clickable link that opens the diff at that line. The line info itself is NOT shown to the user — only the descriptive text is visible.
|
|
168
|
+
|
|
169
|
+
RULES:
|
|
170
|
+
- Use EXACT group names and file paths from the context above.
|
|
171
|
+
- Every group MUST be referenced at least once with [[group:...]].
|
|
172
|
+
- For line references, ALWAYS use the form [[line:path#Lstart-Lend]](text). NEVER use bare [[line:...]] without (text).
|
|
173
|
+
- The (text) should be a natural description of what the code does, NOT the file name or line numbers. The reader should not see any line numbers — they just see underlined text they can click.
|
|
174
|
+
- Do NOT place [[file:...]] and [[line:...]] next to each other for the same file. Use [[line:...]] with descriptive text instead — it already opens the file.
|
|
175
|
+
- Aim for most sentences about code to have at least one [[line:...]](...) reference.
|
|
176
|
+
|
|
177
|
+
GOOD example:
|
|
178
|
+
"The [[group:Auth Flow]] group introduces session management. [[line:src/auth/session.ts#L15-L30]](The new validateToken function) handles JWT parsing, and [[line:src/auth/middleware.ts#L8-L12]](the auth middleware) invokes it on every request."
|
|
179
|
+
|
|
180
|
+
BAD example (DO NOT do this):
|
|
181
|
+
"The new validateToken function [[line:src/auth/session.ts#L15-L30]] in [[file:src/auth/session.ts]] handles JWT parsing."`,
|
|
182
|
+
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
|
|
123
183
|
};
|
|
124
184
|
}
|
|
125
185
|
|
|
@@ -162,15 +222,16 @@ export function buildEnrichedNarrativePrompt(
|
|
|
162
222
|
groups: FileGroup[],
|
|
163
223
|
exploration: ExplorationResult,
|
|
164
224
|
ctx?: PromptContext,
|
|
225
|
+
fileDiffs?: Array<{ path: string; diff: string }>,
|
|
165
226
|
): PromptPair {
|
|
166
|
-
const base = buildNarrativePrompt(prTitle, summary, groups, ctx);
|
|
227
|
+
const base = buildNarrativePrompt(prTitle, summary, groups, ctx, fileDiffs);
|
|
167
228
|
const context = formatCodebaseContext(exploration);
|
|
168
229
|
|
|
169
230
|
return {
|
|
170
231
|
system: `${base.system}
|
|
171
232
|
You have access to full codebase analysis. Use it to explain HOW the changes relate to existing code, not just WHAT changed.
|
|
172
233
|
Mention specific existing functions, modules, or patterns that are affected.
|
|
173
|
-
|
|
234
|
+
Use [[group:Name]], [[file:path]], and [[line:path#L42-L50]](descriptive text) as instructed above.`,
|
|
174
235
|
user: `${base.user}\n\n--- CODEBASE CONTEXT (from agentic exploration) ---\n${context}`,
|
|
175
236
|
};
|
|
176
237
|
}
|
|
@@ -40,12 +40,24 @@ export function parseGroups(raw: string): FileGroup[] {
|
|
|
40
40
|
? (rawType as GroupType)
|
|
41
41
|
: "chore";
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const group: FileGroup = {
|
|
44
44
|
name: String(item.name ?? "Ungrouped"),
|
|
45
45
|
type,
|
|
46
46
|
description: String(item.description ?? ""),
|
|
47
47
|
files: Array.isArray(item.files) ? item.files.map(String) : [],
|
|
48
48
|
};
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(item.key_changes) && item.key_changes.length > 0) {
|
|
51
|
+
group.key_changes = item.key_changes.map(String);
|
|
52
|
+
}
|
|
53
|
+
if (item.risk && typeof item.risk === "string") {
|
|
54
|
+
group.risk = item.risk;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(item.dependencies) && item.dependencies.length > 0) {
|
|
57
|
+
group.dependencies = item.dependencies.map(String);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return group;
|
|
49
61
|
});
|
|
50
62
|
}
|
|
51
63
|
|
package/src/tui/Shell.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type { SessionRecord } from "../history/types.ts";
|
|
|
7
7
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
8
8
|
import { parsePrInput } from "../github/parse-pr.ts";
|
|
9
9
|
import { analyzePr } from "../analyzer/pipeline.ts";
|
|
10
|
-
import { saveSession, listSessions, loadSession } from "../history/store.ts";
|
|
10
|
+
import { saveSession, savePatchesSidecar, listSessions, loadSession } from "../history/store.ts";
|
|
11
11
|
import { detectAgents } from "../workspace/agent.ts";
|
|
12
12
|
import { App } from "./App.tsx";
|
|
13
13
|
import { InputBar } from "./InputBar.tsx";
|
|
@@ -94,10 +94,12 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
94
94
|
setState({ phase: "loading", steps: [], startTime });
|
|
95
95
|
setElapsed(0);
|
|
96
96
|
|
|
97
|
+
let capturedPatches: Record<string, string> = {};
|
|
97
98
|
const result = await analyzePr({
|
|
98
99
|
pr,
|
|
99
100
|
token,
|
|
100
101
|
config: liveConfig,
|
|
102
|
+
onFilePatches: (patches) => { capturedPatches = patches; },
|
|
101
103
|
onProgress: (event: ProgressEvent) => {
|
|
102
104
|
const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
|
|
103
105
|
const prev = eventsRef.current;
|
|
@@ -117,7 +119,10 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
117
119
|
},
|
|
118
120
|
});
|
|
119
121
|
|
|
120
|
-
await saveSession(result);
|
|
122
|
+
const record = await saveSession(result);
|
|
123
|
+
if (Object.keys(capturedPatches).length > 0) {
|
|
124
|
+
await savePatchesSidecar(record.id, capturedPatches).catch(() => {});
|
|
125
|
+
}
|
|
121
126
|
const updated = await listSessions(10);
|
|
122
127
|
setSessions(updated);
|
|
123
128
|
|
package/src/types/github.ts
CHANGED
|
@@ -12,10 +12,24 @@ export interface PrCommit {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface PrComment {
|
|
16
|
+
id: number;
|
|
17
|
+
author: string;
|
|
18
|
+
author_avatar?: string;
|
|
19
|
+
body: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
updated_at: string;
|
|
22
|
+
html_url: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PrState = "open" | "closed" | "merged" | "draft";
|
|
26
|
+
|
|
15
27
|
export interface GithubPrData {
|
|
16
28
|
number: number;
|
|
17
29
|
title: string;
|
|
30
|
+
body: string;
|
|
18
31
|
url: string;
|
|
32
|
+
state: PrState;
|
|
19
33
|
base_branch: string;
|
|
20
34
|
head_branch: string;
|
|
21
35
|
author: string;
|
package/src/types/output.ts
CHANGED
|
@@ -11,10 +11,14 @@ export type GroupType =
|
|
|
11
11
|
|
|
12
12
|
export type RiskLevel = "low" | "medium" | "high";
|
|
13
13
|
|
|
14
|
+
export type PrStateLabel = "open" | "closed" | "merged" | "draft";
|
|
15
|
+
|
|
14
16
|
export interface PrMeta {
|
|
15
17
|
pr_number: number;
|
|
16
18
|
pr_title: string;
|
|
19
|
+
pr_body?: string;
|
|
17
20
|
pr_url: string;
|
|
21
|
+
pr_state?: PrStateLabel;
|
|
18
22
|
base_branch: string;
|
|
19
23
|
head_branch: string;
|
|
20
24
|
author: string;
|
|
@@ -39,6 +43,9 @@ export interface FileGroup {
|
|
|
39
43
|
type: GroupType;
|
|
40
44
|
description: string;
|
|
41
45
|
files: string[];
|
|
46
|
+
key_changes?: string[];
|
|
47
|
+
risk?: string;
|
|
48
|
+
dependencies?: string[];
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface FileChange {
|
|
@@ -56,6 +63,49 @@ export interface CartoonImage {
|
|
|
56
63
|
generatedAt: string;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
export interface DiffComment {
|
|
67
|
+
id: string;
|
|
68
|
+
sessionId: string;
|
|
69
|
+
filePath: string;
|
|
70
|
+
line: number;
|
|
71
|
+
startLine?: number;
|
|
72
|
+
side: "old" | "new";
|
|
73
|
+
body: string;
|
|
74
|
+
author: string;
|
|
75
|
+
authorAvatar?: string;
|
|
76
|
+
createdAt: string;
|
|
77
|
+
githubUrl?: string;
|
|
78
|
+
githubCommentId?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PendingComment {
|
|
82
|
+
tempId: string;
|
|
83
|
+
filePath: string;
|
|
84
|
+
line: number;
|
|
85
|
+
side: "old" | "new";
|
|
86
|
+
body: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ChatToolCall {
|
|
90
|
+
id: string;
|
|
91
|
+
name: string;
|
|
92
|
+
arguments: Record<string, unknown>;
|
|
93
|
+
result?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ChatSegment =
|
|
97
|
+
| { type: "text"; content: string }
|
|
98
|
+
| { type: "tool_call"; toolCall: ChatToolCall };
|
|
99
|
+
|
|
100
|
+
export interface ChatMessage {
|
|
101
|
+
role: "user" | "assistant" | "tool";
|
|
102
|
+
content: string;
|
|
103
|
+
toolCalls?: ChatToolCall[];
|
|
104
|
+
segments?: ChatSegment[];
|
|
105
|
+
toolCallId?: string;
|
|
106
|
+
timestamp: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
59
109
|
export interface NewprOutput {
|
|
60
110
|
meta: PrMeta;
|
|
61
111
|
summary: PrSummary;
|
package/src/web/client/App.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
|
|
|
10
10
|
import { ResultsScreen } from "./components/ResultsScreen.tsx";
|
|
11
11
|
import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
12
12
|
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
13
|
+
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
14
|
+
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
13
15
|
|
|
14
16
|
function getUrlParam(key: string): string | null {
|
|
15
17
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -57,7 +59,7 @@ export function App() {
|
|
|
57
59
|
}
|
|
58
60
|
}, [analysis.phase, analysis.sessionId]);
|
|
59
61
|
|
|
60
|
-
const handleAnchorClick = useCallback((kind: "group" | "file", id: string) => {
|
|
62
|
+
const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
|
|
61
63
|
const key = `${kind}:${id}`;
|
|
62
64
|
setActiveId((prev) => prev === key ? null : key);
|
|
63
65
|
}, []);
|
|
@@ -66,7 +68,7 @@ export function App() {
|
|
|
66
68
|
if (!activeId || !analysis.result) return null;
|
|
67
69
|
const [kind, ...rest] = activeId.split(":");
|
|
68
70
|
const id = rest.join(":");
|
|
69
|
-
return resolveDetail(kind as "group" | "file", id, analysis.result.groups, analysis.result.files);
|
|
71
|
+
return resolveDetail(kind as "group" | "file" | "line", id, analysis.result.groups, analysis.result.files);
|
|
70
72
|
}, [activeId, analysis.result]);
|
|
71
73
|
|
|
72
74
|
function handleSessionSelect(id: string) {
|
|
@@ -80,11 +82,29 @@ export function App() {
|
|
|
80
82
|
analysis.reset();
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const diffSessionId = analysis.historyId ?? analysis.sessionId;
|
|
86
|
+
const prUrl = analysis.result?.meta.pr_url;
|
|
83
87
|
const detailPanel = detailTarget ? (
|
|
84
|
-
<DetailPane target={detailTarget} onClose={() => setActiveId(null)} />
|
|
88
|
+
<DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
|
|
85
89
|
) : null;
|
|
86
90
|
|
|
91
|
+
const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
|
|
92
|
+
const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
|
|
93
|
+
|
|
94
|
+
const anchorItems = useMemo<AnchorItem[]>(() => {
|
|
95
|
+
if (!analysis.result) return [];
|
|
96
|
+
const items: AnchorItem[] = [];
|
|
97
|
+
for (const g of analysis.result.groups) {
|
|
98
|
+
items.push({ kind: "group", id: g.name, label: g.name });
|
|
99
|
+
}
|
|
100
|
+
for (const f of analysis.result.files) {
|
|
101
|
+
items.push({ kind: "file", id: f.path, label: f.path });
|
|
102
|
+
}
|
|
103
|
+
return items;
|
|
104
|
+
}, [analysis.result]);
|
|
105
|
+
|
|
87
106
|
return (
|
|
107
|
+
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
88
108
|
<AppShell
|
|
89
109
|
theme={themeCtx.theme}
|
|
90
110
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -93,9 +113,15 @@ export function App() {
|
|
|
93
113
|
onSessionSelect={handleSessionSelect}
|
|
94
114
|
onNewAnalysis={handleNewAnalysis}
|
|
95
115
|
detailPanel={detailPanel}
|
|
116
|
+
bottomBar={analysis.phase === "done" && activeTab === "story" ? <ChatInput /> : undefined}
|
|
117
|
+
activeSessionId={diffSessionId}
|
|
96
118
|
>
|
|
97
119
|
{analysis.phase === "idle" && (
|
|
98
|
-
<InputScreen
|
|
120
|
+
<InputScreen
|
|
121
|
+
onSubmit={(pr) => analysis.start(pr)}
|
|
122
|
+
sessions={sessions}
|
|
123
|
+
onSessionSelect={handleSessionSelect}
|
|
124
|
+
/>
|
|
99
125
|
)}
|
|
100
126
|
{analysis.phase === "loading" && (
|
|
101
127
|
<LoadingTimeline
|
|
@@ -110,7 +136,8 @@ export function App() {
|
|
|
110
136
|
activeId={activeId}
|
|
111
137
|
onAnchorClick={handleAnchorClick}
|
|
112
138
|
cartoonEnabled={features.cartoon}
|
|
113
|
-
sessionId={
|
|
139
|
+
sessionId={diffSessionId}
|
|
140
|
+
onTabChange={setActiveTab}
|
|
114
141
|
/>
|
|
115
142
|
)}
|
|
116
143
|
{analysis.phase === "error" && (
|
|
@@ -121,5 +148,6 @@ export function App() {
|
|
|
121
148
|
/>
|
|
122
149
|
)}
|
|
123
150
|
</AppShell>
|
|
151
|
+
</ChatProvider>
|
|
124
152
|
);
|
|
125
153
|
}
|