newpr 0.3.0 → 0.5.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 (35) hide show
  1. package/README.md +135 -103
  2. package/package.json +2 -2
  3. package/src/analyzer/pipeline.ts +1 -4
  4. package/src/cli/args.ts +1 -1
  5. package/src/cli/index.ts +2 -1
  6. package/src/github/fetch-pr.ts +1 -0
  7. package/src/history/store.ts +25 -1
  8. package/src/llm/prompts.ts +82 -27
  9. package/src/llm/slides.ts +381 -0
  10. package/src/types/config.ts +1 -1
  11. package/src/types/github.ts +1 -0
  12. package/src/types/output.ts +26 -0
  13. package/src/version.ts +23 -0
  14. package/src/web/client/App.tsx +51 -1
  15. package/src/web/client/components/AppShell.tsx +173 -45
  16. package/src/web/client/components/ChatSection.tsx +76 -185
  17. package/src/web/client/components/DetailPane.tsx +1 -0
  18. package/src/web/client/components/DiffViewer.tsx +200 -4
  19. package/src/web/client/components/InputScreen.tsx +3 -0
  20. package/src/web/client/components/Markdown.tsx +66 -16
  21. package/src/web/client/components/ResultsScreen.tsx +32 -2
  22. package/src/web/client/components/SettingsPanel.tsx +1 -1
  23. package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
  24. package/src/web/client/hooks/useChatStore.ts +247 -0
  25. package/src/web/client/hooks/useFeatures.ts +2 -1
  26. package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
  27. package/src/web/client/lib/notify.ts +21 -0
  28. package/src/web/client/panels/SlidesPanel.tsx +316 -0
  29. package/src/web/index.html +1 -0
  30. package/src/web/server/routes.ts +226 -4
  31. package/src/web/server/session-manager.ts +34 -0
  32. package/src/web/server.ts +20 -1
  33. package/src/web/styles/built.css +1 -1
  34. package/src/workspace/explore.ts +39 -6
  35. package/src/workspace/types.ts +1 -0
@@ -1,15 +1,16 @@
1
1
  import type { NewprConfig } from "../../types/config.ts";
2
2
  import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
3
3
  import { DEFAULT_CONFIG } from "../../types/config.ts";
4
- import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
4
+ import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar, saveSlidesSidecar, loadSlidesSidecar } from "../../history/store.ts";
5
5
  import type { DiffComment } from "../../types/output.ts";
6
6
  import { fetchPrDiff } from "../../github/fetch-diff.ts";
7
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
+ import { generateSlides } from "../../llm/slides.ts";
13
14
  import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
14
15
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
15
16
  import { randomBytes } from "node:crypto";
@@ -70,6 +71,16 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
70
71
  return { login: "anonymous" };
71
72
  }
72
73
 
74
+ interface SlideJob {
75
+ status: "running" | "done" | "error";
76
+ message: string;
77
+ current: number;
78
+ total: number;
79
+ plan?: { stylePrompt: string; slides: Array<{ index: number; title: string; contentPrompt: string }> };
80
+ imagePrompts?: Array<{ index: number; prompt: string }>;
81
+ }
82
+ const slideJobs = new Map<string, SlideJob>();
83
+
73
84
  function buildChatSystemPrompt(data: NewprOutput): string {
74
85
  const fileSummaries = data.files
75
86
  .map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
@@ -182,6 +193,14 @@ $$
182
193
  parameters: { type: "object", properties: {} },
183
194
  },
184
195
  },
196
+ {
197
+ type: "function",
198
+ function: {
199
+ name: "run_react_doctor",
200
+ 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.",
201
+ parameters: { type: "object", properties: {} },
202
+ },
203
+ },
185
204
  {
186
205
  type: "function",
187
206
  function: {
@@ -215,7 +234,7 @@ $$
215
234
 
216
235
  return {
217
236
  "POST /api/analysis": async (req: Request) => {
218
- const body = await req.json() as { pr: string };
237
+ const body = await req.json() as { pr: string; reuseSessionId?: string };
219
238
  if (!body.pr) return json({ error: "Missing 'pr' field" }, 400);
220
239
 
221
240
  const result = startAnalysis(body.pr, token, config);
@@ -223,6 +242,7 @@ $$
223
242
 
224
243
  return json({
225
244
  sessionId: result.sessionId,
245
+ reuseSessionId: body.reuseSessionId,
226
246
  eventsUrl: `/api/analysis/${result.sessionId}/events`,
227
247
  });
228
248
  },
@@ -526,7 +546,8 @@ $$
526
546
  },
527
547
 
528
548
  "GET /api/features": () => {
529
- return json({ cartoon: !!options.cartoon });
549
+ const { getVersion } = require("../../version.ts");
550
+ return json({ cartoon: !!options.cartoon, version: getVersion() });
530
551
  },
531
552
 
532
553
  "POST /api/review": async (req: Request) => {
@@ -564,6 +585,10 @@ $$
564
585
  return json(options.preflight ?? null);
565
586
  },
566
587
 
588
+ "GET /api/active-analyses": () => {
589
+ return json(listActiveSessions());
590
+ },
591
+
567
592
  "GET /api/sessions/:id/comments": async (req: Request) => {
568
593
  const url = new URL(req.url);
569
594
  const segments = url.pathname.split("/");
@@ -713,6 +738,107 @@ $$
713
738
  return json({ ok: true });
714
739
  },
715
740
 
741
+ "POST /api/sessions/:id/ask-inline": async (req: Request) => {
742
+ const url = new URL(req.url);
743
+ const segments = url.pathname.split("/");
744
+ const sessionId = segments[3]!;
745
+
746
+ if (!config.openrouter_api_key) {
747
+ return json({ error: "OpenRouter API key required" }, 400);
748
+ }
749
+
750
+ const body = await req.json() as { message: string };
751
+ if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
752
+
753
+ const sessionData = await loadSession(sessionId);
754
+ if (!sessionData) return json({ error: "Session not found" }, 404);
755
+
756
+ const systemPrompt = buildChatSystemPrompt(sessionData);
757
+ const apiMessages = [
758
+ { role: "system" as const, content: systemPrompt },
759
+ { role: "user" as const, content: body.message.trim() },
760
+ ];
761
+
762
+ const encoder = new TextEncoder();
763
+ const stream = new ReadableStream({
764
+ async start(controller) {
765
+ const send = (eventType: string, data: string) => {
766
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
767
+ };
768
+ try {
769
+ await chatWithTools(
770
+ { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
771
+ apiMessages as Parameters<typeof chatWithTools>[1],
772
+ buildChatTools(),
773
+ async (name: string, args: Record<string, unknown>): Promise<string> => {
774
+ if (name === "get_file_diff") {
775
+ const filePath = args.path as string;
776
+ if (!filePath) return "Error: path required";
777
+ const patches = await loadPatchesSidecar(sessionId);
778
+ if (patches?.[filePath]) return patches[filePath];
779
+ const patch = await loadSinglePatch(sessionId, filePath);
780
+ if (patch) return patch;
781
+ return `File "${filePath}" not found`;
782
+ }
783
+ if (name === "list_files") {
784
+ return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
785
+ }
786
+ return `Tool ${name} not available in inline mode`;
787
+ },
788
+ (event: ChatStreamEvent) => {
789
+ if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
790
+ else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
791
+ else if (event.type === "done") send("done", JSON.stringify({}));
792
+ },
793
+ );
794
+ send("done", JSON.stringify({}));
795
+ } catch (err) {
796
+ send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
797
+ } finally {
798
+ controller.close();
799
+ }
800
+ },
801
+ });
802
+
803
+ return new Response(stream, {
804
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
805
+ });
806
+ },
807
+
808
+ "GET /api/sessions/:id/outdated": async (req: Request) => {
809
+ const url = new URL(req.url);
810
+ const segments = url.pathname.split("/");
811
+ const id = segments[3]!;
812
+ const sessionData = await loadSession(id);
813
+ if (!sessionData) return json({ error: "Session not found" }, 404);
814
+
815
+ const prUrl = sessionData.meta.pr_url;
816
+ const analyzedUpdatedAt = sessionData.meta.pr_updated_at;
817
+ if (!analyzedUpdatedAt) return json({ outdated: false, reason: "no_baseline" });
818
+
819
+ try {
820
+ const pr = parsePrInput(prUrl);
821
+ const res = await fetch(
822
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
823
+ { headers: ghHeaders },
824
+ );
825
+ if (!res.ok) return json({ outdated: false, reason: "api_error" });
826
+ const data = await res.json() as { updated_at?: string; title?: string; state?: string; merged?: boolean; draft?: boolean };
827
+ const currentUpdatedAt = data.updated_at ?? "";
828
+ const outdated = currentUpdatedAt !== analyzedUpdatedAt;
829
+ return json({
830
+ outdated,
831
+ analyzed_at: sessionData.meta.analyzed_at,
832
+ analyzed_updated_at: analyzedUpdatedAt,
833
+ current_updated_at: currentUpdatedAt,
834
+ current_title: data.title,
835
+ current_state: data.draft ? "draft" : data.merged ? "merged" : data.state === "closed" ? "closed" : "open",
836
+ });
837
+ } catch {
838
+ return json({ outdated: false, reason: "fetch_error" });
839
+ }
840
+ },
841
+
716
842
  "GET /api/sessions/:id/chat": async (req: Request) => {
717
843
  const url = new URL(req.url);
718
844
  const segments = url.pathname.split("/");
@@ -875,6 +1001,32 @@ $$
875
1001
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
876
1002
  }
877
1003
  }
1004
+ case "run_react_doctor": {
1005
+ const agents = await detectAgents();
1006
+ if (agents.length > 0) {
1007
+ try {
1008
+ const result = await runAgent(
1009
+ agents[0]!,
1010
+ process.cwd(),
1011
+ "Run react-doctor on this project:\n\nnpx -y react-doctor@latest . --verbose\n\nReturn the FULL output including the score and all diagnostics.",
1012
+ { timeout: 60_000 },
1013
+ );
1014
+ if (result.answer.trim()) return result.answer;
1015
+ } catch {}
1016
+ }
1017
+ try {
1018
+ const proc = Bun.spawn(["npx", "-y", "react-doctor@latest", ".", "--verbose"], {
1019
+ cwd: process.cwd(),
1020
+ stdout: "pipe",
1021
+ stderr: "pipe",
1022
+ });
1023
+ const output = await new Response(proc.stdout).text();
1024
+ const stderr = await new Response(proc.stderr).text();
1025
+ return output.trim() || stderr.trim() || "react-doctor produced no output";
1026
+ } catch (err) {
1027
+ return `Error running react-doctor: ${err instanceof Error ? err.message : String(err)}`;
1028
+ }
1029
+ }
878
1030
  case "web_search": {
879
1031
  const query = args.query as string;
880
1032
  if (!query) return "Error: query argument required";
@@ -1088,5 +1240,75 @@ $$
1088
1240
  return json({ error: msg }, 500);
1089
1241
  }
1090
1242
  },
1243
+ "GET /api/sessions/:id/slides": async (req: Request) => {
1244
+ const url = new URL(req.url);
1245
+ const segments = url.pathname.split("/");
1246
+ const id = segments[3]!;
1247
+ const deck = await loadSlidesSidecar(id);
1248
+ if (!deck) return json(null);
1249
+ return json(deck);
1250
+ },
1251
+
1252
+ "POST /api/slides": async (req: Request) => {
1253
+ if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required" }, 400);
1254
+
1255
+ const body = await req.json() as { sessionId?: string; language?: string; resume?: boolean };
1256
+ const sessionId = body.sessionId;
1257
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1258
+
1259
+ const data = await loadSession(sessionId);
1260
+ if (!data) return json({ error: "Session not found" }, 404);
1261
+
1262
+ if (slideJobs.has(sessionId) && slideJobs.get(sessionId)!.status === "running") {
1263
+ return json({ status: "already_running" });
1264
+ }
1265
+
1266
+ const existingDeck = body.resume ? await loadSlidesSidecar(sessionId) : null;
1267
+ const job: SlideJob = { status: "running", message: "Planning slide deck...", current: 0, total: 0 };
1268
+ slideJobs.set(sessionId, job);
1269
+
1270
+ (async () => {
1271
+ try {
1272
+ const deck = await generateSlides(
1273
+ config.openrouter_api_key,
1274
+ data,
1275
+ config.model,
1276
+ body.language ?? config.language,
1277
+ (msg, current, total) => {
1278
+ job.message = msg;
1279
+ job.current = current;
1280
+ job.total = total;
1281
+ },
1282
+ existingDeck,
1283
+ (plan, prompts) => {
1284
+ job.plan = plan;
1285
+ job.imagePrompts = prompts;
1286
+ },
1287
+ (partialDeck) => {
1288
+ saveSlidesSidecar(sessionId, partialDeck).catch(() => {});
1289
+ },
1290
+ );
1291
+ await saveSlidesSidecar(sessionId, deck);
1292
+ job.status = "done";
1293
+ job.message = `Generated ${deck.slides.length} slides`;
1294
+ job.total = deck.slides.length;
1295
+ job.current = deck.slides.length;
1296
+ } catch (err) {
1297
+ job.status = "error";
1298
+ job.message = err instanceof Error ? err.message : String(err);
1299
+ }
1300
+ })();
1301
+
1302
+ return json({ status: "started" });
1303
+ },
1304
+
1305
+ "GET /api/slides/status": async (req: Request) => {
1306
+ const url = new URL(req.url);
1307
+ const sessionId = url.searchParams.get("sessionId");
1308
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1309
+ const job = slideJobs.get(sessionId);
1310
+ if (!job) return json({ status: "idle" });
1311
+ return json(job);
1312
+ },
1091
1313
  };
1092
1314
  }
@@ -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;
@@ -101,6 +102,12 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
101
102
  if (path.match(/^\/api\/sessions\/[^/]+\/comments$/) && req.method === "POST") {
102
103
  return routes["POST /api/sessions/:id/comments"](req);
103
104
  }
105
+ if (path.match(/^\/api\/sessions\/[^/]+\/ask-inline$/) && req.method === "POST") {
106
+ return routes["POST /api/sessions/:id/ask-inline"](req);
107
+ }
108
+ if (path.match(/^\/api\/sessions\/[^/]+\/outdated$/) && req.method === "GET") {
109
+ return routes["GET /api/sessions/:id/outdated"](req);
110
+ }
104
111
  if (path.match(/^\/api\/sessions\/[^/]+\/chat\/undo$/) && req.method === "POST") {
105
112
  return routes["POST /api/sessions/:id/chat/undo"](req);
106
113
  }
@@ -125,9 +132,21 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
125
132
  if (path === "/api/preflight" && req.method === "GET") {
126
133
  return routes["GET /api/preflight"]();
127
134
  }
135
+ if (path === "/api/active-analyses" && req.method === "GET") {
136
+ return routes["GET /api/active-analyses"]();
137
+ }
128
138
  if (path === "/api/cartoon" && req.method === "POST") {
129
139
  return routes["POST /api/cartoon"](req);
130
140
  }
141
+ if (path.match(/^\/api\/sessions\/[^/]+\/slides$/) && req.method === "GET") {
142
+ return routes["GET /api/sessions/:id/slides"](req);
143
+ }
144
+ if (path === "/api/slides" && req.method === "POST") {
145
+ return routes["POST /api/slides"](req);
146
+ }
147
+ if (path === "/api/slides/status" && req.method === "GET") {
148
+ return routes["GET /api/slides/status"](req);
149
+ }
131
150
  if (path === "/api/review" && req.method === "POST") {
132
151
  return routes["POST /api/review"](req);
133
152
  }
@@ -148,7 +167,7 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
148
167
  const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
149
168
 
150
169
  console.log("");
151
- console.log(` ${bold("newpr")} ${dim("v0.3.0")}`);
170
+ console.log(` ${bold("newpr")} ${dim(`v${getVersion()}`)}`);
152
171
  console.log("");
153
172
  console.log(` ${dim("→")} Local ${cyan(url)}`);
154
173
  console.log(` ${dim("→")} Model ${dim(config.model)}`);