newpr 0.3.0 → 0.4.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.
@@ -0,0 +1,147 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
+ import type { NewprOutput } from "../../../types/output.ts";
4
+
5
+ export type BgStatus = "running" | "done" | "error";
6
+
7
+ export interface BackgroundAnalysis {
8
+ sessionId: string;
9
+ prInput: string;
10
+ prTitle?: string;
11
+ prNumber?: number;
12
+ status: BgStatus;
13
+ startedAt: number;
14
+ lastStage?: string;
15
+ lastMessage?: string;
16
+ result?: NewprOutput;
17
+ historyId?: string;
18
+ error?: string;
19
+ }
20
+
21
+ export function useBackgroundAnalyses() {
22
+ const [analyses, setAnalyses] = useState<BackgroundAnalysis[]>([]);
23
+ const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
24
+ const restoredRef = useRef(false);
25
+
26
+ useEffect(() => {
27
+ if (restoredRef.current) return;
28
+ restoredRef.current = true;
29
+ fetch("/api/active-analyses")
30
+ .then((r) => r.json())
31
+ .then((data) => {
32
+ const active = data as Array<{
33
+ id: string;
34
+ prInput: string;
35
+ status: string;
36
+ startedAt: number;
37
+ prTitle?: string;
38
+ prNumber?: number;
39
+ lastStage?: string;
40
+ lastMessage?: string;
41
+ }>;
42
+ for (const a of active) {
43
+ if (!eventSourcesRef.current.has(a.id)) {
44
+ trackInternal(a.id, a.prInput, a.prTitle, a.prNumber, a.lastMessage);
45
+ }
46
+ }
47
+ })
48
+ .catch(() => {});
49
+ }, []);
50
+
51
+ const trackInternal = useCallback((sessionId: string, prInput: string, initTitle?: string, initNumber?: number, initMessage?: string) => {
52
+ if (eventSourcesRef.current.has(sessionId)) return;
53
+
54
+ const entry: BackgroundAnalysis = {
55
+ sessionId,
56
+ prInput,
57
+ status: "running",
58
+ startedAt: Date.now(),
59
+ prTitle: initTitle,
60
+ prNumber: initNumber,
61
+ lastMessage: initMessage,
62
+ };
63
+
64
+ setAnalyses((prev) => [...prev.filter((a) => a.sessionId !== sessionId), entry]);
65
+
66
+ const es = new EventSource(`/api/analysis/${sessionId}/events`);
67
+ eventSourcesRef.current.set(sessionId, es);
68
+
69
+ es.addEventListener("progress", (e) => {
70
+ const event = JSON.parse(e.data) as ProgressEvent;
71
+ setAnalyses((prev) =>
72
+ prev.map((a) =>
73
+ a.sessionId === sessionId
74
+ ? {
75
+ ...a,
76
+ lastStage: event.stage,
77
+ lastMessage: event.message,
78
+ prTitle: event.pr_title ?? a.prTitle,
79
+ prNumber: event.pr_number ?? a.prNumber,
80
+ }
81
+ : a,
82
+ ),
83
+ );
84
+ });
85
+
86
+ es.addEventListener("done", async () => {
87
+ es.close();
88
+ eventSourcesRef.current.delete(sessionId);
89
+ try {
90
+ const res = await fetch(`/api/analysis/${sessionId}`);
91
+ const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
92
+ setAnalyses((prev) =>
93
+ prev.map((a) =>
94
+ a.sessionId === sessionId
95
+ ? { ...a, status: "done", result: data.result, historyId: data.historyId }
96
+ : a,
97
+ ),
98
+ );
99
+ } catch {
100
+ setAnalyses((prev) =>
101
+ prev.map((a) =>
102
+ a.sessionId === sessionId ? { ...a, status: "done" } : a,
103
+ ),
104
+ );
105
+ }
106
+ });
107
+
108
+ es.addEventListener("analysis_error", (e) => {
109
+ es.close();
110
+ eventSourcesRef.current.delete(sessionId);
111
+ let msg = "Analysis failed";
112
+ try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
113
+ setAnalyses((prev) =>
114
+ prev.map((a) =>
115
+ a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
116
+ ),
117
+ );
118
+ });
119
+
120
+ es.onerror = () => {
121
+ if (es.readyState === EventSource.CLOSED) {
122
+ eventSourcesRef.current.delete(sessionId);
123
+ }
124
+ };
125
+ }, []);
126
+
127
+ const dismiss = useCallback((sessionId: string) => {
128
+ const es = eventSourcesRef.current.get(sessionId);
129
+ if (es) {
130
+ es.close();
131
+ eventSourcesRef.current.delete(sessionId);
132
+ }
133
+ setAnalyses((prev) => prev.filter((a) => a.sessionId !== sessionId));
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ return () => {
138
+ for (const es of eventSourcesRef.current.values()) es.close();
139
+ };
140
+ }, []);
141
+
142
+ const track = useCallback((sessionId: string, prInput: string) => {
143
+ trackInternal(sessionId, prInput);
144
+ }, [trackInternal]);
145
+
146
+ return { analyses, track, dismiss };
147
+ }
@@ -0,0 +1,244 @@
1
+ import { useEffect, useCallback, useSyncExternalStore } from "react";
2
+ import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
3
+
4
+ interface ChatSessionState {
5
+ messages: ChatMessage[];
6
+ loading: boolean;
7
+ streaming: { segments: ChatSegment[]; activeToolName?: string } | null;
8
+ loaded: boolean;
9
+ }
10
+
11
+ type Listener = () => void;
12
+
13
+ class ChatStore {
14
+ private sessions = new Map<string, ChatSessionState>();
15
+ private listeners = new Set<Listener>();
16
+ private abortControllers = new Map<string, AbortController>();
17
+
18
+ private getOrCreate(sessionId: string): ChatSessionState {
19
+ let s = this.sessions.get(sessionId);
20
+ if (!s) {
21
+ s = { messages: [], loading: false, streaming: null, loaded: false };
22
+ this.sessions.set(sessionId, s);
23
+ }
24
+ return s;
25
+ }
26
+
27
+ private notify() {
28
+ for (const l of this.listeners) l();
29
+ }
30
+
31
+ subscribe(listener: Listener): () => void {
32
+ this.listeners.add(listener);
33
+ return () => this.listeners.delete(listener);
34
+ }
35
+
36
+ getState(sessionId: string): ChatSessionState | null {
37
+ return this.sessions.get(sessionId) ?? null;
38
+ }
39
+
40
+ isLoading(sessionId: string): boolean {
41
+ return this.sessions.get(sessionId)?.loading ?? false;
42
+ }
43
+
44
+ getLoadingSessions(): Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> {
45
+ const result: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = [];
46
+ for (const [id, s] of this.sessions) {
47
+ if (s.loading) result.push({ sessionId: id, streaming: s.streaming });
48
+ }
49
+ return result;
50
+ }
51
+
52
+ async loadHistory(sessionId: string): Promise<void> {
53
+ const s = this.getOrCreate(sessionId);
54
+ if (s.loaded) return;
55
+ try {
56
+ const res = await fetch(`/api/sessions/${sessionId}/chat`);
57
+ const data = await res.json() as ChatMessage[];
58
+ s.messages = data;
59
+ s.loaded = true;
60
+ } catch {
61
+ s.loaded = true;
62
+ }
63
+ this.notify();
64
+ }
65
+
66
+ async sendMessage(sessionId: string, text: string): Promise<void> {
67
+ const s = this.getOrCreate(sessionId);
68
+ if (s.loading) return;
69
+
70
+ const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
71
+ s.messages = [...s.messages, userMsg];
72
+ s.loading = true;
73
+ s.streaming = { segments: [] };
74
+ this.notify();
75
+
76
+ const controller = new AbortController();
77
+ this.abortControllers.set(sessionId, controller);
78
+
79
+ try {
80
+ const res = await fetch(`/api/sessions/${sessionId}/chat`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ message: text }),
84
+ signal: controller.signal,
85
+ });
86
+
87
+ if (!res.ok) {
88
+ const err = await res.json() as { error?: string };
89
+ throw new Error(err.error ?? `HTTP ${res.status}`);
90
+ }
91
+
92
+ const reader = res.body!.getReader();
93
+ const decoder = new TextDecoder();
94
+ let buffer = "";
95
+ let fullText = "";
96
+ const orderedSegments: ChatSegment[] = [];
97
+ const allToolCalls: ChatToolCall[] = [];
98
+ let pendingEvent = "";
99
+
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done) break;
103
+
104
+ buffer += decoder.decode(value, { stream: true });
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop() ?? "";
107
+
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) { pendingEvent = ""; continue; }
111
+ if (trimmed.startsWith("event: ")) { pendingEvent = trimmed.slice(7); continue; }
112
+ if (!trimmed.startsWith("data: ")) continue;
113
+
114
+ try {
115
+ const data = JSON.parse(trimmed.slice(6));
116
+ switch (pendingEvent) {
117
+ case "text": {
118
+ fullText += data.content ?? "";
119
+ const lastSeg = orderedSegments[orderedSegments.length - 1];
120
+ if (lastSeg && lastSeg.type === "text") {
121
+ lastSeg.content += data.content ?? "";
122
+ } else {
123
+ orderedSegments.push({ type: "text", content: data.content ?? "" });
124
+ }
125
+ s.streaming = { segments: [...orderedSegments] };
126
+ this.notify();
127
+ break;
128
+ }
129
+ case "tool_call": {
130
+ const tc: ChatToolCall = { id: data.id, name: data.name, arguments: data.arguments ?? {} };
131
+ allToolCalls.push(tc);
132
+ orderedSegments.push({ type: "tool_call", toolCall: tc });
133
+ s.streaming = { segments: [...orderedSegments], activeToolName: data.name };
134
+ this.notify();
135
+ break;
136
+ }
137
+ case "tool_result": {
138
+ const tc = allToolCalls.find((c) => c.id === data.id);
139
+ if (tc) tc.result = data.result;
140
+ s.streaming = { segments: [...orderedSegments] };
141
+ this.notify();
142
+ break;
143
+ }
144
+ case "done": break;
145
+ case "chat_error": throw new Error(data.message ?? "Chat error");
146
+ }
147
+ } catch (parseErr) {
148
+ if (parseErr instanceof Error && parseErr.message === "Chat error") throw parseErr;
149
+ }
150
+ pendingEvent = "";
151
+ }
152
+ }
153
+
154
+ s.messages = [...s.messages, {
155
+ role: "assistant",
156
+ content: fullText,
157
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
158
+ segments: orderedSegments.length > 0 ? orderedSegments : undefined,
159
+ timestamp: new Date().toISOString(),
160
+ }];
161
+ } catch (err) {
162
+ if ((err as Error).name !== "AbortError") {
163
+ s.messages = [...s.messages, {
164
+ role: "assistant",
165
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
166
+ timestamp: new Date().toISOString(),
167
+ }];
168
+ }
169
+ } finally {
170
+ s.loading = false;
171
+ s.streaming = null;
172
+ this.abortControllers.delete(sessionId);
173
+ this.notify();
174
+ }
175
+ }
176
+
177
+ async undo(sessionId: string): Promise<void> {
178
+ const s = this.getOrCreate(sessionId);
179
+ const lastAssistantIdx = s.messages.findLastIndex((m) => m.role === "assistant");
180
+ if (lastAssistantIdx === -1) return;
181
+ const lastUserIdx = s.messages.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
182
+ const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
183
+ s.messages = s.messages.slice(0, removeFrom);
184
+ this.notify();
185
+ await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
186
+ }
187
+ }
188
+
189
+ export const chatStore = new ChatStore();
190
+
191
+ const subscribeFn = (cb: () => void) => chatStore.subscribe(cb);
192
+
193
+ const EMPTY_STATE: ChatSessionState = { messages: [], loading: false, streaming: null, loaded: false };
194
+ const EMPTY_LOADING: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = [];
195
+
196
+ export function useChatStore(sessionId?: string | null) {
197
+ const stableId = sessionId ?? "";
198
+
199
+ const getSnapshot = useCallback(
200
+ () => (stableId ? chatStore.getState(stableId) : null) ?? EMPTY_STATE,
201
+ [stableId],
202
+ );
203
+
204
+ const state = useSyncExternalStore(subscribeFn, getSnapshot);
205
+
206
+ useEffect(() => {
207
+ if (stableId) chatStore.loadHistory(stableId);
208
+ }, [stableId]);
209
+
210
+ const sendMessage = useCallback((text?: string) => {
211
+ const msg = text?.trim();
212
+ if (!stableId || !msg) return;
213
+ if (msg.replace(/\n/g, "").trim() === "/undo") {
214
+ chatStore.undo(stableId);
215
+ return;
216
+ }
217
+ chatStore.sendMessage(stableId, msg);
218
+ }, [stableId]);
219
+
220
+ return {
221
+ messages: state.messages,
222
+ loading: state.loading,
223
+ streaming: state.streaming,
224
+ loaded: state.loaded,
225
+ sendMessage,
226
+ };
227
+ }
228
+
229
+ let lastLoadingSnapshot: Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> = EMPTY_LOADING;
230
+
231
+ function getLoadingSnapshot() {
232
+ const current = chatStore.getLoadingSessions();
233
+ if (current.length === 0 && lastLoadingSnapshot.length === 0) return lastLoadingSnapshot;
234
+ if (
235
+ current.length === lastLoadingSnapshot.length &&
236
+ current.every((c, i) => c.sessionId === lastLoadingSnapshot[i]?.sessionId)
237
+ ) return lastLoadingSnapshot;
238
+ lastLoadingSnapshot = current;
239
+ return lastLoadingSnapshot;
240
+ }
241
+
242
+ export function useChatLoadingIndicator(): Array<{ sessionId: string; streaming: ChatSessionState["streaming"] }> {
243
+ return useSyncExternalStore(subscribeFn, getLoadingSnapshot);
244
+ }
@@ -2,10 +2,11 @@ import { useState, useEffect } from "react";
2
2
 
3
3
  interface Features {
4
4
  cartoon: boolean;
5
+ version: string;
5
6
  }
6
7
 
7
8
  export function useFeatures(): Features {
8
- const [features, setFeatures] = useState<Features>({ cartoon: false });
9
+ const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
9
10
 
10
11
  useEffect(() => {
11
12
  fetch("/api/features")
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>newpr</title>
7
7
  <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.28/dist/katex.min.css" crossorigin />
8
9
  <script>document.head.appendChild(Object.assign(document.createElement("link"),{rel:"stylesheet",href:"/styles.css"}))</script>
9
10
  </head>
10
11
  <body>
@@ -8,7 +8,7 @@ import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
8
8
  import { parseDiff } from "../../diff/parser.ts";
9
9
  import { parsePrInput } from "../../github/parse-pr.ts";
10
10
  import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
11
- import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
11
+ import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
13
  import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
14
14
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
@@ -182,6 +182,14 @@ $$
182
182
  parameters: { type: "object", properties: {} },
183
183
  },
184
184
  },
185
+ {
186
+ type: "function",
187
+ function: {
188
+ name: "run_react_doctor",
189
+ description: "Run react-doctor on the PR's codebase to get a React code quality score (0-100) and diagnostics for security, performance, correctness, and architecture issues. Only useful for React/JSX/TSX projects.",
190
+ parameters: { type: "object", properties: {} },
191
+ },
192
+ },
185
193
  {
186
194
  type: "function",
187
195
  function: {
@@ -526,7 +534,8 @@ $$
526
534
  },
527
535
 
528
536
  "GET /api/features": () => {
529
- return json({ cartoon: !!options.cartoon });
537
+ const { getVersion } = require("../../version.ts");
538
+ return json({ cartoon: !!options.cartoon, version: getVersion() });
530
539
  },
531
540
 
532
541
  "POST /api/review": async (req: Request) => {
@@ -564,6 +573,10 @@ $$
564
573
  return json(options.preflight ?? null);
565
574
  },
566
575
 
576
+ "GET /api/active-analyses": () => {
577
+ return json(listActiveSessions());
578
+ },
579
+
567
580
  "GET /api/sessions/:id/comments": async (req: Request) => {
568
581
  const url = new URL(req.url);
569
582
  const segments = url.pathname.split("/");
@@ -875,6 +888,32 @@ $$
875
888
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
876
889
  }
877
890
  }
891
+ case "run_react_doctor": {
892
+ const agents = await detectAgents();
893
+ if (agents.length > 0) {
894
+ try {
895
+ const result = await runAgent(
896
+ agents[0]!,
897
+ process.cwd(),
898
+ "Run react-doctor on this project:\n\nnpx -y react-doctor@latest . --verbose\n\nReturn the FULL output including the score and all diagnostics.",
899
+ { timeout: 60_000 },
900
+ );
901
+ if (result.answer.trim()) return result.answer;
902
+ } catch {}
903
+ }
904
+ try {
905
+ const proc = Bun.spawn(["npx", "-y", "react-doctor@latest", ".", "--verbose"], {
906
+ cwd: process.cwd(),
907
+ stdout: "pipe",
908
+ stderr: "pipe",
909
+ });
910
+ const output = await new Response(proc.stdout).text();
911
+ const stderr = await new Response(proc.stderr).text();
912
+ return output.trim() || stderr.trim() || "react-doctor produced no output";
913
+ } catch (err) {
914
+ return `Error running react-doctor: ${err instanceof Error ? err.message : String(err)}`;
915
+ }
916
+ }
878
917
  case "web_search": {
879
918
  const query = args.query as string;
880
919
  if (!query) return "Error: query argument required";
@@ -9,6 +9,7 @@ type SessionStatus = "running" | "done" | "error" | "canceled";
9
9
 
10
10
  interface AnalysisSession {
11
11
  id: string;
12
+ prInput: string;
12
13
  status: SessionStatus;
13
14
  events: ProgressEvent[];
14
15
  result?: NewprOutput;
@@ -16,6 +17,8 @@ interface AnalysisSession {
16
17
  error?: string;
17
18
  startedAt: number;
18
19
  finishedAt?: number;
20
+ prTitle?: string;
21
+ prNumber?: number;
19
22
  abortController: AbortController;
20
23
  subscribers: Set<(event: ProgressEvent | { type: "done" | "error"; data?: string }) => void>;
21
24
  }
@@ -53,6 +56,7 @@ export function startAnalysis(
53
56
 
54
57
  const session: AnalysisSession = {
55
58
  id,
59
+ prInput,
56
60
  status: "running",
57
61
  events: [],
58
62
  startedAt: Date.now(),
@@ -84,6 +88,8 @@ async function runPipeline(
84
88
  onProgress: (event: ProgressEvent) => {
85
89
  const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
86
90
  session.events.push(stamped);
91
+ if (event.pr_title) session.prTitle = event.pr_title;
92
+ if (event.pr_number) session.prNumber = event.pr_number;
87
93
  for (const sub of session.subscribers) {
88
94
  sub(stamped);
89
95
  }
@@ -129,6 +135,34 @@ export function cancelAnalysis(id: string): boolean {
129
135
  return true;
130
136
  }
131
137
 
138
+ export function listActiveSessions(): Array<{
139
+ id: string;
140
+ prInput: string;
141
+ status: SessionStatus;
142
+ startedAt: number;
143
+ prTitle?: string;
144
+ prNumber?: number;
145
+ lastStage?: string;
146
+ lastMessage?: string;
147
+ }> {
148
+ const result: ReturnType<typeof listActiveSessions> = [];
149
+ for (const s of sessions.values()) {
150
+ if (s.status !== "running") continue;
151
+ const lastEvent = s.events[s.events.length - 1];
152
+ result.push({
153
+ id: s.id,
154
+ prInput: s.prInput,
155
+ status: s.status,
156
+ startedAt: s.startedAt,
157
+ prTitle: s.prTitle,
158
+ prNumber: s.prNumber,
159
+ lastStage: lastEvent?.stage,
160
+ lastMessage: lastEvent?.message,
161
+ });
162
+ }
163
+ return result;
164
+ }
165
+
132
166
  export function subscribe(
133
167
  id: string,
134
168
  callback: (event: ProgressEvent | { type: "done" | "error"; data?: string }) => void,
package/src/web/server.ts CHANGED
@@ -4,6 +4,7 @@ import { createRoutes } from "./server/routes.ts";
4
4
  import index from "./index.html";
5
5
 
6
6
  import type { PreflightResult } from "../cli/preflight.ts";
7
+ import { getVersion } from "../version.ts";
7
8
 
8
9
  interface WebServerOptions {
9
10
  port: number;
@@ -125,6 +126,9 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
125
126
  if (path === "/api/preflight" && req.method === "GET") {
126
127
  return routes["GET /api/preflight"]();
127
128
  }
129
+ if (path === "/api/active-analyses" && req.method === "GET") {
130
+ return routes["GET /api/active-analyses"]();
131
+ }
128
132
  if (path === "/api/cartoon" && req.method === "POST") {
129
133
  return routes["POST /api/cartoon"](req);
130
134
  }
@@ -148,7 +152,7 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
148
152
  const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
149
153
 
150
154
  console.log("");
151
- console.log(` ${bold("newpr")} ${dim("v0.3.0")}`);
155
+ console.log(` ${bold("newpr")} ${dim(`v${getVersion()}`)}`);
152
156
  console.log("");
153
157
  console.log(` ${dim("→")} Local ${cyan(url)}`);
154
158
  console.log(` ${dim("→")} Model ${dim(config.model)}`);