diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index dc8848e288..0a6cd0a4bd 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -51,7 +51,7 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; -import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFromFolderId"; +import { useInitialRepoSelectionFromFolderId } from "../hooks/useInitialRepoSelectionFromFolderId"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; @@ -110,6 +110,7 @@ export function TaskInput({ ); const { setLastUsedLocalWorkspaceMode, + lastUsedLocalWorkspaceMode, lastUsedWorkspaceMode, setLastUsedWorkspaceMode, lastUsedAdapter, @@ -466,7 +467,25 @@ export function TaskInput({ setLastUsedCloudRepository, ]); - useInitialDirectoryFromFolderId(view.folderId, folders, setSelectedDirectory); + // Switch mode for a folder-scoped prefill ("+" in the sidebar) without persisting it as + // the user's mode preference. Marks the mode as resolved so the last-used resolver above + // doesn't override the explicit pick. + const switchWorkspaceModeForFolder = useCallback((mode: WorkspaceMode) => { + didResolveWorkspaceModeRef.current = true; + setWorkspaceModeState(mode); + }, []); + + useInitialRepoSelectionFromFolderId({ + folderId: view.folderId, + folders, + repositories, + reposLoaded: !isLoadingRepos && repositories.length > 0, + currentMode: workspaceMode, + lastUsedLocalMode: lastUsedLocalWorkspaceMode, + setSelectedDirectory, + setSelectedRepository, + switchWorkspaceMode: switchWorkspaceModeForFolder, + }); useEffect(() => { setCloudBranchSearchQuery(""); diff --git a/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts deleted file mode 100644 index 3f1b781aee..0000000000 --- a/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import type { RegisteredFolder } from "../../folders/types"; -import { useInitialDirectoryFromFolderId } from "./useInitialDirectoryFromFolderId"; - -const folder = (id: string, path: string): RegisteredFolder => ({ - id, - path, - name: id, - remoteUrl: null, - lastAccessed: "2026-05-21T00:00:00Z", - createdAt: "2026-05-21T00:00:00Z", -}); - -describe("useInitialDirectoryFromFolderId", () => { - it("syncs the directory to the folder matching folderId on first render", () => { - const setSelectedDirectory = vi.fn(); - renderHook(() => - useInitialDirectoryFromFolderId( - "a", - [folder("a", "/repos/a")], - setSelectedDirectory, - ), - ); - expect(setSelectedDirectory).toHaveBeenCalledExactlyOnceWith("/repos/a"); - }); - - it("waits for folders to load before syncing", () => { - const setSelectedDirectory = vi.fn(); - const { rerender } = renderHook( - ({ folders }: { folders: RegisteredFolder[] }) => - useInitialDirectoryFromFolderId("a", folders, setSelectedDirectory), - { initialProps: { folders: [] as RegisteredFolder[] } }, - ); - expect(setSelectedDirectory).not.toHaveBeenCalled(); - - rerender({ folders: [folder("a", "/repos/a")] }); - expect(setSelectedDirectory).toHaveBeenCalledExactlyOnceWith("/repos/a"); - }); - - it("does not re-sync when folders changes but folderId stays the same", () => { - const setSelectedDirectory = vi.fn(); - const { rerender } = renderHook( - ({ folders }: { folders: RegisteredFolder[] }) => - useInitialDirectoryFromFolderId("a", folders, setSelectedDirectory), - { initialProps: { folders: [folder("a", "/repos/a")] } }, - ); - expect(setSelectedDirectory).toHaveBeenCalledExactlyOnceWith("/repos/a"); - - // Simulate adding a folder (e.g. after the user picks one via "Open - // folder..."). The folders list changes but the user's pick must not be - // clobbered by re-syncing from the original folderId. - rerender({ - folders: [folder("a", "/repos/a"), folder("b", "/repos/picked")], - }); - expect(setSelectedDirectory).toHaveBeenCalledTimes(1); - }); - - it("re-syncs when folderId changes", () => { - const setSelectedDirectory = vi.fn(); - const folders = [folder("a", "/repos/a"), folder("b", "/repos/b")]; - const { rerender } = renderHook( - ({ folderId }: { folderId: string }) => - useInitialDirectoryFromFolderId( - folderId, - folders, - setSelectedDirectory, - ), - { initialProps: { folderId: "a" } }, - ); - expect(setSelectedDirectory).toHaveBeenLastCalledWith("/repos/a"); - - rerender({ folderId: "b" }); - expect(setSelectedDirectory).toHaveBeenLastCalledWith("/repos/b"); - expect(setSelectedDirectory).toHaveBeenCalledTimes(2); - }); - - it("does nothing when folderId is undefined", () => { - const setSelectedDirectory = vi.fn(); - renderHook(() => - useInitialDirectoryFromFolderId( - undefined, - [folder("a", "/repos/a")], - setSelectedDirectory, - ), - ); - expect(setSelectedDirectory).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts deleted file mode 100644 index e39dd1574a..0000000000 --- a/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { RegisteredFolder } from "../../folders/types"; - -/** - * Syncs `selectedDirectory` to the path of `folders[view.folderId]` once per - * folderId. The dependency on `folders` is required so the sync still fires - * when the folder list hasn't loaded yet on initial mount, but we must not - * re-sync on later `folders` refetches (e.g. after `addFolder`) — that would - * clobber a folder the user just picked via the file dialog. - */ -export function useInitialDirectoryFromFolderId( - folderId: string | undefined, - folders: RegisteredFolder[], - setSelectedDirectory: (path: string) => void, -) { - const lastInitializedRef = useRef(undefined); - useEffect(() => { - if (!folderId) { - lastInitializedRef.current = undefined; - return; - } - if (lastInitializedRef.current === folderId) return; - const folder = folders.find((f) => f.id === folderId); - if (folder) { - setSelectedDirectory(folder.path); - lastInitializedRef.current = folderId; - } - }, [folderId, folders, setSelectedDirectory]); -} diff --git a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts new file mode 100644 index 0000000000..27ba0dddd0 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts @@ -0,0 +1,272 @@ +import type { WorkspaceMode } from "@posthog/shared"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { RegisteredFolder } from "../../folders/types"; +import { + resolveRepoSelectionForFolder, + useInitialRepoSelectionFromFolderId, +} from "./useInitialRepoSelectionFromFolderId"; + +const folder = ( + id: string, + path: string, + remoteUrl: string | null = null, +): RegisteredFolder => ({ + id, + path, + name: id, + remoteUrl, + lastAccessed: "2026-05-21T00:00:00Z", + createdAt: "2026-05-21T00:00:00Z", +}); + +describe("resolveRepoSelectionForFolder", () => { + it("prefills both selectors for a cloud-capable folder and keeps cloud mode", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", "posthog/posthog"), + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "local", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: "posthog/posthog", + nextMode: undefined, + }); + }); + + it("prefills the cloud repo while keeping local mode (no switch)", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", "posthog/posthog"), + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "local", + lastUsedLocalMode: "local", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: "posthog/posthog", + nextMode: undefined, + }); + }); + + it("lower-cases the remote slug before matching", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", "PostHog/PostHog"), + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "local", + }).cloudRepository, + ).toBe("posthog/posthog"); + }); + + it("switches to the last-used local mode for a local-only folder while in cloud", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", null), + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "worktree", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: undefined, + nextMode: "worktree", + }); + }); + + it("treats a remote not in the integrations list as not cloud-capable", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", "acme/private"), + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "local", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: undefined, + nextMode: "local", + }); + }); + + it("ignores legacy single-segment remote values", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", "posthog"), + repositories: ["posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "local", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: undefined, + nextMode: "local", + }); + }); + + it("never switches mode before the integrations list has loaded", () => { + expect( + resolveRepoSelectionForFolder({ + folder: folder("a", "/repos/a", null), + repositories: [], + reposLoaded: false, + currentMode: "cloud", + lastUsedLocalMode: "local", + }), + ).toEqual({ + directory: "/repos/a", + cloudRepository: undefined, + nextMode: undefined, + }); + }); +}); + +type HookArgs = { + folderId: string | undefined; + folders: RegisteredFolder[]; + repositories: string[]; + reposLoaded: boolean; + currentMode: WorkspaceMode; +}; + +function renderRepoSelectionHook(initial: HookArgs) { + const setSelectedDirectory = vi.fn(); + const setSelectedRepository = vi.fn(); + const setWorkspaceMode = vi.fn(); + const utils = renderHook( + (props: HookArgs) => + useInitialRepoSelectionFromFolderId({ + folderId: props.folderId, + folders: props.folders, + repositories: props.repositories, + reposLoaded: props.reposLoaded, + currentMode: props.currentMode, + lastUsedLocalMode: "local", + setSelectedDirectory, + setSelectedRepository, + switchWorkspaceMode: setWorkspaceMode, + }), + { initialProps: initial }, + ); + return { + ...utils, + setSelectedDirectory, + setSelectedRepository, + setWorkspaceMode, + }; +} + +describe("useInitialRepoSelectionFromFolderId", () => { + it("syncs the directory immediately and the cloud repo once repos load", () => { + const { rerender, setSelectedDirectory, setSelectedRepository } = + renderRepoSelectionHook({ + folderId: "a", + folders: [folder("a", "/repos/a", "posthog/posthog")], + repositories: [], + reposLoaded: false, + currentMode: "cloud", + }); + // Directory applies right away, even before the integrations list loads. + expect(setSelectedDirectory).toHaveBeenCalledExactlyOnceWith("/repos/a"); + expect(setSelectedRepository).not.toHaveBeenCalled(); + + rerender({ + folderId: "a", + folders: [folder("a", "/repos/a", "posthog/posthog")], + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + }); + expect(setSelectedRepository).toHaveBeenCalledExactlyOnceWith( + "posthog/posthog", + ); + // Directory is not re-applied (once per folderId). + expect(setSelectedDirectory).toHaveBeenCalledTimes(1); + }); + + it("switches to local mode for a local-only folder once repos load", () => { + const { setWorkspaceMode, setSelectedRepository } = renderRepoSelectionHook( + { + folderId: "a", + folders: [folder("a", "/repos/a", null)], + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + }, + ); + expect(setWorkspaceMode).toHaveBeenCalledExactlyOnceWith("local"); + expect(setSelectedRepository).not.toHaveBeenCalled(); + }); + + it("does not re-sync when folders changes but folderId stays the same", () => { + const { rerender, setSelectedDirectory } = renderRepoSelectionHook({ + folderId: "a", + folders: [folder("a", "/repos/a")], + repositories: [], + reposLoaded: false, + currentMode: "local", + }); + expect(setSelectedDirectory).toHaveBeenCalledExactlyOnceWith("/repos/a"); + + // Simulate the user picking a different folder afterward; the changed list must + // not clobber their pick by re-syncing from the original folderId. + rerender({ + folderId: "a", + folders: [folder("a", "/repos/a"), folder("b", "/repos/picked")], + repositories: [], + reposLoaded: false, + currentMode: "local", + }); + expect(setSelectedDirectory).toHaveBeenCalledTimes(1); + }); + + it("re-syncs when folderId changes", () => { + const folders = [ + folder("a", "/repos/a", "posthog/a"), + folder("b", "/repos/b", "posthog/b"), + ]; + const { rerender, setSelectedDirectory, setSelectedRepository } = + renderRepoSelectionHook({ + folderId: "a", + folders, + repositories: ["posthog/a", "posthog/b"], + reposLoaded: true, + currentMode: "cloud", + }); + expect(setSelectedDirectory).toHaveBeenLastCalledWith("/repos/a"); + expect(setSelectedRepository).toHaveBeenLastCalledWith("posthog/a"); + + rerender({ + folderId: "b", + folders, + repositories: ["posthog/a", "posthog/b"], + reposLoaded: true, + currentMode: "cloud", + }); + expect(setSelectedDirectory).toHaveBeenLastCalledWith("/repos/b"); + expect(setSelectedRepository).toHaveBeenLastCalledWith("posthog/b"); + }); + + it("does nothing when folderId is undefined", () => { + const { setSelectedDirectory, setSelectedRepository, setWorkspaceMode } = + renderRepoSelectionHook({ + folderId: undefined, + folders: [folder("a", "/repos/a", "posthog/posthog")], + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + }); + expect(setSelectedDirectory).not.toHaveBeenCalled(); + expect(setSelectedRepository).not.toHaveBeenCalled(); + expect(setWorkspaceMode).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts new file mode 100644 index 0000000000..711494c18b --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts @@ -0,0 +1,152 @@ +import { parseRepository, type WorkspaceMode } from "@posthog/shared"; +import { useEffect, useRef } from "react"; +import type { RegisteredFolder } from "../../folders/types"; + +export interface RepoSelectionInput { + folder: RegisteredFolder; + /** Lower-cased `owner/repo` slugs the user can use in cloud mode. */ + repositories: string[]; + /** Whether the integrations list has finished loading (gate the mode switch). */ + reposLoaded: boolean; + currentMode: WorkspaceMode; + /** Mode to fall back to when leaving cloud (local or worktree). */ + lastUsedLocalMode: WorkspaceMode; +} + +export interface RepoSelection { + /** Local directory to select (always the folder's path). */ + directory: string; + /** Cloud `owner/repo` slug to select, or undefined to leave the cloud pick as-is. */ + cloudRepository?: string; + /** Workspace mode to switch to, or undefined to keep the current mode. */ + nextMode?: WorkspaceMode; +} + +/** + * Pure resolver: given the folder a user picked (e.g. via the sidebar "+"), decide + * what to select in both the local-directory and cloud-repo pickers, and whether the + * workspace mode must change. + * + * Rules (see plan): prefill both selectors; keep the current mode when it can represent + * the repo; only switch when it can't — i.e. you're in cloud but the repo has no cloud + * counterpart (no remote slug, or the slug isn't a connected integration), in which case + * fall back to the last-used local mode. + */ +export function resolveRepoSelectionForFolder({ + folder, + repositories, + reposLoaded, + currentMode, + lastUsedLocalMode, +}: RepoSelectionInput): RepoSelection { + const slug = folder.remoteUrl?.toLowerCase(); + // A folder is cloud-capable only when its remote is a real `owner/repo` (guards against + // legacy single-segment values) AND that repo is one of the user's connected integrations. + const cloudRepository = + slug && parseRepository(slug) !== null && repositories.includes(slug) + ? slug + : undefined; + + const selection: RepoSelection = { + directory: folder.path, + cloudRepository, + }; + + // Only decide the mode once the integrations list has loaded, so we never switch out + // of cloud while the repo list is still in flight (it would look "not cloud-capable"). + if (reposLoaded && currentMode === "cloud" && !cloudRepository) { + selection.nextMode = lastUsedLocalMode; + } + + return selection; +} + +export interface UseInitialRepoSelectionParams { + folderId: string | undefined; + folders: RegisteredFolder[]; + /** Lower-cased `owner/repo` slugs the user can use in cloud mode. */ + repositories: string[]; + /** Whether the integrations list has finished loading (gate the mode switch). */ + reposLoaded: boolean; + currentMode: WorkspaceMode; + /** Mode to fall back to when leaving cloud (local or worktree). */ + lastUsedLocalMode: WorkspaceMode; + setSelectedDirectory: (path: string) => void; + setSelectedRepository: (repo: string) => void; + /** Switches the workspace mode (without persisting it as the user's preference). */ + switchWorkspaceMode: (mode: WorkspaceMode) => void; +} + +/** + * Applies {@link resolveRepoSelectionForFolder} to the live pickers when a `folderId` + * prefill arrives, syncing both the local directory and the cloud repo and switching + * mode when required. Runs once per `folderId` (guarded by refs) so it never clobbers a + * repo/mode the user changed afterward, and re-runs when `folderId` changes. + * + * The dependency on `folders` / `repositories` lets the sync still fire when those lists + * load after the initial mount. + */ +export function useInitialRepoSelectionFromFolderId({ + folderId, + folders, + repositories, + reposLoaded, + currentMode, + lastUsedLocalMode, + setSelectedDirectory, + setSelectedRepository, + switchWorkspaceMode, +}: UseInitialRepoSelectionParams) { + // Two guards: the local directory syncs immediately (once the folder loads), while the + // cloud repo + mode decision waits for the integrations list — so it isn't marked "done" + // before it can tell whether the repo is cloud-capable. + const dirInitRef = useRef(undefined); + const repoModeInitRef = useRef(undefined); + // Read the current mode through a ref so it doesn't retrigger the effect (which would + // re-run the once-per-folderId logic after we change the mode ourselves). + const currentModeRef = useRef(currentMode); + currentModeRef.current = currentMode; + + useEffect(() => { + if (!folderId) { + dirInitRef.current = undefined; + repoModeInitRef.current = undefined; + return; + } + const folder = folders.find((f) => f.id === folderId); + if (!folder) return; + + const selection = resolveRepoSelectionForFolder({ + folder, + repositories, + reposLoaded, + currentMode: currentModeRef.current, + lastUsedLocalMode, + }); + + if (dirInitRef.current !== folderId) { + setSelectedDirectory(selection.directory); + dirInitRef.current = folderId; + } + + // Defer the cloud/mode decision until the integrations list has loaded. + if (reposLoaded && repoModeInitRef.current !== folderId) { + if (selection.cloudRepository) { + setSelectedRepository(selection.cloudRepository); + } + if (selection.nextMode && selection.nextMode !== currentModeRef.current) { + switchWorkspaceMode(selection.nextMode); + } + repoModeInitRef.current = folderId; + } + }, [ + folderId, + folders, + repositories, + reposLoaded, + lastUsedLocalMode, + setSelectedDirectory, + setSelectedRepository, + switchWorkspaceMode, + ]); +}