Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { requireNativeModule } from "expo";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform, StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
Expand Down Expand Up @@ -77,6 +77,12 @@ import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarning
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
import {
launchSplashHomeRowsPending,
settleLaunchSplashHomeRows,
splashCanHide,
useLaunchSplashHomeRowsPending,
} from "./features/home/home-launch-splash";
import { restoreSettingsLinkState } from "./features/settings/settings-navigation";
import {
hydrateWorkspaceSelections,
Expand Down Expand Up @@ -166,6 +172,8 @@ function handleNavigationStateChange(state: NavigationState | undefined): void {
resolvePendingNewTaskPromptLink(state);
resolvePendingThreadPromptLink(state);
if (!state) return;
// A launch that opens another route has no Home rows for the splash to wait for.
if (state.routes[state.index]?.name !== "Home") settleLaunchSplashHomeRows();
setAgentNotificationPathname(workspaceLocationFromState(state).pathname);
recordAppShortcutNavigationState(state);
pruneNewTaskFlowStates(state);
Expand Down Expand Up @@ -204,28 +212,38 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho
for (const dispose of appServiceDisposers) dispose();
});

// Cached shells are local reads that end in a fraction of this time; the limit only keeps a
// failed read from holding the splash screen.
const SPLASH_SHELL_CACHE_WAIT_MS = 1_500;
// Cached shells are local reads that end in a fraction of this time; the limit keeps a failed
// read, or a list that does not lay out, from holding the splash screen.
const SPLASH_WAIT_MS = 1_500;

function SplashScreenCoordinator() {
const { isReady } = useAppearancePreferences();
const shellCachesRead = useAtomValue(shellCachesReadAtom);
const homeRowsPending = useLaunchSplashHomeRowsPending();
const [waitEnded, setWaitEnded] = useState(false);
const hidden = useRef(false);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells and their rows
useEffect(() => {
const timer = setTimeout(() => setWaitEnded(true), SPLASH_SHELL_CACHE_WAIT_MS);
const timer = setTimeout(() => setWaitEnded(true), SPLASH_WAIT_MS);
return () => clearTimeout(timer);
}, []);

// The thread list paints every environment's cached rows at once when the splash hides
// after they are read.
const canHide = isReady && (shellCachesRead || waitEnded);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences and cached shells have loaded
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences have loaded and the cached rows are laid out
useEffect(() => {
if (canHide) void SplashScreen.hide();
}, [canHide]);
const canHide = splashCanHide({
preferencesReady: isReady,
shellCachesRead,
// Home holds the splash in a layout effect of the commit that gives it rows. That can be
// this commit, after this render read the store.
homeRowsPending: homeRowsPending || launchSplashHomeRowsPending(),
waitEnded,
});
if (!canHide || hidden.current) return;
hidden.current = true;
settleLaunchSplashHomeRows();
void SplashScreen.hide();
}, [isReady, shellCachesRead, homeRowsPending, waitEnded]);

return null;
}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export function HomeRouteScreen() {
selectedEnvironmentId={selectedEnvironmentId}
selectedProjectKey={selectedProjectKey}
threads={threads}
visible={visible}
/>
<ThreadUndoNotice visible={visible} />
</>
Expand Down
24 changes: 22 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import {
Platform,
ScrollView,
Expand Down Expand Up @@ -88,11 +96,14 @@ import {
import { createSwipeRowActivation } from "./swipe-row-activation";
import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
import { useFabScroll } from "./FabScrollContext";
import { holdLaunchSplashForHomeRows, settleLaunchSplashHomeRows } from "./home-launch-splash";
import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext";
import { ThreadListFilters } from "./ThreadListFilters";
import { deriveHomeEmptyState } from "./home-empty-state";

interface HomeScreenProps {
/** Whether this list is the one on screen; see `useHomeRouteVisible`. */
readonly visible: boolean;
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
Expand Down Expand Up @@ -597,6 +608,12 @@ export function HomeScreen(props: HomeScreenProps) {
props.threads.some((thread) => thread.archivedAt === null) ||
props.pendingTasks.length > 0 ||
threadListV2Layout.settledCount > 0;
// A hidden list gets no layout, so it never reports its rows as laid out.
const hasRowsToLayOut = props.visible && hasAnyThreads && threadListV2Items.length > 0;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the native launch splash, which is outside React, until the rows are gone or this list unmounts; a layout effect runs before the splash hides in this commit
useLayoutEffect(() => {
return hasRowsToLayOut ? holdLaunchSplashForHomeRows() : undefined;
}, [hasRowsToLayOut]);
const selectedEnvironmentLabel =
props.selectedEnvironmentId === null
? null
Expand Down Expand Up @@ -729,7 +746,10 @@ export function HomeScreen(props: HomeScreenProps) {
<AnimatedLegendList
itemLayoutAnimation={sidebarSurface ? THREAD_SIDEBAR_ROW_LAYOUT : undefined}
ref={listRef}
onLoad={() => activateVisibleRows(threadListV2Items)}
onLoad={() => {
settleLaunchSplashHomeRows();
activateVisibleRows(threadListV2Items);
}}
onTouchStart={(event) => trackListTouches(event, true)}
onTouchEnd={(event) => trackListTouches(event, false)}
onTouchCancel={(event) => trackListTouches(event, false)}
Expand Down
70 changes: 70 additions & 0 deletions apps/mobile/src/features/home/home-launch-splash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, it, vi } from "vite-plus/test";

import { createLaunchSplashHomeRows, splashCanHide } from "./home-launch-splash";

const launched = {
preferencesReady: true,
shellCachesRead: true,
homeRowsPending: false,
waitEnded: false,
};

it.each([
{
name: "hides when the cached shells are read and no rows wait for layout",
input: launched,
hides: true,
},
{
name: "waits while Home still lays out the cached rows",
input: { ...launched, homeRowsPending: true },
hides: false,
},
{
name: "waits for the cached shells when Home has no rows yet",
input: { ...launched, shellCachesRead: false },
hides: false,
},
{
name: "stops waiting for rows at the limit",
input: { ...launched, homeRowsPending: true, waitEnded: true },
hides: true,
},
{
name: "stops waiting for a cache read that fails at the limit",
input: { ...launched, shellCachesRead: false, homeRowsPending: true, waitEnded: true },
hides: true,
},
{
name: "waits for preferences even after the limit",
input: { ...launched, preferencesReady: false, waitEnded: true },
hides: false,
},
])("the launch splash $name", ({ input, hides }) => {
expect(splashCanHide(input)).toBe(hides);
});

it("the launch splash is held only while a Home list has rows to lay out", () => {
const rows = createLaunchSplashHomeRows();
const onChange = vi.fn();
rows.subscribe(onChange);

const release = rows.hold();
expect(rows.pending()).toBe(true);
// The server answered with no rows, or the list unmounted, before its first layout.
release();
expect(rows.pending(), "a list that lost its rows releases the splash").toBe(false);
expect(onChange).toHaveBeenCalledTimes(2);

const releaseLater = rows.hold();
const releaseOther = rows.hold();
releaseLater();
release();
expect(rows.pending(), "another list still has rows to lay out").toBe(true);

rows.settle();
expect(rows.pending()).toBe(false);
releaseOther();
rows.hold();
expect(rows.pending(), "a list that mounts after the launch holds nothing").toBe(false);
});
75 changes: 75 additions & 0 deletions apps/mobile/src/features/home/home-launch-splash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useSyncExternalStore } from "react";

/**
* Whether the launch splash still waits for Home's list. A list holds the splash while it
* has cached rows to lay out, so the first frame after the splash shows them.
*/
export function createLaunchSplashHomeRows() {
let holds = 0;
let settled = false;
const listeners = new Set<() => void>();
const pending = () => !settled && holds > 0;
const change = (apply: () => void) => {
const before = pending();
apply();
if (pending() !== before) for (const listener of listeners) listener();
};
return {
pending,
/**
* A Home list has rows that are not laid out yet. Call the returned release when it no
* longer has them or unmounts; rows that come later can hold again.
*/
hold: () => {
let held = true;
change(() => {
holds += 1;
});
return () => {
if (!held) return;
held = false;
change(() => {
holds -= 1;
});
};
},
/**
* The rows are laid out, the launch opened another route, or the splash is gone. It is
* final: lists that mount after the launch must not hold anything.
*/
settle: () =>
change(() => {
settled = true;
}),
subscribe: (onChange: () => void) => {
listeners.add(onChange);
return () => {
listeners.delete(onChange);
};
},
};
}

const homeRows = createLaunchSplashHomeRows();

export const holdLaunchSplashForHomeRows = homeRows.hold;
export const settleLaunchSplashHomeRows = homeRows.settle;
export const launchSplashHomeRowsPending = homeRows.pending;

export function useLaunchSplashHomeRowsPending(): boolean {
return useSyncExternalStore(homeRows.subscribe, homeRows.pending);
}

/**
* Whether the native splash can hide: after the cached shells are read and Home's list has
* laid out their rows. One limit ends both waits.
*/
export function splashCanHide(input: {
readonly preferencesReady: boolean;
readonly shellCachesRead: boolean;
readonly homeRowsPending: boolean;
readonly waitEnded: boolean;
}): boolean {
if (!input.preferencesReady) return false;
return input.waitEnded || (input.shellCachesRead && !input.homeRowsPending);
}
3 changes: 2 additions & 1 deletion docs/internals/connection-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ thread's own shell, route checks) keep the live snapshot, where a row the server
sent must not look missing. The decision reads the snapshot's own sequence: a second
atom derived from the same shell state can be one update behind the rows. The mobile
splash screen also waits, for a bounded time, until every enabled environment has
read its cached shell, so the first frame holds all cached rows.
read its cached shell and Home's list has laid out those rows, so the first frame
holds all cached rows.

Shell snapshots can leave rows out when the client asks. Servers that advertise
`shellThreadFamilies` leave out idle subagent children: a child stays while it
Expand Down
Loading