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.
@@ -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
+ }
@@ -30,6 +30,7 @@ export interface GithubPrData {
30
30
  body: string;
31
31
  url: string;
32
32
  state: PrState;
33
+ updated_at: string;
33
34
  base_branch: string;
34
35
  head_branch: string;
35
36
  author: string;
@@ -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;
@@ -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
- return seg.content ? (
117
+ if (!seg.content) return null;
118
+ return (
90
119
  <div key={`text-${i}`} className="text-xs leading-relaxed">
91
- <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
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
- ) : null;
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}`} className="flex justify-end">
172
- <div className="max-w-[80%] rounded-xl rounded-br-sm bg-foreground text-background px-3.5 py-2 text-[11px] leading-relaxed">
173
- {msg.content}
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
- <AssistantMessage
180
- key={`assistant-${i}`}
181
- segments={segmentsFromMessage(msg)}
182
- onAnchorClick={onAnchorClick}
183
- activeId={activeId}
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 group = groups.find((g) => g.name === id);
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>