diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7f76d91066130..d587cc6fcb88b 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -2,7 +2,7 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect } from "react"; +import { use, useEffect, useId, useLayoutEffect, useState } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -78,15 +78,59 @@ export function AppBar(props: AppBarProps) { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; - const compactSearch = + // Reserve mail-toolbar chrome from the first frame even before search attaches, so menus[0] + // never flashes as a trailing bar button during the push. + const wantsCompactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && usesNativeMailSearchToolbar; - const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); + // Token identity: search stays detached until transitionEnd marks this key ready. + const searchAttachKey = wantsCompactSearch ? `${headerId}:compact` : null; + const [attachedSearchKey, setAttachedSearchKey] = useState(null); + const searchAttached = searchAttachKey === null || attachedSearchKey === searchAttachKey; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches native search after the push so UIKit does not flash its trailing bar-button form + useEffect(() => { + if (searchAttachKey === null) return; + let done = false; + let opening = false; + const finish = () => { + if (done) return; + done = true; + setAttachedSearchKey(searchAttachKey); + }; + const stackNavigation = navigation as unknown as { + readonly addListener: ( + type: "transitionStart" | "transitionEnd", + listener: (event: { readonly data: { readonly closing: boolean } }) => void, + ) => () => void; + } | null; + const removeStart = stackNavigation?.addListener("transitionStart", ({ data }) => { + if (!data.closing) opening = true; + }); + const removeEnd = stackNavigation?.addListener("transitionEnd", ({ data }) => { + if (!data.closing) finish(); + }); + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!opening) finish(); + }); + }); + const fallback = setTimeout(finish, 1_000); + return () => { + done = true; + removeStart?.(); + removeEnd?.(); + cancelAnimationFrame(frame); + clearTimeout(fallback); + }; + }, [navigation, searchAttachKey]); + const compactSearch = wantsCompactSearch && searchAttached; + const activeSearch = wantsCompactSearch && !searchAttached ? undefined : search; + const visibleMenus = wantsCompactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); // Where the mail toolbar isn't used, the Duo keeps search at the bottom of the pane like its // workspace lists; elsewhere inline search is a button in the bar. const duoBottomSearch = - search !== undefined && + activeSearch !== undefined && !compactSearch && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && isDuoDevice(deviceType); @@ -94,7 +138,11 @@ export function AppBar(props: AppBarProps) { return ( <> [ createNativeMailSearchToolbarItem({ showsSearchDismissButton: true, - placeholder: search.compactPlaceholder ?? search.placeholder, - onSearchTextChange: search.onChangeText, + placeholder: activeSearch.compactPlaceholder ?? activeSearch.placeholder, + onSearchTextChange: activeSearch.onChangeText, searchTextChangeId: `${headerId}-search-text`, ...(menu ? { @@ -133,20 +181,25 @@ export function AppBar(props: AppBarProps) { allowToolbarIntegration: true, ...(duoBottomSearch ? { placement: "integrated" as const } - : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline" + : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && activeSearch.mode === "inline" ? { placement: "integratedButton" as const } : undefined), autoCapitalize: "none", hideNavigationBar: false, - ...(search.mode === "inline" || duoBottomSearch + ...(activeSearch.mode === "inline" || duoBottomSearch ? { obscureBackground: false } : undefined), - placeholder: search.placeholder, - onChangeText: (event) => search.onChangeText(event.nativeEvent.text), - onCancelButtonPress: () => search.onChangeText(""), + placeholder: activeSearch.placeholder, + onChangeText: (event) => activeSearch.onChangeText(event.nativeEvent.text), + onCancelButtonPress: () => activeSearch.onChangeText(""), }, } - : undefined), + : wantsCompactSearch + ? { + // Keep the mail-toolbar filter slot reserved before search attaches. + unstable_headerToolbarItems: () => [], + } + : undefined), }} /> {props.onClose ? ( diff --git a/apps/mobile/src/components/ui/RowPressable.tsx b/apps/mobile/src/components/ui/RowPressable.tsx index 6f37d9beba61e..9620504aa0656 100644 --- a/apps/mobile/src/components/ui/RowPressable.tsx +++ b/apps/mobile/src/components/ui/RowPressable.tsx @@ -20,12 +20,16 @@ export function RowPressable({ return ( - {() => ( + {({ pressed }) => ( <> {children} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index fd3ce42403c8a..b48b8e58b46ea 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -423,8 +423,10 @@ export function ArchivedThreadsScreen(props: { }} contentInsetAdjustmentBehavior="automatic" data={listItems} + drawDistance={500} estimatedItemSize={62} getItemType={(item) => item.kind} + itemsAreEqual={(previous, next) => previous === next} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" keyExtractor={(item) => item.key} @@ -433,6 +435,7 @@ export function ArchivedThreadsScreen(props: { props.error ? : null } onScrollBeginDrag={() => openSwipeableRef.current?.close()} + recycleItems refreshControl={ + const body = ( + <> ) : null} + + ); + + // Details push doesn't expand inline — skip layout animation work on that path. + if (props.opensDetails) { + return {body}; + } + return ( + + {body} ); } diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 279269b8a13c3..7204099a8bb6d 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -4,7 +4,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, RefreshControl, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -209,7 +209,11 @@ export function FileTreeBrowser(props: { /> ); - const extraData = { expandedPaths, loadingDirectories, selectedPath }; + // Keep LegendList from refreshing every visible row on parent ticks that rebuild this object. + const extraData = useMemo( + () => ({ expandedPaths, loadingDirectories, selectedPath }), + [expandedPaths, loadingDirectories, selectedPath], + ); // Keep the list as direct screen content so UIKit can apply header insets. return ( @@ -226,6 +230,7 @@ export function FileTreeBrowser(props: { keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" estimatedItemSize={42} + drawDistance={500} recycleItems maintainVisibleContentPosition extraData={extraData} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 9ee512d9a1421..24280221b406a 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -392,9 +392,9 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { refreshControl={refreshControl} data={lines} keyExtractor={(_line, index) => String(index)} - initialNumToRender={80} - maxToRenderPerBatch={80} - windowSize={12} + initialNumToRender={24} + maxToRenderPerBatch={24} + windowSize={7} {...(codeWordBreak ? {} : { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 11fa0a0c7af46..d64f0561f9ce9 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -8,7 +8,14 @@ import { usePreventRemove, type StaticScreenProps, } from "@react-navigation/native"; -import { type ComponentProps, type ReactNode, useId, useState } from "react"; +import { + type ComponentProps, + type ReactNode, + startTransition, + useEffect, + useId, + useState, +} from "react"; import { Platform, View } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; @@ -20,6 +27,7 @@ import { } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; import { DelimitedFilePreview } from "./DelimitedFilePreview"; @@ -460,6 +468,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const navigation = useNavigation(); const { fileInspector } = useWorkspacePaneState(); const [searchQuery, setSearchQuery] = useState(""); + // Push presents with the Files bar first; LegendList mounts after the open frame. + const [treeReady, setTreeReady] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- defers the file tree until after the Files route open frame + useEffect(() => { + startTransition(() => { + setTreeReady(true); + }); + }, []); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, @@ -584,28 +600,34 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { onBack={handleReturnToThread} /> - { - entriesQuery.selectPath(path); - handleSelectFile(path); - }} - /> + {treeReady ? ( + { + entriesQuery.selectPath(path); + handleSelectFile(path); + }} + /> + ) : ( + + + + )} diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index dd95959b30899..afa85f93d5440 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,9 +1,10 @@ import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "../../state/session"; import type { EnvironmentId } from "@t3tools/contracts"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { useState } from "react"; +import { startTransition, useEffect, useState } from "react"; import { View } from "react-native"; import { useEnvironmentQuery } from "../../state/query"; @@ -20,6 +21,14 @@ export function ThreadFileNavigatorPane(props: { readonly selectedPath: string | null; readonly onSelectFile: (path: string) => void; }) { + // Sheet/column present with the Files bar first; LegendList mounts after the open frame. + const [treeReady, setTreeReady] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- defers the file tree until after the inspector open frame + useEffect(() => { + startTransition(() => { + setTreeReady(true); + }); + }, []); const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const fileAccessSession = useEnvironmentQuery( @@ -46,7 +55,7 @@ export function ThreadFileNavigatorPane(props: { theme: highlightTheme, }); }; - const fileTree = ( + const fileTree = treeReady ? ( + ) : ( + + + ); return ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index f1b163461b6c7..9b87cca4389f9 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -101,22 +101,33 @@ export function HomeRouteScreen() { const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(selectThreadRoute); + // Labels/ids only — keep filter UIMenu actions stable across reconnect phase churn. + const filterEnvironments = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput( + Order.String, + (environment: { readonly label: string }) => environment.label, + ), + ), + [savedConnectionsById], + ); const connectionStateByEnvironmentId = new Map( workspaceEnvironments.map( (environment) => [environment.environmentId, environment.connectionState] as const, ), ); - const environments = Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - connectionState: connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", - })), - Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), - ); + const environments = filterEnvironments.map((environment) => ({ + ...environment, + connectionState: connectionStateByEnvironmentId.get(environment.environmentId) ?? "available", + })); const availableEnvironmentIds = useMemo( - () => new Set(environments.map((environment) => environment.environmentId)), - [environments], + () => new Set(filterEnvironments.map((environment) => environment.environmentId)), + [filterEnvironments], ); const { options: listOptions, @@ -179,7 +190,7 @@ export function HomeRouteScreen() { filter={{ customized: selectedEnvironmentId !== null || selectedProjectKey !== null, items: buildHomeListFilterMenuItems({ - environments, + environments: filterEnvironments, projects: projectFilterOptions, selectedEnvironmentId, selectedProjectKey, @@ -192,6 +203,8 @@ export function HomeRouteScreen() { openSettings(navigation, "SettingsEnvironmentNew")} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f23..cad5f022fb2fa 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -7,7 +7,10 @@ import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSi import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { LegendListRef } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; -import { THREAD_SIDEBAR_ROW_LAYOUT } from "../threads/threadListAnimations"; +import { + threadListShelfExpansionKey, + useThreadListShelfLayoutAnimation, +} from "../threads/threadListAnimations"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -16,7 +19,15 @@ import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-gro import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { use, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { + use, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { Platform, ScrollView, @@ -62,6 +73,7 @@ import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { + ThreadListShelfChromeProvider, ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, @@ -79,7 +91,10 @@ import { type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; -import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; +import type { + HomeListFilterMenuEnvironment, + HomeListFilterMenuProject, +} from "./home-list-filter-menu"; import { buildHomeProjectScopes, findHomeProjectScope, @@ -100,6 +115,9 @@ interface HomeScreenProps { readonly environments: ReadonlyArray< HomeListFilterMenuEnvironment & Pick >; + /** Stable across reconnect phase churn — drives the iOS filter UIMenu actions. */ + readonly filterEnvironments: ReadonlyArray; + readonly filterProjects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -143,7 +161,7 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // Rows away from the viewport skip their swipe actions (see // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. -const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; +const THREAD_LIST_V2_DRAW_DISTANCE = 500; function deriveEmptyState(props: { readonly catalogState: WorkspaceState; @@ -285,17 +303,20 @@ export function HomeScreen(props: HomeScreenProps) { 24, workspace?.bottomClearance ?? 0, ); + // Chip labels update on the press frame; the list catches up on the deferred pass. + const listEnvironmentId = useDeferredValue(props.selectedEnvironmentId); + const listProjectKey = useDeferredValue(props.selectedProjectKey); const searchEnvironmentIds = - props.selectedEnvironmentId === null + listEnvironmentId === null ? props.environments .filter((environment) => environment.connectionState === "connected") .map((environment) => environment.environmentId) : props.environments.some( (environment) => - environment.environmentId === props.selectedEnvironmentId && + environment.environmentId === listEnvironmentId && environment.connectionState === "connected", ) - ? [props.selectedEnvironmentId] + ? [listEnvironmentId] : []; const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { @@ -369,30 +390,47 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); - const { projects, selectedEnvironmentId, projectGroupingMode } = props; - const projectScopes = useMemo( + const { projects, projectGroupingMode } = props; + // Filter chips keep the urgent selection; list scopes follow the deferred ids. + const filterProjectScopes = useMemo( () => buildHomeProjectScopes({ projects, - environmentId: selectedEnvironmentId, + environmentId: props.selectedEnvironmentId, projectGroupingMode, }), - [projects, selectedEnvironmentId, projectGroupingMode], + [projects, props.selectedEnvironmentId, projectGroupingMode], + ); + const listProjectScopes = useMemo( + () => + listEnvironmentId === props.selectedEnvironmentId + ? filterProjectScopes + : buildHomeProjectScopes({ + projects, + environmentId: listEnvironmentId, + projectGroupingMode, + }), + [ + filterProjectScopes, + listEnvironmentId, + projectGroupingMode, + projects, + props.selectedEnvironmentId, + ], ); const hasSearchQuery = props.searchQuery.trim().length > 0; - const projectByKey = (() => { + const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; - })(); + }, [props.projects]); - const v2ProjectScopeKey = props.selectedProjectKey; // An archived project's filter reads as all projects, matching the filter menu. const v2ScopedProjectGroup = findHomeProjectScope( - pickableProjectGroups(projectScopes), - v2ProjectScopeKey, + pickableProjectGroups(listProjectScopes), + listProjectKey, ); const threadSearch = useThreadSearch( searchEnvironmentIds, @@ -402,13 +440,20 @@ export function HomeScreen(props: HomeScreenProps) { : { projectIds: v2ScopedProjectGroup.projectRefs.map((ref) => ref.projectId) }, ); const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch); - const v2ProjectTitleByProjectKey = new Map( - projectScopes.flatMap((scope) => - scope.projectRefs.map( - (projectRef) => - [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title] as const, + const v2ProjectTitleByProjectKey = useMemo( + () => + new Map( + listProjectScopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), ), - ), + [listProjectScopes], ); const handleSettleThread = props.onSettleThread; const handleSnoozeThread = props.onSnoozeThread; @@ -424,7 +469,7 @@ export function HomeScreen(props: HomeScreenProps) { const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); - const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const settledResetKey = `${listEnvironmentId ?? "all"}:${listProjectKey ?? "all"}:${props.searchQuery.trim()}`; const [lastSettledResetKey, setLastSettledResetKey] = useState(settledResetKey); if (lastSettledResetKey !== settledResetKey) { setLastSettledResetKey(settledResetKey); @@ -442,6 +487,17 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); + // Chevron reads the urgent flags; the list inserts/removes rows on the deferred pass. + const listWorkingShelfExpanded = useDeferredValue(workingShelfExpanded); + const listSnoozedShelfExpanded = useDeferredValue(snoozedShelfExpanded); + const listSettledShelfExpanded = useDeferredValue(settledShelfExpanded); + const shelfLayoutAnimation = useThreadListShelfLayoutAnimation( + threadListShelfExpansionKey({ + workingShelfExpanded: listWorkingShelfExpanded, + snoozedShelfExpanded: listSnoozedShelfExpanded, + settledShelfExpanded: listSettledShelfExpanded, + }), + ); const now = useThreadListNow(props.threads); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { @@ -466,7 +522,7 @@ export function HomeScreen(props: HomeScreenProps) { } = useThreadListV2({ threads: props.threads, pendingTasks: props.pendingTasks, - environmentId: props.selectedEnvironmentId, + environmentId: listEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, searchMatches: threadSearchMatchByKey, @@ -480,9 +536,9 @@ export function HomeScreen(props: HomeScreenProps) { pendingOrder, shelfPreferencesLoaded, workingShelfEnabled, - workingShelfExpanded, - snoozedShelfExpanded, - settledShelfExpanded, + workingShelfExpanded: listWorkingShelfExpanded, + snoozedShelfExpanded: listSnoozedShelfExpanded, + settledShelfExpanded: listSettledShelfExpanded, selectedThreadKey, }); // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole @@ -503,7 +559,10 @@ export function HomeScreen(props: HomeScreenProps) { useThreadJumpShortcuts(threadListV2Items, selectThread); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the list's visible range after commit to enable swipe gestures on those rows useEffect(() => { - if (swipeEnabled) activateVisibleRows(threadListV2Items); + if (!swipeEnabled) return; + // Defer past filter/shelf paint so arming swipeables does not compete with row mount. + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout(() => activateVisibleRows(threadListV2Items), 32); }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = ({ item }: { readonly item: ThreadListV2ListItem }) => { @@ -647,27 +706,48 @@ export function HomeScreen(props: HomeScreenProps) { const v2KeyExtractor = (item: ThreadListV2ListItem) => item.key; // extraData changes invalidate every visible row; put the minute clock on items to limit updates. - const v2ExtraData = { - activeNewTask, - projectByKey, - projectTitleByProjectKey: v2ProjectTitleByProjectKey, - listEnvironments, - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - selectedThreadKey, - threadSearchMatchByKey, - workingShelfEnabled, - }; + // Shelf chevrons use ThreadListShelfChromeProvider — keep them out of extraData. + const v2ExtraData = useMemo( + () => ({ + activeNewTask, + projectByKey, + projectTitleByProjectKey: v2ProjectTitleByProjectKey, + listEnvironments, + savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + selectedThreadKey, + threadSearchMatchByKey, + workingShelfEnabled, + }), + [ + activeNewTask, + projectByKey, + v2ProjectTitleByProjectKey, + listEnvironments, + props.savedConnectionsById, + props.searchQuery, + selectedThreadKey, + threadSearchMatchByKey, + workingShelfEnabled, + ], + ); + const shelfChrome = useMemo( + () => ({ + workingExpanded: workingShelfExpanded, + snoozedExpanded: snoozedShelfExpanded, + settledExpanded: settledShelfExpanded, + }), + [workingShelfExpanded, snoozedShelfExpanded, settledShelfExpanded], + ); const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0 || threadListV2Layout.settledCount > 0; const selectedEnvironmentLabel = - props.selectedEnvironmentId === null + listEnvironmentId === null ? null - : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? - "this environment"); + : (props.savedConnectionsById[listEnvironmentId]?.environmentLabel ?? "this environment"); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, @@ -733,11 +813,8 @@ export function HomeScreen(props: HomeScreenProps) { {Platform.OS === "ios" && workspace ? ( ({ - key: scope.key, - label: scope.title, - }))} + environments={props.filterEnvironments} + projects={props.filterProjects} selectedEnvironmentId={props.selectedEnvironmentId} selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} @@ -792,70 +869,74 @@ export function HomeScreen(props: HomeScreenProps) { - - activateVisibleRows(threadListV2Items)} - onTouchStart={(event) => trackListTouches(event, true)} - onTouchEnd={(event) => trackListTouches(event, false)} - onTouchCancel={(event) => trackListTouches(event, false)} - renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} - data={threadListV2Items} - renderItem={renderV2Item} - keyExtractor={v2KeyExtractor} - getItemType={(item) => item.type} - itemsAreEqual={threadListV2ListItemsAreEqual} - estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} - drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} - recycleItems - extraData={v2ExtraData} - ListHeaderComponent={v2ListHeader} - // Lifted above the rows so an iOS filter menu's dismissal morph is not covered. - ListHeaderComponentStyle={{ zIndex: 1 }} - ListFooterComponent={ - settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( - { - olderSettledShelf.retry(); - showMoreSettled(); - }} - /> - ) : null - } - ListEmptyComponent={v2ListEmpty} - style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior="never" - contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined} - initialScrollOffset={ - initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined - } - showsVerticalScrollIndicator={false} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - {...scrollGateHandlers} - scrollEventThrottle={16} - contentContainerStyle={{ - paddingLeft: - (contentSideInsets?.left ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter, - paddingRight: - (contentSideInsets?.right ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter, - paddingBottom: - Platform.OS === "ios" - ? iosBottomClearance - : (workspace?.bottomClearance ?? - Math.max(insets.bottom, 16) + - (Platform.OS === "android" ? fabClearance : 88)), - }} - /> - + + + activateVisibleRows(threadListV2Items)} + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} + data={threadListV2Items} + renderItem={renderV2Item} + keyExtractor={v2KeyExtractor} + getItemType={(item) => item.type} + itemsAreEqual={threadListV2ListItemsAreEqual} + estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} + drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} + recycleItems + extraData={v2ExtraData} + ListHeaderComponent={v2ListHeader} + // Lifted above the rows so an iOS filter menu's dismissal morph is not covered. + ListHeaderComponentStyle={{ zIndex: 1 }} + ListFooterComponent={ + listSettledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( + { + olderSettledShelf.retry(); + showMoreSettled(); + }} + /> + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior="never" + contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined} + initialScrollOffset={ + initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined + } + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingLeft: + (contentSideInsets?.left ?? 0) + (sidebarSurface ? 8 : 0) + androidListGutter, + paddingRight: + (contentSideInsets?.right ?? 0) + + (sidebarSurface ? 8 : 0) + + androidListGutter, + paddingBottom: + Platform.OS === "ios" + ? iosBottomClearance + : (workspace?.bottomClearance ?? + Math.max(insets.bottom, 16) + + (Platform.OS === "android" ? fabClearance : 88)), + }} + /> + + diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index 196e6a86480b5..d01a4350b21cb 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -1,13 +1,16 @@ import { Pressable, ScrollView } from "react-native"; +import { memo, useMemo } from "react"; import { SymbolView } from "../../components/ui/AppSymbol"; import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { - HomeListFilterMenuEnvironment, - HomeListFilterMenuProject, +import { + buildHomeEnvironmentFilterActions, + buildHomeProjectFilterActions, + type HomeListFilterMenuEnvironment, + type HomeListFilterMenuProject, } from "./home-list-filter-menu"; -export function ThreadListFilters(props: { +export const ThreadListFilters = memo(function ThreadListFilters(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; @@ -23,6 +26,22 @@ export function ThreadListFilters(props: { const selectedProject = props.projects.find( (project) => project.key === props.selectedProjectKey, ); + const environmentActions = useMemo( + () => + buildHomeEnvironmentFilterActions({ + environments: props.environments, + selectedEnvironmentId: props.selectedEnvironmentId, + }), + [props.environments, props.selectedEnvironmentId], + ); + const projectActions = useMemo( + () => + buildHomeProjectFilterActions({ + projects: props.projects, + selectedProjectKey: props.selectedProjectKey, + }), + [props.projects, props.selectedProjectKey], + ); return ( ({ - id: JSON.stringify(environment.environmentId), - title: environment.label, - state: - environment.environmentId === props.selectedEnvironmentId - ? ("on" as const) - : ("off" as const), - })), - ]} + actions={environmentActions} onSelect={(id) => { if (id === "all") props.onEnvironmentChange(null); else { const environment = props.environments.find( - (candidate) => JSON.stringify(candidate.environmentId) === id, + (candidate) => String(candidate.environmentId) === id, ); if (environment) props.onEnvironmentChange(environment.environmentId); } @@ -63,24 +68,11 @@ export function ThreadListFilters(props: { label={selectedProject?.label ?? "All projects"} accessibilityLabel="Filter threads by project" active={selectedProject !== undefined} - actions={[ - { - id: "all", - title: "All projects", - state: props.selectedProjectKey === null ? "on" : "off", - }, - ...props.projects.map((project) => ({ - id: JSON.stringify(project.key), - title: project.label, - state: project.key === props.selectedProjectKey ? ("on" as const) : ("off" as const), - })), - ]} + actions={projectActions} onSelect={(id) => { if (id === "all") props.onProjectChange(null); else { - const project = props.projects.find( - (candidate) => JSON.stringify(candidate.key) === id, - ); + const project = props.projects.find((candidate) => candidate.key === id); if (project) props.onProjectChange(project.key); } }} @@ -100,4 +92,4 @@ export function ThreadListFilters(props: { ) : null} ); -} +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts new file mode 100644 index 0000000000000..5e33d89e67691 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -0,0 +1,32 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildHomeEnvironmentFilterActions, + buildHomeProjectFilterActions, +} from "./home-list-filter-menu"; + +describe("home list filter menu actions", () => { + it("keeps environment action ids stable for the native UIMenu", () => { + const environmentId = EnvironmentId.make("env-1"); + const actions = buildHomeEnvironmentFilterActions({ + environments: [{ environmentId, label: "MacBook" }], + selectedEnvironmentId: environmentId, + }); + expect(actions).toEqual([ + { id: "all", title: "All environments", state: "off" }, + { id: "env-1", title: "MacBook", state: "on" }, + ]); + }); + + it("keeps project action ids stable for the native UIMenu", () => { + const actions = buildHomeProjectFilterActions({ + projects: [{ key: "env-1:project-1", label: "t3code" }], + selectedProjectKey: null, + }); + expect(actions).toEqual([ + { id: "all", title: "All projects", state: "on" }, + { id: "env-1:project-1", title: "t3code", state: "off" }, + ]); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 617c3fa0988ef..ad94af248708b 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; +import type { MenuProps } from "../../components/ui/Menu.types"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -12,6 +13,45 @@ export interface HomeListFilterMenuProject { readonly label: string; } +export function buildHomeEnvironmentFilterActions(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; +}): MenuProps["actions"] { + return [ + { + id: "all", + title: "All environments", + state: props.selectedEnvironmentId === null ? "on" : "off", + }, + ...props.environments.map((environment) => ({ + id: String(environment.environmentId), + title: environment.label, + state: + environment.environmentId === props.selectedEnvironmentId + ? ("on" as const) + : ("off" as const), + })), + ]; +} + +export function buildHomeProjectFilterActions(props: { + readonly projects: ReadonlyArray; + readonly selectedProjectKey: string | null; +}): MenuProps["actions"] { + return [ + { + id: "all", + title: "All projects", + state: props.selectedProjectKey === null ? "on" : "off", + }, + ...props.projects.map((project) => ({ + id: project.key, + title: project.label, + state: project.key === props.selectedProjectKey ? ("on" as const) : ("off" as const), + })), + ]; +} + export function buildHomeListFilterMenuItems(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; diff --git a/apps/mobile/src/features/home/home-list-options.test.tsx b/apps/mobile/src/features/home/home-list-options.test.tsx index f25d4ff690851..e85eeede9965c 100644 --- a/apps/mobile/src/features/home/home-list-options.test.tsx +++ b/apps/mobile/src/features/home/home-list-options.test.tsx @@ -35,9 +35,11 @@ it("saves the scope from every update made in one event", async () => { , ), ); - act(() => { + await act(async () => { controls!.setSelectedEnvironmentId(ENVIRONMENT); controls!.setSelectedProjectKey("env-1:project-1"); + // Filter scope persists on a microtask so preference writes miss the list rebuild frame. + await Promise.resolve(); }); expect(harness.saved.at(-1)).toEqual({ diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 09c8be2c9a7c1..edf0e13d6f56e 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -54,16 +54,20 @@ export function HomeListOptionsProvider({ const updateOptions = (patch: Partial) => { const current = registry.get(optionsAtom); const next = { ...current, ...patch }; - registry.set(optionsAtom, next); - if ( + const scopeChanged = next.selectedEnvironmentId !== current.selectedEnvironmentId || - next.selectedProjectKey !== current.selectedProjectKey - ) { - savePreferences({ - threadListFilters: { - environmentId: next.selectedEnvironmentId, - projectKey: next.selectedProjectKey, - }, + next.selectedProjectKey !== current.selectedProjectKey; + // Chip labels read this immediately; HomeScreen defers the list rebuild with + // useDeferredValue. Preference writes wait a tick so they miss that rebuild frame. + registry.set(optionsAtom, next); + if (scopeChanged) { + queueMicrotask(() => { + savePreferences({ + threadListFilters: { + environmentId: next.selectedEnvironmentId, + projectKey: next.selectedProjectKey, + }, + }); }); } }; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58e..cb2c810b594de 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -12,11 +12,13 @@ import { type AdaptiveWorkspaceActions, } from "./adaptive-workspace-context"; import { threadSelectionDeferral } from "./deferred-navigation"; +import { useStableEdgeInsetsOverride } from "../../lib/stable-edge-insets"; import { uuidv4 } from "../../lib/uuid"; import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { + startTransition, use, useEffect, useEffectEvent, @@ -53,9 +55,7 @@ import { isWorkspaceListPath, nativeInspectorColumnWidth, } from "../../lib/layout"; -import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; -import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; @@ -103,7 +103,11 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvider"; -import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; +import { + dispatchRootStackReset, + rootStackRoutesUnchanged, + rootStackState, +} from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, workspaceSelectionCheck, @@ -224,21 +228,25 @@ export function AdaptiveWorkspaceLayout(props: { readonly workspaceRouteKey: string | undefined; readonly workspaceRouteName: string | undefined; }) { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - if (!AsyncResult.isSuccess(preferencesResult)) { - return AsyncResult.isFailure(preferencesResult) ? ( - - ) : null; - } - const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); + // Field selectors: filter persistence must not rebuild the workspace tree. + const preferencesStatus = useAtomValue(mobilePreferencesAtom, (result) => + AsyncResult.isSuccess(result) ? "ready" : AsyncResult.isFailure(result) ? "failed" : "loading", + ); + const projectGroupingMode = useAtomValue(mobilePreferencesAtom, (result) => + AsyncResult.isSuccess(result) + ? resolveMobileProjectGroupingSettings(result.value).sidebarProjectGroupingMode + : DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode, + ); + if (preferencesStatus === "loading") return null; return ( ); } @@ -251,9 +259,15 @@ function AdaptiveWorkspaceLayoutContent( readonly workspaceRouteName: string | undefined; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; - readonly savedThreadListFilters?: ThreadListFilters | undefined; + /** When true, read saved thread-list filters once on mount (not on later preference writes). */ + readonly hydrateThreadListFilters?: boolean; }, ) { + const [savedThreadListFilters] = useState(() => { + if (!props.hydrateThreadListFilters) return undefined; + const result = appAtomRegistry.get(mobilePreferencesAtom); + return AsyncResult.isSuccess(result) ? result.value.threadListFilters : undefined; + }); const floatingTabsEnabled = useAndroidFloatingTabs(); const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); @@ -433,48 +447,50 @@ function AdaptiveWorkspaceLayoutContent( // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. const revealedInspectors = useRef>(new Map()); const workspaceInspectorOwner = useRef(null); - const registerWorkspaceInspector = ( - content: InspectorContent, - routeKey: string | undefined, - revealKey?: string, - ) => { - const owner = Symbol("workspace-inspector"); - workspaceInspectorOwner.current = owner; - setWorkspaceInspector({ content, active: true, routeKey }); - if ( - routeKey !== undefined && - revealKey !== undefined && - revealedInspectors.current.get(routeKey) !== revealKey - ) { - const state = rootStackState(navigation); - const live = new Set(state?.routes.slice(0, state.index + 1).map((route) => route.key)); - revealedInspectors.current = new Map( - [...revealedInspectors.current, [routeKey, revealKey] as const].filter( - ([key]) => live.has(key) || key === routeKey, - ), - ); - setFileInspectorPreferredVisible(true); - } - - return () => { - // Outgoing screens deactivate after incoming registration; only the current owner may deactivate. - if (workspaceInspectorOwner.current !== owner) { - return; + // Effect events keep action identities stable so the workspace context does not fan out + // to every screen on navigation ticks that only recreate handlers. + const registerWorkspaceInspector = useEffectEvent( + (content: InspectorContent, routeKey: string | undefined, revealKey?: string) => { + const owner = Symbol("workspace-inspector"); + workspaceInspectorOwner.current = owner; + setWorkspaceInspector({ content, active: true, routeKey }); + if ( + routeKey !== undefined && + revealKey !== undefined && + revealedInspectors.current.get(routeKey) !== revealKey + ) { + const state = rootStackState(navigation); + const live = new Set(state?.routes.slice(0, state.index + 1).map((route) => route.key)); + revealedInspectors.current = new Map( + [...revealedInspectors.current, [routeKey, revealKey] as const].filter( + ([key]) => live.has(key) || key === routeKey, + ), + ); + setFileInspectorPreferredVisible(true); } - setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); - }; - }; + + return () => { + // Outgoing screens deactivate after incoming registration; only the current owner may deactivate. + if (workspaceInspectorOwner.current !== owner) { + return; + } + setWorkspaceInspector((current) => + current === null ? null : { ...current, active: false }, + ); + }; + }, + ); const handleWorkspaceInspectorClosed = () => { setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); }; - const setPrimarySidebarVisible = (visible: boolean) => { + const setPrimarySidebarVisible = useEffectEvent((visible: boolean) => { if (layout.variant === "medium") { setOverlaySidebar((current) => ({ ...current, visible })); } else { setPrimarySidebarPreferredVisible(visible); } - }; - const togglePrimarySidebar = () => { + }); + const togglePrimarySidebar = useEffectEvent(() => { if (primarySidebarPinned) { return; } @@ -484,7 +500,7 @@ function AdaptiveWorkspaceLayoutContent( return; } setPrimarySidebarVisible(!preferredSidebarVisible); - }; + }); const revealPrimarySidebar = () => { if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); @@ -502,7 +518,7 @@ function AdaptiveWorkspaceLayoutContent( setFileInspectorPreferredVisible(false); setInspectorSheetRoute(null); }; - const showInspector = () => { + const showInspector = useEffectEvent(() => { setFileInspectorPreferredVisible(true); if (fileInspector.supported) { setInspectorSheetRoute(null); @@ -511,7 +527,7 @@ function AdaptiveWorkspaceLayoutContent( const state = rootStackState(navigation); const top = workspaceRoutesOf(state?.routes.slice(0, state.index + 1) ?? []).workspace.at(-1); setInspectorSheetRoute(top?.key ?? null); - }; + }); const handleOpenFilesCommand = () => { const activeThread = parseActiveThreadPath(pathname); if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { @@ -525,67 +541,91 @@ function AdaptiveWorkspaceLayoutContent( return true; }; useHardwareKeyboardCommand("files", handleOpenFilesCommand); - const toggleAuxiliaryPane = () => { + const toggleAuxiliaryPane = useEffectEvent(() => { if (fileInspector.supported) { setFileInspectorPreferredVisible((current) => !current); setInspectorSheetRoute(null); } else if (inspectorSheetOpen) dismissInspectorSheet(); else showInspector(); - }; + }); /** * The one way to open a workspace tab: replaces the workspace with the tab's list and, when the * window has columns, the page it last showed, keeping any sheet on top. An overlay sidebar * stays open over that page. */ - const openWorkspaceTab = ( - tab: WorkspaceTab | "settings", - options?: { readonly columns?: boolean }, - ) => { - const state = rootStackState(navigation); - if (!state) return; - const { workspace, overlays } = workspaceRoutesOf(state.routes.slice(0, state.index + 1)); - const next = workspaceTabNavigationState( - { ...state, routes: workspace }, - tab, - currentWorkspaceSelections(), - { - columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), - check: workspaceSelectionCheck, - overlays, - seedPullRequest: withKnownPullRequestSummary, - }, - ); - if (layout.variant === "medium") { - const opened = next.routes.at(-1 - overlays.length)?.key ?? null; - setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened })); - } - dispatchRootStackReset(navigation, state, next); - }; + const openWorkspaceTab = useEffectEvent( + (tab: WorkspaceTab | "settings", options?: { readonly columns?: boolean }) => { + const state = rootStackState(navigation); + if (!state) return; + const { workspace: workspaceRoutes, overlays } = workspaceRoutesOf( + state.routes.slice(0, state.index + 1), + ); + const next = workspaceTabNavigationState( + { ...state, routes: workspaceRoutes }, + tab, + currentWorkspaceSelections(), + { + columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), + check: workspaceSelectionCheck, + overlays, + seedPullRequest: withKnownPullRequestSummary, + }, + ); + if (layout.variant === "medium") { + const opened = next.routes.at(-1 - overlays.length)?.key ?? null; + setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened })); + } + // List-only tab flips keep the same Home route; RESET would re-reconcile the whole stack. + if (rootStackRoutesUnchanged(state, next)) return; + dispatchRootStackReset(navigation, state, next); + }, + ); const handleOpenSettings = () => openWorkspaceTab("settings"); /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ - function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { - threadSelectionDeferral.run(() => { - if (Platform.OS === "android") Keyboard.dismiss(); - setFileInspectorPreferredVisible(false); - setInspectorSheetRoute(null); - if (layout.variant === "medium") setPrimarySidebarVisible(false); - workspace?.dispatch({ - type: "select", - tab: detail.name === "Thread" ? "threads" : "pullRequests", + const handleSelectDetail = useEffectEvent( + (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { + threadSelectionDeferral.run(() => { + // Dispatch first so the native push can start on this frame. Warming, keyboard, and + // chrome updates run after — they used to steal the press frame before the transition. + navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + startOpeningDetail(detail); + queueMicrotask(() => { + Keyboard.dismiss(); + setFileInspectorPreferredVisible(false); + setInspectorSheetRoute(null); + if (layout.variant === "medium") setPrimarySidebarVisible(false); + startTransition(() => { + workspace?.dispatch({ + type: "select", + tab: detail.name === "Thread" ? "threads" : "pullRequests", + }); + }); + }); }); - startOpeningDetail(detail); - navigation.dispatch(selectWorkspaceDetail(detail, sheet)); - }); - } + }, + ); const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => handleSelectDetail({ name: "Thread", params }); - const projects = useProjects(); const compactAndroidSettings = Platform.OS === "android" && androidSettingsOpen && !layout.usesSplitView; - // Reads the open page when pressed, so the layout context doesn't change on every navigation. - const handleStartNewTask = () => { + const contentSafeAreaOverride = + floatingTabsEnabled && (compactAndroidSettings || (layout.usesSplitView && workspacePage)) + ? { + bottom: + safeAreaInsets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + floatingTabs.height, + } + : compactAndroidSettings + ? { bottom: 0 } + : null; + const contentSafeAreaInsets = useStableEdgeInsetsOverride( + safeAreaInsets, + contentSafeAreaOverride, + ); + // Prefer the open thread's project, else the remembered one; the draft screen opens the + // picker if a remembered id is gone. Skip the full projects scan so New chat can push now. + const handleStartNewTask = useEffectEvent(() => { const state = rootStackState(navigation); const currentRoute = workspaceRoutesOf( state?.routes.slice(0, state.index + 1) ?? [], @@ -611,14 +651,13 @@ function AdaptiveWorkspaceLayoutContent( const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; handleSelectThreadRoute( newTaskDestination({ - projects, requestId: uuidv4(), useLastProject: saved?.useLastProjectForNewThreads, currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, lastProject: saved?.lastThreadProject, }), ); - }; + }); useHardwareKeyboardCommand("newTask", () => { handleStartNewTask(); @@ -818,7 +857,7 @@ function AdaptiveWorkspaceLayoutContent( return true; }); return () => subscription.remove(); - }, [floatingSidebarOpen, nativeWorkspace, navigation, setPrimarySidebarVisible]); + }, [floatingSidebarOpen, nativeWorkspace, navigation]); // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the // animated edge, so the header actions slide with it rather than jumping or being covered. const listPaneGap = layout.listPaneGap ?? 0; @@ -912,13 +951,13 @@ function AdaptiveWorkspaceLayoutContent( startNewTask: handleStartNewTask, selectDetail: handleSelectDetail, registerWorkspaceInspector, - setPrimarySidebarSearchQuery, showInspector, toggleAuxiliaryPane, togglePrimarySidebar, revealPrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, + setPrimarySidebarSearchQuery, setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, setNativeInspectorWidth: (width: number) => setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), @@ -950,7 +989,7 @@ function AdaptiveWorkspaceLayoutContent( return ( @@ -1059,23 +1098,7 @@ function AdaptiveWorkspaceLayoutContent( value={Platform.OS === "android" && layout.usesSplitView ? contentTargetWidth : null} > {/* Compact Settings keeps the bottom navigation under all of its pages. */} - + {children} @@ -1146,7 +1169,7 @@ function AdaptiveWorkspaceLayoutContent( return ( diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx index 382336367e2fd..6fce51554111a 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx @@ -1,6 +1,7 @@ import { selectedNavigationTab, visibleNavigationTabs } from "./navigation-tab-order"; import { requireNativeView } from "expo"; import { CommonActions, useNavigation } from "@react-navigation/native"; +import { startTransition } from "react"; import { Keyboard, processColor, @@ -14,6 +15,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; import { useToolbarLayout } from "../../../components/ui/useToolbarLayout"; import { rootNavigationRef } from "../../../lib/rootNavigation"; +import { rootStackRoutesUnchanged } from "../../../lib/root-stack-reset"; import { useWorkspaceConnectionState } from "../../../state/workspace"; import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; import { @@ -136,17 +138,22 @@ export function AndroidWorkspaceNavigation(props: { onTabSelected={(event) => { Keyboard.dismiss(); const tab = event.nativeEvent.tab; - if (tab !== "settings") workspace.dispatch({ type: "select", tab }); // Reset the root stack even when a nested settings navigator has focus. // The provider renders inside the root stack's layout, so the container's root state is - // that stack's current state. + // that stack's current state. List-only flips that stay on Home skip RESET. const rootState = rootNavigationRef.getRootState(); if (root && rootState) { - navigation.dispatch({ - ...CommonActions.reset( - androidWorkspaceTabNavigationState(rootState, tab, layout.usesSplitView), - ), - target: root.key, + const next = androidWorkspaceTabNavigationState(rootState, tab, layout.usesSplitView); + if (!rootStackRoutesUnchanged(rootState, next)) { + navigation.dispatch({ + ...CommonActions.reset(next), + target: root.key, + }); + } + } + if (tab !== "settings") { + startTransition(() => { + workspace.dispatch({ type: "select", tab }); }); } props.onSelect?.(); diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx index 5fc9b50097ba2..ef76ada6b4363 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx @@ -1,5 +1,5 @@ import type { NavigationState } from "@react-navigation/native"; -import { createContext, use, type ReactNode } from "react"; +import { createContext, use, useMemo, type ReactNode } from "react"; import { Platform } from "react-native"; import { androidWorkspaceSettingsOpen } from "./android-workspace-tabs"; import { WorkspaceTabsProvider } from "./WorkspaceTabsContext"; @@ -33,7 +33,10 @@ export function AndroidWorkspaceTabsProvider(props: { const settingsRoute = settingsIndex < 0 ? undefined : props.state.routes[settingsIndex]; const settingsPage = settingsIndex < 0 ? null : (props.state.routes[settingsIndex + 1]?.name ?? null); - const root = { key, settingsOpen, settingsRoute, settingsPage }; + const root = useMemo( + () => ({ key, settingsOpen, settingsRoute, settingsPage }), + [key, settingsOpen, settingsPage, settingsRoute], + ); return Platform.OS === "android" ? ( {props.children} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx index a03a36bc94762..5029b48a240b5 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx @@ -3,9 +3,11 @@ import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; -import type { ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useStableEdgeInsetsOverride } from "../../../lib/stable-edge-insets"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { AppBar } from "../../../components/ui/AppBar"; import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; @@ -38,17 +40,23 @@ function WorkspaceRoot() { const enabled = useEnabledNavigationTabs(); const insets = useSafeAreaInsets(); const { fabClearance, fabSize } = useAndroidControlSizing(); - const context = { - ...workspace, - bottomClearance: layout.usesSplitView - ? workspace.bottomClearance - : floatingTabsEnabled - ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize - : workspace.state.selectedTab === "threads" - ? fabClearance + 16 - : 16, - }; + const bottomClearance = layout.usesSplitView + ? workspace.bottomClearance + : floatingTabsEnabled + ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize + : workspace.state.selectedTab === "threads" + ? fabClearance + 16 + : 16; + const context = useMemo(() => ({ ...workspace, bottomClearance }), [bottomClearance, workspace]); const tab = workspace.state.selectedTab; + const safeAreaOverride = layout.usesSplitView + ? null + : floatingTabsEnabled + ? tab === "usage" + ? { bottom: bottomClearance } + : null + : { bottom: 0 }; + const safeAreaInsets = useStableEdgeInsetsOverride(insets, safeAreaOverride); return ( @@ -56,17 +64,7 @@ function WorkspaceRoot() { {!layout.usesSplitView ? ( ) : null} - + {enabled.length === 0 ? ( layout.usesSplitView ? ( diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx index e8e7c0d325983..c82bcd998efc7 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx @@ -1,14 +1,17 @@ import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { WORKSPACE_TAB_LABELS, useEnabledNavigationTabs } from "./navigation-tabs"; +import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; import { WorkspaceSettingsList } from "../../settings/SettingsRouteScreen"; import { WorkspaceSectionToolbar } from "./WorkspaceSectionToolbar"; import { useNavigation } from "@react-navigation/native"; -import { use } from "react"; +import { use, useMemo, useState } from "react"; import { View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { useStableEdgeInsetsOverride } from "../../../lib/stable-edge-insets"; + import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; import { HeaderItemsWidthContext } from "../../../native/header-items-width"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; @@ -29,8 +32,13 @@ import { WorkspaceTabsProvider, useWorkspaceTabs, } from "./WorkspaceTabsContext"; +import { WorkspaceTabPanes } from "./WorkspaceTabPanes"; import { WorkspaceToolbar } from "./WorkspaceToolbar"; -import { WORKSPACE_DOCK, workspaceDockBottomClearance } from "./workspace-tabs-state"; +import { + WORKSPACE_DOCK, + workspaceDockBottomClearance, + type WorkspaceTab, +} from "./workspace-tabs-state"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; import { useCompactWorkspaceTabs, @@ -39,6 +47,8 @@ import { } from "./useSystemWorkspaceTabBar"; import { openSettings } from "../../settings/settings-navigation"; +const TAB_PANES: readonly WorkspaceTab[] = NAVIGATION_TAB_ORDER; + const TAB_TITLES = WORKSPACE_TAB_LABELS; function WorkspaceRootTitle(props: { @@ -89,6 +99,9 @@ function WorkspaceRootContent() { const { state } = workspace; // Settings is a workspace tab too; its list takes the place of the selected tab's. const settingsOpen = workspace.settingsOpen; + // Keep the list mounted after the first open so returning to Settings is instant. + const [settingsMounted, setSettingsMounted] = useState(settingsOpen); + if (settingsOpen && !settingsMounted) setSettingsMounted(true); const renderHeaderTitle = () => ( ({ ...workspace, bottomClearance }), [bottomClearance, workspace]); + const safeAreaInsets = useStableEdgeInsetsOverride(insets, compactTabs ? { bottom: 0 } : null); return ( @@ -119,7 +133,6 @@ function WorkspaceRootContent() { optionsVersion={[ settingsOpen, state.selectedTab, - state.selectionRevision, width, sidebarNavigation, enabled.length, @@ -148,17 +161,34 @@ function WorkspaceRootContent() { : undefined, }} /> - - {settingsOpen ? ( - - ) : enabled.length === 0 ? ( + + {settingsMounted ? ( + + + + ) : null} + {settingsOpen ? null : enabled.length === 0 ? ( - ) : state.selectedTab === "threads" ? ( - - ) : state.selectedTab === "pullRequests" ? ( - ) : ( - + + tab === "threads" ? ( + + ) : tab === "pullRequests" ? ( + + ) : ( + + ) + } + /> )} {deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? ( diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 9ecb6fa933fa8..db15697525bcb 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx @@ -1,6 +1,6 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { AsyncResult, Atom } from "effect/reactivity"; -import { createContext, use, useState, type Dispatch, type ReactNode } from "react"; +import { createContext, use, useMemo, useState, type Dispatch, type ReactNode } from "react"; import { mobilePreferencesAtom } from "../../../state/preferences"; import { @@ -18,7 +18,7 @@ import { type WorkspaceTabsState, } from "./workspace-tabs-state"; -export const WorkspaceTabsContext = createContext<{ +export type WorkspaceTabsContextValue = { readonly state: WorkspaceTabsState; readonly dispatch: Dispatch; readonly isTabActive: (tab: WorkspaceTab) => boolean; @@ -26,12 +26,26 @@ export const WorkspaceTabsContext = createContext<{ readonly settingsOpen: boolean; readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; +}; + +export const WorkspaceTabsContext = createContext(null); + +/** Stable helpers that do not change when the selected tab does. */ +export const WorkspaceTabsApiContext = createContext<{ + readonly dispatch: Dispatch; + readonly isTabActive: (tab: WorkspaceTab) => boolean; + readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; + readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; } | null>(null); export function useWorkspaceTabs() { return use(WorkspaceTabsContext); } +export function useWorkspaceTabsApi() { + return use(WorkspaceTabsApiContext); +} + type MobilePreferencesResult = Atom.Type; /** Moves the selection off a tab the user has hidden or that no longer exists. */ @@ -79,15 +93,29 @@ export function WorkspaceTabsProvider(props: { const registry = use(RegistryContext); const [model] = useState(createWorkspaceTabsModel); const state = useAtomValue(model.resolved); - const context = { - state, - dispatch: (action: WorkspaceTabsAction) => - registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)), - isTabActive: (tab: WorkspaceTab) => registry.get(model.resolved).selectedTab === tab, - bottomClearance: 0, - settingsOpen: props.settingsOpen ?? false, - getScrollOffset: model.getScrollOffset, - recordScrollOffset: model.recordScrollOffset, - }; - return {props.children}; + const settingsOpen = props.settingsOpen ?? false; + const api = useMemo( + () => ({ + dispatch: (action: WorkspaceTabsAction) => + registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)), + isTabActive: (tab: WorkspaceTab) => registry.get(model.resolved).selectedTab === tab, + getScrollOffset: model.getScrollOffset, + recordScrollOffset: model.recordScrollOffset, + }), + [model, registry], + ); + const context = useMemo( + () => ({ + state, + ...api, + bottomClearance: 0, + settingsOpen, + }), + [api, settingsOpen, state], + ); + return ( + + {props.children} + + ); } diff --git a/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts index e487d9af071ba..5d248335a2b4a 100644 --- a/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts @@ -31,8 +31,8 @@ export function useSelectWorkspaceDetail() { return ( selectDetail ?? ((detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { - startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + startOpeningDetail(detail); }) ); } diff --git a/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts index 25d227b2736dc..1d887afbc8507 100644 --- a/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts @@ -1,3 +1,4 @@ +import { startTransition } from "react"; import { Keyboard } from "react-native"; import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; @@ -11,6 +12,10 @@ export function useSelectWorkspaceTab() { return (tab: WorkspaceTab | "settings") => { Keyboard.dismiss(); openWorkspaceTab(tab); - if (tab !== "settings") dispatch?.({ type: "select", tab }); + if (tab !== "settings") { + startTransition(() => { + dispatch?.({ type: "select", tab }); + }); + } }; } diff --git a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts index 94e4e3be7dbca..9aa365098a1ba 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts @@ -32,6 +32,8 @@ const options = { "Settings", "SettingsAppearance", "GitOverview", + "AddProject", + "AddProjectLocal", ], routeParamList: {}, routeGetIdList: {}, @@ -72,6 +74,13 @@ const page = (state: State) => ({ }); describe("selectWorkspaceDetail", () => { + it("pushes from a list-only stack so the native transition can start immediately", () => { + const opened = select(home(), threadA); + expect(page(opened)).toEqual({ names: ["Home", "Thread"], index: 1 }); + // A PUSH assigns a fresh key; RESET-from-empty would too, but swapping later must reuse it. + expect(opened.routes[1]?.key).toEqual(expect.any(String)); + }); + it("swaps the open thread in place instead of pushing another", () => { const first = select(home(), threadA); const second = select(first, threadB); @@ -122,6 +131,12 @@ describe("selectWorkspaceDetail", () => { setNativelyPoppingWorkspaceRoute(null); } }); + + it("resets instead of pushing when a creation overlay is already open", () => { + const creating = push(push(home(), "AddProject"), "AddProjectLocal"); + const opened = select(creating, threadA); + expect(page(opened)).toEqual({ names: ["Home", "Thread"], index: 1 }); + }); }); describe("workspaceDetailLinkAction", () => { diff --git a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts index ad7f37f14036c..3c54051971f52 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts @@ -85,6 +85,20 @@ function workspaceDetailState< return { ...state, index: routes.length - 1, routes }; } +/** + * List-only stacks can open detail with a real PUSH so the native transition starts on the + * press frame. RESET is reserved for swapping/collapsing an already-open detail column. + */ +function canPushWorkspaceDetail< + State extends { readonly index: number; readonly routes: readonly RouteLike[] }, +>(state: State, sheet: WorkspaceDetailSheet | undefined, popping: string | null) { + // A native Back in flight must go through RESET so we don't push on top of the leaving card. + if (sheet || popping) return false; + const { workspace, overlays } = workspaceRoutesOf(state.routes.slice(0, state.index + 1)); + // Overlay stacks (Add project, New task picker) must RESET so Back does not return into them. + return overlays.length === 0 && workspace.length === 1 && workspace[0]?.name === "Home"; +} + /** Root stack router overrides that handle `selectWorkspaceDetail`. */ export function workspaceDetailRouter( original: Router, Action>, @@ -95,6 +109,17 @@ export function workspaceDetailRouter( return original.getStateForAction(state, action, options); } const { detail, sheet } = (action as unknown as SelectWorkspaceDetailAction).payload; + if (canPushWorkspaceDetail(state, sheet, nativelyPoppingRoute)) { + // Inline PUSH — importing StackActions pulls @react-navigation/native into unit tests. + return original.getStateForAction( + state, + { + type: "PUSH", + payload: { name: detail.name, params: detail.params }, + } as unknown as Action, + options, + ); + } const next = workspaceDetailState(state, detail, sheet, nativelyPoppingRoute); const reset = { type: "RESET", payload: { ...next, stale: false } }; return original.getStateForAction(state, reset as unknown as Action, options); diff --git a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx index aa6fc8ec1d240..6b6e0cd7f5193 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx @@ -137,9 +137,11 @@ export function PullRequestActionToolbar(props: { const threadMenuItems = agent.threadActions("ask"); const openFiles = props.onOpenFiles ?? (() => navigation.navigate("PullRequestFiles", props.params)); - if (!props.pr) return null; + // Keep the bar mounted from the list summary so PR→diff does not blank chrome. + if (!props.pr && !props.params.initialSummary) return null; const primaryDisabled = pendingReview ? false : agent.disabled; - const showPrimary = pendingReview || canOpenThread; + // Keep the bar for initialSummary, but do not offer Work on PR until detail exists. + const showPrimary = pendingReview || (props.pr !== null && canOpenThread); if (Platform.OS === "android") { const primary = (