newpr 0.1.3 → 0.2.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 (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,5 +1,7 @@
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
7
  kind: "group" | "file";
@@ -22,14 +24,14 @@ const STATUS_COLOR: Record<FileStatus, string> = {
22
24
  renamed: "text-blue-500",
23
25
  };
24
26
 
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",
27
+ const TYPE_DOT: Record<string, string> = {
28
+ feature: "bg-blue-500",
29
+ refactor: "bg-purple-500",
30
+ bugfix: "bg-red-500",
31
+ chore: "bg-neutral-400",
32
+ docs: "bg-teal-500",
33
+ test: "bg-yellow-500",
34
+ config: "bg-orange-500",
33
35
  };
34
36
 
35
37
  export function resolveDetail(
@@ -49,93 +51,179 @@ export function resolveDetail(
49
51
  return { kind: "file", file, files: [file] };
50
52
  }
51
53
 
52
- export function DetailPane({ target, onClose }: { target: DetailTarget | null; onClose?: () => void }) {
53
- if (!target) return null;
54
+ function usePatchFetcher(sessionId: string | null | undefined, filePath: string | undefined) {
55
+ const [patch, setPatch] = useState<string | null>(null);
56
+ const [loading, setLoading] = useState(false);
57
+ const [error, setError] = useState<string | null>(null);
54
58
 
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>
59
+ useEffect(() => {
60
+ setPatch(null);
61
+ setError(null);
62
+ setLoading(false);
63
+ }, [sessionId, filePath]);
64
+
65
+ const fetchPatch = useCallback(async () => {
66
+ if (!sessionId || !filePath) return;
67
+ setLoading(true);
68
+ setError(null);
69
+ try {
70
+ const res = await fetch(`/api/sessions/${sessionId}/diff?path=${encodeURIComponent(filePath)}`);
71
+ if (!res.ok) {
72
+ const body = await res.json().catch(() => ({ error: "Failed to load diff" }));
73
+ throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
74
+ }
75
+ const data = await res.json() as { patch: string };
76
+ setPatch(data.patch);
77
+ } catch (err) {
78
+ setError(err instanceof Error ? err.message : String(err));
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ }, [sessionId, filePath]);
83
+
84
+ return { patch, loading, error, fetchPatch };
85
+ }
86
+
87
+ function FileDetail({
88
+ file,
89
+ sessionId,
90
+ prUrl,
91
+ onClose,
92
+ }: {
93
+ file: FileChange;
94
+ sessionId?: string | null;
95
+ prUrl?: string;
96
+ onClose?: () => void;
97
+ }) {
98
+ const Icon = STATUS_ICON[file.status];
99
+ const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
100
+
101
+ useEffect(() => {
102
+ if (sessionId && !patch && !loading && !error) {
103
+ fetchPatch();
104
+ }
105
+ }, [sessionId, patch, loading, error, fetchPatch]);
106
+
107
+ return (
108
+ <div className="flex flex-col h-full">
109
+ <div className="shrink-0 flex items-center justify-between gap-2 px-4 h-12 border-b">
110
+ <div className="flex items-center gap-2 min-w-0">
111
+ <Icon className={`h-3 w-3 shrink-0 ${STATUS_COLOR[file.status]}`} />
112
+ <span className="text-[11px] font-mono truncate" title={file.path}>{file.path}</span>
113
+ </div>
114
+ <div className="flex items-center gap-2 shrink-0">
115
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{file.additions}</span>
116
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{file.deletions}</span>
69
117
  {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">
118
+ <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
119
  <X className="h-3.5 w-3.5" />
72
120
  </button>
73
121
  )}
74
122
  </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
- })}
123
+ </div>
124
+
125
+ <div className="flex-1 overflow-y-auto">
126
+ {loading && (
127
+ <div className="flex items-center justify-center py-16 gap-2">
128
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
129
+ <span className="text-xs text-muted-foreground/50">Loading diff</span>
93
130
  </div>
94
- </div>
131
+ )}
132
+ {error && (
133
+ <div className="flex flex-col items-center justify-center py-16 gap-3">
134
+ <div className="flex items-center gap-2 text-destructive">
135
+ <AlertCircle className="h-3.5 w-3.5" />
136
+ <p className="text-xs">{error}</p>
137
+ </div>
138
+ <button
139
+ type="button"
140
+ onClick={fetchPatch}
141
+ className="text-[11px] text-muted-foreground/50 hover:text-foreground transition-colors"
142
+ >
143
+ Retry
144
+ </button>
145
+ </div>
146
+ )}
147
+ {patch && (
148
+ <DiffViewer
149
+ patch={patch}
150
+ filePath={file.path}
151
+ sessionId={sessionId}
152
+ githubUrl={prUrl ? `${prUrl}/files` : undefined}
153
+ />
154
+ )}
95
155
  </div>
96
- );
97
- }
156
+ </div>
157
+ );
158
+ }
159
+
160
+ export function DetailPane({
161
+ target,
162
+ sessionId,
163
+ prUrl,
164
+ onClose,
165
+ }: {
166
+ target: DetailTarget | null;
167
+ sessionId?: string | null;
168
+ prUrl?: string;
169
+ onClose?: () => void;
170
+ }) {
171
+ if (!target) return null;
172
+
173
+ if (target.kind === "group" && target.group) {
174
+ const g = target.group;
175
+ const totalAdd = target.files.reduce((s, f) => s + f.additions, 0);
176
+ const totalDel = target.files.reduce((s, f) => s + f.deletions, 0);
98
177
 
99
- if (target.kind === "file" && target.file) {
100
- const f = target.file;
101
- const Icon = STATUS_ICON[f.status];
102
178
  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>
179
+ <div className="flex flex-col h-full">
180
+ <div className="shrink-0 flex items-center justify-between gap-2 px-4 h-12 border-b">
181
+ <div className="flex items-center gap-2 min-w-0">
182
+ <span className={`h-2 w-2 rounded-full shrink-0 ${TYPE_DOT[g.type] ?? TYPE_DOT.chore}`} />
183
+ <span className="text-xs font-medium truncate">{g.name}</span>
184
+ <span className="text-[10px] text-muted-foreground/30">{g.type}</span>
118
185
  </div>
119
186
  {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">
187
+ <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
188
  <X className="h-3.5 w-3.5" />
122
189
  </button>
123
190
  )}
124
191
  </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
- ))}
192
+
193
+ <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
194
+ <p className="text-[11px] text-muted-foreground/60 leading-relaxed">{g.description}</p>
195
+
196
+ <div>
197
+ <div className="flex items-center gap-2 mb-2.5">
198
+ <span className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{target.files.length} files</span>
199
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{totalAdd}</span>
200
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{totalDel}</span>
201
+ </div>
202
+ <div className="space-y-px">
203
+ {target.files.map((f) => {
204
+ const Icon = STATUS_ICON[f.status];
205
+ return (
206
+ <div key={f.path} className="py-2">
207
+ <div className="flex items-center gap-2 min-w-0">
208
+ <Icon className={`h-2.5 w-2.5 shrink-0 ${STATUS_COLOR[f.status]}`} />
209
+ <span className="text-[11px] font-mono truncate flex-1" title={f.path}>{f.path}</span>
210
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400 shrink-0">+{f.additions}</span>
211
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400 shrink-0">-{f.deletions}</span>
212
+ </div>
213
+ <p className="text-[11px] text-muted-foreground/40 mt-1 pl-[18px] leading-relaxed">{f.summary}</p>
214
+ </div>
215
+ );
216
+ })}
133
217
  </div>
134
218
  </div>
135
- )}
219
+ </div>
136
220
  </div>
137
221
  );
138
222
  }
139
223
 
224
+ if (target.kind === "file" && target.file) {
225
+ return <FileDetail file={target.file} sessionId={sessionId} prUrl={prUrl} onClose={onClose} />;
226
+ }
227
+
140
228
  return null;
141
229
  }