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.
Files changed (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. 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
+ }
@@ -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 a descriptive name, a type (one of: feature, refactor, bugfix, chore, docs, test, config), a description, and a list of file paths.
67
- A file MAY appear in multiple groups if it serves multiple purposes (e.g., index.ts re-exporting for both a feature and a refactor).
68
- Use the commit history to understand which changes belong together logically.
69
- Respond ONLY with a JSON array. Each element: {"name": "group name", "type": "feature|refactor|bugfix|chore|docs|test|config", "description": "what this group of changes does", "files": ["path1", "path2"]}.
70
- The "name" and "description" values are human-readable text. The "type" value must be one of the English keywords listed above. File paths stay as-is.
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) => `### ${g.name} (${g.type})\n${g.description}\nFiles: ${g.files.join(", ")}`)
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: When referencing a change group, wrap it as [[group:Group Name]]. When referencing a specific file, wrap it as [[file:path/to/file.ts]].
120
- Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
121
- Example: "The [[group:Auth Flow]] group introduces session management via [[file:src/auth/session.ts]] and [[file:src/auth/token.ts]]."`,
122
- 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}`,
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
- Remember to use [[group:Name]] and [[file:path]] tokens as instructed.`,
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
- return {
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
 
@@ -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;
@@ -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;
@@ -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 onSubmit={(pr) => analysis.start(pr)} />
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={analysis.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
  }