diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 94a33aa7ea..843bb26229 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -537,21 +537,17 @@ snapshots: git-dialogs--sync-success--light: hash: v1.k4693efd2.8d79d77c9338aeaa73734836f0e17fde987f6d3239914f4013889d3110e5088d.Rq-w2o1uhes_a5hUWqMXeiDkfnoPCZPt932LkEQXCco loops-loopslistview--comprehensive--dark: - hash: v1.k4693efd2.c128012b7f910839e65e8eaf14d5456c93f90d77747d0625ca35fddd8d5adf7a.fuugB5Ov6boP7fg6fWP7A7IG2yYnPUAAWLKZLuV8rZ0 + hash: v1.k4693efd2.25744a9471ec664c70e346ac5cb330c025a031626bc486abe8820b7b6dbc1a19.Shh8Yl7SdombnNc4-uJjlIbx-n4C0MGXxtOGB1bjJTY loops-loopslistview--comprehensive--light: - hash: v1.k4693efd2.4d25a48e0bd7e60d3bbb433e246486e4c9ac95979db86e20eb8bf5cb119d1eb2.EzrENjVHwVMyV00pEA-71TeVomJtchiym5DqWFkujlA + hash: v1.k4693efd2.ef8e012cc506a0bf5e761ec8e89943820d4b391f7023f506da7d79586b49a356.ATiN8Y0hUmfGvr0rUytYWX-fITyHRTgeN1i_HOOGZuc loops-loopslistview--long-mixed-list--dark: - hash: v1.k4693efd2.fdbf0c24cb386c4345279db3e648efe72aa8a2a984d491361522f62074a771c9.2QmRxRMWYFFfBFrbMm0Dm9rWcvd1lfRbc0egfzoESnY + hash: v1.k4693efd2.c9915e22655fe1f6b77eabaf11d8876648c387ceebac6cd8be1880e7255bd7a5.y-gyTKSp7wYbuS57TABRbEyMSL9_U1wvHYvbyCeAbiQ loops-loopslistview--long-mixed-list--light: - hash: v1.k4693efd2.aa17d9b6f40fbd3914bd742951db9f90add0bd233c54170803c8483aeb45b43b.yXpqqGczOq1uW5k4Ie7ddNTnne-cFTglZ9cL800pXB4 - loops-loopslistview--shared-page-header--dark: - hash: v1.k4693efd2.14ab860dcb0df51b4019f012288e45f3a2c4c23fa08e809efef8c04b5687fe36.qoMLHprZmV2wON156NBbl3sqP84a4nXgdYm1H6WpTVM - loops-loopslistview--shared-page-header--light: - hash: v1.k4693efd2.23be5408ff3b4a912a0ce0457af8638124b99a01c88a499d0f4204cee2c62487.dGBP1K08MA2Fvhml-BPl5eFra8U83JXmy04NK9wOQnM + hash: v1.k4693efd2.5fec9e1a3c14f1bc319cd7e5abc24b3d66ed787986a103b5330f49341d4983ad.mpD-eTS6vkSbsJdOYaHXASHQd5IiJGWRCb6uDGlzhpk loops-loopslistview--with-builder-sessions--dark: - hash: v1.k4693efd2.df4a1a4764e1de7b69905814b09c30d9b43f705f5e66c7ad83829c567d4faa1a.jthufoX_KPbbxScjj3pPBA4lEtT9Ym6zUCH-v1759GM + hash: v1.k4693efd2.06b404e4016ba0eb86769331a574bc640be1ad26ebf3a04d0256334d1da041a3.fPfF8c2wNFREfy8dVRdsw7W9Fpqj7HEtuaERMroNHMA loops-loopslistview--with-builder-sessions--light: - hash: v1.k4693efd2.eb65c38bb0de7efedbc0d7a6c27a6695528e2e6b60949617e7d22335b1e68c8b.fJr9uykvc8S7LI_ASXk5KRqxXCwIqh6ld9zfzs4shP0 + hash: v1.k4693efd2.30f89915a12cadf313216403fb0f3e221f3c33cee763f0692ae2b96b4332e2b9.j00vexnH4QS0wJAHn7pQNpeITYYRtOxlvB2ngyzqU0c primitives-pageheader--title-only--dark: hash: v1.k4693efd2.f7f8c560b1e5cf20050189e10fc0f129e555ba672300a7a09bef5827c171a743.yP9Pp_k51agu_9Ri55Y7uCAPBQ1UTeUQJA9U9dPMKfQ primitives-pageheader--title-only--light: @@ -696,10 +692,6 @@ snapshots: hash: v1.k4693efd2.e747a3b5d0983a7a125335f1d95cc7735dafda73abdb3f63ccfadde0482c5ce9.tjc4lMKeaOJjLLxd9tivmgKLshzHZwuSAa_MbqwN_20 skill-buttons-skillbuttonactionmessage--run-experiment--light: hash: v1.k4693efd2.c18264887c67224ea1196646ff27f31ef9d79add87b5e9dfead377fcaf0dc941.MwzR7Yyd4le31Qm7RB2yk-qLk5ujgLkN-cMAB2WQ9_k - skill-buttons-skillbuttonsmenu--default--dark: - hash: v1.k4693efd2.cfff4c6bbb0acef1c9941b21d2f69c24b234c2c206282acf6fcada0cb2bd2386.xILjVPp8ecQmGbPjGDdhbRyi9KFPpA_1nfYF3bfjwuc - skill-buttons-skillbuttonsmenu--default--light: - hash: v1.k4693efd2.eb9bd49b9700641f6f3c90653447c9767c52e997afec9843e8649b90752faad3.VdxEtBwtkU3ioy2evcNbrEKxPjQ1GDUpZAQzFe-Uho4 spaces-taskfeedrow--agent-origin--dark: hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.yEZq_qnKxItv5u0Ydcqa1B4VCwv8lwXUvcaFScE2XO0 spaces-taskfeedrow--agent-origin--light: diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts index 655faf65cc..6cc4f69e06 100644 --- a/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts @@ -1,9 +1,11 @@ -import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { beforeEach, expect, it, vi } from "vitest"; -import { ensurePersonalChannel } from "./ensurePersonalChannel"; +import { + ensurePersonalChannel, + type PersonalChannel, +} from "./ensurePersonalChannel"; -function channel(id: string, name = "me"): Channel { - return { id, name, path: `/${name}`, type: "folder" } as Channel; +function channel(id: string, name = "me"): PersonalChannel { + return { id, name }; } // The module memoises the created folder, so each test needs a fresh copy. @@ -25,7 +27,8 @@ it("shares one create between callers racing before it settles", async () => { "./ensurePersonalChannel" ); const create = vi.fn( - () => new Promise((r) => setTimeout(() => r(channel("1")), 5)), + () => + new Promise((r) => setTimeout(() => r(channel("1")), 5)), ); const [a, b] = await Promise.all([ensure([], create), ensure([], create)]); @@ -69,7 +72,7 @@ it("lets a later caller retry after a failed create", async () => { "./ensurePersonalChannel" ); const create = vi - .fn<() => Promise>() + .fn<() => Promise>() .mockRejectedValueOnce(new Error("offline")) .mockResolvedValueOnce(channel("1")); @@ -77,3 +80,15 @@ it("lets a later caller retry after a failed create", async () => { await expect(ensure([], create)).resolves.toEqual(channel("1")); expect(create).toHaveBeenCalledTimes(2); }); + +it("does not share a created folder between scopes", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const createFirst = vi.fn(async () => channel("1")); + const createSecond = vi.fn(async () => channel("2")); + + await expect(ensure([], createFirst, {})).resolves.toEqual(channel("1")); + await expect(ensure([], createSecond, {})).resolves.toEqual(channel("2")); + expect(createSecond).toHaveBeenCalledOnce(); +}); diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.ts index 8d1fe49dc9..9dcb8cac56 100644 --- a/packages/ui/src/features/canvas/ensurePersonalChannel.ts +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.ts @@ -1,19 +1,34 @@ -import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +export interface PersonalChannel { + id: string; + name: string; +} + +export type PersonalChannelClient = Pick< + PostHogAPIClient, + "createDesktopFileSystemChannel" | "getDesktopFileSystemChannels" +>; + // The "me" folder is provisioned on first use, and folder creation is not // server-side idempotent by path — so two callers racing before the first // create lands in the channels cache would each make their own "me". The entry // points are trivially concurrent (Cmd+T's new tab, the sidebar row, its "+" // menu), so they share one in-flight create rather than guarding separately: // per-caller guards would still race each other. -let inFlight: Promise | null = null; +interface PersonalChannelState { + inFlight: Promise | null; + created: PersonalChannel | null; +} + +const sharedScope = {}; +const stateByScope = new WeakMap(); // The in-flight promise alone isn't enough: it settles the moment the POST // returns, but callers pass the `channels` from their last render, which hasn't // re-rendered with the seeded cache yet. A click landing in that gap sees // neither an existing "me" nor an in-flight create, and makes a second one. // Remember what was created until the list catches up. -let created: Channel | null = null; /** * The user's "me" folder, creating it once if it doesn't exist yet. Concurrent @@ -21,26 +36,47 @@ let created: Channel | null = null; * messaging. */ export async function ensurePersonalChannel( - channels: readonly Channel[], - createChannel: (name: string) => Promise, -): Promise { + channels: readonly PersonalChannel[], + createChannel: (name: string) => Promise, + scope: object = sharedScope, +): Promise { + const state = stateByScope.get(scope) ?? { inFlight: null, created: null }; + stateByScope.set(scope, state); const existing = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); if (existing) { // The list is authoritative once it carries the folder: drop the memo, so a // deleted-then-recreated "me" resolves fresh rather than to a dead id. - created = null; + state.created = null; return existing; } - if (created) return created; - if (!inFlight) { - inFlight = createChannel(PERSONAL_CHANNEL_NAME) + if (state.created) return state.created; + if (!state.inFlight) { + state.inFlight = createChannel(PERSONAL_CHANNEL_NAME) .then((channel) => { - created = channel; + state.created = channel; return channel; }) .finally(() => { - inFlight = null; + state.inFlight = null; }); } - return inFlight; + return state.inFlight; +} + +export async function ensurePersonalChannelFromClient( + client: PersonalChannelClient, +): Promise { + const toPersonalChannel = ({ id, path }: { id: string; path: string }) => ({ + id, + name: path.replace(/^\/+/, ""), + }); + const channels = (await client.getDesktopFileSystemChannels()) + .filter((channel) => channel.type === "folder") + .map(toPersonalChannel); + return await ensurePersonalChannel( + channels, + async (name) => + toPersonalChannel(await client.createDesktopFileSystemChannel(name)), + client, + ); } diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index 4963ab3970..d74f0a03d9 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -1,3 +1,4 @@ +import { getAuthIdentity } from "@posthog/core/auth/authIdentity"; import { ToastProvider } from "@posthog/quill"; import { EXTERNAL_LINKS, isNotAuthenticatedError } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; @@ -24,7 +25,12 @@ import { router } from "@posthog/ui/router/router"; import { AppLoadingScreen } from "@posthog/ui/shell/AppLoadingScreen"; import { track } from "@posthog/ui/shell/analytics"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; +import { logger } from "@posthog/ui/shell/logger"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { + rememberStartupLocation, + resolveStartupLocation, +} from "@posthog/ui/shell/startupLocation"; import { useAppVisibilityWatchdog } from "@posthog/ui/shell/useAppVisibilityWatchdog"; import { RouterProvider } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; @@ -35,6 +41,8 @@ interface AppProps { devToolbar?: ReactNode; } +const log = logger.scope("app"); + function App({ devToolbar }: AppProps) { const { isBootstrapped } = useAuthSession(); const authState = useAuthStateValue((state) => state); @@ -88,12 +96,10 @@ function App({ devToolbar }: AppProps) { !isCheckingAccess && !needsInviteCode && !needsAiApproval; + const startupIdentity = getAuthIdentity(authState); - // Run the initial route's loaders before the router ever mounts, so the boot - // loading screen holds until the route is ready. The router turns loader - // errors into route error UI itself; the catch is only unhandled-rejection - // hygiene. Resets when the user leaves the main app (logout, gates) so - // re-entry loads fresh. + // Resolve and load the initial route before mounting the router. Reset when + // the user leaves the main app so a later re-entry starts fresh. const [initialRouteLoaded, setInitialRouteLoaded] = useState(false); useEffect(() => { if (!readyForMainApp) { @@ -101,17 +107,42 @@ function App({ devToolbar }: AppProps) { return; } if (initialRouteLoaded) return; + if (!startupIdentity || !authenticatedClient) return; + let cancelled = false; - void router - .load() - .catch(() => undefined) - .finally(() => { + const loadInitialRoute = async (): Promise => { + try { + const href = await resolveStartupLocation( + startupIdentity, + authenticatedClient, + ); + router.history.replace(href); + rememberStartupLocation(startupIdentity, href); + await router.load(); + } catch (error) { + log.error("Failed to load initial route", { error }); + } finally { if (!cancelled) setInitialRouteLoaded(true); - }); + } + }; + void loadInitialRoute(); + return () => { cancelled = true; }; - }, [readyForMainApp, initialRouteLoaded]); + }, [ + readyForMainApp, + initialRouteLoaded, + startupIdentity, + authenticatedClient, + ]); + + useEffect(() => { + if (!initialRouteLoaded || !startupIdentity) return; + return router.history.subscribe(({ location }) => { + rememberStartupLocation(startupIdentity, location.href); + }); + }, [initialRouteLoaded, startupIdentity]); const mainRef = useRef(null); // Mirrors the "main" branch of renderContent() below; keep the two in sync. diff --git a/packages/ui/src/shell/rendererStorage.ts b/packages/ui/src/shell/rendererStorage.ts index 7bc95cf11d..dcb70ac353 100644 --- a/packages/ui/src/shell/rendererStorage.ts +++ b/packages/ui/src/shell/rendererStorage.ts @@ -118,7 +118,7 @@ export function registerRendererStateStorage( hostStorageReady.resolve(storage); } -const deferredHostStorage: StateStorage = { +export const stateStorage: StateStorage = { getItem: async (key) => { // A coalesced write that has not flushed yet is newer than the backend // copy; land it first so the read never observes older state. A queued @@ -170,4 +170,4 @@ const deferredHostStorage: StateStorage = { }, }; -export const electronStorage = createJSONStorage(() => deferredHostStorage); +export const electronStorage = createJSONStorage(() => stateStorage); diff --git a/packages/ui/src/shell/startupLocation.test.ts b/packages/ui/src/shell/startupLocation.test.ts new file mode 100644 index 0000000000..91934cbcf0 --- /dev/null +++ b/packages/ui/src/shell/startupLocation.test.ts @@ -0,0 +1,35 @@ +import { stateStorage } from "@posthog/ui/shell/rendererStorage"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveStartupLocation } from "./startupLocation"; + +describe("startup location", () => { + afterEach(() => vi.restoreAllMocks()); + + it("restores the exact last location", async () => { + vi.spyOn(stateStorage, "getItem").mockResolvedValue("/code"); + const client = { + getDesktopFileSystemChannels: vi.fn(), + createDesktopFileSystemChannel: vi.fn(), + }; + + await expect(resolveStartupLocation("project", client)).resolves.toBe( + "/code", + ); + expect(client.getDesktopFileSystemChannels).not.toHaveBeenCalled(); + }); + + it("opens a new task in me when there is no saved location", async () => { + vi.spyOn(stateStorage, "getItem").mockResolvedValue(null); + const client = { + getDesktopFileSystemChannels: vi + .fn() + .mockResolvedValue([{ id: "me-id", path: "me", type: "folder" }]), + createDesktopFileSystemChannel: vi.fn(), + }; + + await expect(resolveStartupLocation("project", client)).resolves.toBe( + "/website/me-id/new", + ); + expect(client.createDesktopFileSystemChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/shell/startupLocation.ts b/packages/ui/src/shell/startupLocation.ts new file mode 100644 index 0000000000..bafa4a491b --- /dev/null +++ b/packages/ui/src/shell/startupLocation.ts @@ -0,0 +1,21 @@ +import { + ensurePersonalChannelFromClient, + type PersonalChannelClient, +} from "@posthog/ui/features/canvas/ensurePersonalChannel"; +import { stateStorage } from "@posthog/ui/shell/rendererStorage"; + +const storageKey = (identity: string): string => `startup-location:${identity}`; + +export async function resolveStartupLocation( + identity: string, + client: PersonalChannelClient, +): Promise { + const saved = await stateStorage.getItem(storageKey(identity)); + if (saved) return saved; + const personal = await ensurePersonalChannelFromClient(client); + return `/website/${personal.id}/new`; +} + +export function rememberStartupLocation(identity: string, href: string): void { + void stateStorage.setItem(storageKey(identity), href); +}