Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 21 additions & 6 deletions packages/ui/src/features/canvas/ensurePersonalChannel.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -25,7 +27,8 @@ it("shares one create between callers racing before it settles", async () => {
"./ensurePersonalChannel"
);
const create = vi.fn(
() => new Promise<Channel>((r) => setTimeout(() => r(channel("1")), 5)),
() =>
new Promise<PersonalChannel>((r) => setTimeout(() => r(channel("1")), 5)),
);

const [a, b] = await Promise.all([ensure([], create), ensure([], create)]);
Expand Down Expand Up @@ -69,11 +72,23 @@ it("lets a later caller retry after a failed create", async () => {
"./ensurePersonalChannel"
);
const create = vi
.fn<() => Promise<Channel>>()
.fn<() => Promise<PersonalChannel>>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(channel("1"));

await expect(ensure([], create)).rejects.toThrow("offline");
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();
});
62 changes: 49 additions & 13 deletions packages/ui/src/features/canvas/ensurePersonalChannel.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,82 @@
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<Channel> | null = null;
interface PersonalChannelState {
inFlight: Promise<PersonalChannel> | null;
created: PersonalChannel | null;
}

const sharedScope = {};
const stateByScope = new WeakMap<object, PersonalChannelState>();
// 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
* callers await the same create. Rejects if the create fails; callers own the
* messaging.
*/
export async function ensurePersonalChannel(
channels: readonly Channel[],
createChannel: (name: string) => Promise<Channel>,
): Promise<Channel> {
channels: readonly PersonalChannel[],
createChannel: (name: string) => Promise<PersonalChannel>,
scope: object = sharedScope,
): Promise<PersonalChannel> {
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<PersonalChannel> {
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,
);
}
53 changes: 42 additions & 11 deletions packages/ui/src/shell/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -88,30 +96,53 @@ 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) {
setInitialRouteLoaded(false);
return;
}
if (initialRouteLoaded) return;
if (!startupIdentity || !authenticatedClient) return;

let cancelled = false;
void router
.load()
.catch(() => undefined)
.finally(() => {
const loadInitialRoute = async (): Promise<void> => {
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<HTMLDivElement>(null);
// Mirrors the "main" branch of renderContent() below; keep the two in sync.
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/shell/rendererStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -170,4 +170,4 @@ const deferredHostStorage: StateStorage = {
},
};

export const electronStorage = createJSONStorage(() => deferredHostStorage);
export const electronStorage = createJSONStorage(() => stateStorage);
35 changes: 35 additions & 0 deletions packages/ui/src/shell/startupLocation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions packages/ui/src/shell/startupLocation.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const saved = await stateStorage.getItem(storageKey(identity));
if (saved) return saved;
const personal = await ensurePersonalChannelFromClient(client);
return `/website/${personal.id}/new`;
}
Comment thread
puemos marked this conversation as resolved.
Comment thread
puemos marked this conversation as resolved.

export function rememberStartupLocation(identity: string, href: string): void {
void stateStorage.setItem(storageKey(identity), href);
}
Loading