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 +1 -1
- package/src/cli/preflight.ts +8 -3
- package/src/llm/client.ts +4 -0
- package/src/web/assets/sionic-hero-bg.png +0 -0
- package/src/web/assets/sionic-logo.png +0 -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 +5 -2
- package/src/web/client/components/InputScreen.tsx +111 -71
- 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 +5 -1
- package/src/web/client/hooks/useTheme.ts +2 -0
- package/src/web/client/lib/analytics.ts +114 -0
- package/src/web/server/routes.ts +144 -92
- package/src/web/server.ts +14 -0
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
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: {
|
|
Binary file
|
|
Binary file
|
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
|
>
|
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
174
|
-
<
|
|
175
|
-
<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
|
-
</
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<span>{timeAgo(s.analyzed_at)}</span>
|
|
184
|
-
</div>
|
|
185
|
-
</button>
|
|
186
|
-
))}
|
|
188
|
+
</button>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
187
191
|
</div>
|
|
188
|
-
|
|
189
|
-
)}
|
|
192
|
+
)}
|
|
190
193
|
|
|
191
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
|
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 };
|