diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14a9720cb4e9..4aa44e73c558 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -327,72 +327,13 @@ export function NewTaskFlowProvider( null); const linkedEnvironments = useLinkedEnvironments(selectedProject?.environmentId ?? null); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const modelSelectionNow = useNowMinuteMs(); const projectDefaults = useNewTaskProjectDefaults(selectedProject); const selectedEnvironmentServerConfig = projectDefaults.serverConfig; const isScratchDraft = projectDefaults.isScratch; const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); - const scratchEnvironments = connectedEnvironments.filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ); - - const selectedRepositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const selectedProjectTitle = selectedProject?.title ?? null; - const environments = (() => { - if (isScratchDraft) { - return scratchEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - environmentLabel: environment.environmentLabel, - })); - } - const seen = new Set(); - const result: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - }> = []; - const hostsSelectedRepository = (project: EnvironmentProject) => { - if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { - return true; - } - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - if (selectedRepositoryKey !== null && projectKey !== null) { - return projectKey === selectedRepositoryKey; - } - return ( - project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || - (selectedProjectTitle !== null && project.title === selectedProjectTitle) - ); - }; - for (const project of projects) { - if (!hostsSelectedRepository(project)) { - continue; - } - if (seen.has(project.environmentId)) { - continue; - } - const environment = savedConnectionsById[project.environmentId]; - if (!environment) { - continue; - } - seen.add(project.environmentId); - result.push({ - environmentId: project.environmentId, - environmentLabel: environment.environmentLabel, - }); - } - return result; - })(); - const selectedProjectDraftKey = selectedProject ? activeDraftKey : null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the persistent draft in the new-task flow store once a project is selected, before the composer mounts useEffect(() => { @@ -485,13 +426,11 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); - const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); - const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -842,6 +781,67 @@ export function NewTaskFlowProvider( }; }; + const scratchEnvironments = connectedEnvironments.filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ); + + const selectedRepositoryKey = selectedProject?.repositoryIdentity + ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) + : null; + // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. + const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + const selectedProjectTitle = selectedProject?.title ?? null; + const environments = (() => { + if (isScratchDraft) { + return scratchEnvironments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + })); + } + const seen = new Set(); + const result: Array<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + }> = []; + const hostsSelectedRepository = (project: EnvironmentProject) => { + if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { + return true; + } + const projectKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; + if (selectedRepositoryKey !== null && projectKey !== null) { + return projectKey === selectedRepositoryKey; + } + return ( + project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || + (selectedProjectTitle !== null && project.title === selectedProjectTitle) + ); + }; + for (const project of projects) { + if (!hostsSelectedRepository(project)) { + continue; + } + if (seen.has(project.environmentId)) { + continue; + } + const environment = savedConnectionsById[project.environmentId]; + if (!environment) { + continue; + } + seen.add(project.environmentId); + result.push({ + environmentId: project.environmentId, + environmentLabel: environment.environmentLabel, + }); + } + return result; + })(); + const value = { activeNewTask, projectScopes, diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 654ab883d555..192b70edba47 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -182,6 +182,14 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // The compiler once silently dropped these caches, so the new-task context + // value changed on every provider render and re-rendered every consumer. + it("caches the new-task model options and host environments", () => { + const file = "src/features/threads/new-task-flow-provider.tsx"; + expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]); + expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]); + }); + // An uncached registration would re-register the workspace inspector on every render. it("registers the workspace inspector only when its renderer changes", () => { expect( diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index 0a4c8ff828f3..dcef7887012d 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -1,11 +1,16 @@ import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; +import { RegistryContext } from "@effect/atom-react"; +import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ sessionAtom: {}, searchEntries: vi.fn(() => null), + listRefs: vi.fn(), + refresh: vi.fn(), })); vi.mock("./session", () => ({ @@ -21,9 +26,9 @@ vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); vi.mock("./projects", () => ({ projectEnvironment: { searchEntries: mocks.searchEntries }, })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { refresh: mocks.refresh } })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); -vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./vcs", () => ({ vcsEnvironment: { listRefs: mocks.listRefs } })); vi.mock("./threads", () => ({ olderSettledThreads: {}, threadFamilies: {} })); vi.mock("./query", () => ({ useEnvironmentQuery: (atom: unknown) => ({ @@ -37,7 +42,11 @@ vi.mock("./query", () => ({ }), })); -import { useComposerPathSearch, type ComposerPathSearchTarget } from "./queries"; +import { + useComposerPathSearch, + usePaginatedBranches, + type ComposerPathSearchTarget, +} from "./queries"; function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -104,3 +113,91 @@ it.each([ input: { cwd: changed.cwd, query: changed.query, limit: 20 }, }); }); + +it("keeps compiled branch results stable and resets pagination for a new target or refresh", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const registry = AtomRegistry.make(); + const pages = Atom.family((key: string) => { + const [, cwd, cursor] = JSON.parse(key) as [string, string, number | null]; + return Atom.make( + AsyncResult.success({ + refs: [ + { name: `${cwd}-${cursor ?? 0}`, current: false, isDefault: false, worktreePath: null }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: cursor === null ? 100 : null, + totalCount: 2, + }), + ); + }); + mocks.listRefs.mockImplementation( + ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: { cwd: string; cursor?: number }; + }) => pages(JSON.stringify([environmentId, input.cwd, input.cursor ?? null])), + ); + mocks.refresh.mockClear(); + const root = createRoot(document.createElement("div")); + using _cleanup = { + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; + let current: ReturnType; + function Probe({ target }: { target: VcsRefTarget }) { + const result = usePaginatedBranches(target); + useLayoutEffect(() => { + current = result; + }); + return null; + } + const render = (cwd: string | null, options: Partial = {}) => + act(() => + root.render( + + + , + ), + ); + render("/repo"); + const first = current!; + render("/repo"); + expect(current!.data).toBe(first.data); + expect(current!.refs).toBe(first.refs); + expect(current!.refresh).toBe(first.refresh); + expect(current!.loadNext).toBe(first.loadNext); + act(() => + registry.set( + pages(JSON.stringify([target.environmentId, "/repo", null])), + AsyncResult.success({ + ...first.data!, + refs: [{ name: "/repo-0", current: true, isDefault: false, worktreePath: null }], + }), + ), + ); + expect(current!.refs[0]?.current).toBe(true); + act(() => current!.loadNext()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0", "/repo-100"]); + act(() => current!.refresh()); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/repo-0"]); + expect(mocks.refresh).toHaveBeenCalledExactlyOnceWith( + pages(JSON.stringify([target.environmentId, "/repo", null])), + ); + act(() => current!.loadNext()); + render("/other"); + expect(current!.refs.map((ref) => ref.name)).toEqual(["/other-0"]); + act(() => current!.loadNext()); + render("/other", { query: "feature" }); + expect(current!.refs).toHaveLength(1); + act(() => current!.loadNext()); + render("/other", { environmentId: EnvironmentId.make("other-environment"), query: "feature" }); + expect(current!.refs).toHaveLength(1); + render(null); + expect(current!.data).toBeNull(); + expect(current!.refs).toEqual([]); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 75e60887546d..8721b9ecfda8 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -31,7 +31,7 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -299,22 +299,29 @@ export function usePaginatedBranches(target: VcsRefTarget) { cursors: INITIAL_BRANCH_CURSORS, }); const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; - const pageAtoms = - target.environmentId !== null && target.cwd !== null - ? cursors.map((cursor) => - vcsEnvironment.listRefs({ - environmentId: target.environmentId!, - input: { - cwd: target.cwd!, - ...(query.length > 0 ? { query } : {}), - ...(cursor === undefined ? {} : { cursor }), - limit: VCS_REF_LIST_LIMIT, - }, - }), - ) - : []; - const pagesAtom = Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( - Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [target.environmentId, target.cwd, query, cursors], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => {