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.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +44 -0
- package/src/web/client/App.tsx +29 -3
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +66 -71
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +716 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +33 -0
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/src/types/output.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type RiskLevel = "low" | "medium" | "high";
|
|
|
14
14
|
export interface PrMeta {
|
|
15
15
|
pr_number: number;
|
|
16
16
|
pr_title: string;
|
|
17
|
+
pr_body?: string;
|
|
17
18
|
pr_url: string;
|
|
18
19
|
base_branch: string;
|
|
19
20
|
head_branch: string;
|
|
@@ -56,6 +57,49 @@ export interface CartoonImage {
|
|
|
56
57
|
generatedAt: string;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export interface DiffComment {
|
|
61
|
+
id: string;
|
|
62
|
+
sessionId: string;
|
|
63
|
+
filePath: string;
|
|
64
|
+
line: number;
|
|
65
|
+
startLine?: number;
|
|
66
|
+
side: "old" | "new";
|
|
67
|
+
body: string;
|
|
68
|
+
author: string;
|
|
69
|
+
authorAvatar?: string;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
githubUrl?: string;
|
|
72
|
+
githubCommentId?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PendingComment {
|
|
76
|
+
tempId: string;
|
|
77
|
+
filePath: string;
|
|
78
|
+
line: number;
|
|
79
|
+
side: "old" | "new";
|
|
80
|
+
body: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ChatToolCall {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
arguments: Record<string, unknown>;
|
|
87
|
+
result?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ChatSegment =
|
|
91
|
+
| { type: "text"; content: string }
|
|
92
|
+
| { type: "tool_call"; toolCall: ChatToolCall };
|
|
93
|
+
|
|
94
|
+
export interface ChatMessage {
|
|
95
|
+
role: "user" | "assistant" | "tool";
|
|
96
|
+
content: string;
|
|
97
|
+
toolCalls?: ChatToolCall[];
|
|
98
|
+
segments?: ChatSegment[];
|
|
99
|
+
toolCallId?: string;
|
|
100
|
+
timestamp: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
export interface NewprOutput {
|
|
60
104
|
meta: PrMeta;
|
|
61
105
|
summary: PrSummary;
|
package/src/web/client/App.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
|
|
|
10
10
|
import { ResultsScreen } from "./components/ResultsScreen.tsx";
|
|
11
11
|
import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
12
12
|
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
13
|
+
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
14
|
+
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
13
15
|
|
|
14
16
|
function getUrlParam(key: string): string | null {
|
|
15
17
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -80,11 +82,28 @@ export function App() {
|
|
|
80
82
|
analysis.reset();
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const diffSessionId = analysis.historyId ?? analysis.sessionId;
|
|
86
|
+
const prUrl = analysis.result?.meta.pr_url;
|
|
83
87
|
const detailPanel = detailTarget ? (
|
|
84
|
-
<DetailPane target={detailTarget} onClose={() => setActiveId(null)} />
|
|
88
|
+
<DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
|
|
85
89
|
) : null;
|
|
86
90
|
|
|
91
|
+
const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
|
|
92
|
+
|
|
93
|
+
const anchorItems = useMemo<AnchorItem[]>(() => {
|
|
94
|
+
if (!analysis.result) return [];
|
|
95
|
+
const items: AnchorItem[] = [];
|
|
96
|
+
for (const g of analysis.result.groups) {
|
|
97
|
+
items.push({ kind: "group", id: g.name, label: g.name });
|
|
98
|
+
}
|
|
99
|
+
for (const f of analysis.result.files) {
|
|
100
|
+
items.push({ kind: "file", id: f.path, label: f.path });
|
|
101
|
+
}
|
|
102
|
+
return items;
|
|
103
|
+
}, [analysis.result]);
|
|
104
|
+
|
|
87
105
|
return (
|
|
106
|
+
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
88
107
|
<AppShell
|
|
89
108
|
theme={themeCtx.theme}
|
|
90
109
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -93,9 +112,15 @@ export function App() {
|
|
|
93
112
|
onSessionSelect={handleSessionSelect}
|
|
94
113
|
onNewAnalysis={handleNewAnalysis}
|
|
95
114
|
detailPanel={detailPanel}
|
|
115
|
+
bottomBar={analysis.phase === "done" ? <ChatInput /> : undefined}
|
|
116
|
+
activeSessionId={diffSessionId}
|
|
96
117
|
>
|
|
97
118
|
{analysis.phase === "idle" && (
|
|
98
|
-
<InputScreen
|
|
119
|
+
<InputScreen
|
|
120
|
+
onSubmit={(pr) => analysis.start(pr)}
|
|
121
|
+
sessions={sessions}
|
|
122
|
+
onSessionSelect={handleSessionSelect}
|
|
123
|
+
/>
|
|
99
124
|
)}
|
|
100
125
|
{analysis.phase === "loading" && (
|
|
101
126
|
<LoadingTimeline
|
|
@@ -110,7 +135,7 @@ export function App() {
|
|
|
110
135
|
activeId={activeId}
|
|
111
136
|
onAnchorClick={handleAnchorClick}
|
|
112
137
|
cartoonEnabled={features.cartoon}
|
|
113
|
-
sessionId={
|
|
138
|
+
sessionId={diffSessionId}
|
|
114
139
|
/>
|
|
115
140
|
)}
|
|
116
141
|
{analysis.phase === "error" && (
|
|
@@ -121,5 +146,6 @@ export function App() {
|
|
|
121
146
|
/>
|
|
122
147
|
)}
|
|
123
148
|
</AppShell>
|
|
149
|
+
</ChatProvider>
|
|
124
150
|
);
|
|
125
151
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
2
|
-
import { Sun, Moon, Monitor, Plus,
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Sun, Moon, Monitor, Plus, Settings, ArrowUp } from "lucide-react";
|
|
3
3
|
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
4
|
import type { GithubUser } from "../hooks/useGithubUser.ts";
|
|
5
5
|
import { SettingsPanel } from "./SettingsPanel.tsx";
|
|
@@ -13,9 +13,9 @@ const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
|
|
|
13
13
|
const LEFT_MIN = 180;
|
|
14
14
|
const LEFT_MAX = 400;
|
|
15
15
|
const LEFT_DEFAULT = 256;
|
|
16
|
-
const RIGHT_MIN =
|
|
17
|
-
const RIGHT_MAX =
|
|
18
|
-
const RIGHT_DEFAULT =
|
|
16
|
+
const RIGHT_MIN = 400;
|
|
17
|
+
const RIGHT_MAX = 1200;
|
|
18
|
+
const RIGHT_DEFAULT = 560;
|
|
19
19
|
|
|
20
20
|
const RISK_DOT: Record<string, string> = {
|
|
21
21
|
low: "bg-green-500",
|
|
@@ -43,6 +43,8 @@ export function AppShell({
|
|
|
43
43
|
onSessionSelect,
|
|
44
44
|
onNewAnalysis,
|
|
45
45
|
detailPanel,
|
|
46
|
+
bottomBar,
|
|
47
|
+
activeSessionId,
|
|
46
48
|
children,
|
|
47
49
|
}: {
|
|
48
50
|
theme: Theme;
|
|
@@ -52,11 +54,27 @@ export function AppShell({
|
|
|
52
54
|
onSessionSelect: (sessionId: string) => void;
|
|
53
55
|
onNewAnalysis: () => void;
|
|
54
56
|
detailPanel?: React.ReactNode;
|
|
57
|
+
bottomBar?: React.ReactNode;
|
|
58
|
+
activeSessionId?: string | null;
|
|
55
59
|
children: React.ReactNode;
|
|
56
60
|
}) {
|
|
57
61
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
58
62
|
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
|
|
59
63
|
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
|
|
64
|
+
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
65
|
+
const mainRef = useRef<HTMLElement>(null);
|
|
66
|
+
const prevDetailPanel = useRef(detailPanel);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const wasNull = prevDetailPanel.current == null;
|
|
70
|
+
prevDetailPanel.current = detailPanel;
|
|
71
|
+
if (wasNull && detailPanel != null) {
|
|
72
|
+
const available = window.innerWidth - leftWidth - 2;
|
|
73
|
+
const half = Math.floor(available * 0.55);
|
|
74
|
+
setRightWidth(Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, half)));
|
|
75
|
+
}
|
|
76
|
+
}, [detailPanel, leftWidth]);
|
|
77
|
+
|
|
60
78
|
const Icon = THEME_ICON[theme];
|
|
61
79
|
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]!;
|
|
62
80
|
|
|
@@ -64,104 +82,122 @@ export function AppShell({
|
|
|
64
82
|
setLeftWidth((w) => Math.min(LEFT_MAX, Math.max(LEFT_MIN, w + delta)));
|
|
65
83
|
}, []);
|
|
66
84
|
|
|
85
|
+
const CENTER_MIN = 400;
|
|
86
|
+
|
|
67
87
|
const handleRightResize = useCallback((delta: number) => {
|
|
68
|
-
setRightWidth((w) =>
|
|
88
|
+
setRightWidth((w) => {
|
|
89
|
+
const available = window.innerWidth - leftWidth - 2;
|
|
90
|
+
const max = Math.min(RIGHT_MAX, available - CENTER_MIN);
|
|
91
|
+
return Math.min(max, Math.max(RIGHT_MIN, w + delta));
|
|
92
|
+
});
|
|
93
|
+
}, [leftWidth]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const el = mainRef.current;
|
|
97
|
+
if (!el) return;
|
|
98
|
+
const onScroll = () => setShowScrollTop(el.scrollTop > 300);
|
|
99
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
100
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const scrollToTop = useCallback(() => {
|
|
104
|
+
mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
69
105
|
}, []);
|
|
70
106
|
|
|
71
107
|
return (
|
|
72
108
|
<div className="flex h-screen bg-background overflow-hidden">
|
|
73
109
|
<aside className="flex flex-col shrink-0 border-r bg-background" style={{ width: leftWidth }}>
|
|
74
|
-
<div className="flex h-
|
|
110
|
+
<div className="flex h-12 items-center justify-between px-4 shrink-0">
|
|
75
111
|
<button
|
|
76
112
|
type="button"
|
|
77
113
|
onClick={onNewAnalysis}
|
|
78
|
-
className="flex items-center gap-
|
|
114
|
+
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
|
79
115
|
>
|
|
80
|
-
<span className="text-
|
|
81
|
-
<span className="text-[10px] text-muted-foreground">v0.1.0</span>
|
|
116
|
+
<span className="text-xs font-semibold tracking-tight font-mono">newpr</span>
|
|
82
117
|
</button>
|
|
83
118
|
<button
|
|
84
119
|
type="button"
|
|
85
120
|
onClick={onNewAnalysis}
|
|
86
|
-
className="flex h-
|
|
121
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
|
87
122
|
title="New analysis"
|
|
88
123
|
>
|
|
89
|
-
<Plus className="h-
|
|
124
|
+
<Plus className="h-3.5 w-3.5" />
|
|
90
125
|
</button>
|
|
91
126
|
</div>
|
|
92
127
|
|
|
93
|
-
<div className="flex-1 overflow-y-auto">
|
|
94
|
-
{sessions.length > 0
|
|
95
|
-
<div className="
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<div className="space-y-0.5">
|
|
100
|
-
{sessions.map((s) => (
|
|
128
|
+
<div className="flex-1 overflow-y-auto px-2">
|
|
129
|
+
{sessions.length > 0 ? (
|
|
130
|
+
<div className="space-y-px">
|
|
131
|
+
{sessions.map((s) => {
|
|
132
|
+
const isActive = activeSessionId === s.id;
|
|
133
|
+
return (
|
|
101
134
|
<button
|
|
102
135
|
key={s.id}
|
|
103
136
|
type="button"
|
|
104
137
|
onClick={() => onSessionSelect(s.id)}
|
|
105
|
-
className=
|
|
138
|
+
className={`w-full flex items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors group ${
|
|
139
|
+
isActive
|
|
140
|
+
? "bg-accent text-foreground"
|
|
141
|
+
: "hover:bg-accent/40"
|
|
142
|
+
}`}
|
|
106
143
|
>
|
|
107
|
-
<span className={`mt-1.5
|
|
144
|
+
<span className={`mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
108
145
|
<div className="flex-1 min-w-0">
|
|
109
|
-
<div className=
|
|
146
|
+
<div className={`text-xs truncate leading-tight ${isActive ? "font-medium" : "text-foreground/80 group-hover:text-foreground"} transition-colors`}>
|
|
110
147
|
{s.pr_title}
|
|
111
148
|
</div>
|
|
112
|
-
<div className="flex items-center gap-1
|
|
113
|
-
<span className="truncate">{s.repo.split("/").pop()}</span>
|
|
114
|
-
<span>#{s.pr_number}</span>
|
|
115
|
-
<span className="text-muted-foreground/
|
|
116
|
-
<Clock className="h-2.5 w-2.5" />
|
|
149
|
+
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/50">
|
|
150
|
+
<span className="font-mono truncate">{s.repo.split("/").pop()}</span>
|
|
151
|
+
<span className="font-mono">#{s.pr_number}</span>
|
|
152
|
+
<span className="text-muted-foreground/20 mx-0.5">·</span>
|
|
117
153
|
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
118
154
|
</div>
|
|
119
155
|
</div>
|
|
120
156
|
</button>
|
|
121
|
-
)
|
|
122
|
-
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-2 opacity-40">
|
|
162
|
+
<p className="text-[11px] text-muted-foreground">No analyses yet</p>
|
|
123
163
|
</div>
|
|
124
164
|
)}
|
|
125
165
|
</div>
|
|
126
166
|
|
|
127
|
-
<div className="border-t px-
|
|
167
|
+
<div className="shrink-0 border-t px-2 py-2 space-y-1">
|
|
128
168
|
{githubUser && (
|
|
129
169
|
<a
|
|
130
170
|
href={githubUser.html_url}
|
|
131
171
|
target="_blank"
|
|
132
172
|
rel="noopener noreferrer"
|
|
133
|
-
className="flex items-center gap-2
|
|
173
|
+
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-accent/40 transition-colors"
|
|
134
174
|
>
|
|
135
175
|
<img
|
|
136
176
|
src={githubUser.avatar_url}
|
|
137
177
|
alt={githubUser.login}
|
|
138
|
-
className="h-
|
|
178
|
+
className="h-5 w-5 rounded-full"
|
|
139
179
|
/>
|
|
140
|
-
<
|
|
141
|
-
<div className="text-xs font-medium truncate">{githubUser.name ?? githubUser.login}</div>
|
|
142
|
-
{githubUser.name && (
|
|
143
|
-
<div className="text-[10px] text-muted-foreground truncate">@{githubUser.login}</div>
|
|
144
|
-
)}
|
|
145
|
-
</div>
|
|
180
|
+
<span className="text-[11px] font-medium truncate flex-1">{githubUser.name ?? githubUser.login}</span>
|
|
146
181
|
</a>
|
|
147
182
|
)}
|
|
148
|
-
<div className="flex items-center
|
|
183
|
+
<div className="flex items-center gap-1 px-1">
|
|
149
184
|
<button
|
|
150
185
|
type="button"
|
|
151
186
|
onClick={() => onThemeChange(next)}
|
|
152
|
-
className="flex items-center gap-
|
|
187
|
+
className="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-[11px] text-muted-foreground/50 hover:text-foreground hover:bg-accent/40 transition-colors"
|
|
153
188
|
title={`Switch to ${next} mode`}
|
|
154
189
|
>
|
|
155
|
-
<Icon className="h-3
|
|
190
|
+
<Icon className="h-3 w-3" />
|
|
156
191
|
<span className="capitalize">{theme}</span>
|
|
157
192
|
</button>
|
|
193
|
+
<div className="flex-1" />
|
|
158
194
|
<button
|
|
159
195
|
type="button"
|
|
160
196
|
onClick={() => setSettingsOpen(true)}
|
|
161
|
-
className="flex h-
|
|
197
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:bg-accent/40 hover:text-foreground transition-colors"
|
|
162
198
|
title="Settings"
|
|
163
199
|
>
|
|
164
|
-
<Settings className="h-3
|
|
200
|
+
<Settings className="h-3 w-3" />
|
|
165
201
|
</button>
|
|
166
202
|
</div>
|
|
167
203
|
</div>
|
|
@@ -169,12 +205,23 @@ export function AppShell({
|
|
|
169
205
|
|
|
170
206
|
<ResizeHandle onResize={handleLeftResize} side="right" />
|
|
171
207
|
|
|
172
|
-
<div className="flex-1 flex flex-col
|
|
173
|
-
<main className="flex-1 overflow-y-auto">
|
|
174
|
-
<div className="mx-auto max-w-
|
|
208
|
+
<div className="flex-1 flex flex-col overflow-hidden relative" style={{ minWidth: 400 }}>
|
|
209
|
+
<main ref={mainRef} className="flex-1 overflow-y-auto">
|
|
210
|
+
<div className="mx-auto max-w-5xl px-10 py-10">
|
|
175
211
|
{children}
|
|
176
212
|
</div>
|
|
177
213
|
</main>
|
|
214
|
+
{bottomBar}
|
|
215
|
+
{showScrollTop && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={scrollToTop}
|
|
219
|
+
className="absolute bottom-3 right-4 z-20 flex h-8 w-8 items-center justify-center rounded-full border bg-background shadow-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
220
|
+
style={{ bottom: bottomBar ? 76 : 12 }}
|
|
221
|
+
>
|
|
222
|
+
<ArrowUp className="h-3.5 w-3.5" />
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
178
225
|
</div>
|
|
179
226
|
|
|
180
227
|
{detailPanel && (
|