newpr 0.2.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -85,25 +85,25 @@ async function runExploration(
85
85
  ): Promise<ExplorationResult> {
86
86
  const agent = await requireAgent(preferredAgent);
87
87
 
88
- onProgress?.({ stage: "cloning", message: `${pr.owner}/${pr.repo}` });
88
+ onProgress?.({ stage: "cloning", message: `Cloning ${pr.owner}/${pr.repo}...` });
89
89
  const bareRepoPath = await ensureRepo(pr.owner, pr.repo, token, (msg) => {
90
- onProgress?.({ stage: "cloning", message: msg });
90
+ onProgress?.({ stage: "cloning", message: `📦 ${msg}` });
91
91
  });
92
- onProgress?.({ stage: "cloning", message: `${pr.owner}/${pr.repo} ready` });
92
+ onProgress?.({ stage: "cloning", message: `📦 ${pr.owner}/${pr.repo} cached` });
93
93
 
94
- onProgress?.({ stage: "checkout", message: `${baseBranch} ← PR #${pr.number}` });
94
+ onProgress?.({ stage: "checkout", message: `🌿 Preparing worktrees: ${baseBranch} ← PR #${pr.number}` });
95
95
  const worktrees = await createWorktrees(
96
96
  bareRepoPath, baseBranch, pr.number, pr.owner, pr.repo,
97
- (msg) => onProgress?.({ stage: "checkout", message: msg }),
97
+ (msg) => onProgress?.({ stage: "checkout", message: `🌿 ${msg}` }),
98
98
  );
99
- onProgress?.({ stage: "checkout", message: `${baseBranch} ← PR #${pr.number} worktrees ready` });
99
+ onProgress?.({ stage: "checkout", message: `🌿 Worktrees ready: ${baseBranch} ← PR #${pr.number}` });
100
100
 
101
- onProgress?.({ stage: "exploring", message: `${agent.name}: analyzing ${changedFiles.length} files...` });
101
+ onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploring ${changedFiles.length} changed files...` });
102
102
  const exploration = await exploreCodebase(
103
103
  agent, worktrees.headPath, changedFiles, prTitle, rawDiff,
104
104
  (msg, current, total) => onProgress?.({ stage: "exploring", message: msg, current, total }),
105
105
  );
106
- onProgress?.({ stage: "exploring", message: `${agent.name}: exploration complete` });
106
+ onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploration complete` });
107
107
 
108
108
  await cleanupWorktrees(bareRepoPath, pr.number, pr.owner, pr.repo).catch(() => {});
109
109
 
@@ -165,7 +165,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
165
165
  fetchPrDiff(pr, token),
166
166
  fetchPrComments(pr, token).catch(() => []),
167
167
  ]);
168
- progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments` });
168
+ progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments`, pr_title: prData.title, pr_number: prData.number });
169
169
 
170
170
  progress({ stage: "parsing", message: "Parsing diff..." });
171
171
  const parsed = parseDiff(rawDiff);
@@ -259,9 +259,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
259
259
  progress({ stage: "summarizing", message: `${summary.risk_level} risk · ${summary.purpose.slice(0, 60)}` });
260
260
 
261
261
  progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
262
+ const fileDiffs = chunks.slice(0, 30).map((c) => ({
263
+ path: c.file_path,
264
+ diff: c.diff_content.length > 3000 ? `${c.diff_content.slice(0, 3000)}\n... (truncated)` : c.diff_content,
265
+ }));
262
266
  const narrativePrompt = exploration
263
- ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx)
264
- : buildNarrativePrompt(prData.title, summary, groups, promptCtx);
267
+ ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
268
+ : buildNarrativePrompt(prData.title, summary, groups, promptCtx, fileDiffs);
265
269
  const narrativeResponse = await streamLlmCall(
266
270
  client, narrativePrompt.system, narrativePrompt.user, "narrating", "Writing narrative...", progress,
267
271
  );
@@ -297,6 +301,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
297
301
  pr_title: prData.title,
298
302
  pr_body: prData.body || undefined,
299
303
  pr_url: prData.url,
304
+ pr_state: prData.state,
300
305
  base_branch: prData.base_branch,
301
306
  head_branch: prData.head_branch,
302
307
  author: prData.author,
@@ -17,6 +17,8 @@ export interface ProgressEvent {
17
17
  total?: number;
18
18
  partial_content?: string;
19
19
  timestamp?: number;
20
+ pr_title?: string;
21
+ pr_number?: number;
20
22
  }
21
23
 
22
24
  export type ProgressCallback = (event: ProgressEvent) => void;
package/src/cli/index.ts CHANGED
@@ -10,8 +10,9 @@ import { analyzePr } from "../analyzer/pipeline.ts";
10
10
  import { createStderrProgress, createSilentProgress, createStreamJsonProgress } from "../analyzer/progress.ts";
11
11
  import { renderLoading, renderShell } from "../tui/render.tsx";
12
12
  import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
13
+ import { runPreflight, printPreflight } from "./preflight.ts";
13
14
 
14
- const VERSION = "0.1.3";
15
+ const VERSION = "0.3.0";
15
16
 
16
17
  async function main(): Promise<void> {
17
18
  const args = parseArgs(process.argv);
@@ -51,12 +52,14 @@ async function main(): Promise<void> {
51
52
 
52
53
  if (args.command === "web") {
53
54
  try {
55
+ const preflight = await runPreflight();
56
+ printPreflight(preflight);
54
57
  const config = await loadConfig({ model: args.model });
55
58
  const token = await getGithubToken();
56
59
  const updateInfo = await updatePromise;
57
60
  if (updateInfo) printUpdateNotice(updateInfo);
58
61
  const { startWebServer } = await import("../web/server.ts");
59
- await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
62
+ await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon, preflight });
60
63
  } catch (error) {
61
64
  const message = error instanceof Error ? error.message : String(error);
62
65
  process.stderr.write(`Error: ${message}\n`);
@@ -67,6 +70,8 @@ async function main(): Promise<void> {
67
70
 
68
71
  if (args.command === "shell") {
69
72
  try {
73
+ const preflight = await runPreflight();
74
+ printPreflight(preflight);
70
75
  const config = await loadConfig({ model: args.model });
71
76
  const token = await getGithubToken();
72
77
  const updateInfo = await updatePromise;
@@ -0,0 +1,126 @@
1
+ import type { AgentToolName } from "../workspace/types.ts";
2
+
3
+ export interface ToolStatus {
4
+ name: string;
5
+ installed: boolean;
6
+ version?: string;
7
+ detail?: string;
8
+ }
9
+
10
+ export interface PreflightResult {
11
+ github: ToolStatus & { authenticated: boolean; user?: string };
12
+ agents: ToolStatus[];
13
+ openrouterKey: boolean;
14
+ }
15
+
16
+ async function which(cmd: string): Promise<string | null> {
17
+ try {
18
+ const result = await Bun.$`which ${cmd}`.text();
19
+ return result.trim() || null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async function getVersion(cmd: string, flag = "--version"): Promise<string | null> {
26
+ try {
27
+ const result = await Bun.$`${cmd} ${flag} 2>&1`.text();
28
+ const match = result.match(/[\d]+\.[\d]+[\d.]*/);
29
+ return match?.[0] ?? result.trim().slice(0, 30);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function checkGithubCli(): Promise<PreflightResult["github"]> {
36
+ const path = await which("gh");
37
+ if (!path) {
38
+ return { name: "gh", installed: false, authenticated: false, detail: "brew install gh" };
39
+ }
40
+ const version = await getVersion("gh");
41
+ try {
42
+ const status = await Bun.$`gh auth status 2>&1`.text();
43
+ const userMatch = status.match(/Logged in to github\.com account (\S+)/i)
44
+ ?? status.match(/account (\S+)/i);
45
+ return {
46
+ name: "gh",
47
+ installed: true,
48
+ version: version ?? undefined,
49
+ authenticated: true,
50
+ user: userMatch?.[1]?.replace(/\s*\(.*/, ""),
51
+ };
52
+ } catch {
53
+ return { name: "gh", installed: true, version: version ?? undefined, authenticated: false, detail: "gh auth login" };
54
+ }
55
+ }
56
+
57
+ async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
58
+ const path = await which(name);
59
+ if (!path) return { name, installed: false };
60
+ const version = await getVersion(name);
61
+ return { name, installed: true, version: version ?? undefined };
62
+ }
63
+
64
+ export async function runPreflight(): Promise<PreflightResult> {
65
+ const [github, claude, opencode, codex] = await Promise.all([
66
+ checkGithubCli(),
67
+ checkAgent("claude"),
68
+ checkAgent("opencode"),
69
+ checkAgent("codex"),
70
+ ]);
71
+
72
+ return {
73
+ github,
74
+ agents: [claude, opencode, codex],
75
+ openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
76
+ };
77
+ }
78
+
79
+ async function hasStoredApiKey(): Promise<boolean> {
80
+ try {
81
+ const { readStoredConfig } = await import("../config/store.ts");
82
+ const stored = await readStoredConfig();
83
+ return !!stored.openrouter_api_key;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ export function printPreflight(result: PreflightResult): void {
90
+ const check = "\x1b[32m✓\x1b[0m";
91
+ const cross = "\x1b[31m✗\x1b[0m";
92
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
93
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
94
+
95
+ console.log("");
96
+ console.log(` ${bold("Preflight")}`);
97
+ console.log("");
98
+
99
+ const gh = result.github;
100
+ if (gh.installed && gh.authenticated) {
101
+ console.log(` ${check} gh ${dim(gh.version ?? "")} ${dim(`· ${gh.user ?? ""}`)}`);
102
+ } else if (gh.installed) {
103
+ console.log(` ${cross} gh ${dim(gh.version ?? "")} ${dim("· not authenticated")}`);
104
+ console.log(` ${dim(`run: ${gh.detail}`)}`);
105
+ } else {
106
+ console.log(` ${cross} gh ${dim("· not installed")}`);
107
+ console.log(` ${dim(`run: ${gh.detail}`)}`);
108
+ }
109
+
110
+ for (const agent of result.agents) {
111
+ if (agent.installed) {
112
+ console.log(` ${check} ${agent.name} ${dim(agent.version ?? "")}`);
113
+ } else {
114
+ console.log(` ${dim("·")} ${dim(agent.name)} ${dim("not found")}`);
115
+ }
116
+ }
117
+
118
+ if (result.openrouterKey) {
119
+ console.log(` ${check} OpenRouter API key`);
120
+ } else {
121
+ console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
122
+ console.log(` ${dim("run: newpr auth")}`);
123
+ }
124
+
125
+ console.log("");
126
+ }
@@ -1,15 +1,25 @@
1
- import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
1
+ import type { GithubPrData, PrComment, PrCommit, PrIdentifier, PrState } from "../types/github.ts";
2
2
 
3
3
  export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
4
4
  const user = json.user as Record<string, unknown> | undefined;
5
5
  const base = json.base as Record<string, unknown> | undefined;
6
6
  const head = json.head as Record<string, unknown> | undefined;
7
7
 
8
+ let state: PrState = "open";
9
+ if (json.draft) {
10
+ state = "draft";
11
+ } else if (json.merged) {
12
+ state = "merged";
13
+ } else if (json.state === "closed") {
14
+ state = "closed";
15
+ }
16
+
8
17
  return {
9
18
  number: json.number as number,
10
19
  title: json.title as string,
11
20
  body: (json.body as string) ?? "",
12
21
  url: json.html_url as string,
22
+ state,
13
23
  base_branch: (base?.ref as string) ?? "unknown",
14
24
  head_branch: (head?.ref as string) ?? "unknown",
15
25
  author: (user?.login as string) ?? "unknown",
@@ -25,6 +25,7 @@ export function buildSessionRecord(id: string, data: NewprOutput): SessionRecord
25
25
  pr_url: meta.pr_url,
26
26
  pr_number: meta.pr_number,
27
27
  pr_title: meta.pr_title,
28
+ pr_state: meta.pr_state,
28
29
  repo: repoParts?.[1] ?? "unknown",
29
30
  author: meta.author,
30
31
  analyzed_at: meta.analyzed_at,
@@ -3,6 +3,7 @@ export interface SessionRecord {
3
3
  pr_url: string;
4
4
  pr_number: number;
5
5
  pr_title: string;
6
+ pr_state?: string;
6
7
  repo: string;
7
8
  author: string;
8
9
  analyzed_at: string;
@@ -83,12 +83,20 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
83
83
  const discussionCtx = formatDiscussion(ctx);
84
84
 
85
85
  return {
86
- system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
87
- 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.
88
- 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).
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.
89
97
  Use the commit history and PR discussion to understand which changes belong together logically.
90
- 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"]}.
91
- 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.
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.
92
100
  Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
93
101
  user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
94
102
  };
@@ -124,11 +132,22 @@ export function buildNarrativePrompt(
124
132
  summary: PrSummary,
125
133
  groups: FileGroup[],
126
134
  ctx?: PromptContext,
135
+ fileDiffs?: Array<{ path: string; diff: string }>,
127
136
  ): PromptPair {
128
137
  const groupDetails = groups
129
- .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
+ })
130
145
  .join("\n\n");
131
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
+
132
151
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
133
152
  const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
134
153
 
@@ -141,10 +160,26 @@ Use the commit history and PR discussion to understand the development progressi
141
160
  Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
142
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."}
143
162
 
144
- 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]].
145
- Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
146
- Example: "The [[group:Auth Flow]] group introduces session management via [[file:src/auth/session.ts]] and [[file:src/auth/token.ts]]."`,
147
- 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}`,
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}`,
148
183
  };
149
184
  }
150
185
 
@@ -187,15 +222,16 @@ export function buildEnrichedNarrativePrompt(
187
222
  groups: FileGroup[],
188
223
  exploration: ExplorationResult,
189
224
  ctx?: PromptContext,
225
+ fileDiffs?: Array<{ path: string; diff: string }>,
190
226
  ): PromptPair {
191
- const base = buildNarrativePrompt(prTitle, summary, groups, ctx);
227
+ const base = buildNarrativePrompt(prTitle, summary, groups, ctx, fileDiffs);
192
228
  const context = formatCodebaseContext(exploration);
193
229
 
194
230
  return {
195
231
  system: `${base.system}
196
232
  You have access to full codebase analysis. Use it to explain HOW the changes relate to existing code, not just WHAT changed.
197
233
  Mention specific existing functions, modules, or patterns that are affected.
198
- 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.`,
199
235
  user: `${base.user}\n\n--- CODEBASE CONTEXT (from agentic exploration) ---\n${context}`,
200
236
  };
201
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
 
@@ -22,11 +22,14 @@ export interface PrComment {
22
22
  html_url: string;
23
23
  }
24
24
 
25
+ export type PrState = "open" | "closed" | "merged" | "draft";
26
+
25
27
  export interface GithubPrData {
26
28
  number: number;
27
29
  title: string;
28
30
  body: string;
29
31
  url: string;
32
+ state: PrState;
30
33
  base_branch: string;
31
34
  head_branch: string;
32
35
  author: string;
@@ -11,11 +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;
17
19
  pr_body?: string;
18
20
  pr_url: string;
21
+ pr_state?: PrStateLabel;
19
22
  base_branch: string;
20
23
  head_branch: string;
21
24
  author: string;
@@ -40,6 +43,9 @@ export interface FileGroup {
40
43
  type: GroupType;
41
44
  description: string;
42
45
  files: string[];
46
+ key_changes?: string[];
47
+ risk?: string;
48
+ dependencies?: string[];
43
49
  }
44
50
 
45
51
  export interface FileChange {
@@ -59,7 +59,7 @@ export function App() {
59
59
  }
60
60
  }, [analysis.phase, analysis.sessionId]);
61
61
 
62
- const handleAnchorClick = useCallback((kind: "group" | "file", id: string) => {
62
+ const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
63
63
  const key = `${kind}:${id}`;
64
64
  setActiveId((prev) => prev === key ? null : key);
65
65
  }, []);
@@ -68,7 +68,7 @@ export function App() {
68
68
  if (!activeId || !analysis.result) return null;
69
69
  const [kind, ...rest] = activeId.split(":");
70
70
  const id = rest.join(":");
71
- 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);
72
72
  }, [activeId, analysis.result]);
73
73
 
74
74
  function handleSessionSelect(id: string) {
@@ -88,6 +88,7 @@ export function App() {
88
88
  <DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
89
89
  ) : null;
90
90
 
91
+ const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
91
92
  const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
92
93
 
93
94
  const anchorItems = useMemo<AnchorItem[]>(() => {
@@ -112,7 +113,7 @@ export function App() {
112
113
  onSessionSelect={handleSessionSelect}
113
114
  onNewAnalysis={handleNewAnalysis}
114
115
  detailPanel={detailPanel}
115
- bottomBar={analysis.phase === "done" ? <ChatInput /> : undefined}
116
+ bottomBar={analysis.phase === "done" && activeTab === "story" ? <ChatInput /> : undefined}
116
117
  activeSessionId={diffSessionId}
117
118
  >
118
119
  {analysis.phase === "idle" && (
@@ -136,6 +137,7 @@ export function App() {
136
137
  onAnchorClick={handleAnchorClick}
137
138
  cartoonEnabled={features.cartoon}
138
139
  sessionId={diffSessionId}
140
+ onTabChange={setActiveTab}
139
141
  />
140
142
  )}
141
143
  {analysis.phase === "error" && (
@@ -24,6 +24,13 @@ const RISK_DOT: Record<string, string> = {
24
24
  critical: "bg-red-600",
25
25
  };
26
26
 
27
+ const STATE_LABEL: Record<string, { text: string; class: string }> = {
28
+ open: { text: "Open", class: "text-green-600 dark:text-green-400" },
29
+ merged: { text: "Merged", class: "text-purple-600 dark:text-purple-400" },
30
+ closed: { text: "Closed", class: "text-red-600 dark:text-red-400" },
31
+ draft: { text: "Draft", class: "text-neutral-500" },
32
+ };
33
+
27
34
  function formatTimeAgo(isoDate: string): string {
28
35
  const diff = Date.now() - new Date(isoDate).getTime();
29
36
  const minutes = Math.floor(diff / 60000);
@@ -149,6 +156,12 @@ export function AppShell({
149
156
  <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/50">
150
157
  <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
151
158
  <span className="font-mono">#{s.pr_number}</span>
159
+ {s.pr_state && STATE_LABEL[s.pr_state] && (
160
+ <>
161
+ <span className="text-muted-foreground/20 mx-0.5">·</span>
162
+ <span className={STATE_LABEL[s.pr_state]!.class}>{STATE_LABEL[s.pr_state]!.text}</span>
163
+ </>
164
+ )}
152
165
  <span className="text-muted-foreground/20 mx-0.5">·</span>
153
166
  <span>{formatTimeAgo(s.analyzed_at)}</span>
154
167
  </div>
@@ -243,7 +243,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
243
243
  segments: ChatSegment[];
244
244
  activeToolName?: string;
245
245
  isStreaming?: boolean;
246
- onAnchorClick?: (kind: "group" | "file", id: string) => void;
246
+ onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
247
247
  activeId?: string | null;
248
248
  }) {
249
249
  const hasContent = segments.some((s) => s.type === "text" && s.content);
@@ -280,7 +280,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
280
280
  }
281
281
 
282
282
  export function ChatMessages({ onAnchorClick, activeId }: {
283
- onAnchorClick?: (kind: "group" | "file", id: string) => void;
283
+ onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
284
284
  activeId?: string | null;
285
285
  }) {
286
286
  const ctx = useContext(ChatContext);