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.
- package/package.json +2 -1
- package/src/cli/args.ts +1 -1
- package/src/config/index.ts +1 -6
- package/src/stack/co-change.ts +64 -0
- package/src/stack/confidence-score.ts +242 -0
- package/src/stack/feasibility.test.ts +6 -7
- package/src/stack/feasibility.ts +86 -30
- package/src/stack/import-deps.ts +131 -0
- package/src/stack/integration.test.ts +2 -2
- package/src/stack/partition.ts +41 -21
- package/src/stack/symbol-flow.ts +229 -0
- package/src/web/client/components/AnalyticsConsent.tsx +13 -13
- package/src/web/client/components/ChatSection.tsx +1 -1
- package/src/web/client/components/DetailPane.tsx +9 -9
- package/src/web/client/components/DiffViewer.tsx +17 -17
- package/src/web/client/components/ErrorScreen.tsx +1 -1
- package/src/web/client/components/InputScreen.tsx +2 -2
- package/src/web/client/components/LoadingTimeline.tsx +12 -12
- package/src/web/client/components/Markdown.tsx +10 -10
- package/src/web/client/components/ResultsScreen.tsx +4 -4
- package/src/web/client/components/ReviewModal.tsx +2 -2
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/panels/DiscussionPanel.tsx +24 -24
- package/src/web/client/panels/FilesPanel.tsx +26 -26
- package/src/web/client/panels/GroupsPanel.tsx +25 -25
- package/src/web/client/panels/StoryPanel.tsx +25 -25
- package/src/web/index.html +2 -0
- package/src/web/server/routes.ts +6 -4
- package/src/web/server/stack-manager.ts +103 -7
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +16 -11
- package/src/workspace/agent.ts +13 -5
package/src/stack/partition.ts
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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 (
|
|
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
|
|
77
|
-
? `\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
|
|
81
|
-
? `\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
|
|
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.
|
|
89
|
-
2.
|
|
90
|
-
3.
|
|
91
|
-
4.
|
|
92
|
-
5.
|
|
93
|
-
6.
|
|
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": "
|
|
116
|
+
{ "path": "file.ts", "group": "exact-group-name", "reason": "one sentence" }
|
|
99
117
|
],
|
|
100
118
|
"shared_foundation": null
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
|
|
121
|
+
Only include shared_foundation if truly necessary:
|
|
104
122
|
{
|
|
105
123
|
"assignments": [...],
|
|
106
|
-
"shared_foundation": { "name": "Shared Foundation", "description": "
|
|
124
|
+
"shared_foundation": { "name": "Shared Foundation", "description": "why this is shared", "files": ["path1", "path2"] }
|
|
107
125
|
}`,
|
|
108
|
-
user: `Groups:\n${groupDescriptions}${ambiguousSection}${unassignedSection}${
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
497
|
+
<span className="text-xs text-muted-foreground/40">Analyzing...</span>
|
|
498
498
|
</div>
|
|
499
499
|
)}
|
|
500
500
|
|
|
501
501
|
{response && (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
157
|
-
|
|
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) => {
|