authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:29:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 11:29:01-07:00
logb9bf60907fb6eef292da7f19d464a5b16f82ee6b
treebdb12c48cb3d691ac04176207a6c8ad49d5021fe
parentb4af60253bf013ebd146e23d8f66af94854da5d6
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve filters across layout changes (#13085)


4 files changed, 132 insertions(+), 11 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+15-6
...@@ -37,7 +37,8 @@ export function HomeRouteScreen() {...@@ -37,7 +37,8 @@ export function HomeRouteScreen() {
37 const nativePrimaryColumn = use(NativePrimaryColumnContext);37 const nativePrimaryColumn = use(NativePrimaryColumnContext);
38 const columnMetrics = useNativeColumnLayoutMetrics();38 const columnMetrics = useNativeColumnLayoutMetrics();
39 const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth;39 const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth;
40 const { layout, panes } = useAdaptiveWorkspaceLayout();40 const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } =
41 useAdaptiveWorkspaceLayout();
41 const projects = useProjects();42 const projects = useProjects();
42 // Streaming turns rewrite thread shells many times a second. While a Thread43 // Streaming turns rewrite thread shells many times a second. While a Thread
43 // covers Home, rebuilding this list is invisible work.44 // covers Home, rebuilding this list is invisible work.
...@@ -49,7 +50,12 @@ export function HomeRouteScreen() {...@@ -49,7 +50,12 @@ export function HomeRouteScreen() {
49 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();50 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
50 const { savedConnectionsById } = useSavedRemoteConnections();51 const { savedConnectionsById } = useSavedRemoteConnections();
51 const navigation = useNavigation();52 const navigation = useNavigation();
52 const [searchQuery, setSearchQuery] = useState("");53 const [localSearchQuery, setLocalSearchQuery] = useState("");
54 // Android's controlled fields share search text across compact/split remounts.
55 // Keep iOS search local because its native fields do not restore text from this state.
56 const searchQuery = Platform.OS === "android" ? primarySidebarSearchQuery : localSearchQuery;
57 const setSearchQuery =
58 Platform.OS === "android" ? setPrimarySidebarSearchQuery : setLocalSearchQuery;
53 const handleSelectThread = useHomeThreadSelection();59 const handleSelectThread = useHomeThreadSelection();
54 const handleNewThreadOnBranch = useCallback(60 const handleNewThreadOnBranch = useCallback(
55 (thread: EnvironmentThreadShell) => {61 (thread: EnvironmentThreadShell) => {
...@@ -107,10 +113,13 @@ export function HomeRouteScreen() {...@@ -107,10 +113,13 @@ export function HomeRouteScreen() {
107 () => new Set(environments.map((environment) => environment.environmentId)),113 () => new Set(environments.map((environment) => environment.environmentId)),
108 [environments],114 [environments],
109 );115 );
110 const { options: listOptions, setSelectedEnvironmentId } =116 const {
111 useHomeListOptions(availableEnvironmentIds);117 options: listOptions,
118 setSelectedEnvironmentId,
119 setSelectedProjectKey,
120 } = useHomeListOptions(availableEnvironmentIds);
112 const selectedEnvironmentId = listOptions.selectedEnvironmentId;121 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
113 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);122 const selectedProjectKey = listOptions.selectedProjectKey;
114 const projectFilterOptions = useMemo(123 const projectFilterOptions = useMemo(
115 () =>124 () =>
116 buildHomeProjectScopes({125 buildHomeProjectScopes({
...@@ -130,7 +139,7 @@ export function HomeRouteScreen() {...@@ -130,7 +139,7 @@ export function HomeRouteScreen() {
130 ) {139 ) {
131 setSelectedProjectKey(null);140 setSelectedProjectKey(null);
132 }141 }
133 }, [projectFilterOptions, selectedProjectKey]);142 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
134143
135 // In split layouts the persistent sidebar IS the thread list — Home becomes144 // In split layouts the persistent sidebar IS the thread list — Home becomes
136 // an empty detail pane so selecting a thread never transitions layouts.145 // an empty detail pane so selecting a thread never transitions layouts.
apps/mobile/src/features/home/home-list-options.test.ts created+95
...@@ -0,0 +1,95 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { act, createElement, useLayoutEffect } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";
7
8describe("home list options across layout changes", () => {
9 const environmentId = EnvironmentId.make("environment-1");
10 const availableEnvironmentIds = new Set([environmentId]);
11 let root: Root;
12 let latest: ReturnType<typeof useHomeListOptions>;
13
14 function ThreadList() {
15 const state = useHomeListOptions(availableEnvironmentIds);
16 useLayoutEffect(() => {
17 latest = state;
18 });
19 return null;
20 }
21
22 async function showLayout(layout: "compact" | "sidebar") {
23 await act(() => {
24 root.render(
25 createElement(
26 HomeListOptionsProvider,
27 { projectGroupingMode: "repository" },
28 // Replace the list while keeping the workspace provider mounted.
29 createElement(ThreadList, { key: layout }),
30 ),
31 );
32 });
33 }
34
35 beforeEach(() => {
36 // The hook probe renders no DOM, but ReactDOM needs an event target.
37 const document = {
38 nodeType: 9,
39 addEventListener() {},
40 removeEventListener() {},
41 };
42 const container = {
43 nodeType: 1,
44 tagName: "DIV",
45 namespaceURI: "http://www.w3.org/1999/xhtml",
46 ownerDocument: document,
47 addEventListener() {},
48 removeEventListener() {},
49 };
50 vi.stubGlobal("document", document);
51 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
52 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
53 root = createRoot(container as unknown as HTMLElement);
54 });
55
56 afterEach(async () => {
57 await act(() => root.unmount());
58 vi.unstubAllGlobals();
59 });
60
61 it.each([
62 ["compact", "sidebar"],
63 ["sidebar", "compact"],
64 ] as const)(
65 "retains filters through repeated %s → %s round trips and keeps clears",
66 async (initialLayout, otherLayout) => {
67 await showLayout(initialLayout);
68
69 for (const [selectedEnvironmentId, selectedProjectKey] of [
70 [environmentId, "repository:one"],
71 [environmentId, "repository:two"],
72 [environmentId, null],
73 [null, null],
74 [null, "repository:one"],
75 ] as const) {
76 await act(() => {
77 latest.setSelectedEnvironmentId(selectedEnvironmentId);
78 latest.setSelectedProjectKey(selectedProjectKey);
79 });
80 for (let cycle = 0; cycle < 3; cycle += 1) {
81 for (const layout of [otherLayout, initialLayout]) {
82 await showLayout(layout);
83 expect(latest.options).toMatchObject({
84 selectedEnvironmentId,
85 selectedProjectKey,
86 projectGroupingMode: "repository",
87 });
88 }
89 }
90 // Make the next selection (or clear it) from the opposite layout.
91 await showLayout(otherLayout);
92 }
93 },
94 );
95});
apps/mobile/src/features/home/home-list-options.ts+9
...@@ -16,6 +16,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList";...@@ -16,6 +16,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList";
1616
17export interface HomeListOptions {17export interface HomeListOptions {
18 readonly selectedEnvironmentId: EnvironmentId | null;18 readonly selectedEnvironmentId: EnvironmentId | null;
19 readonly selectedProjectKey: string | null;
19 readonly projectSortOrder: HomeProjectSortOrder;20 readonly projectSortOrder: HomeProjectSortOrder;
20}21}
2122
...@@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions {...@@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
26function defaultHomeListOptions(): HomeListOptions {27function defaultHomeListOptions(): HomeListOptions {
27 return {28 return {
28 selectedEnvironmentId: null,29 selectedEnvironmentId: null,
30 selectedProjectKey: null,
29 projectSortOrder:31 projectSortOrder:
30 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"32 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
31 ? "updated_at"33 ? "updated_at"
...@@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm...@@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm
78 const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {80 const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {
79 setOptions((current) => ({ ...current, selectedEnvironmentId: value }));81 setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
80 }, []);82 }, []);
83 const setSelectedProjectKey = useCallback(
84 (value: string | null) => {
85 setOptions((current) => ({ ...current, selectedProjectKey: value }));
86 },
87 [setOptions],
88 );
81 const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {89 const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {
82 setOptions((current) => ({ ...current, projectSortOrder: value }));90 setOptions((current) => ({ ...current, projectSortOrder: value }));
83 }, []);91 }, []);
84 return {92 return {
85 options: resolvedOptions,93 options: resolvedOptions,
86 setSelectedEnvironmentId,94 setSelectedEnvironmentId,
95 setSelectedProjectKey,
87 setProjectSortOrder,96 setProjectSortOrder,
88 } as const;97 } as const;
89}98}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+13-5
...@@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane(...@@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane(
175 () => new Set(environments.map((environment) => environment.environmentId)),175 () => new Set(environments.map((environment) => environment.environmentId)),
176 [environments],176 [environments],
177 );177 );
178 const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds);178 const { options, setSelectedEnvironmentId, setSelectedProjectKey } =
179 useHomeListOptions(availableEnvironmentIds);
179 const searchEnvironmentIds = useMemo(180 const searchEnvironmentIds = useMemo(
180 () =>181 () =>
181 options.selectedEnvironmentId === null182 options.selectedEnvironmentId === null
...@@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane(...@@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane(
205 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),206 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
206 [threadSearch.matches],207 [threadSearch.matches],
207 );208 );
208 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);209 const selectedProjectKey = options.selectedProjectKey;
209 const projectScopes = useMemo(210 const projectScopes = useMemo(
210 () =>211 () =>
211 buildHomeProjectScopes({212 buildHomeProjectScopes({
...@@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane(...@@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane(
252 ) {253 ) {
253 setSelectedProjectKey(null);254 setSelectedProjectKey(null);
254 }255 }
255 }, [projectFilterOptions, selectedProjectKey]);256 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
256 const selectedProjectRefs = useMemo(257 const selectedProjectRefs = useMemo(
257 () =>258 () =>
258 selectedProjectScope === null259 selectedProjectScope === null
...@@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane(...@@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane(
550 return;551 return;
551 }552 }
552 },553 },
553 [environments, projectFilterOptions, setSelectedEnvironmentId],554 [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey],
554 );555 );
555556
556 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);557 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
...@@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane(...@@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane(
853 onEnvironmentChange: setSelectedEnvironmentId,854 onEnvironmentChange: setSelectedEnvironmentId,
854 onProjectChange: setSelectedProjectKey,855 onProjectChange: setSelectedProjectKey,
855 }),856 }),
856 [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId],857 [
858 environments,
859 options,
860 projectFilterOptions,
861 selectedProjectKey,
862 setSelectedEnvironmentId,
863 setSelectedProjectKey,
864 ],
857 );865 );
858 const nativeHeaderItems = useMemo(866 const nativeHeaderItems = useMemo(
859 () =>867 () =>