newpr 0.6.1 β†’ 0.6.4

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.1",
3
+ "version": "0.6.4",
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",
@@ -40,7 +40,7 @@
40
40
  "type": "git",
41
41
  "url": "git+https://github.com/jiwonMe/newpr.git"
42
42
  },
43
- "homepage": "https://github.com/jiwonMe/newpr",
43
+ "homepage": "https://jiwonme.github.io/newpr/",
44
44
  "bugs": {
45
45
  "url": "https://github.com/jiwonMe/newpr/issues"
46
46
  },
@@ -67,7 +67,7 @@ import {
67
67
  } from "../llm/response-parser.ts";
68
68
  import { ensureRepo } from "../workspace/repo-cache.ts";
69
69
  import { createWorktrees, cleanupWorktrees } from "../workspace/worktree.ts";
70
- import { requireAgent } from "../workspace/agent.ts";
70
+ import { getAvailableAgents } from "../workspace/agent.ts";
71
71
  import { exploreCodebase } from "../workspace/explore.ts";
72
72
  import type { ProgressCallback, ProgressStage } from "./progress.ts";
73
73
  import { createSilentProgress } from "./progress.ts";
@@ -123,7 +123,7 @@ async function runExploration(
123
123
  preferredAgent?: AgentToolName,
124
124
  onProgress?: ProgressCallback,
125
125
  ): Promise<ExplorationResult> {
126
- const agent = await requireAgent(preferredAgent);
126
+ const agents = await getAvailableAgents(preferredAgent);
127
127
 
128
128
  const bareRepoPath = await ensureRepo(pr.owner, pr.repo, token, (msg) => {
129
129
  onProgress?.({ stage: "cloning", message: `πŸ“¦ ${msg}` });
@@ -134,12 +134,13 @@ async function runExploration(
134
134
  (msg) => onProgress?.({ stage: "checkout", message: `🌿 ${msg}` }),
135
135
  );
136
136
 
137
- onProgress?.({ stage: "exploring", message: `πŸ€– ${agent.name}: exploring ${changedFiles.length} changed files...` });
137
+ const agentNames = agents.map((a) => a.name).join(" β†’ ");
138
+ onProgress?.({ stage: "exploring", message: `πŸ€– exploring ${changedFiles.length} files (agents: ${agentNames})` });
138
139
  const exploration = await exploreCodebase(
139
- agent, worktrees.headPath, changedFiles, prTitle, rawDiff,
140
+ agents, worktrees.headPath, changedFiles, prTitle, rawDiff,
140
141
  (msg, current, total) => onProgress?.({ stage: "exploring", message: msg, current, total }),
141
142
  );
142
- onProgress?.({ stage: "exploring", message: `πŸ€– ${agent.name}: exploration complete` });
143
+ onProgress?.({ stage: "exploring", message: "πŸ€– exploration complete" });
143
144
 
144
145
  await cleanupWorktrees(bareRepoPath, pr.number, pr.owner, pr.repo).catch(() => {});
145
146
 
@@ -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: {
@@ -105,6 +105,7 @@ export interface ChatMessage {
105
105
  segments?: ChatSegment[];
106
106
  toolCallId?: string;
107
107
  timestamp: string;
108
+ durationMs?: number;
108
109
  isCompactSummary?: boolean;
109
110
  compactedCount?: number;
110
111
  }
@@ -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
  >
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
2
- import { Loader2, ChevronRight, ChevronDown, CornerDownLeft, FoldVertical } from "lucide-react";
2
+ import { Loader2, ChevronRight, ChevronDown, CornerDownLeft, FoldVertical, Check } from "lucide-react";
3
3
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
4
4
  import { Markdown } from "./Markdown.tsx";
5
5
  import { TipTapEditor, getTextWithAnchors, type AnchorItem, type CommandItem } from "./TipTapEditor.tsx";
@@ -29,6 +29,26 @@ export function ChatProvider({ state, anchorItems, analyzedAt, children }: { sta
29
29
  return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
30
30
  }
31
31
 
32
+ function formatDuration(ms: number): string {
33
+ if (ms < 1000) return `${ms}ms`;
34
+ const s = ms / 1000;
35
+ if (s < 60) return `${s.toFixed(1)}s`;
36
+ const m = Math.floor(s / 60);
37
+ const rem = Math.round(s % 60);
38
+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
39
+ }
40
+
41
+ function CompletionFooter({ durationMs }: { durationMs: number }) {
42
+ return (
43
+ <div className="flex items-center gap-1.5 mt-1.5 animate-in fade-in duration-300">
44
+ <Check className="h-3 w-3 text-emerald-500/70" />
45
+ <span className="text-[10px] text-muted-foreground/40">
46
+ Done Β· {formatDuration(durationMs)}
47
+ </span>
48
+ </div>
49
+ );
50
+ }
51
+
32
52
  function ToolCallDisplay({ tc }: { tc: ChatToolCall }) {
33
53
  const [open, setOpen] = useState(false);
34
54
  const truncated = tc.result && tc.result.length > 200;
@@ -261,18 +281,22 @@ export function ChatMessages({ onAnchorClick, activeId }: {
261
281
  </div>
262
282
  );
263
283
  }
264
- return (
265
- <div key={`assistant-${i}`}>
266
- {divider}
267
- <div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
268
- <AssistantMessage
269
- segments={segmentsFromMessage(msg)}
270
- onAnchorClick={onAnchorClick}
271
- activeId={activeId}
272
- />
273
- </div>
284
+ const isLastAssistant = !loading && !streaming && i === messages.findLastIndex((m) => m.role === "assistant");
285
+ return (
286
+ <div key={`assistant-${i}`}>
287
+ {divider}
288
+ <div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
289
+ <AssistantMessage
290
+ segments={segmentsFromMessage(msg)}
291
+ onAnchorClick={onAnchorClick}
292
+ activeId={activeId}
293
+ />
294
+ {isLastAssistant && msg.durationMs != null && (
295
+ <CompletionFooter durationMs={msg.durationMs} />
296
+ )}
274
297
  </div>
275
- );
298
+ </div>
299
+ );
276
300
  })}
277
301
 
278
302
  {streaming && (
@@ -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 {
@@ -72,6 +73,8 @@ class ChatStore {
72
73
  const s = this.getOrCreate(sessionId);
73
74
  if (s.loading) return;
74
75
 
76
+ const startTime = Date.now();
77
+ analytics.chatSent();
75
78
  const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
76
79
  this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
77
80
 
@@ -151,6 +154,8 @@ class ChatStore {
151
154
  }
152
155
 
153
156
  const cur = this.getOrCreate(sessionId);
157
+ const durationMs = Date.now() - startTime;
158
+ analytics.chatCompleted(Math.round(durationMs / 1000), allToolCalls.length > 0);
154
159
  this.update(sessionId, {
155
160
  messages: [...cur.messages, {
156
161
  role: "assistant",
@@ -158,6 +163,7 @@ class ChatStore {
158
163
  toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
159
164
  segments: orderedSegments.length > 0 ? orderedSegments : undefined,
160
165
  timestamp: new Date().toISOString(),
166
+ durationMs,
161
167
  }],
162
168
  });
163
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 };
@@ -0,0 +1,111 @@
1
+ declare global {
2
+ interface Window {
3
+ gtag?: (...args: unknown[]) => void;
4
+ dataLayer?: unknown[];
5
+ }
6
+ }
7
+
8
+ const GA_ID = "G-L3SL6T6JQ1";
9
+ const CONSENT_KEY = "newpr-analytics-consent";
10
+
11
+ export type ConsentState = "granted" | "denied" | "pending";
12
+
13
+ export function getConsent(): ConsentState {
14
+ const stored = localStorage.getItem(CONSENT_KEY);
15
+ if (stored === "granted" || stored === "denied") return stored;
16
+ return "pending";
17
+ }
18
+
19
+ export function setConsent(state: "granted" | "denied"): void {
20
+ localStorage.setItem(CONSENT_KEY, state);
21
+ if (state === "granted") {
22
+ loadGA();
23
+ } else {
24
+ disableGA();
25
+ }
26
+ }
27
+
28
+ let gaLoaded = false;
29
+
30
+ function loadGA(): void {
31
+ if (gaLoaded) return;
32
+ gaLoaded = true;
33
+
34
+ const script = document.createElement("script");
35
+ script.async = true;
36
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
37
+ document.head.appendChild(script);
38
+
39
+ window.dataLayer = window.dataLayer || [];
40
+ window.gtag = function (...args: unknown[]) {
41
+ window.dataLayer!.push(args);
42
+ };
43
+ window.gtag("js", new Date());
44
+ window.gtag("config", GA_ID);
45
+ }
46
+
47
+ function disableGA(): void {
48
+ (window as unknown as Record<string, unknown>)[`ga-disable-${GA_ID}`] = true;
49
+ }
50
+
51
+ export function initAnalytics(): void {
52
+ if (getConsent() === "granted") {
53
+ loadGA();
54
+ }
55
+ }
56
+
57
+ function gtag(command: string, ...args: unknown[]): void {
58
+ if (getConsent() !== "granted") return;
59
+ window.gtag?.(command, ...args);
60
+ }
61
+
62
+ function trackEvent(name: string, params?: Record<string, string | number | boolean>): void {
63
+ gtag("event", name, params);
64
+ }
65
+
66
+ export const analytics = {
67
+ analysisStarted: (fileCount: number) =>
68
+ trackEvent("analysis_started", { file_count: fileCount }),
69
+
70
+ analysisCompleted: (fileCount: number, durationSec: number) =>
71
+ trackEvent("analysis_completed", { file_count: fileCount, duration_sec: durationSec }),
72
+
73
+ analysisError: (errorType: string) =>
74
+ trackEvent("analysis_error", { error_type: errorType }),
75
+
76
+ tabChanged: (tab: string) =>
77
+ trackEvent("tab_changed", { tab }),
78
+
79
+ chatSent: () =>
80
+ trackEvent("chat_sent"),
81
+
82
+ chatCompleted: (durationSec: number, hasTools: boolean) =>
83
+ trackEvent("chat_completed", { duration_sec: durationSec, has_tools: hasTools }),
84
+
85
+ detailOpened: (kind: string) =>
86
+ trackEvent("detail_opened", { kind }),
87
+
88
+ themeChanged: (theme: string) =>
89
+ trackEvent("theme_changed", { theme }),
90
+
91
+ settingsOpened: () =>
92
+ trackEvent("settings_opened"),
93
+
94
+ settingsChanged: (field: string) =>
95
+ trackEvent("settings_changed", { field }),
96
+
97
+ sessionLoaded: () =>
98
+ trackEvent("session_loaded"),
99
+
100
+ reviewSubmitted: (event: string) =>
101
+ trackEvent("review_submitted", { review_event: event }),
102
+
103
+ agentUsed: (agent: string) =>
104
+ trackEvent("agent_used", { agent }),
105
+
106
+ updateClicked: () =>
107
+ trackEvent("update_clicked"),
108
+
109
+ featureUsed: (feature: string) =>
110
+ trackEvent("feature_used", { feature }),
111
+ };