mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
348a1f8502 | ||
|
|
7decfd0a62 | ||
|
|
826818b55a |
@@ -10,6 +10,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
@@ -160,8 +161,15 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
// Pageview tracker sits at the app root so it covers every visible
|
||||
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
|
||||
// would miss the logged-out and overlay states.
|
||||
return (
|
||||
<>
|
||||
<PageviewTracker />
|
||||
{user ? <DesktopShell /> : <DesktopLoginPage />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
* 1. Logged-out state → `/login`. No workspace context, no tabs.
|
||||
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
|
||||
* that match the equivalent web routes. Overlays are NOT tab routes on
|
||||
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
|
||||
* tab path alone would either miss them or mislabel them as "/".
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
* implementation in `apps/web/components/pageview-tracker.tsx`.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
return "/workspaces/new";
|
||||
case "onboarding":
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
}
|
||||
}
|
||||
88
packages/core/analytics/index.test.ts
Normal file
88
packages/core/analytics/index.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock posthog-js before importing the module under test so the module's
|
||||
// top-level `import posthog from "posthog-js"` resolves to the mock.
|
||||
vi.mock("posthog-js", () => {
|
||||
const mock = {
|
||||
init: vi.fn(),
|
||||
register: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
|
||||
// Re-import per test so module-level `initialized` / cached super-props
|
||||
// don't leak between cases.
|
||||
async function loadModule() {
|
||||
vi.resetModules();
|
||||
const analytics = await import("./index");
|
||||
const posthog = (await import("posthog-js")).default as unknown as {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {});
|
||||
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("initAnalytics super-properties", () => {
|
||||
it("registers client_type and app_version after posthog.init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits app_version when not provided", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
|
||||
});
|
||||
|
||||
it("detects desktop when window.electron is present", async () => {
|
||||
vi.stubGlobal("window", { electron: {} });
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAnalytics", () => {
|
||||
it("re-registers super-properties after reset so subsequent events keep client_type", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
posthog.register.mockClear();
|
||||
|
||||
analytics.resetAnalytics();
|
||||
|
||||
// reset() wipes persisted super-props; we re-register the cached set so
|
||||
// the next session's events keep client_type + app_version.
|
||||
expect(posthog.reset).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when analytics was never initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.resetAnalytics();
|
||||
expect(posthog.reset).not.toHaveBeenCalled();
|
||||
expect(posthog.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -48,10 +48,44 @@ type PendingOp =
|
||||
| { kind: "event"; name: string; props?: Record<string, unknown> }
|
||||
| { kind: "set"; props: Record<string, unknown> };
|
||||
const pendingOps: PendingOp[] = [];
|
||||
// Cached super-properties so resetAnalytics() can re-register them after
|
||||
// posthog.reset() wipes the persisted set. Without this, logout / account
|
||||
// switch silently drops client_type + app_version from every subsequent
|
||||
// event until a full reload.
|
||||
let superProperties: Record<string, unknown> = {};
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
/**
|
||||
* Client app version — attached to every event as an `app_version`
|
||||
* super-property. Web injects the build-time tag / sha; desktop reads from
|
||||
* the Electron API. Optional because local dev may not have a version
|
||||
* available.
|
||||
*/
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export type ClientType = "desktop" | "web";
|
||||
|
||||
/**
|
||||
* Classify the current runtime as desktop (Electron renderer) or web. Used as
|
||||
* a super-property so every event can be split by client without relying on
|
||||
* PostHog's `$lib`, which reports "web" in both the Next.js app and the
|
||||
* Electron renderer (both Chromium).
|
||||
*
|
||||
* Signals we trust:
|
||||
* - `window.electron` is exposed by the preload script in every renderer.
|
||||
* - `navigator.userAgent` contains "Electron" as a fallback.
|
||||
*/
|
||||
export function detectClientType(): ClientType {
|
||||
if (typeof window === "undefined") return "web";
|
||||
const w = window as unknown as { electron?: unknown; desktopAPI?: unknown };
|
||||
if (w.electron || w.desktopAPI) return "desktop";
|
||||
if (typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)) {
|
||||
return "desktop";
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +121,16 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
// Register super-properties — attached to every event emitted from this
|
||||
// client. `client_type` is the canonical split between desktop and web
|
||||
// (PostHog's own `$lib` reports "web" for both because Electron renderers
|
||||
// are Chromium). `app_version` is optional so self-hosted or local dev
|
||||
// builds without a version don't pollute the property.
|
||||
// We cache the set so resetAnalytics() can re-apply it after
|
||||
// posthog.reset() — reset() clears persisted super-properties otherwise.
|
||||
superProperties = { client_type: detectClientType() };
|
||||
if (config.appVersion) superProperties.app_version = config.appVersion;
|
||||
posthog.register(superProperties);
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
@@ -141,6 +185,12 @@ export function resetAnalytics(): void {
|
||||
pendingOps.length = 0;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
// reset() wipes persisted super-properties too, so re-register the ones
|
||||
// set at init time. Otherwise every event after logout / account-switch
|
||||
// would be missing client_type + app_version until a full reload.
|
||||
if (Object.keys(superProperties).length > 0) {
|
||||
posthog.register(superProperties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import { workspaceKeys } from "../workspace/queries";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { setCurrentWorkspace } from "./workspace-storage";
|
||||
import type { ClientIdentity } from "./types";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
import type { User } from "../types";
|
||||
|
||||
@@ -26,12 +27,14 @@ export function AuthInitializer({
|
||||
onLogout,
|
||||
storage = defaultStorage,
|
||||
cookieAuth,
|
||||
identity,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
storage?: StorageAdapter;
|
||||
cookieAuth?: boolean;
|
||||
identity?: ClientIdentity;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -48,7 +51,11 @@ export function AuthInitializer({
|
||||
.then((cfg) => {
|
||||
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
|
||||
if (cfg.posthog_key) {
|
||||
initAnalytics({ key: cfg.posthog_key, host: cfg.posthog_host || "" });
|
||||
initAnalytics({
|
||||
key: cfg.posthog_key,
|
||||
host: cfg.posthog_host || "",
|
||||
appVersion: identity?.version,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -73,7 +73,13 @@ export function CoreProvider({
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage} cookieAuth={cookieAuth}>
|
||||
<AuthInitializer
|
||||
onLogin={onLogin}
|
||||
onLogout={onLogout}
|
||||
storage={storage}
|
||||
cookieAuth={cookieAuth}
|
||||
identity={identity}
|
||||
>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
|
||||
Reference in New Issue
Block a user