newpr 0.4.0 → 0.5.1
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 -2
- package/src/analyzer/pipeline.ts +42 -1
- package/src/config/store.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +37 -17
- package/src/llm/slides.ts +381 -0
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +7 -1
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +241 -37
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +37 -3
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +407 -5
- package/src/web/server.ts +30 -0
- package/src/web/styles/built.css +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
|
|
2
|
+
import type { SlideDeck } from "../types/output.ts";
|
|
3
|
+
import { generateSlides } from "../llm/slides.ts";
|
|
4
|
+
import { saveSlidesSidecar, loadSlidesSidecar } from "../history/store.ts";
|
|
5
|
+
|
|
6
|
+
export const slidesPlugin: GeneratorPlugin = {
|
|
7
|
+
id: "slides",
|
|
8
|
+
name: "Slide Deck",
|
|
9
|
+
description: "Generate a presentation that explains this PR to your team.",
|
|
10
|
+
icon: "Presentation",
|
|
11
|
+
tabLabel: "Slides",
|
|
12
|
+
|
|
13
|
+
isAvailable: (ctx) => !!ctx.apiKey,
|
|
14
|
+
|
|
15
|
+
async generate(ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown): Promise<PluginResult> {
|
|
16
|
+
const existing = existingData as SlideDeck | null | undefined;
|
|
17
|
+
const deck = await generateSlides(
|
|
18
|
+
ctx.apiKey,
|
|
19
|
+
ctx.data,
|
|
20
|
+
undefined,
|
|
21
|
+
ctx.language,
|
|
22
|
+
(msg, current, total) => onProgress?.({ message: msg, current, total }),
|
|
23
|
+
existing,
|
|
24
|
+
undefined,
|
|
25
|
+
(partialDeck) => {
|
|
26
|
+
saveSlidesSidecar(ctx.sessionId, partialDeck).catch(() => {});
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
return { type: "slides", data: deck };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async save(sessionId: string, data: unknown): Promise<void> {
|
|
33
|
+
await saveSlidesSidecar(sessionId, data as SlideDeck);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async load(sessionId: string): Promise<SlideDeck | null> {
|
|
37
|
+
return loadSlidesSidecar(sessionId);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NewprOutput } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
export interface PluginContext {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
data: NewprOutput;
|
|
7
|
+
language: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PluginProgressEvent {
|
|
11
|
+
message: string;
|
|
12
|
+
current: number;
|
|
13
|
+
total: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PluginProgressCallback = (event: PluginProgressEvent) => void;
|
|
17
|
+
|
|
18
|
+
export interface PluginResult {
|
|
19
|
+
type: string;
|
|
20
|
+
data: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GeneratorPlugin {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
icon: string;
|
|
28
|
+
tabLabel: string;
|
|
29
|
+
isAvailable: (ctx: PluginContext) => boolean;
|
|
30
|
+
generate: (ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown) => Promise<PluginResult>;
|
|
31
|
+
save: (sessionId: string, data: unknown) => Promise<void>;
|
|
32
|
+
load: (sessionId: string) => Promise<unknown | null>;
|
|
33
|
+
}
|
package/src/types/github.ts
CHANGED
package/src/types/output.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface PrMeta {
|
|
|
19
19
|
pr_body?: string;
|
|
20
20
|
pr_url: string;
|
|
21
21
|
pr_state?: PrStateLabel;
|
|
22
|
+
pr_updated_at?: string;
|
|
22
23
|
base_branch: string;
|
|
23
24
|
head_branch: string;
|
|
24
25
|
author: string;
|
|
@@ -106,6 +107,31 @@ export interface ChatMessage {
|
|
|
106
107
|
timestamp: string;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
export interface SlideImage {
|
|
111
|
+
index: number;
|
|
112
|
+
imageBase64: string;
|
|
113
|
+
mimeType: string;
|
|
114
|
+
title: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SlideSpec {
|
|
118
|
+
index: number;
|
|
119
|
+
title: string;
|
|
120
|
+
contentPrompt: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SlidePlan {
|
|
124
|
+
stylePrompt: string;
|
|
125
|
+
slides: SlideSpec[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SlideDeck {
|
|
129
|
+
slides: SlideImage[];
|
|
130
|
+
plan?: SlidePlan;
|
|
131
|
+
failedIndices?: number[];
|
|
132
|
+
generatedAt: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
109
135
|
export interface NewprOutput {
|
|
110
136
|
meta: PrMeta;
|
|
111
137
|
summary: PrSummary;
|
package/src/web/client/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
|
13
13
|
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
14
14
|
import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
|
|
15
15
|
import type { AnchorItem } from "./components/TipTapEditor.tsx";
|
|
16
|
+
import { requestNotificationPermission } from "./lib/notify.ts";
|
|
16
17
|
|
|
17
18
|
function getUrlParam(key: string): string | null {
|
|
18
19
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -38,6 +39,8 @@ export function App() {
|
|
|
38
39
|
const features = useFeatures();
|
|
39
40
|
const bgAnalyses = useBackgroundAnalyses();
|
|
40
41
|
const initialLoadDone = useRef(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => { requestNotificationPermission(); }, []);
|
|
41
44
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
42
45
|
|
|
43
46
|
useEffect(() => {
|
|
@@ -145,7 +148,7 @@ export function App() {
|
|
|
145
148
|
}, [analysis.result]);
|
|
146
149
|
|
|
147
150
|
return (
|
|
148
|
-
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
151
|
+
<ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
|
|
149
152
|
<AppShell
|
|
150
153
|
theme={themeCtx.theme}
|
|
151
154
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -160,6 +163,7 @@ export function App() {
|
|
|
160
163
|
bgAnalyses={bgAnalyses.analyses}
|
|
161
164
|
onBgClick={handleBgClick}
|
|
162
165
|
onBgDismiss={bgAnalyses.dismiss}
|
|
166
|
+
onFeaturesChange={features.refresh}
|
|
163
167
|
>
|
|
164
168
|
{analysis.phase === "idle" && (
|
|
165
169
|
<InputScreen
|
|
@@ -184,6 +188,8 @@ export function App() {
|
|
|
184
188
|
cartoonEnabled={features.cartoon}
|
|
185
189
|
sessionId={diffSessionId}
|
|
186
190
|
onTabChange={setActiveTab}
|
|
191
|
+
onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
|
|
192
|
+
enabledPlugins={features.enabledPlugins}
|
|
187
193
|
/>
|
|
188
194
|
)}
|
|
189
195
|
{analysis.phase === "error" && (
|
|
@@ -166,6 +166,7 @@ export function AppShell({
|
|
|
166
166
|
bgAnalyses,
|
|
167
167
|
onBgClick,
|
|
168
168
|
onBgDismiss,
|
|
169
|
+
onFeaturesChange,
|
|
169
170
|
children,
|
|
170
171
|
}: {
|
|
171
172
|
theme: Theme;
|
|
@@ -181,6 +182,7 @@ export function AppShell({
|
|
|
181
182
|
bgAnalyses?: BackgroundAnalysis[];
|
|
182
183
|
onBgClick?: (sessionId: string) => void;
|
|
183
184
|
onBgDismiss?: (sessionId: string) => void;
|
|
185
|
+
onFeaturesChange?: () => void;
|
|
184
186
|
children: React.ReactNode;
|
|
185
187
|
}) {
|
|
186
188
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
@@ -382,7 +384,7 @@ export function AppShell({
|
|
|
382
384
|
onKeyDown={(e) => { if (e.key === "Escape") setSettingsOpen(false); }}
|
|
383
385
|
/>
|
|
384
386
|
<div className="relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border bg-background p-6 shadow-lg">
|
|
385
|
-
<SettingsPanel onClose={() => setSettingsOpen(false)} />
|
|
387
|
+
<SettingsPanel onClose={() => setSettingsOpen(false)} onFeaturesChange={onFeaturesChange} />
|
|
386
388
|
</div>
|
|
387
389
|
</div>
|
|
388
390
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
2
2
|
import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
|
|
3
3
|
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
4
4
|
import { Markdown } from "./Markdown.tsx";
|
|
@@ -17,14 +17,15 @@ export interface ChatState {
|
|
|
17
17
|
interface ChatContextValue {
|
|
18
18
|
state: ChatState;
|
|
19
19
|
anchorItems?: AnchorItem[];
|
|
20
|
+
analyzedAt?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
23
24
|
|
|
24
25
|
export { useChatStore as useChatState };
|
|
25
26
|
|
|
26
|
-
export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
|
|
27
|
-
const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
|
|
27
|
+
export function ChatProvider({ state, anchorItems, analyzedAt, children }: { state: ChatState; anchorItems?: AnchorItem[]; analyzedAt?: string; children: React.ReactNode }) {
|
|
28
|
+
const value = useMemo(() => ({ state, anchorItems, analyzedAt }), [state, anchorItems, analyzedAt]);
|
|
28
29
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -71,6 +72,33 @@ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
|
|
|
71
72
|
return segs;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
|
|
76
|
+
content: string;
|
|
77
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
78
|
+
activeId?: string | null;
|
|
79
|
+
}) {
|
|
80
|
+
const [rendered, setRendered] = useState(content);
|
|
81
|
+
const pendingRef = useRef(content);
|
|
82
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
pendingRef.current = content;
|
|
86
|
+
if (!timerRef.current) {
|
|
87
|
+
timerRef.current = setTimeout(() => {
|
|
88
|
+
setRendered(pendingRef.current);
|
|
89
|
+
timerRef.current = null;
|
|
90
|
+
}, 150);
|
|
91
|
+
}
|
|
92
|
+
return () => {};
|
|
93
|
+
}, [content]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
|
|
75
103
|
segments: ChatSegment[];
|
|
76
104
|
activeToolName?: string;
|
|
@@ -86,11 +114,16 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
|
|
|
86
114
|
if (seg.type === "tool_call") {
|
|
87
115
|
return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
|
|
88
116
|
}
|
|
89
|
-
|
|
117
|
+
if (!seg.content) return null;
|
|
118
|
+
return (
|
|
90
119
|
<div key={`text-${i}`} className="text-xs leading-relaxed">
|
|
91
|
-
|
|
120
|
+
{isStreaming ? (
|
|
121
|
+
<ThrottledMarkdown content={seg.content} onAnchorClick={onAnchorClick} activeId={activeId} />
|
|
122
|
+
) : (
|
|
123
|
+
<Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
|
|
124
|
+
)}
|
|
92
125
|
</div>
|
|
93
|
-
)
|
|
126
|
+
);
|
|
94
127
|
})}
|
|
95
128
|
{activeToolName && (
|
|
96
129
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
|
|
@@ -149,6 +182,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
149
182
|
|
|
150
183
|
if (!ctx) return null;
|
|
151
184
|
const { messages, streaming, loaded, loading } = ctx.state;
|
|
185
|
+
const { analyzedAt } = ctx;
|
|
152
186
|
const hasMessages = messages.length > 0 || loading;
|
|
153
187
|
|
|
154
188
|
if (!hasMessages && loaded) {
|
|
@@ -162,26 +196,51 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
162
196
|
|
|
163
197
|
if (!hasMessages) return null;
|
|
164
198
|
|
|
199
|
+
let shownOutdatedDivider = false;
|
|
200
|
+
|
|
165
201
|
return (
|
|
166
202
|
<div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
|
|
167
203
|
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
|
|
168
204
|
{messages.map((msg, i) => {
|
|
205
|
+
const isFromPreviousAnalysis = analyzedAt && msg.timestamp && msg.timestamp < analyzedAt;
|
|
206
|
+
let divider = null;
|
|
207
|
+
if (isFromPreviousAnalysis && !shownOutdatedDivider) {
|
|
208
|
+
shownOutdatedDivider = true;
|
|
209
|
+
divider = (
|
|
210
|
+
<div className="flex items-center gap-2 py-1">
|
|
211
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
212
|
+
<span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">Previous analysis</span>
|
|
213
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
169
217
|
if (msg.role === "user") {
|
|
170
218
|
return (
|
|
171
|
-
<div key={`user-${i}`}
|
|
172
|
-
|
|
173
|
-
|
|
219
|
+
<div key={`user-${i}`}>
|
|
220
|
+
{divider}
|
|
221
|
+
<div className="flex justify-end">
|
|
222
|
+
<div className={`max-w-[80%] rounded-xl rounded-br-sm px-3.5 py-2 text-[11px] leading-relaxed ${
|
|
223
|
+
isFromPreviousAnalysis
|
|
224
|
+
? "bg-foreground/60 text-background"
|
|
225
|
+
: "bg-foreground text-background"
|
|
226
|
+
}`}>
|
|
227
|
+
{msg.content}
|
|
228
|
+
</div>
|
|
174
229
|
</div>
|
|
175
230
|
</div>
|
|
176
231
|
);
|
|
177
232
|
}
|
|
178
233
|
return (
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
234
|
+
<div key={`assistant-${i}`}>
|
|
235
|
+
{divider}
|
|
236
|
+
<div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
|
|
237
|
+
<AssistantMessage
|
|
238
|
+
segments={segmentsFromMessage(msg)}
|
|
239
|
+
onAnchorClick={onAnchorClick}
|
|
240
|
+
activeId={activeId}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
185
244
|
);
|
|
186
245
|
})}
|
|
187
246
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { Plus, Pencil, Trash2, ArrowRight, X, Loader2, AlertCircle } from "lucide-react";
|
|
3
3
|
import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
|
|
4
4
|
import { DiffViewer } from "./DiffViewer.tsx";
|
|
@@ -43,7 +43,8 @@ export function resolveDetail(
|
|
|
43
43
|
files: FileChange[],
|
|
44
44
|
): DetailTarget | null {
|
|
45
45
|
if (kind === "group") {
|
|
46
|
-
const
|
|
46
|
+
const cleanId = id.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
47
|
+
const group = groups.find((g) => g.name === id || g.name === cleanId || g.name.toLowerCase() === cleanId.toLowerCase());
|
|
47
48
|
if (!group) return null;
|
|
48
49
|
const groupFiles = files.filter((f) => group.files.includes(f.path));
|
|
49
50
|
return { kind: "group", group, files: groupFiles };
|
|
@@ -115,6 +116,7 @@ function FileDetail({
|
|
|
115
116
|
}) {
|
|
116
117
|
const Icon = STATUS_ICON[file.status];
|
|
117
118
|
const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
|
|
119
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
118
120
|
|
|
119
121
|
useEffect(() => {
|
|
120
122
|
if (sessionId && !patch && !loading && !error) {
|
|
@@ -140,7 +142,7 @@ function FileDetail({
|
|
|
140
142
|
</div>
|
|
141
143
|
</div>
|
|
142
144
|
|
|
143
|
-
<div className="flex-1 overflow-y-auto">
|
|
145
|
+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
|
144
146
|
{loading && (
|
|
145
147
|
<div className="flex items-center justify-center py-16 gap-2">
|
|
146
148
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
|
|
@@ -164,13 +166,13 @@ function FileDetail({
|
|
|
164
166
|
)}
|
|
165
167
|
{patch && (
|
|
166
168
|
<DiffViewer
|
|
167
|
-
key={`${file.path}-${scrollToLine ?? 0}-${scrollToLineEnd ?? 0}`}
|
|
168
169
|
patch={patch}
|
|
169
170
|
filePath={file.path}
|
|
170
171
|
sessionId={sessionId}
|
|
171
172
|
githubUrl={prUrl ? `${prUrl}/files` : undefined}
|
|
172
173
|
scrollToLine={scrollToLine}
|
|
173
174
|
scrollToLineEnd={scrollToLineEnd}
|
|
175
|
+
scrollContainerRef={scrollContainerRef}
|
|
174
176
|
/>
|
|
175
177
|
)}
|
|
176
178
|
</div>
|