newpr 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,11 +1,15 @@
1
- import { Layers, FileText, Plus, Pencil, Trash2, ArrowRight, X } from "lucide-react";
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Plus, Pencil, Trash2, ArrowRight, X, Loader2, AlertCircle } from "lucide-react";
2
3
  import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
4
+ import { DiffViewer } from "./DiffViewer.tsx";
3
5
 
4
6
  export interface DetailTarget {
5
- kind: "group" | "file";
7
+ kind: "group" | "file" | "line";
6
8
  group?: FileGroup;
7
9
  file?: FileChange;
8
10
  files: FileChange[];
11
+ scrollToLine?: number;
12
+ scrollToLineEnd?: number;
9
13
  }
10
14
 
11
15
  const STATUS_ICON: Record<FileStatus, typeof Plus> = {
@@ -22,18 +26,18 @@ const STATUS_COLOR: Record<FileStatus, string> = {
22
26
  renamed: "text-blue-500",
23
27
  };
24
28
 
25
- const TYPE_COLORS: Record<string, string> = {
26
- feature: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
27
- refactor: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
28
- bugfix: "bg-red-500/10 text-red-600 dark:text-red-400",
29
- chore: "bg-gray-500/10 text-gray-600 dark:text-gray-400",
30
- docs: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
31
- test: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
32
- config: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
29
+ const TYPE_DOT: Record<string, string> = {
30
+ feature: "bg-blue-500",
31
+ refactor: "bg-purple-500",
32
+ bugfix: "bg-red-500",
33
+ chore: "bg-neutral-400",
34
+ docs: "bg-teal-500",
35
+ test: "bg-yellow-500",
36
+ config: "bg-orange-500",
33
37
  };
34
38
 
35
39
  export function resolveDetail(
36
- kind: "group" | "file",
40
+ kind: "group" | "file" | "line",
37
41
  id: string,
38
42
  groups: FileGroup[],
39
43
  files: FileChange[],
@@ -44,98 +48,234 @@ export function resolveDetail(
44
48
  const groupFiles = files.filter((f) => group.files.includes(f.path));
45
49
  return { kind: "group", group, files: groupFiles };
46
50
  }
51
+ if (kind === "line") {
52
+ const hashIdx = id.indexOf("#");
53
+ if (hashIdx < 0) return null;
54
+ const filePath = id.slice(0, hashIdx);
55
+ const lineRef = id.slice(hashIdx + 1);
56
+ const rangeMatch = lineRef.match(/^L(\d+)(?:-L?(\d+))?/);
57
+ const lineNum = rangeMatch ? Number.parseInt(rangeMatch[1]!, 10) : undefined;
58
+ const lineEnd = rangeMatch?.[2] ? Number.parseInt(rangeMatch[2]!, 10) : undefined;
59
+ const file = files.find((f) => f.path === filePath);
60
+ if (!file) return null;
61
+ return { kind: "line", file, files: [file], scrollToLine: lineNum, scrollToLineEnd: lineEnd };
62
+ }
47
63
  const file = files.find((f) => f.path === id);
48
64
  if (!file) return null;
49
65
  return { kind: "file", file, files: [file] };
50
66
  }
51
67
 
52
- export function DetailPane({ target, onClose }: { target: DetailTarget | null; onClose?: () => void }) {
53
- if (!target) return null;
68
+ function usePatchFetcher(sessionId: string | null | undefined, filePath: string | undefined) {
69
+ const [patch, setPatch] = useState<string | null>(null);
70
+ const [loading, setLoading] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
54
72
 
55
- if (target.kind === "group" && target.group) {
56
- const g = target.group;
57
- return (
58
- <div className="p-4 space-y-4">
59
- <div className="flex items-start justify-between gap-2">
60
- <div className="space-y-2 min-w-0">
61
- <div className="flex items-center gap-2">
62
- <Layers className="h-4 w-4 text-muted-foreground shrink-0" />
63
- <h4 className="text-sm font-semibold break-words">{g.name}</h4>
64
- </div>
65
- <span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${TYPE_COLORS[g.type] ?? TYPE_COLORS.chore}`}>
66
- {g.type}
67
- </span>
68
- </div>
73
+ useEffect(() => {
74
+ setPatch(null);
75
+ setError(null);
76
+ setLoading(false);
77
+ }, [sessionId, filePath]);
78
+
79
+ const fetchPatch = useCallback(async () => {
80
+ if (!sessionId || !filePath) return;
81
+ setLoading(true);
82
+ setError(null);
83
+ try {
84
+ const res = await fetch(`/api/sessions/${sessionId}/diff?path=${encodeURIComponent(filePath)}`);
85
+ if (!res.ok) {
86
+ const body = await res.json().catch(() => ({ error: "Failed to load diff" }));
87
+ throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
88
+ }
89
+ const data = await res.json() as { patch: string };
90
+ setPatch(data.patch);
91
+ } catch (err) {
92
+ setError(err instanceof Error ? err.message : String(err));
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }, [sessionId, filePath]);
97
+
98
+ return { patch, loading, error, fetchPatch };
99
+ }
100
+
101
+ function FileDetail({
102
+ file,
103
+ sessionId,
104
+ prUrl,
105
+ onClose,
106
+ scrollToLine,
107
+ scrollToLineEnd,
108
+ }: {
109
+ file: FileChange;
110
+ sessionId?: string | null;
111
+ prUrl?: string;
112
+ onClose?: () => void;
113
+ scrollToLine?: number;
114
+ scrollToLineEnd?: number;
115
+ }) {
116
+ const Icon = STATUS_ICON[file.status];
117
+ const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
118
+
119
+ useEffect(() => {
120
+ if (sessionId && !patch && !loading && !error) {
121
+ fetchPatch();
122
+ }
123
+ }, [sessionId, patch, loading, error, fetchPatch]);
124
+
125
+ return (
126
+ <div className="flex flex-col h-full">
127
+ <div className="shrink-0 flex items-center justify-between gap-2 px-4 h-12 border-b">
128
+ <div className="flex items-center gap-2 min-w-0">
129
+ <Icon className={`h-3 w-3 shrink-0 ${STATUS_COLOR[file.status]}`} />
130
+ <span className="text-[11px] font-mono truncate" title={file.path}>{file.path}</span>
131
+ </div>
132
+ <div className="flex items-center gap-2 shrink-0">
133
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{file.additions}</span>
134
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{file.deletions}</span>
69
135
  {onClose && (
70
- <button type="button" onClick={onClose} className="shrink-0 p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
136
+ <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">
71
137
  <X className="h-3.5 w-3.5" />
72
138
  </button>
73
139
  )}
74
140
  </div>
75
- <p className="text-sm text-muted-foreground leading-relaxed break-words">{g.description}</p>
76
- <div className="border-t pt-3">
77
- <div className="text-xs text-muted-foreground mb-2">{target.files.length} files</div>
78
- <div className="space-y-2">
79
- {target.files.map((f) => {
80
- const Icon = STATUS_ICON[f.status];
81
- return (
82
- <div key={f.path} className="space-y-1">
83
- <div className="flex items-center gap-2 min-w-0">
84
- <Icon className={`h-3 w-3 shrink-0 ${STATUS_COLOR[f.status]}`} />
85
- <span className="text-xs font-mono truncate" title={f.path}>{f.path}</span>
86
- <span className="text-xs text-green-500 shrink-0">+{f.additions}</span>
87
- <span className="text-xs text-red-500 shrink-0">−{f.deletions}</span>
88
- </div>
89
- <p className="text-xs text-muted-foreground pl-5 break-words">{f.summary}</p>
90
- </div>
91
- );
92
- })}
141
+ </div>
142
+
143
+ <div className="flex-1 overflow-y-auto">
144
+ {loading && (
145
+ <div className="flex items-center justify-center py-16 gap-2">
146
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
147
+ <span className="text-xs text-muted-foreground/50">Loading diff</span>
93
148
  </div>
94
- </div>
149
+ )}
150
+ {error && (
151
+ <div className="flex flex-col items-center justify-center py-16 gap-3">
152
+ <div className="flex items-center gap-2 text-destructive">
153
+ <AlertCircle className="h-3.5 w-3.5" />
154
+ <p className="text-xs">{error}</p>
155
+ </div>
156
+ <button
157
+ type="button"
158
+ onClick={fetchPatch}
159
+ className="text-[11px] text-muted-foreground/50 hover:text-foreground transition-colors"
160
+ >
161
+ Retry
162
+ </button>
163
+ </div>
164
+ )}
165
+ {patch && (
166
+ <DiffViewer
167
+ patch={patch}
168
+ filePath={file.path}
169
+ sessionId={sessionId}
170
+ githubUrl={prUrl ? `${prUrl}/files` : undefined}
171
+ scrollToLine={scrollToLine}
172
+ scrollToLineEnd={scrollToLineEnd}
173
+ />
174
+ )}
95
175
  </div>
96
- );
97
- }
176
+ </div>
177
+ );
178
+ }
179
+
180
+ export function DetailPane({
181
+ target,
182
+ sessionId,
183
+ prUrl,
184
+ onClose,
185
+ }: {
186
+ target: DetailTarget | null;
187
+ sessionId?: string | null;
188
+ prUrl?: string;
189
+ onClose?: () => void;
190
+ }) {
191
+ if (!target) return null;
192
+
193
+ if (target.kind === "group" && target.group) {
194
+ const g = target.group;
195
+ const totalAdd = target.files.reduce((s, f) => s + f.additions, 0);
196
+ const totalDel = target.files.reduce((s, f) => s + f.deletions, 0);
98
197
 
99
- if (target.kind === "file" && target.file) {
100
- const f = target.file;
101
- const Icon = STATUS_ICON[f.status];
102
198
  return (
103
- <div className="p-4 space-y-4">
104
- <div className="flex items-start justify-between gap-2">
105
- <div className="space-y-2 min-w-0">
106
- <div className="flex items-center gap-2 min-w-0">
107
- <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
108
- <span className="text-sm font-mono font-medium break-all">{f.path}</span>
109
- </div>
110
- <div className="flex items-center gap-3">
111
- <div className="flex items-center gap-1.5">
112
- <Icon className={`h-3 w-3 ${STATUS_COLOR[f.status]}`} />
113
- <span className="text-xs text-muted-foreground">{f.status}</span>
114
- </div>
115
- <span className="text-xs text-green-500">+{f.additions}</span>
116
- <span className="text-xs text-red-500">−{f.deletions}</span>
117
- </div>
199
+ <div className="flex flex-col h-full">
200
+ <div className="shrink-0 flex items-center justify-between gap-2 px-4 h-12 border-b">
201
+ <div className="flex items-center gap-2 min-w-0">
202
+ <span className={`h-2 w-2 rounded-full shrink-0 ${TYPE_DOT[g.type] ?? TYPE_DOT.chore}`} />
203
+ <span className="text-xs font-medium truncate">{g.name}</span>
204
+ <span className="text-[10px] text-muted-foreground/30">{g.type}</span>
118
205
  </div>
119
206
  {onClose && (
120
- <button type="button" onClick={onClose} className="shrink-0 p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
207
+ <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">
121
208
  <X className="h-3.5 w-3.5" />
122
209
  </button>
123
210
  )}
124
211
  </div>
125
- <p className="text-sm text-muted-foreground leading-relaxed break-words">{f.summary}</p>
126
- {f.groups.length > 0 && (
127
- <div className="border-t pt-3">
128
- <div className="text-xs text-muted-foreground mb-2">Groups</div>
129
- <div className="flex flex-wrap gap-1.5">
130
- {f.groups.map((g) => (
131
- <span key={g} className="text-xs bg-muted px-2 py-0.5 rounded-full">{g}</span>
132
- ))}
212
+
213
+ <div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
214
+ <p className="text-[11px] text-muted-foreground/60 leading-relaxed">{g.description}</p>
215
+
216
+ {g.key_changes && g.key_changes.length > 0 && (
217
+ <div>
218
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">Key Changes</div>
219
+ <ul className="space-y-1.5">
220
+ {g.key_changes.map((change, i) => (
221
+ <li key={i} className="flex gap-2 text-[11px] text-muted-foreground/70 leading-relaxed">
222
+ <span className="text-muted-foreground/25 shrink-0 mt-px">·</span>
223
+ <span>{change}</span>
224
+ </li>
225
+ ))}
226
+ </ul>
227
+ </div>
228
+ )}
229
+
230
+ {g.risk && (
231
+ <div>
232
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">Risk</div>
233
+ <p className="text-[11px] text-muted-foreground/60 leading-relaxed">{g.risk}</p>
234
+ </div>
235
+ )}
236
+
237
+ {g.dependencies && g.dependencies.length > 0 && (
238
+ <div>
239
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">Dependencies</div>
240
+ <div className="flex flex-wrap gap-1.5">
241
+ {g.dependencies.map((dep) => (
242
+ <span key={dep} className="text-[10px] px-1.5 py-0.5 rounded-md bg-accent/60 text-muted-foreground/60">{dep}</span>
243
+ ))}
244
+ </div>
245
+ </div>
246
+ )}
247
+
248
+ <div>
249
+ <div className="flex items-center gap-2 mb-2.5">
250
+ <span className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{target.files.length} files</span>
251
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{totalAdd}</span>
252
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{totalDel}</span>
253
+ </div>
254
+ <div className="space-y-px">
255
+ {target.files.map((f) => {
256
+ const Icon = STATUS_ICON[f.status];
257
+ return (
258
+ <div key={f.path} className="py-2">
259
+ <div className="flex items-center gap-2 min-w-0">
260
+ <Icon className={`h-2.5 w-2.5 shrink-0 ${STATUS_COLOR[f.status]}`} />
261
+ <span className="text-[11px] font-mono truncate flex-1" title={f.path}>{f.path}</span>
262
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400 shrink-0">+{f.additions}</span>
263
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400 shrink-0">-{f.deletions}</span>
264
+ </div>
265
+ <p className="text-[11px] text-muted-foreground/40 mt-1 pl-[18px] leading-relaxed">{f.summary}</p>
266
+ </div>
267
+ );
268
+ })}
133
269
  </div>
134
270
  </div>
135
- )}
271
+ </div>
136
272
  </div>
137
273
  );
138
274
  }
139
275
 
276
+ if ((target.kind === "file" || target.kind === "line") && target.file) {
277
+ return <FileDetail file={target.file} sessionId={sessionId} prUrl={prUrl} onClose={onClose} scrollToLine={target.scrollToLine} scrollToLineEnd={target.scrollToLineEnd} />;
278
+ }
279
+
140
280
  return null;
141
281
  }