newpr 1.0.19 → 1.0.21

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 (32) hide show
  1. package/package.json +2 -1
  2. package/src/cli/args.ts +1 -1
  3. package/src/config/index.ts +1 -6
  4. package/src/stack/co-change.ts +64 -0
  5. package/src/stack/confidence-score.ts +242 -0
  6. package/src/stack/feasibility.test.ts +6 -7
  7. package/src/stack/feasibility.ts +86 -30
  8. package/src/stack/import-deps.ts +131 -0
  9. package/src/stack/integration.test.ts +2 -2
  10. package/src/stack/partition.ts +41 -21
  11. package/src/stack/symbol-flow.ts +229 -0
  12. package/src/web/client/components/AnalyticsConsent.tsx +13 -13
  13. package/src/web/client/components/ChatSection.tsx +1 -1
  14. package/src/web/client/components/DetailPane.tsx +9 -9
  15. package/src/web/client/components/DiffViewer.tsx +17 -17
  16. package/src/web/client/components/ErrorScreen.tsx +1 -1
  17. package/src/web/client/components/InputScreen.tsx +2 -2
  18. package/src/web/client/components/LoadingTimeline.tsx +12 -12
  19. package/src/web/client/components/Markdown.tsx +10 -10
  20. package/src/web/client/components/ResultsScreen.tsx +4 -4
  21. package/src/web/client/components/ReviewModal.tsx +2 -2
  22. package/src/web/client/components/SettingsPanel.tsx +1 -1
  23. package/src/web/client/panels/DiscussionPanel.tsx +24 -24
  24. package/src/web/client/panels/FilesPanel.tsx +26 -26
  25. package/src/web/client/panels/GroupsPanel.tsx +25 -25
  26. package/src/web/client/panels/StoryPanel.tsx +25 -25
  27. package/src/web/index.html +2 -0
  28. package/src/web/server/routes.ts +6 -4
  29. package/src/web/server/stack-manager.ts +103 -7
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +16 -11
  32. package/src/workspace/agent.ts +13 -5
@@ -13,6 +13,7 @@ interface FileSummaryInput {
13
13
  export interface PartitionInput {
14
14
  groups: FileGroup[];
15
15
  changed_files: string[];
16
+ group_order_hint?: string[];
16
17
  }
17
18
 
18
19
  export interface AmbiguityReport {
@@ -58,54 +59,71 @@ export function buildStackPartitionPrompt(
58
59
  groups: FileGroup[],
59
60
  fileSummaries: FileSummaryInput[],
60
61
  commits: PrCommit[],
62
+ groupOrderHint?: string[],
61
63
  ): { system: string; user: string } {
64
+ const summaryByPath = new Map(fileSummaries.map((f) => [f.path, f.summary]));
65
+
62
66
  const groupDescriptions = groups
63
- .map((g) => `- "${g.name}" (${g.type}): ${g.description}`)
67
+ .map((g) => {
68
+ const canonicalFiles = g.files.slice(0, 8);
69
+ const fileHints = canonicalFiles.length > 0
70
+ ? `\n Representative files: ${canonicalFiles.join(", ")}${g.files.length > 8 ? ` (+${g.files.length - 8} more)` : ""}`
71
+ : "";
72
+ return `- "${g.name}" (${g.type}): ${g.description}${fileHints}`;
73
+ })
64
74
  .join("\n");
65
75
 
76
+ const buildFileEntry = (path: string, extra = ""): string => {
77
+ const summary = summaryByPath.get(path);
78
+ const summaryNote = summary ? ` — ${summary}` : "";
79
+ return `- ${path}${summaryNote}${extra}`;
80
+ };
81
+
66
82
  const ambiguousSection = ambiguous.length > 0
67
- ? `\n\nAmbiguous files (appear in multiple groups):\n${ambiguous
68
- .map((a) => `- ${a.path}in groups: ${a.groups.join(", ")}`)
83
+ ? `\n\nAmbiguous files (appear in multiple groups — pick the BEST ONE):\n${ambiguous
84
+ .map((a) => buildFileEntry(a.path, ` candidate groups: ${a.groups.join(", ")}`))
69
85
  .join("\n")}`
70
86
  : "";
71
87
 
72
88
  const unassignedSection = unassigned.length > 0
73
- ? `\n\nUnassigned files (not in any group):\n${unassigned.map((f) => `- ${f}`).join("\n")}`
89
+ ? `\n\nUnassigned files (assign to the most relevant group — prefer an EXISTING group over creating Shared Foundation):\n${unassigned.map((f) => buildFileEntry(f)).join("\n")}`
74
90
  : "";
75
91
 
76
- const fileSummarySection = fileSummaries.length > 0
77
- ? `\n\nFile summaries:\n${fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n")}`
92
+ const commitSection = commits.length > 0
93
+ ? `\n\nCommit history (use to understand intent of each change):\n${commits.map((c) => `- ${c.sha.substring(0, 7)} ${c.message}`).join("\n")}`
78
94
  : "";
79
95
 
80
- const commitSection = commits.length > 0
81
- ? `\n\nCommit history:\n${commits.map((c) => `- ${c.sha.substring(0, 7)} ${c.message}`).join("\n")}`
96
+ const orderHintSection = groupOrderHint && groupOrderHint.length > 1
97
+ ? `\n\nSuggested PR stack order (foundation → integration, for context only — use to judge which group a file logically "enables"):\n${groupOrderHint.map((g, i) => `${i + 1}. ${g}`).join("\n")}`
82
98
  : "";
83
99
 
84
100
  return {
85
- system: `You are a code organization expert. Your task is to assign each file to EXACTLY ONE group for PR stacking.
101
+ system: `You are a senior engineer helping organize a pull request into a reviewable stack.
102
+
103
+ Your task: assign each ambiguous or unassigned file to EXACTLY ONE group.
86
104
 
87
105
  Rules:
88
- 1. Each file must be assigned to exactly one group
89
- 2. Do not change files that are already exclusively assigned
90
- 3. For ambiguous files, choose the group where the file's changes are most relevant
91
- 4. For unassigned files, assign them to the most appropriate existing group
92
- 5. You may create a "Shared Foundation" group ONLY if files truly don't fit any existing group
93
- 6. Respond ONLY with a JSON object
94
-
95
- Response format:
106
+ 1. Assign every file to exactly one group — no file may be skipped.
107
+ 2. Use file path structure, file summary, and commit messages to judge relevance.
108
+ 3. An unassigned file that touches shared utilities (e.g. schema types, constants, index re-exports) belongs to the group that INTRODUCES or PRIMARILY USES those utilities in this PR.
109
+ 4. Prefer existing groups. Only create a "Shared Foundation" group if a file is genuinely orthogonal to ALL existing groups AND is depended on by multiple groups.
110
+ 5. Do NOT dump hard-to-classify files into Shared Foundation that creates an oversized catch-all PR that defeats the purpose of stacking.
111
+ 6. When in doubt, pick the group whose file paths are most similar (same directory prefix, same feature area).
112
+
113
+ Response format (JSON only):
96
114
  {
97
115
  "assignments": [
98
- { "path": "file.ts", "group": "group-name", "reason": "brief reason" }
116
+ { "path": "file.ts", "group": "exact-group-name", "reason": "one sentence" }
99
117
  ],
100
118
  "shared_foundation": null
101
119
  }
102
120
 
103
- If creating a Shared Foundation group:
121
+ Only include shared_foundation if truly necessary:
104
122
  {
105
123
  "assignments": [...],
106
- "shared_foundation": { "name": "Shared Foundation", "description": "Common infrastructure changes", "files": [...] }
124
+ "shared_foundation": { "name": "Shared Foundation", "description": "why this is shared", "files": ["path1", "path2"] }
107
125
  }`,
108
- user: `Groups:\n${groupDescriptions}${ambiguousSection}${unassignedSection}${fileSummarySection}${commitSection}\n\nAssign each ambiguous/unassigned file to exactly one group.`,
126
+ user: `Groups:\n${groupDescriptions}${ambiguousSection}${unassignedSection}${commitSection}${orderHintSection}\n\nAssign every listed file to exactly one group. Prefer existing groups over Shared Foundation.`,
109
127
  };
110
128
  }
111
129
 
@@ -115,6 +133,7 @@ export async function partitionGroups(
115
133
  changedFiles: string[],
116
134
  fileSummaries: FileSummaryInput[],
117
135
  commits: PrCommit[],
136
+ groupOrderHint?: string[],
118
137
  ): Promise<PartitionResult> {
119
138
  const report = detectAmbiguousPaths({ groups, changed_files: changedFiles });
120
139
 
@@ -133,6 +152,7 @@ export async function partitionGroups(
133
152
  groups,
134
153
  fileSummaries,
135
154
  commits,
155
+ groupOrderHint,
136
156
  );
137
157
 
138
158
  const response = await client.complete(prompt.system, prompt.user);
@@ -0,0 +1,229 @@
1
+ import { parse } from "meriyah";
2
+
3
+ const ANALYZABLE_EXTENSIONS = new Set([
4
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
5
+ ]);
6
+
7
+ export interface NamedImport {
8
+ from: string;
9
+ names: string[];
10
+ }
11
+
12
+ export interface FileSymbols {
13
+ path: string;
14
+ exports: string[];
15
+ imports: NamedImport[];
16
+ }
17
+
18
+ type AstNode = { type: string;[key: string]: unknown };
19
+ type AstProgram = { type: "Program"; body: AstNode[] };
20
+
21
+ function safeParseAst(source: string): AstProgram | null {
22
+ try {
23
+ return parse(source, { module: true, jsx: true, next: true, raw: false, ranges: false }) as unknown as AstProgram;
24
+ } catch {
25
+ }
26
+ try {
27
+ return parse(source, { module: true, jsx: false, next: true, raw: false, ranges: false }) as unknown as AstProgram;
28
+ } catch {
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function getId(node: AstNode): string | null {
34
+ const id = node["id"] as { name: string } | undefined;
35
+ return id?.name ?? null;
36
+ }
37
+
38
+ function extractFromAst(
39
+ ast: AstProgram,
40
+ resolveSpecifier: (s: string) => string | null,
41
+ ): { exports: string[]; imports: NamedImport[] } {
42
+ const exports: string[] = [];
43
+ const importsMap = new Map<string, string[]>();
44
+
45
+ const addImport = (from: string | null, names: string[]) => {
46
+ if (!from) return;
47
+ const arr = importsMap.get(from) ?? [];
48
+ for (const n of names) arr.push(n);
49
+ importsMap.set(from, arr);
50
+ };
51
+
52
+ for (const node of ast.body) {
53
+ if (node.type === "ExportNamedDeclaration") {
54
+ const decl = node["declaration"] as AstNode | null;
55
+ if (decl) {
56
+ const name = getId(decl);
57
+ if (name) exports.push(name);
58
+ if (decl.type === "VariableDeclaration") {
59
+ for (const d of (decl["declarations"] as AstNode[])) {
60
+ const id = d["id"] as AstNode;
61
+ if (id.type === "Identifier") exports.push(id["name"] as string);
62
+ }
63
+ }
64
+ }
65
+ const specifiers = node["specifiers"] as Array<{ exported: { name: string } }>;
66
+ for (const s of specifiers ?? []) exports.push(s.exported.name);
67
+
68
+ const src = (node["source"] as { value: string } | null)?.value;
69
+ if (src) {
70
+ const from = resolveSpecifier(src);
71
+ const names = (specifiers ?? []).map((s) => s.exported.name);
72
+ addImport(from, names);
73
+ }
74
+ } else if (node.type === "ExportDefaultDeclaration") {
75
+ exports.push("default");
76
+ } else if (node.type === "ExportAllDeclaration") {
77
+ const src = (node["source"] as { value: string } | null)?.value;
78
+ if (src) addImport(resolveSpecifier(src), ["*"]);
79
+ } else if (node.type === "ImportDeclaration") {
80
+ const src = (node["source"] as { value: string }).value;
81
+ const from = resolveSpecifier(src);
82
+ const names = (node["specifiers"] as AstNode[]).map((s) => {
83
+ if (s.type === "ImportSpecifier") return (s["imported"] as { name: string }).name;
84
+ if (s.type === "ImportDefaultSpecifier") return "default";
85
+ if (s.type === "ImportNamespaceSpecifier") return "*";
86
+ return "";
87
+ }).filter(Boolean);
88
+ addImport(from, names);
89
+ }
90
+ }
91
+
92
+ const imports: NamedImport[] = [];
93
+ for (const [from, names] of importsMap) {
94
+ imports.push({ from, names: [...new Set(names)] });
95
+ }
96
+ return { exports: [...new Set(exports)], imports };
97
+ }
98
+
99
+ const EXPORT_RE = /\bexport\s+(?:default\s+)?(?:(?:async\s+)?function\s*\*?\s*(\w+)|class\s+(\w+)|const\s+(\w+)|let\s+(\w+)|var\s+(\w+)|type\s+(\w+)|interface\s+(\w+)|enum\s+(\w+))/g;
100
+ const NAMED_EXPORT_RE = /\bexport\s*\{([^}]+)\}/g;
101
+ const IMPORT_FROM_RE = /\bimport\s+(?:type\s+)?(?:\*\s+as\s+\w+|(?:\w+\s*,\s*)?\{([^}]*)\}|\w+)\s+from\s+['"]([^'"]+)['"]/g;
102
+ const EXPORT_FROM_RE = /\bexport\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g;
103
+
104
+ function extractFallback(
105
+ source: string,
106
+ resolveSpecifier: (s: string) => string | null,
107
+ ): { exports: string[]; imports: NamedImport[] } {
108
+ const exports: string[] = [];
109
+ const importsMap = new Map<string, string[]>();
110
+
111
+ let m: RegExpExecArray | null;
112
+
113
+ EXPORT_RE.lastIndex = 0;
114
+ while ((m = EXPORT_RE.exec(source)) !== null) {
115
+ const name = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? m[6] ?? m[7] ?? m[8];
116
+ if (name) exports.push(name);
117
+ }
118
+
119
+ NAMED_EXPORT_RE.lastIndex = 0;
120
+ while ((m = NAMED_EXPORT_RE.exec(source)) !== null) {
121
+ for (const spec of m[1]!.split(",")) {
122
+ const name = spec.trim().split(/\s+as\s+/).pop()?.trim();
123
+ if (name) exports.push(name);
124
+ }
125
+ }
126
+
127
+ const addFromSpec = (rawNames: string | null, rawSpecifier: string | undefined) => {
128
+ if (!rawSpecifier) return;
129
+ const from = resolveSpecifier(rawSpecifier);
130
+ if (!from) return;
131
+ const names = rawNames
132
+ ? rawNames.split(",").map((s) => s.trim().split(/\s+as\s+/)[0]?.trim() ?? "").filter(Boolean)
133
+ : [];
134
+ const arr = importsMap.get(from) ?? [];
135
+ arr.push(...names);
136
+ importsMap.set(from, arr);
137
+ };
138
+
139
+ IMPORT_FROM_RE.lastIndex = 0;
140
+ while ((m = IMPORT_FROM_RE.exec(source)) !== null) addFromSpec(m[1] ?? null, m[2]);
141
+
142
+ EXPORT_FROM_RE.lastIndex = 0;
143
+ while ((m = EXPORT_FROM_RE.exec(source)) !== null) addFromSpec(m[1] ?? null, m[2]);
144
+
145
+ const imports: NamedImport[] = [];
146
+ for (const [from, names] of importsMap) {
147
+ imports.push({ from, names: [...new Set(names)] });
148
+ }
149
+ return { exports: [...new Set(exports)], imports };
150
+ }
151
+
152
+ function fileExt(f: string): string {
153
+ const dot = f.lastIndexOf(".");
154
+ return dot >= 0 ? f.slice(dot) : "";
155
+ }
156
+
157
+ function resolveRelative(fromFile: string, specifier: string): string {
158
+ const parts = fromFile.split("/");
159
+ parts.pop();
160
+ for (const seg of specifier.split("/")) {
161
+ if (seg === "..") parts.pop();
162
+ else if (seg !== ".") parts.push(seg);
163
+ }
164
+ return parts.join("/");
165
+ }
166
+
167
+ function resolveToFile(candidate: string, fileSet: Set<string>): string | null {
168
+ if (fileSet.has(candidate)) return candidate;
169
+ for (const ext of ANALYZABLE_EXTENSIONS) {
170
+ if (fileSet.has(`${candidate}${ext}`)) return `${candidate}${ext}`;
171
+ }
172
+ for (const idx of ["index.ts", "index.tsx", "index.js"]) {
173
+ if (fileSet.has(`${candidate}/${idx}`)) return `${candidate}/${idx}`;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ export async function extractSymbols(
179
+ repoPath: string,
180
+ headSha: string,
181
+ filePaths: string[],
182
+ ): Promise<Map<string, FileSymbols>> {
183
+ const fileSet = new Set(filePaths);
184
+ const analyzable = filePaths.filter((f) => ANALYZABLE_EXTENSIONS.has(fileExt(f)));
185
+ const result = new Map<string, FileSymbols>();
186
+
187
+ await Promise.all(analyzable.map(async (filePath) => {
188
+ const r = await Bun.$`git -C ${repoPath} show ${headSha}:${filePath}`.quiet().nothrow();
189
+ if (r.exitCode !== 0) return;
190
+
191
+ const source = r.stdout.toString();
192
+
193
+ const resolveSpecifier = (specifier: string): string | null => {
194
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null;
195
+ const candidate = resolveRelative(filePath, specifier);
196
+ return resolveToFile(candidate, fileSet);
197
+ };
198
+
199
+ let symbols: { exports: string[]; imports: NamedImport[] } | null = null;
200
+ const ast = safeParseAst(source);
201
+ if (ast) {
202
+ try {
203
+ symbols = extractFromAst(ast, resolveSpecifier);
204
+ } catch {
205
+ }
206
+ }
207
+ if (!symbols) symbols = extractFallback(source, resolveSpecifier);
208
+
209
+ result.set(filePath, {
210
+ path: filePath,
211
+ exports: symbols.exports,
212
+ imports: symbols.imports,
213
+ });
214
+ }));
215
+
216
+ return result;
217
+ }
218
+
219
+ export function buildSymbolIndex(symbolMap: Map<string, FileSymbols>): Map<string, string[]> {
220
+ const exportedBy = new Map<string, string[]>();
221
+ for (const [file, info] of symbolMap) {
222
+ for (const sym of info.exports) {
223
+ const arr = exportedBy.get(sym) ?? [];
224
+ arr.push(file);
225
+ exportedBy.set(sym, arr);
226
+ }
227
+ }
228
+ return exportedBy;
229
+ }
@@ -37,21 +37,21 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
37
37
  <BarChart3 className="h-5 w-5 text-blue-500" />
38
38
  </div>
39
39
  <div>
40
- <h2 className="text-sm font-semibold">Help improve newpr</h2>
41
- <p className="text-[11px] text-muted-foreground">Anonymous usage analytics</p>
42
- </div>
40
+ <h2 className="text-base font-semibold">Help improve newpr</h2>
41
+ <p className="text-xs text-muted-foreground">Anonymous usage analytics</p>
43
42
  </div>
43
+ </div>
44
44
 
45
- <p className="text-xs text-muted-foreground leading-relaxed mb-3">
45
+ <p className="text-sm text-muted-foreground leading-relaxed mb-3">
46
46
  We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
47
47
  </p>
48
48
 
49
49
  <div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
50
50
  <div className="flex items-start gap-2">
51
51
  <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
52
- <div className="text-[11px] text-muted-foreground leading-relaxed">
53
- <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
54
- <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
52
+ <div className="text-xs text-muted-foreground leading-relaxed">
53
+ <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
54
+ <ul className="space-y-0.5 list-disc list-inside text-xs">
55
55
  <li>Feature usage (which tabs, buttons, and actions you use)</li>
56
56
  <li>Performance metrics (analysis duration, error rates)</li>
57
57
  <li>Basic device info (browser, screen size)</li>
@@ -60,9 +60,9 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
60
60
  </div>
61
61
  <div className="flex items-start gap-2 pt-1">
62
62
  <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
63
- <div className="text-[11px] text-muted-foreground leading-relaxed">
64
- <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
65
- <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
63
+ <div className="text-xs text-muted-foreground leading-relaxed">
64
+ <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
65
+ <ul className="space-y-0.5 list-disc list-inside text-xs">
66
66
  <li>PR content, code, or commit messages</li>
67
67
  <li>Chat messages or review comments</li>
68
68
  <li>API keys, tokens, or personal data</li>
@@ -71,7 +71,7 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
71
71
  </div>
72
72
  </div>
73
73
 
74
- <p className="text-[10px] text-muted-foreground/50 mb-4">
74
+ <p className="text-xs text-muted-foreground/50 mb-4">
75
75
  Powered by Google Analytics. You can change this anytime in Settings.
76
76
  </p>
77
77
  </div>
@@ -80,14 +80,14 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
80
80
  <button
81
81
  type="button"
82
82
  onClick={handleDecline}
83
- className="flex-1 px-4 py-3 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
83
+ className="flex-1 px-4 py-3 text-sm text-muted-foreground hover:bg-muted/50 transition-colors"
84
84
  >
85
85
  Decline
86
86
  </button>
87
87
  <button
88
88
  type="button"
89
89
  onClick={handleAccept}
90
- className="flex-1 px-4 py-3 text-xs font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
90
+ className="flex-1 px-4 py-3 text-sm font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
91
91
  >
92
92
  Accept
93
93
  </button>
@@ -111,7 +111,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
111
111
  const isTrailingSegment = i === segments.length - 1;
112
112
  const isStreamingTail = isStreaming && isTrailingSegment;
113
113
  return (
114
- <div key={`text-${i}`} className="text-xs leading-relaxed">
114
+ <div key={`text-${i}`} className="text-sm leading-relaxed">
115
115
  {isStreamingTail ? (
116
116
  <Markdown streaming onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
117
117
  ) : (
@@ -146,19 +146,19 @@ function FileDetail({
146
146
  {loading && (
147
147
  <div className="flex items-center justify-center py-16 gap-2">
148
148
  <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
149
- <span className="text-xs text-muted-foreground/50">Loading diff</span>
149
+ <span className="text-sm text-muted-foreground/50">Loading diff</span>
150
150
  </div>
151
151
  )}
152
152
  {error && (
153
153
  <div className="flex flex-col items-center justify-center py-16 gap-3">
154
154
  <div className="flex items-center gap-2 text-destructive">
155
155
  <AlertCircle className="h-3.5 w-3.5" />
156
- <p className="text-xs">{error}</p>
157
- </div>
158
- <button
159
- type="button"
160
- onClick={fetchPatch}
161
- className="text-[11px] text-muted-foreground/50 hover:text-foreground transition-colors"
156
+ <p className="text-sm">{error}</p>
157
+ </div>
158
+ <button
159
+ type="button"
160
+ onClick={fetchPatch}
161
+ className="text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
162
162
  >
163
163
  Retry
164
164
  </button>
@@ -203,8 +203,8 @@ export function DetailPane({
203
203
  <div className="shrink-0 flex items-center justify-between gap-2 px-4 h-12 border-b">
204
204
  <div className="flex items-center gap-2 min-w-0">
205
205
  <span className={`h-2 w-2 rounded-full shrink-0 ${TYPE_DOT[g.type] ?? TYPE_DOT.chore}`} />
206
- <span className="text-xs font-medium truncate">{g.name}</span>
207
- <span className="text-[10px] text-muted-foreground/30">{g.type}</span>
206
+ <span className="text-sm font-medium truncate">{g.name}</span>
207
+ <span className="text-xs text-muted-foreground/30">{g.type}</span>
208
208
  </div>
209
209
  {onClose && (
210
210
  <button type="button" onClick={onClose} className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/40 transition-colors">
@@ -257,11 +257,11 @@ function CommentCard({
257
257
  ) : (
258
258
  <div className="h-4 w-4 rounded-full bg-muted-foreground/20 shrink-0" />
259
259
  )}
260
- <span className="text-[11px] font-medium text-foreground/90">{comment.author}</span>
261
- <span className="text-[10px] text-muted-foreground/60">{formatTimeAgo(comment.createdAt)}</span>
262
- {comment.startLine != null && comment.startLine !== comment.line && (
263
- <span className="text-[10px] text-muted-foreground/40 font-mono">L{comment.startLine}-{comment.line}</span>
264
- )}
260
+ <span className="text-xs font-medium text-foreground/90">{comment.author}</span>
261
+ <span className="text-xs text-muted-foreground/60">{formatTimeAgo(comment.createdAt)}</span>
262
+ {comment.startLine != null && comment.startLine !== comment.line && (
263
+ <span className="text-xs text-muted-foreground/40 font-mono">L{comment.startLine}-{comment.line}</span>
264
+ )}
265
265
  {comment.githubUrl && (
266
266
  <a href={comment.githubUrl} target="_blank" rel="noopener noreferrer" className="text-muted-foreground/40 hover:text-foreground/60 transition-colors">
267
267
  <ExternalLink className="h-2.5 w-2.5" />
@@ -318,7 +318,7 @@ function CommentCard({
318
318
  </div>
319
319
  </div>
320
320
  ) : (
321
- <p className="text-[12px] text-foreground/80 whitespace-pre-wrap break-words leading-[1.6] pl-[22px]">{comment.body}</p>
321
+ <p className="text-sm text-foreground/80 whitespace-pre-wrap break-words leading-[1.6] pl-[22px]">{comment.body}</p>
322
322
  )}
323
323
  </div>
324
324
  );
@@ -374,7 +374,7 @@ function CommentForm({
374
374
  <button
375
375
  type="button"
376
376
  onClick={onCancel}
377
- className="text-[11px] text-muted-foreground/60 hover:text-foreground/80 px-2 py-1 rounded-md transition-colors"
377
+ className="text-xs text-muted-foreground/60 hover:text-foreground/80 px-2 py-1 rounded-md transition-colors"
378
378
  >
379
379
  Cancel
380
380
  </button>
@@ -383,7 +383,7 @@ function CommentForm({
383
383
  onClick={handleSubmit}
384
384
  disabled={!hasContent || submitting}
385
385
  className={`
386
- text-[11px] font-medium px-3 py-1 rounded-md transition-all
386
+ text-xs font-medium px-3 py-1 rounded-md transition-all
387
387
  ${hasContent && !submitting
388
388
  ? "bg-foreground text-background hover:bg-foreground/90"
389
389
  : "bg-muted text-muted-foreground/40 cursor-not-allowed"}
@@ -391,7 +391,7 @@ function CommentForm({
391
391
  >
392
392
  {submitting ? "Posting..." : "Comment"}
393
393
  </button>
394
- <kbd className="hidden sm:flex items-center gap-0.5 text-[10px] text-muted-foreground/40 select-none">
394
+ <kbd className="hidden sm:flex items-center gap-0.5 text-xs text-muted-foreground/40 select-none">
395
395
  {modKey}<CornerDownLeft className="h-2.5 w-2.5" />
396
396
  </kbd>
397
397
  </div>
@@ -481,7 +481,7 @@ function AskAiPanel({
481
481
  <div className="px-3 py-2.5 font-sans">
482
482
  <div className="space-y-2.5">
483
483
  <div className="flex items-center justify-between">
484
- <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
484
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground/60">
485
485
  <Sparkles className="h-3 w-3" />
486
486
  <span>AI Analysis</span>
487
487
  <span className="text-muted-foreground/30">L{startLine}{endLine !== startLine ? `-L${endLine}` : ""}</span>
@@ -494,14 +494,14 @@ function AskAiPanel({
494
494
  {loading && !response && (
495
495
  <div className="flex items-center gap-1.5 py-2">
496
496
  <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />
497
- <span className="text-[11px] text-muted-foreground/40">Analyzing...</span>
497
+ <span className="text-xs text-muted-foreground/40">Analyzing...</span>
498
498
  </div>
499
499
  )}
500
500
 
501
501
  {response && (
502
- <div className="text-xs leading-relaxed">
503
- <Markdown>{response}</Markdown>
504
- </div>
502
+ <div className="text-sm leading-relaxed">
503
+ <Markdown>{response}</Markdown>
504
+ </div>
505
505
  )}
506
506
 
507
507
  {!loading && (
@@ -512,13 +512,13 @@ function AskAiPanel({
512
512
  onChange={(e) => setQuestion(e.target.value)}
513
513
  onKeyDown={(e) => { if (e.key === "Enter" && question.trim()) ask(); }}
514
514
  placeholder="Ask a follow-up..."
515
- className="flex-1 h-7 rounded-md border bg-background px-2.5 text-[11px] placeholder:text-muted-foreground/30 focus:outline-none focus:border-foreground/20"
515
+ className="flex-1 h-7 rounded-md border bg-background px-2.5 text-xs placeholder:text-muted-foreground/30 focus:outline-none focus:border-foreground/20"
516
516
  />
517
517
  <button
518
518
  type="button"
519
519
  onClick={() => ask()}
520
520
  disabled={loading}
521
- className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-30 hover:opacity-80 transition-opacity"
521
+ className="h-7 px-2.5 rounded-md bg-foreground text-background text-xs font-medium disabled:opacity-30 hover:opacity-80 transition-opacity"
522
522
  >
523
523
  Ask
524
524
  </button>
@@ -573,7 +573,7 @@ function InlineComments({
573
573
  <button
574
574
  type="button"
575
575
  onClick={() => setShowAi(true)}
576
- className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
576
+ className="flex items-center gap-1.5 text-xs text-muted-foreground/40 hover:text-foreground transition-colors"
577
577
  >
578
578
  <Sparkles className="h-3 w-3" />
579
579
  Ask AI about this code
@@ -82,7 +82,7 @@ export function ErrorScreen({
82
82
 
83
83
  <div className="flex flex-col items-center gap-2 text-center">
84
84
  <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
85
- <p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
85
+ <p className="text-base text-muted-foreground leading-relaxed max-w-sm">
86
86
  {hint}
87
87
  </p>
88
88
  </div>
@@ -126,7 +126,7 @@ export function InputScreen({
126
126
  </span>
127
127
  )}
128
128
  </div>
129
- <p className="text-sm text-muted-foreground/50">
129
+ <p className="text-base text-muted-foreground/50">
130
130
  Turn PRs into navigable stories
131
131
  </p>
132
132
  </div>
@@ -144,7 +144,7 @@ export function InputScreen({
144
144
  onFocus={() => setFocused(true)}
145
145
  onBlur={() => setFocused(false)}
146
146
  placeholder="https://github.com/owner/repo/pull/123"
147
- className="flex-1 h-12 bg-transparent px-3 text-sm font-mono placeholder:text-muted-foreground/25 focus:outline-none"
147
+ className="flex-1 h-12 bg-transparent px-3 text-base font-mono placeholder:text-muted-foreground/25 focus:outline-none"
148
148
  autoFocus
149
149
  />
150
150
  <button
@@ -125,12 +125,12 @@ export function LoadingTimeline({
125
125
  <span className="text-sm font-semibold font-mono">newpr</span>
126
126
  )}
127
127
  <span className="text-muted-foreground/30">·</span>
128
- <span className="text-xs text-muted-foreground/50 tabular-nums shrink-0">
128
+ <span className="text-sm text-muted-foreground/50 tabular-nums shrink-0">
129
129
  {seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`}
130
130
  </span>
131
131
  </div>
132
132
  {prNum && (
133
- <span className="text-[11px] text-muted-foreground/40 font-mono">#{prNum}</span>
133
+ <span className="text-xs text-muted-foreground/40 font-mono">#{prNum}</span>
134
134
  )}
135
135
  </div>
136
136
 
@@ -153,18 +153,18 @@ export function LoadingTimeline({
153
153
  )}
154
154
  <div className="min-w-0 flex-1">
155
155
  <div className="flex items-center gap-2">
156
- <span className={`text-sm font-medium ${step.done ? "text-muted-foreground" : step.active ? "text-foreground" : "text-muted-foreground/50"}`}>
157
- {STAGE_LABELS[step.stage]}{progress}
156
+ <span className={`text-base font-medium ${step.done ? "text-muted-foreground" : step.active ? "text-foreground" : "text-muted-foreground/50"}`}>
157
+ {STAGE_LABELS[step.stage]}{progress}
158
+ </span>
159
+ {step.done && step.durationMs !== undefined && (
160
+ <span className="text-sm text-muted-foreground/60">
161
+ {formatDuration(step.durationMs)}
158
162
  </span>
159
- {step.done && step.durationMs !== undefined && (
160
- <span className="text-xs text-muted-foreground/60">
161
- {formatDuration(step.durationMs)}
162
- </span>
163
- )}
164
- </div>
165
- {step.done && completionDetail && (
166
- <p className="text-xs text-muted-foreground/60 mt-0.5 truncate">{completionDetail}</p>
167
163
  )}
164
+ </div>
165
+ {step.done && completionDetail && (
166
+ <p className="text-sm text-muted-foreground/60 mt-0.5 truncate">{completionDetail}</p>
167
+ )}
168
168
  {step.active && recentLog.length > 0 && (
169
169
  <div className="mt-1.5 space-y-0.5 max-h-40 overflow-y-auto">
170
170
  {recentLog.map((line, j) => {