diff --git a/.gitignore b/.gitignore index 863d0ea..7271c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,5 @@ typings/ out/ sqlite.db -userData/ \ No newline at end of file +userData/ +.env.local \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d33acfd..bb78001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dyad", - "version": "0.1.5-beta.3", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dyad", - "version": "0.1.5-beta.3", + "version": "0.1.6", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^1.2.8", @@ -51,6 +51,7 @@ "lucide-react": "^0.487.0", "monaco-editor": "^0.52.2", "openai": "^4.91.1", + "posthog-js": "^1.236.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -9515,6 +9516,17 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", + "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -16946,6 +16958,36 @@ "node": ">=4" } }, + "node_modules/posthog-js": { + "version": "1.236.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.236.3.tgz", + "integrity": "sha512-pu/km63Ad930buL01cBBtYNP7IiJZphqnlAvuV9kaeawnaVqFd3BdxtoauYDmhCrvkwuFxrSwhtYIVD5Cnr9IQ==", + "license": "MIT", + "dependencies": { + "core-js": "^3.38.1", + "fflate": "^0.4.8", + "preact": "^10.19.3", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@rrweb/types": "2.0.0-alpha.17", + "rrweb-snapshot": "2.0.0-alpha.17" + }, + "peerDependenciesMeta": { + "@rrweb/types": { + "optional": true + }, + "rrweb-snapshot": { + "optional": true + } + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", @@ -16972,6 +17014,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/preact": { + "version": "10.26.5", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.5.tgz", + "integrity": "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -20254,6 +20306,12 @@ "node": ">= 14" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index a98e082..8840d18 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "lucide-react": "^0.487.0", "monaco-editor": "^0.52.2", "openai": "^4.91.1", + "posthog-js": "^1.236.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -121,4 +122,4 @@ "update-electron-app": "^3.1.1", "uuid": "^11.1.0" } -} \ No newline at end of file +} diff --git a/src/App.css b/src/App.css deleted file mode 100644 index a571b3a..0000000 --- a/src/App.css +++ /dev/null @@ -1,43 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; - } - - .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; - } - .logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); - } - .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); - } - - @keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } - - @media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } - } - - .card { - padding: 2em; - } - - .read-the-docs { - color: #888; - } - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index eb57b84..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { RouterProvider } from "@tanstack/react-router"; -import { router } from "./router"; - -// The router is automatically initialized by RouterProvider -// so we don't need to call initialize() manually - -function App() { - return ; -} - -export default App; diff --git a/src/components/SetupBanner.tsx b/src/components/SetupBanner.tsx index 445cc2e..15d1058 100644 --- a/src/components/SetupBanner.tsx +++ b/src/components/SetupBanner.tsx @@ -21,13 +21,14 @@ import { import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { NodeSystemInfo } from "@/ipc/ipc_types"; - +import { usePostHog } from "posthog-js/react"; type NodeInstallStep = | "install" | "waiting-for-continue" | "continue-processing"; export function SetupBanner() { + const { capture } = usePostHog(); const navigate = useNavigate(); const { isAnyProviderSetup, loading } = useSettings(); const [nodeSystemInfo, setNodeSystemInfo] = useState( @@ -53,6 +54,7 @@ export function SetupBanner() { }, [checkNode]); const handleAiSetupClick = () => { + capture("setup-flow:ai-provider-setup-click"); navigate({ to: providerSettingsRoute.id, params: { provider: "google" }, @@ -60,11 +62,13 @@ export function SetupBanner() { }; const handleNodeInstallClick = useCallback(async () => { + capture("setup-flow:start-node-install-click"); setNodeInstallStep("waiting-for-continue"); IpcClient.getInstance().openExternalUrl(nodeSystemInfo!.nodeDownloadUrl); }, [nodeSystemInfo, setNodeInstallStep]); const finishNodeInstall = useCallback(async () => { + capture("setup-flow:continue-node-install-click"); setNodeInstallStep("continue-processing"); await IpcClient.getInstance().reloadEnvPath(); await checkNode(); diff --git a/src/components/TelemetryBanner.tsx b/src/components/TelemetryBanner.tsx new file mode 100644 index 0000000..792ec1b --- /dev/null +++ b/src/components/TelemetryBanner.tsx @@ -0,0 +1,69 @@ +import { IpcClient } from "@/ipc/ipc_client"; +import React, { useState } from "react"; +import { Button } from "./ui/button"; +import { atom, useAtom } from "jotai"; +import { useSettings } from "@/hooks/useSettings"; + +const hideBannerAtom = atom(false); + +export function PrivacyBanner() { + const [hideBanner, setHideBanner] = useAtom(hideBannerAtom); + const { settings, updateSettings } = useSettings(); + // TODO: Implement state management for banner visibility and user choice + // TODO: Implement functionality for Accept, Reject, Ask me later buttons + // TODO: Add state to hide/show banner based on user choice + if (hideBanner) { + return null; + } + if (settings?.telemetryConsent !== "unset") { + return null; + } + return ( +
+
+
+

+ Share anonymous data? +

+

+ Help improve Dyad with anonymous usage data. + + Note: this does not log your code or messages. + + { + IpcClient.getInstance().openExternalUrl( + "https://dyad.sh/docs/telemetry" + ); + }} + className="cursor-pointer text-sm text-blue-600 dark:text-blue-400 hover:underline" + > + Learn more + +

+
+
+ + + +
+
+
+ ); +} diff --git a/src/components/TelemetrySwitch.tsx b/src/components/TelemetrySwitch.tsx new file mode 100644 index 0000000..b95d359 --- /dev/null +++ b/src/components/TelemetrySwitch.tsx @@ -0,0 +1,25 @@ +import { useSettings } from "@/hooks/useSettings"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { showInfo } from "@/lib/toast"; + +export function TelemetrySwitch() { + const { settings, updateSettings } = useSettings(); + return ( +
+ { + updateSettings({ + telemetryConsent: + settings?.telemetryConsent === "opted_in" + ? "opted_out" + : "opted_in", + }); + }} + /> + +
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 02308ad..9cadffa 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/sidebar"; import { ChatList } from "./ChatList"; import { AppList } from "./AppList"; +import { usePostHog } from "posthog-js/react"; // Menu items. const items = [ @@ -123,6 +124,8 @@ function AppIcons({ }: { onHoverChange: (state: HoverState) => void; }) { + const { capture } = usePostHog(); + const routerState = useRouterState(); const pathname = routerState.location.pathname; diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 88ea041..ac9a300 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -36,8 +36,9 @@ import type { Message } from "@/ipc/ipc_types"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { useRunApp } from "@/hooks/useRunApp"; import { AutoApproveSwitch } from "../AutoApproveSwitch"; - +import { usePostHog } from "posthog-js/react"; export function ChatInput({ chatId }: { chatId?: number }) { + const { capture } = usePostHog(); const [inputValue, setInputValue] = useAtom(chatInputValueAtom); const textareaRef = useRef(null); const { settings, updateSettings, isAnyProviderSetup } = useSettings(); @@ -104,6 +105,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { const currentInput = inputValue; setInputValue(""); await streamMessage({ prompt: currentInput, chatId }); + capture("chat:submit"); }; const handleCancel = () => { @@ -124,6 +126,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { `Approving proposal for chatId: ${chatId}, messageId: ${messageId}` ); setIsApproving(true); + capture("chat:approve"); try { const result = await IpcClient.getInstance().approveProposal({ chatId, @@ -157,6 +160,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { `Rejecting proposal for chatId: ${chatId}, messageId: ${messageId}` ); setIsRejecting(true); + capture("chat:reject"); try { const result = await IpcClient.getInstance().rejectProposal({ chatId, diff --git a/src/components/chat/HomeChatInput.tsx b/src/components/chat/HomeChatInput.tsx index 2317800..3afe740 100644 --- a/src/components/chat/HomeChatInput.tsx +++ b/src/components/chat/HomeChatInput.tsx @@ -6,6 +6,7 @@ import { useSettings } from "@/hooks/useSettings"; import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input import { useAtom } from "jotai"; import { useStreamChat } from "@/hooks/useStreamChat"; +import { usePostHog } from "posthog-js/react"; export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) { const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom); @@ -14,7 +15,6 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) { const { streamMessage, isStreaming, setIsStreaming } = useStreamChat({ hasChatId: false, }); // eslint-disable-line @typescript-eslint/no-unused-vars - const adjustHeight = () => { const textarea = textareaRef.current; if (textarea) { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3825d51..35f932e 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -13,6 +13,17 @@ const PROVIDER_TO_ENV_VAR: Record = { // Define a type for the environment variables we expect type EnvVars = Record; +const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent"; +const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId"; + +export function isTelemetryOptedIn() { + return window.localStorage.getItem(TELEMETRY_CONSENT_KEY) === "opted_in"; +} + +export function getTelemetryUserId(): string | null { + return window.localStorage.getItem(TELEMETRY_USER_ID_KEY); +} + export function useSettings() { const [settings, setSettingsAtom] = useAtom(userSettingsAtom); const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom); @@ -49,6 +60,23 @@ export function useSettings() { const ipcClient = IpcClient.getInstance(); const updatedSettings = await ipcClient.setUserSettings(newSettings); setSettingsAtom(updatedSettings); + if (updatedSettings.telemetryConsent) { + window.localStorage.setItem( + TELEMETRY_CONSENT_KEY, + updatedSettings.telemetryConsent + ); + } else { + window.localStorage.removeItem(TELEMETRY_CONSENT_KEY); + } + if (updatedSettings.telemetryUserId) { + window.localStorage.setItem( + TELEMETRY_USER_ID_KEY, + updatedSettings.telemetryUserId + ); + } else { + window.localStorage.removeItem(TELEMETRY_USER_ID_KEY); + } + setError(null); return updatedSettings; } catch (error) { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 26cf481..573ca7f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -89,6 +89,8 @@ export const UserSettingsSchema = z.object({ githubUser: GithubUserSchema.optional(), githubAccessToken: SecretSchema.optional(), autoApproveChanges: z.boolean().optional(), + telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(), + telemetryUserId: z.string().optional(), // DEPRECATED. runtimeMode: RuntimeModeSchema.optional(), }); diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index adb48da..0000000 --- a/src/main.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import { RouterProvider } from "@tanstack/react-router"; -import { router } from "./router"; -import { TooltipProvider } from "@radix-ui/react-tooltip"; -import { Toaster } from "sonner"; -import "./styles/globals.css"; - -createRoot(document.getElementById("root")!).render( - - - - - -); diff --git a/src/main/settings.ts b/src/main/settings.ts index 74a51f4..7c17158 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { getUserDataPath } from "../paths/paths"; import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas"; import { safeStorage } from "electron"; +import { v4 as uuidv4 } from "uuid"; // IF YOU NEED TO UPDATE THIS, YOU'RE PROBABLY DOING SOMETHING WRONG! // Need to maintain backwards compatibility! @@ -12,6 +13,8 @@ const DEFAULT_SETTINGS: UserSettings = { provider: "auto", }, providerSettings: {}, + telemetryConsent: "unset", + telemetryUserId: uuidv4(), }; const SETTINGS_FILE = "user-settings.json"; diff --git a/src/pages/home.tsx b/src/pages/home.tsx index e6b37c5..e43fe2f 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -11,6 +11,9 @@ import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { useState, useEffect } from "react"; import { useStreamChat } from "@/hooks/useStreamChat"; import { HomeChatInput } from "@/components/chat/HomeChatInput"; +import { usePostHog } from "posthog-js/react"; +import { PrivacyBanner } from "@/components/TelemetryBanner"; + export default function HomePage() { const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom); const navigate = useNavigate(); @@ -21,7 +24,7 @@ export default function HomePage() { const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const [isLoading, setIsLoading] = useState(false); const { streamMessage } = useStreamChat({ hasChatId: false }); - + const { capture } = usePostHog(); // Get the appId from search params const appId = search.appId ? Number(search.appId) : null; @@ -50,6 +53,7 @@ export default function HomePage() { setSelectedAppId(result.app.id); setIsPreviewOpen(false); await refreshApps(); // Ensure refreshApps is awaited if it's async + capture("home:chat-submit"); navigate({ to: "/chat", search: { id: result.chatId } }); } catch (error) { console.error("Failed to create chat:", error); @@ -173,6 +177,7 @@ export default function HomePage() { ))} + ); } diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 8da4e5b..5d0bcd9 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -5,12 +5,14 @@ import ConfirmationDialog from "@/components/ConfirmationDialog"; import { IpcClient } from "@/ipc/ipc_client"; import { showSuccess, showError } from "@/lib/toast"; import { AutoApproveSwitch } from "@/components/AutoApproveSwitch"; - +import { TelemetrySwitch } from "@/components/TelemetrySwitch"; +import { useSettings } from "@/hooks/useSettings"; export default function SettingsPage() { const { theme, setTheme } = useTheme(); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); const [appVersion, setAppVersion] = useState(null); + const { settings } = useSettings(); useEffect(() => { // Fetch app version @@ -103,6 +105,27 @@ export default function SettingsPage() { +
+
+

+ Telemetry +

+
+ +
+ This records anonymous usage data to improve the product. +
+
+ +
+ Telemetry ID: + + {settings ? settings.telemetryUserId : "n/a"} + +
+
+
+
diff --git a/src/renderer.tsx b/src/renderer.tsx index 77d159f..e697dd0 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -1,9 +1,70 @@ -import { StrictMode } from "react"; +import { StrictMode, useEffect } from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import { router } from "./router"; +import { RouterProvider } from "@tanstack/react-router"; +import { PostHogProvider } from "posthog-js/react"; +import posthog from "posthog-js"; +import { getTelemetryUserId, isTelemetryOptedIn } from "./hooks/useSettings"; + +const posthogClient = posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { + api_host: "https://us.i.posthog.com", + debug: import.meta.env.MODE === "development", + autocapture: false, + capture_pageview: false, + before_send: (event) => { + if (!isTelemetryOptedIn()) { + console.debug("Telemetry not opted in, skipping event", event); + return null; + } + const telemetryUserId = getTelemetryUserId(); + if (telemetryUserId) { + posthogClient.identify(telemetryUserId); + } + + if (event?.properties["$ip"]) { + event.properties["$ip"] = null; + } + + console.debug( + "Telemetry opted in - UUID:", + telemetryUserId, + "sending event", + event + ); + return event; + }, + persistence: "localStorage", +}); + +function App() { + useEffect(() => { + // Subscribe to navigation state changes + const unsubscribe = router.subscribe("onResolved", (navigation) => { + // Capture the navigation event in PostHog + posthog.capture("navigation", { + toPath: navigation.toLocation.pathname, + fromPath: navigation.fromLocation?.pathname, + }); + + // Optionally capture as a standard pageview as well + posthog.capture("$pageview", { + path: navigation.toLocation.pathname, + }); + }); + + // Clean up subscription when component unmounts + return () => { + unsubscribe(); + }; + }, []); + + return ; +} createRoot(document.getElementById("root")!).render( - + + + );