newpr 0.6.5 → 1.0.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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/history/store.ts +25 -0
  3. package/src/stack/balance.ts +128 -0
  4. package/src/stack/coupling.test.ts +158 -0
  5. package/src/stack/coupling.ts +135 -0
  6. package/src/stack/delta.test.ts +223 -0
  7. package/src/stack/delta.ts +264 -0
  8. package/src/stack/execute.test.ts +176 -0
  9. package/src/stack/execute.ts +194 -0
  10. package/src/stack/feasibility.test.ts +185 -0
  11. package/src/stack/feasibility.ts +286 -0
  12. package/src/stack/integration.test.ts +266 -0
  13. package/src/stack/merge-groups.test.ts +97 -0
  14. package/src/stack/merge-groups.ts +87 -0
  15. package/src/stack/partition.test.ts +233 -0
  16. package/src/stack/partition.ts +273 -0
  17. package/src/stack/plan.test.ts +154 -0
  18. package/src/stack/plan.ts +139 -0
  19. package/src/stack/pr-title.ts +64 -0
  20. package/src/stack/publish.ts +96 -0
  21. package/src/stack/split.ts +173 -0
  22. package/src/stack/types.ts +202 -0
  23. package/src/stack/verify.test.ts +137 -0
  24. package/src/stack/verify.ts +201 -0
  25. package/src/web/client/components/FeasibilityAlert.tsx +64 -0
  26. package/src/web/client/components/InputScreen.tsx +100 -89
  27. package/src/web/client/components/ResultsScreen.tsx +10 -2
  28. package/src/web/client/components/StackGroupCard.tsx +171 -0
  29. package/src/web/client/components/StackWarnings.tsx +135 -0
  30. package/src/web/client/hooks/useStack.ts +301 -0
  31. package/src/web/client/panels/StackPanel.tsx +289 -0
  32. package/src/web/server/routes.ts +114 -0
  33. package/src/web/server/stack-manager.ts +580 -0
  34. package/src/web/server.ts +15 -0
  35. package/src/web/styles/built.css +1 -1
@@ -0,0 +1,201 @@
1
+ import type { StackExecResult, StackWarning } from "./types.ts";
2
+
3
+ export interface VerifyInput {
4
+ repo_path: string;
5
+ base_sha: string;
6
+ head_sha: string;
7
+ exec_result: StackExecResult;
8
+ ownership: Map<string, string>;
9
+ }
10
+
11
+ export interface VerifyResult {
12
+ verified: boolean;
13
+ errors: string[];
14
+ warnings: string[];
15
+ structured_warnings: StackWarning[];
16
+ }
17
+
18
+ export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
19
+ const { repo_path, base_sha, head_sha, exec_result, ownership } = input;
20
+ const errors: string[] = [];
21
+ const warnings: string[] = [];
22
+ const structuredWarnings: StackWarning[] = [];
23
+
24
+ const scopeLeaks: string[] = [];
25
+ const unionMissing: string[] = [];
26
+ const unionExtra: string[] = [];
27
+
28
+ await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, warnings, scopeLeaks);
29
+ await verifyUnionCompleteness(repo_path, base_sha, head_sha, exec_result, warnings, unionMissing, unionExtra);
30
+ await verifyFinalTreeEquivalence(repo_path, head_sha, exec_result, errors);
31
+
32
+ if (scopeLeaks.length > 0) {
33
+ structuredWarnings.push({
34
+ category: "verification.scope",
35
+ severity: "warn",
36
+ title: `${scopeLeaks.length} file(s) appear in wrong group's diff`,
37
+ message: "Some files changed in a group's commit but are owned by a different group — usually harmless with merge commits",
38
+ details: scopeLeaks,
39
+ });
40
+ }
41
+ if (unionMissing.length > 0) {
42
+ structuredWarnings.push({
43
+ category: "verification.completeness",
44
+ severity: "warn",
45
+ title: `${unionMissing.length} file(s) in original diff but missing from stack`,
46
+ message: "These files were changed in the original PR but don't appear in the stacked commits",
47
+ details: unionMissing,
48
+ });
49
+ }
50
+ if (unionExtra.length > 0) {
51
+ structuredWarnings.push({
52
+ category: "verification.completeness",
53
+ severity: "warn",
54
+ title: `${unionExtra.length} extra file(s) in stack not in original diff`,
55
+ message: "The stacked commits touch files not in the original PR diff — usually transient changes from merge commits",
56
+ details: unionExtra,
57
+ });
58
+ }
59
+
60
+ return {
61
+ verified: errors.length === 0,
62
+ errors,
63
+ warnings,
64
+ structured_warnings: structuredWarnings,
65
+ };
66
+ }
67
+
68
+ async function verifyPerGroupDiffScope(
69
+ repoPath: string,
70
+ baseSha: string,
71
+ execResult: StackExecResult,
72
+ ownership: Map<string, string>,
73
+ warnings: string[],
74
+ scopeLeaks: string[],
75
+ ): Promise<void> {
76
+ let prevCommitSha = baseSha;
77
+
78
+ for (const gc of execResult.group_commits) {
79
+ const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevCommitSha} ${gc.commit_sha}`.quiet().nothrow();
80
+
81
+ if (diffResult.exitCode !== 0) {
82
+ warnings.push(`Failed to diff group "${gc.group_id}": ${diffResult.stderr.toString().trim()}`);
83
+ prevCommitSha = gc.commit_sha;
84
+ continue;
85
+ }
86
+
87
+ const changedPaths = extractPathsFromRawDiff(diffResult.stdout);
88
+
89
+ for (const path of changedPaths) {
90
+ const fileOwner = ownership.get(path);
91
+ if (fileOwner !== gc.group_id) {
92
+ const detail = `"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`;
93
+ warnings.push(
94
+ `Group "${gc.group_id}" diff contains file "${path}" owned by "${fileOwner ?? "unassigned"}"`,
95
+ );
96
+ scopeLeaks.push(detail);
97
+ }
98
+ }
99
+
100
+ prevCommitSha = gc.commit_sha;
101
+ }
102
+ }
103
+
104
+ async function verifyUnionCompleteness(
105
+ repoPath: string,
106
+ baseSha: string,
107
+ headSha: string,
108
+ execResult: StackExecResult,
109
+ warnings: string[],
110
+ unionMissing: string[],
111
+ unionExtra: string[],
112
+ ): Promise<void> {
113
+ const expectedResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${baseSha} ${headSha}`.quiet().nothrow();
114
+
115
+ if (expectedResult.exitCode !== 0) {
116
+ warnings.push(`Failed to get expected diff: ${expectedResult.stderr.toString().trim()}`);
117
+ return;
118
+ }
119
+
120
+ const expectedPaths = new Set(extractPathsFromRawDiff(expectedResult.stdout));
121
+
122
+ const actualPaths = new Set<string>();
123
+ let prevSha = baseSha;
124
+ for (const gc of execResult.group_commits) {
125
+ const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevSha} ${gc.commit_sha}`.quiet().nothrow();
126
+
127
+ if (diffResult.exitCode === 0) {
128
+ for (const path of extractPathsFromRawDiff(diffResult.stdout)) {
129
+ actualPaths.add(path);
130
+ }
131
+ }
132
+ prevSha = gc.commit_sha;
133
+ }
134
+
135
+ for (const path of expectedPaths) {
136
+ if (!actualPaths.has(path)) {
137
+ warnings.push(`File "${path}" present in original diff but missing from stack`);
138
+ unionMissing.push(path);
139
+ }
140
+ }
141
+
142
+ for (const path of actualPaths) {
143
+ if (!expectedPaths.has(path)) {
144
+ warnings.push(`File "${path}" present in stack but not in original diff`);
145
+ unionExtra.push(path);
146
+ }
147
+ }
148
+ }
149
+
150
+ async function verifyFinalTreeEquivalence(
151
+ repoPath: string,
152
+ headSha: string,
153
+ execResult: StackExecResult,
154
+ errors: string[],
155
+ ): Promise<void> {
156
+ const headTreeResult = await Bun.$`git -C ${repoPath} rev-parse ${headSha}^{tree}`.quiet().nothrow();
157
+
158
+ if (headTreeResult.exitCode !== 0) {
159
+ errors.push(`Failed to get HEAD tree: ${headTreeResult.stderr.toString().trim()}`);
160
+ return;
161
+ }
162
+
163
+ const headTree = headTreeResult.stdout.toString().trim();
164
+
165
+ if (execResult.final_tree_sha !== headTree) {
166
+ errors.push(
167
+ `Final tree mismatch: stack top = ${execResult.final_tree_sha}, HEAD = ${headTree}`,
168
+ );
169
+ }
170
+ }
171
+
172
+ function extractPathsFromRawDiff(output: Buffer): string[] {
173
+ const paths: string[] = [];
174
+ const entries = output.toString("utf-8").split("\0").filter((s) => s.length > 0);
175
+
176
+ for (let i = 0; i < entries.length; i++) {
177
+ const entry = entries[i];
178
+ if (!entry) continue;
179
+ if (!entry.startsWith(":")) continue;
180
+
181
+ const match = entry.match(/^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([AMDRC])(\d*)$/);
182
+ if (!match) continue;
183
+
184
+ const status = match[5];
185
+ const pathEntry = entries[i + 1];
186
+ if (!pathEntry) continue;
187
+ i++;
188
+
189
+ if (status === "R") {
190
+ const newPath = entries[i + 1];
191
+ if (newPath) {
192
+ paths.push(newPath);
193
+ i++;
194
+ }
195
+ } else {
196
+ paths.push(pathEntry);
197
+ }
198
+ }
199
+
200
+ return paths;
201
+ }
@@ -0,0 +1,64 @@
1
+ import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { FeasibilityResult } from "../../../stack/types.ts";
4
+
5
+ export function FeasibilityAlert({ result }: { result: FeasibilityResult }) {
6
+ const [expanded, setExpanded] = useState(false);
7
+
8
+ if (result.feasible) {
9
+ return (
10
+ <div className="flex items-center gap-2 px-3 py-2 rounded-md bg-green-500/[0.04]">
11
+ <CheckCircle2 className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
12
+ <span className="text-[11px] text-green-700/70 dark:text-green-300/70 font-medium">Feasible</span>
13
+ {result.ordered_group_ids && (
14
+ <span className="text-[10px] text-muted-foreground/25 truncate">
15
+ {result.ordered_group_ids.join(" → ")}
16
+ </span>
17
+ )}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ const hasCycleDetails = result.cycle && result.cycle.edge_cycle.length > 0;
23
+
24
+ return (
25
+ <div className="rounded-md bg-red-500/[0.04] px-3 py-2.5 space-y-2">
26
+ <button
27
+ type="button"
28
+ onClick={() => hasCycleDetails && setExpanded(!expanded)}
29
+ className={`flex items-center gap-2 w-full text-left ${hasCycleDetails ? "cursor-pointer" : "cursor-default"}`}
30
+ >
31
+ <XCircle className="h-3.5 w-3.5 text-red-500/60 shrink-0" />
32
+ <span className="text-[11px] text-red-600/80 dark:text-red-400/80 font-medium flex-1">
33
+ Not feasible — dependency cycle
34
+ </span>
35
+ {hasCycleDetails && (
36
+ <ChevronRight className={`h-3 w-3 text-red-500/30 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
37
+ )}
38
+ </button>
39
+
40
+ {result.cycle && (
41
+ <p className="text-[10px] text-red-500/50 pl-5.5">
42
+ {result.cycle.group_cycle.join(" → ")} → {result.cycle.group_cycle[0]}
43
+ </p>
44
+ )}
45
+
46
+ {expanded && result.cycle && (
47
+ <div className="pl-5.5 space-y-1">
48
+ {result.cycle.edge_cycle.map((edge, i) => (
49
+ <div key={i} className="text-[10px] text-red-500/40">
50
+ {edge.from} → {edge.to}
51
+ <span className="text-red-500/25"> ({edge.kind}{edge.evidence?.path ? `: ${edge.evidence.path}` : ""})</span>
52
+ </div>
53
+ ))}
54
+ </div>
55
+ )}
56
+
57
+ {result.unassigned_paths && result.unassigned_paths.length > 0 && (
58
+ <p className="text-[10px] text-red-500/40 pl-5.5">
59
+ {result.unassigned_paths.length} unassigned file(s)
60
+ </p>
61
+ )}
62
+ </div>
63
+ );
64
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
- import { CornerDownLeft, Clock, GitPullRequest, Check, X, Minus, ExternalLink } from "lucide-react";
2
+ import { CornerDownLeft, GitPullRequest, ExternalLink, ChevronUp } from "lucide-react";
3
3
  import type { SessionRecord } from "../../../history/types.ts";
4
4
  import { analytics } from "../lib/analytics.ts";
5
5
 
@@ -35,46 +35,50 @@ function timeAgo(date: string): string {
35
35
  return `${Math.floor(d / 30)}mo ago`;
36
36
  }
37
37
 
38
- function StatusIcon({ ok, optional }: { ok: boolean; optional?: boolean }) {
39
- if (ok) return <Check className="h-3 w-3 text-green-500" />;
40
- if (optional) return <Minus className="h-3 w-3 text-muted-foreground/30" />;
41
- return <X className="h-3 w-3 text-red-500" />;
38
+ function StatusDot({ ok, optional }: { ok: boolean; optional?: boolean }) {
39
+ if (ok) return <span className="h-1.5 w-1.5 rounded-full bg-green-500" />;
40
+ if (optional) return <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/15" />;
41
+ return <span className="h-1.5 w-1.5 rounded-full bg-red-500" />;
42
42
  }
43
43
 
44
- function PreflightStatus({ data }: { data: PreflightData }) {
44
+ function CompactStatus({ data }: { data: PreflightData }) {
45
+ const [open, setOpen] = useState(false);
45
46
  const gh = data.github;
47
+ const allOk = gh.installed && gh.authenticated && data.openrouterKey;
46
48
  return (
47
- <div className="space-y-1.5">
48
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">Status</div>
49
- <div className="flex items-center gap-2">
50
- <StatusIcon ok={gh.installed && gh.authenticated} />
51
- <span className="text-[11px] font-mono">gh</span>
52
- {gh.installed && gh.authenticated && gh.user && (
53
- <span className="text-[10px] text-muted-foreground/40">{gh.user}</span>
54
- )}
55
- {gh.installed && !gh.authenticated && (
56
- <span className="text-[10px] text-red-500/70">not authenticated</span>
57
- )}
58
- {!gh.installed && (
59
- <span className="text-[10px] text-red-500/70">not installed</span>
60
- )}
61
- </div>
62
- {data.agents.map((agent) => (
63
- <div key={agent.name} className="flex items-center gap-2">
64
- <StatusIcon ok={agent.installed} optional />
65
- <span className={`text-[11px] font-mono ${agent.installed ? "" : "text-muted-foreground/30"}`}>{agent.name}</span>
66
- {agent.installed && agent.version && (
67
- <span className="text-[10px] text-muted-foreground/30">{agent.version}</span>
68
- )}
49
+ <div className="flex flex-col items-end gap-1">
50
+ <button
51
+ type="button"
52
+ onClick={() => setOpen((v) => !v)}
53
+ className="flex items-center gap-1.5 hover:opacity-70 transition-opacity"
54
+ >
55
+ <StatusDot ok={allOk} />
56
+ <span className="text-[10px] font-mono text-muted-foreground/30">status</span>
57
+ <ChevronUp className={`h-2.5 w-2.5 text-muted-foreground/20 transition-transform ${open ? "" : "rotate-180"}`} />
58
+ </button>
59
+ {open && (
60
+ <div className="flex flex-col items-end gap-1 animate-in fade-in slide-in-from-bottom-1 duration-150">
61
+ <div className="flex items-center gap-1.5">
62
+ <StatusDot ok={gh.installed && gh.authenticated} />
63
+ <span className="text-[10px] font-mono text-muted-foreground/30">gh</span>
64
+ {gh.installed && gh.authenticated && gh.user && (
65
+ <span className="text-[10px] text-muted-foreground/20">{gh.user}</span>
66
+ )}
67
+ </div>
68
+ {data.agents.map((agent) => (
69
+ <div key={agent.name} className="flex items-center gap-1.5">
70
+ <StatusDot ok={agent.installed} optional />
71
+ <span className={`text-[10px] font-mono ${agent.installed ? "text-muted-foreground/30" : "text-muted-foreground/10"}`}>
72
+ {agent.name}
73
+ </span>
74
+ </div>
75
+ ))}
76
+ <div className="flex items-center gap-1.5">
77
+ <StatusDot ok={data.openrouterKey} />
78
+ <span className="text-[10px] font-mono text-muted-foreground/30">OpenRouter</span>
79
+ </div>
69
80
  </div>
70
- ))}
71
- <div className="flex items-center gap-2">
72
- <StatusIcon ok={data.openrouterKey} />
73
- <span className="text-[11px]">OpenRouter</span>
74
- {!data.openrouterKey && (
75
- <span className="text-[10px] text-red-500/70">run newpr auth</span>
76
- )}
77
- </div>
81
+ )}
78
82
  </div>
79
83
  );
80
84
  }
@@ -110,26 +114,29 @@ export function InputScreen({
110
114
  const recents = sessions?.slice(0, 5) ?? [];
111
115
 
112
116
  return (
113
- <div className="flex flex-col items-center justify-center min-h-[60vh]">
114
- <div className="w-full max-w-lg space-y-8">
115
- <SponsorBanner />
116
-
117
- <div className="space-y-2">
118
- <div className="flex items-baseline gap-2">
119
- <h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
120
- {version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
121
- <span className="text-[10px] text-muted-foreground/40">AI code review</span>
122
- </div>
123
- <p className="text-xs text-muted-foreground">
124
- Paste a GitHub PR URL to start analysis
125
- </p>
117
+ <div className="relative flex flex-col items-center justify-center min-h-[80vh]">
118
+ <div className="w-full max-w-xl space-y-10">
119
+
120
+ <div className="flex flex-col items-center text-center space-y-3">
121
+ <div className="flex items-center gap-2.5">
122
+ <h1 className="text-2xl font-bold font-mono tracking-tighter">newpr</h1>
123
+ {version && (
124
+ <span className="text-[10px] text-muted-foreground/30 bg-foreground/[0.03] border border-border/50 rounded-full px-2 py-0.5 font-mono">
125
+ v{version}
126
+ </span>
127
+ )}
126
128
  </div>
129
+ <p className="text-sm text-muted-foreground/50">
130
+ Turn PRs into navigable stories
131
+ </p>
132
+ </div>
127
133
 
134
+ <div>
128
135
  <form onSubmit={handleSubmit}>
129
136
  <div className={`flex items-center rounded-xl border bg-background transition-all ${
130
137
  focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
131
138
  }`}>
132
- <GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
139
+ <GitPullRequest className="h-4 w-4 text-muted-foreground/30 ml-4 shrink-0" />
133
140
  <input
134
141
  type="text"
135
142
  value={value}
@@ -137,62 +144,66 @@ export function InputScreen({
137
144
  onFocus={() => setFocused(true)}
138
145
  onBlur={() => setFocused(false)}
139
146
  placeholder="https://github.com/owner/repo/pull/123"
140
- className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
147
+ className="flex-1 h-12 bg-transparent px-3 text-sm font-mono placeholder:text-muted-foreground/25 focus:outline-none"
141
148
  autoFocus
142
149
  />
143
150
  <button
144
151
  type="submit"
145
152
  disabled={!value.trim()}
146
- className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-20 hover:opacity-80"
153
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-15 hover:opacity-80"
147
154
  >
148
155
  <CornerDownLeft className="h-3.5 w-3.5" />
149
156
  </button>
150
157
  </div>
151
- <div className="flex justify-end mt-2 pr-1">
152
- <span className="text-[10px] text-muted-foreground/30">
153
- Enter to analyze
158
+ <div className="flex justify-center mt-2.5">
159
+ <span className="text-[10px] text-muted-foreground/20 font-mono">
160
+ Enter to analyze
154
161
  </span>
155
162
  </div>
156
163
  </form>
164
+ <div className="mt-4">
165
+ <SponsorBanner />
166
+ </div>
167
+ </div>
157
168
 
158
- {recents.length > 0 && (
159
- <div className="space-y-2">
160
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
161
- Recent
162
- </div>
163
- <div className="space-y-px">
164
- {recents.map((s) => (
165
- <button
166
- key={s.id}
167
- type="button"
168
- onClick={() => onSessionSelect?.(s.id)}
169
- className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
170
- >
171
- <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
172
- <div className="flex-1 min-w-0">
173
- <div className="text-xs truncate group-hover:text-foreground transition-colors">
174
- {s.pr_title}
175
- </div>
176
- <div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
177
- <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
178
- <span className="font-mono">#{s.pr_number}</span>
179
- <span className="text-muted-foreground/20">·</span>
180
- <span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
181
- <span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
182
- </div>
183
- </div>
184
- <div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
185
- <Clock className="h-2.5 w-2.5" />
186
- <span>{timeAgo(s.analyzed_at)}</span>
169
+ {recents.length > 0 && (
170
+ <div className="space-y-3">
171
+ <div className="text-[10px] font-medium text-muted-foreground/25 uppercase tracking-[0.15em] text-center">
172
+ Recent
173
+ </div>
174
+ <div className="space-y-px">
175
+ {recents.map((s) => (
176
+ <button
177
+ key={s.id}
178
+ type="button"
179
+ onClick={() => onSessionSelect?.(s.id)}
180
+ className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/30 transition-colors group"
181
+ >
182
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
183
+ <div className="flex-1 min-w-0">
184
+ <div className="text-[12px] truncate text-foreground/70 group-hover:text-foreground transition-colors">
185
+ {s.pr_title}
187
186
  </div>
188
- </button>
189
- ))}
190
- </div>
187
+ </div>
188
+ <div className="flex items-center gap-2 shrink-0 text-[10px] text-muted-foreground/25">
189
+ <span className="font-mono">{s.repo.split("/").pop()}</span>
190
+ <span className="font-mono">#{s.pr_number}</span>
191
+ <span className="text-muted-foreground/15">·</span>
192
+ <span>{timeAgo(s.analyzed_at)}</span>
193
+ </div>
194
+ </button>
195
+ ))}
191
196
  </div>
192
- )}
197
+ </div>
198
+ )}
193
199
 
194
- {preflight && <PreflightStatus data={preflight} />}
195
200
  </div>
201
+
202
+ {preflight && (
203
+ <div className="absolute bottom-4 right-0">
204
+ <CompactStatus data={preflight} />
205
+ </div>
206
+ )}
196
207
  </div>
197
208
  );
198
209
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect, useRef } from "react";
2
- import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation } from "lucide-react";
2
+ import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation, GitPullRequestArrow } from "lucide-react";
3
3
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
4
4
  import type { NewprOutput } from "../../../types/output.ts";
5
5
  import { GroupsPanel } from "../panels/GroupsPanel.tsx";
@@ -8,10 +8,11 @@ import { StoryPanel } from "../panels/StoryPanel.tsx";
8
8
  import { DiscussionPanel } from "../panels/DiscussionPanel.tsx";
9
9
  import { CartoonPanel } from "../panels/CartoonPanel.tsx";
10
10
  import { SlidesPanel } from "../panels/SlidesPanel.tsx";
11
+ import { StackPanel } from "../panels/StackPanel.tsx";
11
12
  import { ReviewModal } from "./ReviewModal.tsx";
12
13
  import { useOutdatedCheck } from "../hooks/useOutdatedCheck.ts";
13
14
 
14
- const VALID_TABS = ["story", "discussion", "groups", "files", "slides", "cartoon"] as const;
15
+ const VALID_TABS = ["story", "discussion", "groups", "files", "stack", "slides", "cartoon"] as const;
15
16
  type TabValue = typeof VALID_TABS[number];
16
17
 
17
18
  function getInitialTab(): TabValue {
@@ -236,6 +237,10 @@ export function ResultsScreen({
236
237
  <FolderTree className="h-3 w-3 shrink-0" />
237
238
  Files
238
239
  </TabsTrigger>
240
+ <TabsTrigger value="stack">
241
+ <GitPullRequestArrow className="h-3 w-3 shrink-0" />
242
+ Stack
243
+ </TabsTrigger>
239
244
  {(!enabledPlugins || enabledPlugins.includes("slides")) && (
240
245
  <TabsTrigger value="slides">
241
246
  <Presentation className="h-3 w-3 shrink-0" />
@@ -268,6 +273,9 @@ export function ResultsScreen({
268
273
  onFileSelect={(path: string) => onAnchorClick("file", path)}
269
274
  />
270
275
  </TabsContent>
276
+ <TabsContent value="stack">
277
+ <StackPanel sessionId={sessionId} />
278
+ </TabsContent>
271
279
  <TabsContent value="slides">
272
280
  <SlidesPanel data={data} sessionId={sessionId} />
273
281
  </TabsContent>