From 19192622747bd8a3760d06ed74bd7d5133da5dae Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 22 Sep 2026 09:13:37 -0700 Subject: [PATCH 1/2] fix(mobile): preserve Android search and filters when folding --- .../src/features/home/HomeRouteScreen.tsx | 21 +++-- .../features/home/home-list-options.test.ts | 94 +++++++++++++++++++ .../src/features/home/home-list-options.ts | 9 ++ .../threads/ThreadNavigationSidebar.tsx | 18 +++- 4 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/features/home/home-list-options.test.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7a660ef1aadf..cba3e24874b1 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -29,13 +29,19 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); - const { layout, panes } = useAdaptiveWorkspaceLayout(); + const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } = + useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useNavigationThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); - const [searchQuery, setSearchQuery] = useState(""); + const [localSearchQuery, setLocalSearchQuery] = useState(""); + // Android's controlled fields share search text across compact/split remounts. + // Keep iOS search local because its native fields do not restore text from this state. + const searchQuery = Platform.OS === "android" ? primarySidebarSearchQuery : localSearchQuery; + const setSearchQuery = + Platform.OS === "android" ? setPrimarySidebarSearchQuery : setLocalSearchQuery; const handleSelectThread = useHomeThreadSelection(); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { @@ -93,10 +99,13 @@ export function HomeRouteScreen() { () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options: listOptions, setSelectedEnvironmentId } = - useHomeListOptions(availableEnvironmentIds); + const { + options: listOptions, + setSelectedEnvironmentId, + setSelectedProjectKey, + } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const selectedProjectKey = listOptions.selectedProjectKey; const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ @@ -116,7 +125,7 @@ export function HomeRouteScreen() { ) { setSelectedProjectKey(null); } - }, [projectFilterOptions, selectedProjectKey]); + }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts new file mode 100644 index 000000000000..b56b18c739fb --- /dev/null +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -0,0 +1,94 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, createElement, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + HomeListOptionsProvider, + useHomeListOptions, +} from "./home-list-options"; + +describe("home list options across layout changes", () => { + const environmentId = EnvironmentId.make("environment-1"); + const availableEnvironmentIds = new Set([environmentId]); + let root: Root; + let latest: ReturnType; + + function ThreadList() { + const state = useHomeListOptions(availableEnvironmentIds); + useLayoutEffect(() => { + latest = state; + }); + return null; + } + + async function showLayout(layout: "compact" | "sidebar") { + await act(() => { + root.render( + createElement( + HomeListOptionsProvider, + { projectGroupingMode: "repository" }, + // Replace the list while keeping the workspace provider mounted. + createElement(ThreadList, { key: layout }), + ), + ); + }); + } + + beforeEach(() => { + // The hook probe renders no DOM, but ReactDOM needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + }); + + afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + }); + + it("retains filters in both directions and does not restore a cleared project", async () => { + await showLayout("compact"); + await act(() => { + latest.setSelectedEnvironmentId(environmentId); + latest.setSelectedProjectKey("repository:one"); + latest.setProjectSortOrder("created_at"); + }); + + await showLayout("sidebar"); + expect(latest.options).toEqual({ + selectedEnvironmentId: environmentId, + selectedProjectKey: "repository:one", + projectSortOrder: "created_at", + projectGroupingMode: "repository", + }); + + await act(() => latest.setSelectedProjectKey("repository:two")); + await showLayout("compact"); + expect(latest.options.selectedProjectKey).toBe("repository:two"); + + await act(() => latest.setSelectedProjectKey(null)); + await showLayout("sidebar"); + await showLayout("compact"); + expect(latest.options).toEqual({ + selectedEnvironmentId: environmentId, + selectedProjectKey: null, + projectSortOrder: "created_at", + projectGroupingMode: "repository", + }); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index d280c05ee0b1..c9f8e3277def 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -16,6 +16,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; } @@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions { function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, + selectedProjectKey: null, projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" @@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, selectedEnvironmentId: value })); }, []); + const setSelectedProjectKey = useCallback( + (value: string | null) => { + setOptions((current) => ({ ...current, selectedProjectKey: value })); + }, + [setOptions], + ); const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { setOptions((current) => ({ ...current, projectSortOrder: value })); }, []); return { options: resolvedOptions, setSelectedEnvironmentId, + setSelectedProjectKey, setProjectSortOrder, } as const; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d60425a6af72..97c3a5d0ef42 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -173,7 +173,8 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId, setSelectedProjectKey } = + useHomeListOptions(availableEnvironmentIds); const searchEnvironmentIds = useMemo( () => options.selectedEnvironmentId === null @@ -203,7 +204,7 @@ function ThreadNavigationSidebarPane( () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); - const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const selectedProjectKey = options.selectedProjectKey; const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -250,7 +251,7 @@ function ThreadNavigationSidebarPane( ) { setSelectedProjectKey(null); } - }, [projectFilterOptions, selectedProjectKey]); + }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); const selectedProjectRefs = useMemo( () => selectedProjectScope === null @@ -530,7 +531,7 @@ function ThreadNavigationSidebarPane( return; } }, - [environments, projectFilterOptions, setSelectedEnvironmentId], + [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -818,7 +819,14 @@ function ThreadNavigationSidebarPane( onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, }), - [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], + [ + environments, + options, + projectFilterOptions, + selectedProjectKey, + setSelectedEnvironmentId, + setSelectedProjectKey, + ], ); const nativeHeaderItems = useMemo( () => From edbd9c5afbdb0467e5dcd6adde0849967d60cda9 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 22 Sep 2026 09:20:38 -0700 Subject: [PATCH 2/2] test(mobile): cover repeated fold layout round trips --- .../features/home/home-list-options.test.ts | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index b56b18c739fb..4cdb59722246 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -3,10 +3,7 @@ import { act, createElement, useLayoutEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - HomeListOptionsProvider, - useHomeListOptions, -} from "./home-list-options"; +import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; describe("home list options across layout changes", () => { const environmentId = EnvironmentId.make("environment-1"); @@ -61,34 +58,38 @@ describe("home list options across layout changes", () => { vi.unstubAllGlobals(); }); - it("retains filters in both directions and does not restore a cleared project", async () => { - await showLayout("compact"); - await act(() => { - latest.setSelectedEnvironmentId(environmentId); - latest.setSelectedProjectKey("repository:one"); - latest.setProjectSortOrder("created_at"); - }); - - await showLayout("sidebar"); - expect(latest.options).toEqual({ - selectedEnvironmentId: environmentId, - selectedProjectKey: "repository:one", - projectSortOrder: "created_at", - projectGroupingMode: "repository", - }); + it.each([ + ["compact", "sidebar"], + ["sidebar", "compact"], + ] as const)( + "retains filters through repeated %s → %s round trips and keeps clears", + async (initialLayout, otherLayout) => { + await showLayout(initialLayout); - await act(() => latest.setSelectedProjectKey("repository:two")); - await showLayout("compact"); - expect(latest.options.selectedProjectKey).toBe("repository:two"); - - await act(() => latest.setSelectedProjectKey(null)); - await showLayout("sidebar"); - await showLayout("compact"); - expect(latest.options).toEqual({ - selectedEnvironmentId: environmentId, - selectedProjectKey: null, - projectSortOrder: "created_at", - projectGroupingMode: "repository", - }); - }); + for (const [selectedEnvironmentId, selectedProjectKey] of [ + [environmentId, "repository:one"], + [environmentId, "repository:two"], + [environmentId, null], + [null, null], + [null, "repository:one"], + ] as const) { + await act(() => { + latest.setSelectedEnvironmentId(selectedEnvironmentId); + latest.setSelectedProjectKey(selectedProjectKey); + }); + for (let cycle = 0; cycle < 3; cycle += 1) { + for (const layout of [otherLayout, initialLayout]) { + await showLayout(layout); + expect(latest.options).toMatchObject({ + selectedEnvironmentId, + selectedProjectKey, + projectGroupingMode: "repository", + }); + } + } + // Make the next selection (or clear it) from the opposite layout. + await showLayout(otherLayout); + } + }, + ); });