newpr 0.6.2 → 0.6.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.6.2",
3
+ "version": "0.6.5",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -62,16 +62,18 @@ async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
62
62
  }
63
63
 
64
64
  export async function runPreflight(): Promise<PreflightResult> {
65
- const [github, claude, opencode, codex] = await Promise.all([
65
+ const [github, claude, cursor, gemini, opencode, codex] = await Promise.all([
66
66
  checkGithubCli(),
67
67
  checkAgent("claude"),
68
+ checkAgent("cursor"),
69
+ checkAgent("gemini"),
68
70
  checkAgent("opencode"),
69
71
  checkAgent("codex"),
70
72
  ]);
71
73
 
72
74
  return {
73
75
  github,
74
- agents: [claude, opencode, codex],
76
+ agents: [claude, cursor, gemini, opencode, codex],
75
77
  openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
76
78
  };
77
79
  }
@@ -115,11 +117,14 @@ export function printPreflight(result: PreflightResult): void {
115
117
  }
116
118
  }
117
119
 
120
+ const hasAgent = result.agents.some((a) => a.installed);
118
121
  if (result.openrouterKey) {
119
122
  console.log(` ${check} OpenRouter API key`);
123
+ } else if (hasAgent) {
124
+ console.log(` ${dim("·")} OpenRouter API key ${dim("· not configured (using agent as LLM fallback)")}`);
120
125
  } else {
121
126
  console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
122
- console.log(` ${dim("run: newpr auth")}`);
127
+ console.log(` ${dim("run: newpr auth —or— install an agent (claude, gemini, etc.)")}`);
123
128
  }
124
129
 
125
130
  console.log("");
package/src/llm/client.ts CHANGED
@@ -239,6 +239,10 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
239
239
  return create(options.timeout);
240
240
  }
241
241
 
242
+ export function hasApiKey(options: LlmClientOptions): boolean {
243
+ return !!options.api_key;
244
+ }
245
+
242
246
  export interface ChatTool {
243
247
  type: "function";
244
248
  function: {
Binary file
@@ -14,6 +14,8 @@ 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
16
  import { requestNotificationPermission } from "./lib/notify.ts";
17
+ import { analytics, initAnalytics, getConsent } from "./lib/analytics.ts";
18
+ import { AnalyticsConsent } from "./components/AnalyticsConsent.tsx";
17
19
 
18
20
  function getUrlParam(key: string): string | null {
19
21
  return new URLSearchParams(window.location.search).get(key);
@@ -39,8 +41,12 @@ export function App() {
39
41
  const features = useFeatures();
40
42
  const bgAnalyses = useBackgroundAnalyses();
41
43
  const initialLoadDone = useRef(false);
44
+ const [showConsent, setShowConsent] = useState(() => getConsent() === "pending");
42
45
 
43
- useEffect(() => { requestNotificationPermission(); }, []);
46
+ useEffect(() => {
47
+ requestNotificationPermission();
48
+ initAnalytics();
49
+ }, []);
44
50
  const [activeId, setActiveId] = useState<string | null>(null);
45
51
 
46
52
  useEffect(() => {
@@ -73,6 +79,7 @@ export function App() {
73
79
 
74
80
  const scrollGuardRef = useRef<number | null>(null);
75
81
  const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
82
+ analytics.detailOpened(kind);
76
83
  const key = `${kind}:${id}`;
77
84
  const main = document.querySelector("main");
78
85
  const savedScroll = main?.scrollTop ?? 0;
@@ -133,6 +140,10 @@ export function App() {
133
140
  ) : null;
134
141
 
135
142
  const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
143
+ const handleTabChange = useCallback((tab: string) => {
144
+ analytics.tabChanged(tab);
145
+ setActiveTab(tab);
146
+ }, []);
136
147
  const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
137
148
 
138
149
  const anchorItems = useMemo<AnchorItem[]>(() => {
@@ -149,6 +160,7 @@ export function App() {
149
160
 
150
161
  return (
151
162
  <ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
163
+ {showConsent && <AnalyticsConsent onDone={() => setShowConsent(false)} />}
152
164
  <AppShell
153
165
  theme={themeCtx.theme}
154
166
  onThemeChange={themeCtx.setTheme}
@@ -187,7 +199,7 @@ export function App() {
187
199
  onAnchorClick={handleAnchorClick}
188
200
  cartoonEnabled={features.cartoon}
189
201
  sessionId={diffSessionId}
190
- onTabChange={setActiveTab}
202
+ onTabChange={handleTabChange}
191
203
  onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
192
204
  enabledPlugins={features.enabledPlugins}
193
205
  />
@@ -0,0 +1,88 @@
1
+ import { useState } from "react";
2
+ import { BarChart3, Shield } from "lucide-react";
3
+ import { getConsent, setConsent, type ConsentState } from "../lib/analytics.ts";
4
+
5
+ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
6
+ const [state] = useState<ConsentState>(() => getConsent());
7
+
8
+ if (state !== "pending") return null;
9
+
10
+ const handleAccept = () => {
11
+ setConsent("granted");
12
+ onDone();
13
+ };
14
+
15
+ const handleDecline = () => {
16
+ setConsent("denied");
17
+ onDone();
18
+ };
19
+
20
+ return (
21
+ <div className="fixed inset-0 z-[100] flex items-center justify-center">
22
+ <div className="fixed inset-0 bg-background/70 backdrop-blur-sm" />
23
+ <div className="relative z-10 w-full max-w-md mx-4 rounded-2xl border bg-background shadow-2xl overflow-hidden">
24
+ <div className="px-6 pt-6 pb-4">
25
+ <div className="flex items-center gap-3 mb-4">
26
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
27
+ <BarChart3 className="h-5 w-5 text-blue-500" />
28
+ </div>
29
+ <div>
30
+ <h2 className="text-sm font-semibold">Help improve newpr</h2>
31
+ <p className="text-[11px] text-muted-foreground">Anonymous usage analytics</p>
32
+ </div>
33
+ </div>
34
+
35
+ <p className="text-xs text-muted-foreground leading-relaxed mb-3">
36
+ We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
37
+ </p>
38
+
39
+ <div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
40
+ <div className="flex items-start gap-2">
41
+ <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
42
+ <div className="text-[11px] text-muted-foreground leading-relaxed">
43
+ <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
44
+ <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
45
+ <li>Feature usage (which tabs, buttons, and actions you use)</li>
46
+ <li>Performance metrics (analysis duration, error rates)</li>
47
+ <li>Basic device info (browser, screen size)</li>
48
+ </ul>
49
+ </div>
50
+ </div>
51
+ <div className="flex items-start gap-2 pt-1">
52
+ <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
53
+ <div className="text-[11px] text-muted-foreground leading-relaxed">
54
+ <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
55
+ <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
56
+ <li>PR content, code, or commit messages</li>
57
+ <li>Chat messages or review comments</li>
58
+ <li>API keys, tokens, or personal data</li>
59
+ </ul>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <p className="text-[10px] text-muted-foreground/50 mb-4">
65
+ Powered by Google Analytics. You can change this anytime in Settings.
66
+ </p>
67
+ </div>
68
+
69
+ <div className="flex border-t">
70
+ <button
71
+ type="button"
72
+ onClick={handleDecline}
73
+ className="flex-1 px-4 py-3 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
74
+ >
75
+ Decline
76
+ </button>
77
+ <button
78
+ type="button"
79
+ onClick={handleAccept}
80
+ className="flex-1 px-4 py-3 text-xs font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
81
+ >
82
+ Accept
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
@@ -7,6 +7,7 @@ import type { SessionRecord } from "../../../history/types.ts";
7
7
  import type { GithubUser } from "../hooks/useGithubUser.ts";
8
8
  import { SettingsPanel } from "./SettingsPanel.tsx";
9
9
  import { ResizeHandle } from "./ResizeHandle.tsx";
10
+ import { analytics } from "../lib/analytics.ts";
10
11
 
11
12
  type Theme = "light" | "dark" | "system";
12
13
 
@@ -276,7 +277,7 @@ export function AppShell({
276
277
  <button
277
278
  type="button"
278
279
  disabled={update.updating}
279
- onClick={update.doUpdate}
280
+ onClick={() => { analytics.updateClicked(); update.doUpdate(); }}
280
281
  className="w-full flex items-center justify-center gap-1.5 rounded-md bg-blue-500 hover:bg-blue-600 text-white text-[11px] font-medium py-1.5 transition-colors disabled:opacity-50"
281
282
  >
282
283
  {update.updating ? (
@@ -375,7 +376,7 @@ export function AppShell({
375
376
  <div className="flex-1" />
376
377
  <button
377
378
  type="button"
378
- onClick={() => setSettingsOpen(true)}
379
+ onClick={() => { analytics.settingsOpened(); setSettingsOpen(true); }}
379
380
  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"
380
381
  title="Settings"
381
382
  >
@@ -430,3 +431,5 @@ export function AppShell({
430
431
  </div>
431
432
  );
432
433
  }
434
+
435
+
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect } from "react";
2
- import { CornerDownLeft, Clock, GitPullRequest, Check, X, Minus } from "lucide-react";
2
+ import { CornerDownLeft, Clock, GitPullRequest, Check, X, Minus, ExternalLink } from "lucide-react";
3
3
  import type { SessionRecord } from "../../../history/types.ts";
4
+ import { analytics } from "../lib/analytics.ts";
4
5
 
5
6
  interface ToolStatus {
6
7
  name: string;
@@ -111,85 +112,124 @@ export function InputScreen({
111
112
  return (
112
113
  <div className="flex flex-col items-center justify-center min-h-[60vh]">
113
114
  <div className="w-full max-w-lg space-y-8">
114
- <div className="space-y-2">
115
- <div className="flex items-baseline gap-2">
116
- <h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
117
- {version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
118
- <span className="text-[10px] text-muted-foreground/40">AI code review</span>
119
- </div>
120
- <p className="text-xs text-muted-foreground">
121
- Paste a GitHub PR URL to start analysis
122
- </p>
123
- </div>
115
+ <SponsorBanner />
124
116
 
125
- <form onSubmit={handleSubmit}>
126
- <div className={`flex items-center rounded-xl border bg-background transition-all ${
127
- focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
128
- }`}>
129
- <GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
130
- <input
131
- type="text"
132
- value={value}
133
- onChange={(e) => setValue(e.target.value)}
134
- onFocus={() => setFocused(true)}
135
- onBlur={() => setFocused(false)}
136
- placeholder="https://github.com/owner/repo/pull/123"
137
- className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
138
- autoFocus
139
- />
140
- <button
141
- type="submit"
142
- disabled={!value.trim()}
143
- className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-20 hover:opacity-80"
144
- >
145
- <CornerDownLeft className="h-3.5 w-3.5" />
146
- </button>
147
- </div>
148
- <div className="flex justify-end mt-2 pr-1">
149
- <span className="text-[10px] text-muted-foreground/30">
150
- Enter to analyze
151
- </span>
117
+ <div className="space-y-2">
118
+ <div className="flex items-baseline gap-2">
119
+ <h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
120
+ {version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
121
+ <span className="text-[10px] text-muted-foreground/40">AI code review</span>
122
+ </div>
123
+ <p className="text-xs text-muted-foreground">
124
+ Paste a GitHub PR URL to start analysis
125
+ </p>
152
126
  </div>
153
- </form>
154
127
 
155
- {recents.length > 0 && (
156
- <div className="space-y-2 pt-2">
157
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
158
- Recent
128
+ <form onSubmit={handleSubmit}>
129
+ <div className={`flex items-center rounded-xl border bg-background transition-all ${
130
+ focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
131
+ }`}>
132
+ <GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
133
+ <input
134
+ type="text"
135
+ value={value}
136
+ onChange={(e) => setValue(e.target.value)}
137
+ onFocus={() => setFocused(true)}
138
+ onBlur={() => setFocused(false)}
139
+ placeholder="https://github.com/owner/repo/pull/123"
140
+ className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
141
+ autoFocus
142
+ />
143
+ <button
144
+ type="submit"
145
+ disabled={!value.trim()}
146
+ className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-20 hover:opacity-80"
147
+ >
148
+ <CornerDownLeft className="h-3.5 w-3.5" />
149
+ </button>
150
+ </div>
151
+ <div className="flex justify-end mt-2 pr-1">
152
+ <span className="text-[10px] text-muted-foreground/30">
153
+ Enter to analyze
154
+ </span>
159
155
  </div>
160
- <div className="space-y-px">
161
- {recents.map((s) => (
162
- <button
163
- key={s.id}
164
- type="button"
165
- onClick={() => onSessionSelect?.(s.id)}
166
- className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
167
- >
168
- <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
169
- <div className="flex-1 min-w-0">
170
- <div className="text-xs truncate group-hover:text-foreground transition-colors">
171
- {s.pr_title}
156
+ </form>
157
+
158
+ {recents.length > 0 && (
159
+ <div className="space-y-2">
160
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
161
+ Recent
162
+ </div>
163
+ <div className="space-y-px">
164
+ {recents.map((s) => (
165
+ <button
166
+ key={s.id}
167
+ type="button"
168
+ onClick={() => onSessionSelect?.(s.id)}
169
+ className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
170
+ >
171
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
172
+ <div className="flex-1 min-w-0">
173
+ <div className="text-xs truncate group-hover:text-foreground transition-colors">
174
+ {s.pr_title}
175
+ </div>
176
+ <div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
177
+ <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
178
+ <span className="font-mono">#{s.pr_number}</span>
179
+ <span className="text-muted-foreground/20">·</span>
180
+ <span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
181
+ <span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
182
+ </div>
172
183
  </div>
173
- <div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
174
- <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
175
- <span className="font-mono">#{s.pr_number}</span>
176
- <span className="text-muted-foreground/20">·</span>
177
- <span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
178
- <span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
184
+ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
185
+ <Clock className="h-2.5 w-2.5" />
186
+ <span>{timeAgo(s.analyzed_at)}</span>
179
187
  </div>
180
- </div>
181
- <div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
182
- <Clock className="h-2.5 w-2.5" />
183
- <span>{timeAgo(s.analyzed_at)}</span>
184
- </div>
185
- </button>
186
- ))}
188
+ </button>
189
+ ))}
190
+ </div>
187
191
  </div>
188
- </div>
189
- )}
192
+ )}
190
193
 
191
- {preflight && <PreflightStatus data={preflight} />}
194
+ {preflight && <PreflightStatus data={preflight} />}
192
195
  </div>
193
196
  </div>
194
197
  );
195
198
  }
199
+
200
+ const SIONIC_HERO_BG = "https://www.sionic.ai/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fmain-intro-bg.1455295d.png&w=1920&q=75";
201
+
202
+ function SponsorBanner() {
203
+ return (
204
+ <a
205
+ href="https://www.sionic.ai"
206
+ target="_blank"
207
+ rel="noopener noreferrer"
208
+ onClick={() => analytics.sponsorClicked("sionic_ai")}
209
+ className="group relative flex items-center gap-3.5 rounded-xl overflow-hidden px-4 py-3 transition-all hover:shadow-md hover:shadow-blue-500/10"
210
+ style={{ background: "linear-gradient(135deg, #071121 0%, #0d1b33 50%, #1a2d54 100%)" }}
211
+ >
212
+ <img
213
+ src={SIONIC_HERO_BG}
214
+ alt=""
215
+ className="absolute inset-0 w-full h-full object-cover object-bottom opacity-30 group-hover:opacity-45 transition-opacity pointer-events-none"
216
+ />
217
+ <div className="absolute inset-0 bg-gradient-to-r from-[#071121]/70 via-transparent to-transparent pointer-events-none" />
218
+ <div className="relative flex items-center gap-3 flex-1 min-w-0">
219
+ <img
220
+ src="/assets/sionic-logo.png"
221
+ alt="Sionic AI"
222
+ className="h-4 w-auto shrink-0 drop-shadow-sm"
223
+ />
224
+ <div className="h-3 w-px bg-white/15 shrink-0" />
225
+ <span className="text-[10px] text-white/45 truncate">
226
+ The Power of AI for Every Business
227
+ </span>
228
+ </div>
229
+ <div className="relative flex items-center gap-1.5 shrink-0">
230
+ <span className="text-[8px] text-white/20 uppercase tracking-widest">Ad</span>
231
+ <ExternalLink className="h-2.5 w-2.5 text-white/15 group-hover:text-white/40 transition-colors" />
232
+ </div>
233
+ </a>
234
+ );
235
+ }
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from "react";
2
2
  import { X, Check, MessageSquare, Loader2, AlertCircle, ExternalLink } from "lucide-react";
3
3
  import { TipTapEditor, getTextWithAnchors } from "./TipTapEditor.tsx";
4
4
  import type { useEditor } from "@tiptap/react";
5
+ import { analytics } from "../lib/analytics.ts";
5
6
 
6
7
  type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
7
8
 
@@ -53,6 +54,7 @@ export function ReviewModal({ prUrl, onClose }: ReviewModalProps) {
53
54
  });
54
55
  const data = await res.json() as { ok?: boolean; html_url?: string; error?: string };
55
56
  if (data.ok) {
57
+ analytics.reviewSubmitted(event);
56
58
  setResult({ ok: true, html_url: data.html_url });
57
59
  } else {
58
60
  setResult({ ok: false, error: data.error ?? "Failed to submit review" });
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
3
+ import { analytics, getConsent, setConsent } from "../lib/analytics.ts";
3
4
 
4
5
  interface ConfigData {
5
6
  model: string;
@@ -69,6 +70,8 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
69
70
  const save = useCallback(async (update: Record<string, unknown>) => {
70
71
  setSaving(true);
71
72
  setSaved(false);
73
+ const field = Object.keys(update).filter((k) => k !== "openrouter_api_key").join(",");
74
+ if (field) analytics.settingsChanged(field);
72
75
  try {
73
76
  await fetch("/api/config", {
74
77
  method: "PUT",
@@ -265,11 +268,47 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
265
268
  </div>
266
269
  </Section>
267
270
  )}
271
+ <Section title="Privacy">
272
+ <AnalyticsToggle />
273
+ </Section>
268
274
  </div>
269
275
  </div>
270
276
  );
271
277
  }
272
278
 
279
+ function AnalyticsToggle() {
280
+ const [consent, setLocal] = useState(() => getConsent());
281
+ const enabled = consent === "granted";
282
+
283
+ const toggle = () => {
284
+ const next = enabled ? "denied" : "granted";
285
+ setConsent(next);
286
+ setLocal(next);
287
+ analytics.settingsChanged("analytics");
288
+ };
289
+
290
+ return (
291
+ <Row label="Usage Analytics">
292
+ <div className="flex items-center gap-2">
293
+ <span className="text-[11px] text-muted-foreground/50">
294
+ {enabled ? "Enabled" : "Disabled"}
295
+ </span>
296
+ <button
297
+ type="button"
298
+ onClick={toggle}
299
+ className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
300
+ enabled ? "bg-foreground" : "bg-muted"
301
+ }`}
302
+ >
303
+ <span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
304
+ enabled ? "translate-x-3.5" : "translate-x-0.5"
305
+ }`} />
306
+ </button>
307
+ </div>
308
+ </Row>
309
+ );
310
+ }
311
+
273
312
  function ModelSelect({ value, models: allModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
274
313
  const [open, setOpen] = useState(false);
275
314
  const [search, setSearch] = useState("");
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
3
  import type { NewprOutput } from "../../../types/output.ts";
4
+ import { analytics } from "../lib/analytics.ts";
4
5
 
5
6
  type Phase = "idle" | "loading" | "done" | "error";
6
7
 
@@ -29,6 +30,7 @@ export function useAnalysis() {
29
30
  const eventSourceRef = useRef<EventSource | null>(null);
30
31
 
31
32
  const start = useCallback(async (prInput: string) => {
33
+ analytics.analysisStarted(0);
32
34
  setState({
33
35
  phase: "loading",
34
36
  sessionId: null,
@@ -79,12 +81,16 @@ export function useAnalysis() {
79
81
  eventSourceRef.current = null;
80
82
  const resultRes = await fetch(`/api/analysis/${sessionId}`);
81
83
  const data = await resultRes.json() as { result?: NewprOutput; historyId?: string };
82
- setState((s) => ({
83
- ...s,
84
- phase: "done",
85
- result: data.result ?? null,
86
- historyId: data.historyId ?? null,
87
- }));
84
+ setState((s) => {
85
+ const durationSec = s.startedAt ? Math.round((Date.now() - s.startedAt) / 1000) : 0;
86
+ analytics.analysisCompleted(data.result?.files.length ?? 0, durationSec);
87
+ return {
88
+ ...s,
89
+ phase: "done",
90
+ result: data.result ?? null,
91
+ historyId: data.historyId ?? null,
92
+ };
93
+ });
88
94
  });
89
95
 
90
96
  es.addEventListener("analysis_error", (e) => {
@@ -92,6 +98,7 @@ export function useAnalysis() {
92
98
  eventSourceRef.current = null;
93
99
  let msg = "Analysis failed";
94
100
  try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
101
+ analytics.analysisError(msg.slice(0, 100));
95
102
  setState((s) => ({ ...s, phase: "error", error: msg }));
96
103
  });
97
104
 
@@ -125,6 +132,7 @@ export function useAnalysis() {
125
132
  const res = await fetch(`/api/sessions/${sessionId}`);
126
133
  if (!res.ok) throw new Error("Session not found");
127
134
  const data = await res.json() as NewprOutput;
135
+ analytics.sessionLoaded();
128
136
  setState((s) => ({
129
137
  ...s,
130
138
  phase: "done",
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useCallback, useSyncExternalStore } from "react";
2
2
  import { sendNotification } from "../lib/notify.ts";
3
+ import { analytics } from "../lib/analytics.ts";
3
4
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
4
5
 
5
6
  interface ChatSessionState {
@@ -73,6 +74,7 @@ class ChatStore {
73
74
  if (s.loading) return;
74
75
 
75
76
  const startTime = Date.now();
77
+ analytics.chatSent();
76
78
  const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
77
79
  this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
78
80
 
@@ -152,6 +154,8 @@ class ChatStore {
152
154
  }
153
155
 
154
156
  const cur = this.getOrCreate(sessionId);
157
+ const durationMs = Date.now() - startTime;
158
+ analytics.chatCompleted(Math.round(durationMs / 1000), allToolCalls.length > 0);
155
159
  this.update(sessionId, {
156
160
  messages: [...cur.messages, {
157
161
  role: "assistant",
@@ -159,7 +163,7 @@ class ChatStore {
159
163
  toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
160
164
  segments: orderedSegments.length > 0 ? orderedSegments : undefined,
161
165
  timestamp: new Date().toISOString(),
162
- durationMs: Date.now() - startTime,
166
+ durationMs,
163
167
  }],
164
168
  });
165
169
  sendNotification("Chat response ready", fullText.slice(0, 100));
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
+ import { analytics } from "../lib/analytics.ts";
2
3
 
3
4
  type Theme = "light" | "dark" | "system";
4
5
 
@@ -28,6 +29,7 @@ export function useTheme() {
28
29
  const setTheme = useCallback((t: Theme) => {
29
30
  localStorage.setItem("newpr-theme", t);
30
31
  setThemeState(t);
32
+ analytics.themeChanged(t);
31
33
  }, []);
32
34
 
33
35
  return { theme, setTheme };