| author | |
| committer | |
| log | b9bf60907fb6eef292da7f19d464a5b16f82ee6b |
| tree | bdb12c48cb3d691ac04176207a6c8ad49d5021fe |
| parent | b4af60253bf013ebd146e23d8f66af94854da5d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 132 insertions(+), 11 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+15-6| ... | ... | @@ -37,7 +37,8 @@ export function HomeRouteScreen() { |
| 37 | 37 | const nativePrimaryColumn = use(NativePrimaryColumnContext); |
| 38 | 38 | const columnMetrics = useNativeColumnLayoutMetrics(); |
| 39 | 39 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; |
| 40 | const { layout, panes } = useAdaptiveWorkspaceLayout(); | |
| 40 | const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } = | |
| 41 | useAdaptiveWorkspaceLayout(); | |
| 41 | 42 | const projects = useProjects(); |
| 42 | 43 | // Streaming turns rewrite thread shells many times a second. While a Thread |
| 43 | 44 | // covers Home, rebuilding this list is invisible work. |
| ... | ... | @@ -49,7 +50,12 @@ export function HomeRouteScreen() { |
| 49 | 50 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| 50 | 51 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 51 | 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 | 59 | const handleSelectThread = useHomeThreadSelection(); |
| 54 | 60 | const handleNewThreadOnBranch = useCallback( |
| 55 | 61 | (thread: EnvironmentThreadShell) => { |
| ... | ... | @@ -107,10 +113,13 @@ export function HomeRouteScreen() { |
| 107 | 113 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 108 | 114 | [environments], |
| 109 | 115 | ); |
| 110 | const { options: listOptions, setSelectedEnvironmentId } = | |
| 111 | useHomeListOptions(availableEnvironmentIds); | |
| 116 | const { | |
| 117 | options: listOptions, | |
| 118 | setSelectedEnvironmentId, | |
| 119 | setSelectedProjectKey, | |
| 120 | } = useHomeListOptions(availableEnvironmentIds); | |
| 112 | 121 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; |
| 113 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | |
| 122 | const selectedProjectKey = listOptions.selectedProjectKey; | |
| 114 | 123 | const projectFilterOptions = useMemo( |
| 115 | 124 | () => |
| 116 | 125 | buildHomeProjectScopes({ |
| ... | ... | @@ -130,7 +139,7 @@ export function HomeRouteScreen() { |
| 130 | 139 | ) { |
| 131 | 140 | setSelectedProjectKey(null); |
| 132 | 141 | } |
| 133 | }, [projectFilterOptions, selectedProjectKey]); | |
| 142 | }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); | |
| 134 | 143 | |
| 135 | 144 | // In split layouts the persistent sidebar IS the thread list — Home becomes |
| 136 | 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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { act, createElement, useLayoutEffect } from "react"; | |
| 3 | import { createRoot, type Root } from "react-dom/client"; | |
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; | |
| 7 | ||
| 8 | describe("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 | 16 | |
| 17 | 17 | export interface HomeListOptions { |
| 18 | 18 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 19 | readonly selectedProjectKey: string | null; | |
| 19 | 20 | readonly projectSortOrder: HomeProjectSortOrder; |
| 20 | 21 | } |
| 21 | 22 | |
| ... | ... | @@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions { |
| 26 | 27 | function defaultHomeListOptions(): HomeListOptions { |
| 27 | 28 | return { |
| 28 | 29 | selectedEnvironmentId: null, |
| 30 | selectedProjectKey: null, | |
| 29 | 31 | projectSortOrder: |
| 30 | 32 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" |
| 31 | 33 | ? "updated_at" |
| ... | ... | @@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm |
| 78 | 80 | const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { |
| 79 | 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 | 89 | const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { |
| 82 | 90 | setOptions((current) => ({ ...current, projectSortOrder: value })); |
| 83 | 91 | }, []); |
| 84 | 92 | return { |
| 85 | 93 | options: resolvedOptions, |
| 86 | 94 | setSelectedEnvironmentId, |
| 95 | setSelectedProjectKey, | |
| 87 | 96 | setProjectSortOrder, |
| 88 | 97 | } as const; |
| 89 | 98 | } |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+13-5| ... | ... | @@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane( |
| 175 | 175 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 176 | 176 | [environments], |
| 177 | 177 | ); |
| 178 | const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); | |
| 178 | const { options, setSelectedEnvironmentId, setSelectedProjectKey } = | |
| 179 | useHomeListOptions(availableEnvironmentIds); | |
| 179 | 180 | const searchEnvironmentIds = useMemo( |
| 180 | 181 | () => |
| 181 | 182 | options.selectedEnvironmentId === null |
| ... | ... | @@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane( |
| 205 | 206 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), |
| 206 | 207 | [threadSearch.matches], |
| 207 | 208 | ); |
| 208 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | |
| 209 | const selectedProjectKey = options.selectedProjectKey; | |
| 209 | 210 | const projectScopes = useMemo( |
| 210 | 211 | () => |
| 211 | 212 | buildHomeProjectScopes({ |
| ... | ... | @@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane( |
| 252 | 253 | ) { |
| 253 | 254 | setSelectedProjectKey(null); |
| 254 | 255 | } |
| 255 | }, [projectFilterOptions, selectedProjectKey]); | |
| 256 | }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); | |
| 256 | 257 | const selectedProjectRefs = useMemo( |
| 257 | 258 | () => |
| 258 | 259 | selectedProjectScope === null |
| ... | ... | @@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane( |
| 550 | 551 | return; |
| 551 | 552 | } |
| 552 | 553 | }, |
| 553 | [environments, projectFilterOptions, setSelectedEnvironmentId], | |
| 554 | [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey], | |
| 554 | 555 | ); |
| 555 | 556 | |
| 556 | 557 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| ... | ... | @@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane( |
| 853 | 854 | onEnvironmentChange: setSelectedEnvironmentId, |
| 854 | 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 | 866 | const nativeHeaderItems = useMemo( |
| 859 | 867 | () => |