diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index eb00b588c..eed4f7a8c 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -48,6 +48,7 @@ import { useQuery } from "@tanstack/react-query"; import { FOCUSABLE_SELECTOR } from "@utils/overlay"; import { LayoutGroup, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFromFolderId"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; @@ -401,14 +402,7 @@ export function TaskInput({ setLastUsedCloudRepository, ]); - useEffect(() => { - if (view.folderId) { - const folder = folders.find((f) => f.id === view.folderId); - if (folder) { - setSelectedDirectory(folder.path); - } - } - }, [view.folderId, folders]); + useInitialDirectoryFromFolderId(view.folderId, folders, setSelectedDirectory); useEffect(() => { setCloudBranchSearchQuery(""); diff --git a/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts b/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts new file mode 100644 index 000000000..37a5a62ae --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts @@ -0,0 +1,89 @@ +import type { RegisteredFolder } from "@main/services/folders/schemas"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +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/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts b/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts new file mode 100644 index 000000000..dab03d91c --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts @@ -0,0 +1,29 @@ +import type { RegisteredFolder } from "@main/services/folders/schemas"; +import { useEffect, useRef } from "react"; + +/** + * 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]); +}