Skip to content
Merged
126 changes: 63 additions & 63 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentId>();
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(() => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<EnvironmentId>();
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,
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/reactCompilerCoverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
105 changes: 101 additions & 4 deletions apps/mobile/src/state/queries.debounce.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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) => ({
Expand All @@ -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;
Expand Down Expand Up @@ -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<typeof usePaginatedBranches>;
function Probe({ target }: { target: VcsRefTarget }) {
const result = usePaginatedBranches(target);
useLayoutEffect(() => {
current = result;
});
return null;
}
const render = (cwd: string | null, options: Partial<VcsRefTarget> = {}) =>
act(() =>
root.render(
<RegistryContext.Provider value={registry}>
<Probe target={{ environmentId: target.environmentId, cwd, ...options }} />
</RegistryContext.Provider>,
),
);
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([]);
});
41 changes: 24 additions & 17 deletions apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
Loading