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() {
3737 const nativePrimaryColumn = use(NativePrimaryColumnContext);
3838 const columnMetrics = useNativeColumnLayoutMetrics();
3939 const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth;
40 const { layout, panes } = useAdaptiveWorkspaceLayout();
40 const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } =
41 useAdaptiveWorkspaceLayout();
4142 const projects = useProjects();
4243 // Streaming turns rewrite thread shells many times a second. While a Thread
4344 // covers Home, rebuilding this list is invisible work.
......@@ -49,7 +50,12 @@ export function HomeRouteScreen() {
4950 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
5051 const { savedConnectionsById } = useSavedRemoteConnections();
5152 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;
5359 const handleSelectThread = useHomeThreadSelection();
5460 const handleNewThreadOnBranch = useCallback(
5561 (thread: EnvironmentThreadShell) => {
......@@ -107,10 +113,13 @@ export function HomeRouteScreen() {
107113 () => new Set(environments.map((environment) => environment.environmentId)),
108114 [environments],
109115 );
110 const { options: listOptions, setSelectedEnvironmentId } =
111 useHomeListOptions(availableEnvironmentIds);
116 const {
117 options: listOptions,
118 setSelectedEnvironmentId,
119 setSelectedProjectKey,
120 } = useHomeListOptions(availableEnvironmentIds);
112121 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
113 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
122 const selectedProjectKey = listOptions.selectedProjectKey;
114123 const projectFilterOptions = useMemo(
115124 () =>
116125 buildHomeProjectScopes({
......@@ -130,7 +139,7 @@ export function HomeRouteScreen() {
130139 ) {
131140 setSelectedProjectKey(null);
132141 }
133 }, [projectFilterOptions, selectedProjectKey]);
142 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
134143
135144 // In split layouts the persistent sidebar IS the thread list — Home becomes
136145 // 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";
1616
1717export interface HomeListOptions {
1818 readonly selectedEnvironmentId: EnvironmentId | null;
19 readonly selectedProjectKey: string | null;
1920 readonly projectSortOrder: HomeProjectSortOrder;
2021}
2122
......@@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
2627function defaultHomeListOptions(): HomeListOptions {
2728 return {
2829 selectedEnvironmentId: null,
30 selectedProjectKey: null,
2931 projectSortOrder:
3032 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
3133 ? "updated_at"
......@@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm
7880 const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => {
7981 setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
8082 }, []);
83 const setSelectedProjectKey = useCallback(
84 (value: string | null) => {
85 setOptions((current) => ({ ...current, selectedProjectKey: value }));
86 },
87 [setOptions],
88 );
8189 const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => {
8290 setOptions((current) => ({ ...current, projectSortOrder: value }));
8391 }, []);
8492 return {
8593 options: resolvedOptions,
8694 setSelectedEnvironmentId,
95 setSelectedProjectKey,
8796 setProjectSortOrder,
8897 } as const;
8998}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+13-5
......@@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane(
175175 () => new Set(environments.map((environment) => environment.environmentId)),
176176 [environments],
177177 );
178 const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds);
178 const { options, setSelectedEnvironmentId, setSelectedProjectKey } =
179 useHomeListOptions(availableEnvironmentIds);
179180 const searchEnvironmentIds = useMemo(
180181 () =>
181182 options.selectedEnvironmentId === null
......@@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane(
205206 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
206207 [threadSearch.matches],
207208 );
208 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
209 const selectedProjectKey = options.selectedProjectKey;
209210 const projectScopes = useMemo(
210211 () =>
211212 buildHomeProjectScopes({
......@@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane(
252253 ) {
253254 setSelectedProjectKey(null);
254255 }
255 }, [projectFilterOptions, selectedProjectKey]);
256 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
256257 const selectedProjectRefs = useMemo(
257258 () =>
258259 selectedProjectScope === null
......@@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane(
550551 return;
551552 }
552553 },
553 [environments, projectFilterOptions, setSelectedEnvironmentId],
554 [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey],
554555 );
555556
556557 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
......@@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane(
853854 onEnvironmentChange: setSelectedEnvironmentId,
854855 onProjectChange: setSelectedProjectKey,
855856 }),
856 [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId],
857 [
858 environments,
859 options,
860 projectFilterOptions,
861 selectedProjectKey,
862 setSelectedEnvironmentId,
863 setSelectedProjectKey,
864 ],
857865 );
858866 const nativeHeaderItems = useMemo(
859867 () =>