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 +2 -2
- package/src/analyzer/pipeline.ts +6 -5
- package/src/cli/preflight.ts +8 -3
- package/src/llm/client.ts +4 -0
- package/src/types/output.ts +1 -0
- package/src/web/client/App.tsx +14 -2
- package/src/web/client/components/AnalyticsConsent.tsx +88 -0
- package/src/web/client/components/AppShell.tsx +3 -2
- package/src/web/client/components/ChatSection.tsx +36 -12
- package/src/web/client/components/ReviewModal.tsx +2 -0
- package/src/web/client/components/SettingsPanel.tsx +39 -0
- package/src/web/client/hooks/useAnalysis.ts +14 -6
- package/src/web/client/hooks/useChatStore.ts +6 -0
- package/src/web/client/hooks/useTheme.ts +2 -0
- package/src/web/client/lib/analytics.ts +111 -0
- package/src/web/server/routes.ts +144 -92
- package/src/web/styles/built.css +1 -1
- package/src/workspace/agent.ts +82 -1
- package/src/workspace/explore.ts +13 -8
- package/src/workspace/types.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newpr",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
43
|
+
"homepage": "https://jiwonme.github.io/newpr/",
|
|
44
44
|
"bugs": {
|
|
45
45
|
"url": "https://github.com/jiwonMe/newpr/issues"
|
|
46
46
|
},
|
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
143
|
+
onProgress?.({ stage: "exploring", message: "π€ exploration complete" });
|
|
143
144
|
|
|
144
145
|
await cleanupWorktrees(bareRepoPath, pr.number, pr.owner, pr.repo).catch(() => {});
|
|
145
146
|
|
package/src/cli/preflight.ts
CHANGED
|
@@ -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: {
|
package/src/types/output.ts
CHANGED
package/src/web/client/App.tsx
CHANGED
|
@@ -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(() => {
|
|
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={
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
};
|