feat: add iOS simulator worktree preview

Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
This commit is contained in:
Mohamed Boudra
2026-06-11 12:23:53 +07:00
parent 6db285b1d9
commit a682476d11
21 changed files with 1143 additions and 189 deletions
+38 -33
View File
@@ -41,15 +41,20 @@ import {
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -60,7 +65,6 @@ import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
@@ -70,7 +74,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -82,6 +85,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostRootRoute,
parseHostAgentRouteFromPathname,
@@ -103,6 +107,7 @@ export interface HostRuntimeBootstrapState {
retry: () => void;
hasGivenUpWaitingForHost: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
@@ -110,6 +115,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
retry: () => {},
hasGivenUpWaitingForHost: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
@@ -313,18 +319,26 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const waitForConfiguredLocalDaemon =
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
const isDesktopRuntime = shouldUseDesktopDaemon();
const startupBlocker = useMemo(
() =>
resolveStartupBlocker({
isDesktopRuntime,
anyOnlineHostServerId,
daemonStartIsRunning,
daemonStartError,
}),
[anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, isDesktopRuntime],
);
const shouldRunGiveUpTimer = shouldRunStartupGiveUpTimer({
startupBlocker,
anyOnlineHostServerId,
hasGivenUpWaitingForHost,
});
useEffect(() => {
if (
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
waitForConfiguredLocalDaemon ||
hasGivenUpWaitingForHost
) {
if (!shouldRunGiveUpTimer) {
return;
}
const handle = setTimeout(() => {
@@ -333,13 +347,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
clearTimeout(handle);
};
}, [
anyOnlineHostServerId,
daemonStartError,
daemonStartIsRunning,
waitForConfiguredLocalDaemon,
hasGivenUpWaitingForHost,
]);
}, [shouldRunGiveUpTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
@@ -350,14 +358,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
const isCurrentlyStoreReady =
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
const splashError =
startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null;
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
);
return (
@@ -504,7 +511,7 @@ function MobileGestureWrapper({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
@@ -540,7 +547,7 @@ function MobileGestureWrapper({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
@@ -586,11 +593,9 @@ function MobileGestureWrapper({
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -602,7 +607,7 @@ function MobileGestureWrapper({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
handleGestureOpen,
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
@@ -117,6 +120,80 @@ describe("startHostRuntimeBootstrap", () => {
});
});
describe("startup blocking policy", () => {
const noBlockerInput = {
isDesktopRuntime: false,
anyOnlineHostServerId: null,
daemonStartIsRunning: false,
daemonStartError: null,
};
it("runs the give-up timer when no startup blocker is active", () => {
const blocker = resolveStartupBlocker(noBlockerInput);
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(true);
});
it("blocks navigation while desktop is starting the managed daemon", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "managed-daemon-starting" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
it("unblocks navigation when any host is online", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
anyOnlineHostServerId: "srv_desktop",
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
});
it("keeps desktop daemon startup errors on the startup error surface", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartError: "daemon failed to start",
});
expect(blocker).toEqual({
kind: "managed-daemon-error",
message: "daemon failed to start",
});
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
});
describe("resolveStartupRedirectRoute", () => {
const baseInput = {
pathname: "/",
@@ -58,6 +58,56 @@ export function startDaemonIfGateAllows(input: {
export const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker =
| { kind: "none" }
| { kind: "managed-daemon-starting" }
| { kind: "managed-daemon-error"; message: string };
export interface ResolveStartupBlockerInput {
isDesktopRuntime: boolean;
anyOnlineHostServerId: string | null;
daemonStartIsRunning: boolean;
daemonStartError: string | null;
}
export function resolveStartupBlocker(input: ResolveStartupBlockerInput): StartupBlocker {
if (!input.isDesktopRuntime) {
return { kind: "none" };
}
if (input.anyOnlineHostServerId) {
return { kind: "none" };
}
if (input.daemonStartError) {
return { kind: "managed-daemon-error", message: input.daemonStartError };
}
if (input.daemonStartIsRunning) {
return { kind: "managed-daemon-starting" };
}
return { kind: "none" };
}
export function resolveStartupNavigationReady(input: { startupBlocker: StartupBlocker }): boolean {
return input.startupBlocker.kind !== "managed-daemon-starting";
}
export function shouldRunStartupGiveUpTimer(input: {
startupBlocker: StartupBlocker;
anyOnlineHostServerId: string | null;
hasGivenUpWaitingForHost: boolean;
}): boolean {
if (input.anyOnlineHostServerId) {
return false;
}
if (input.hasGivenUpWaitingForHost) {
return false;
}
return input.startupBlocker.kind === "none";
}
export interface ResolveStartupRedirectInput {
pathname: string;
anyOnlineHostServerId: string | null;
@@ -23,11 +23,8 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
@@ -68,7 +65,7 @@ export function ExplorerSidebar({
const { width: viewportWidth } = useWindowDimensions();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
@@ -166,7 +163,7 @@ export function ExplorerSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
@@ -206,11 +203,9 @@ export function ExplorerSidebar({
windowWidth,
});
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
}
})
@@ -222,7 +217,7 @@ export function ExplorerSidebar({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
handleCloseFromGesture,
@@ -1,15 +1,12 @@
import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const storeReady = useStoreReady();
const bootstrapState = useHostRuntimeBootstrapState();
const isDesktop = shouldUseDesktopDaemon();
if (!storeReady) {
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
if (bootstrapState.startupBlocker.kind !== "none") {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
return children;
+5 -10
View File
@@ -40,11 +40,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -64,6 +60,7 @@ import {
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostOpenProjectRoute,
buildHostNewWorkspaceRoute,
@@ -581,7 +578,7 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
@@ -645,7 +642,7 @@ function MobileSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
@@ -679,11 +676,9 @@ function MobileSidebar({
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
}
})
@@ -695,7 +690,7 @@ function MobileSidebar({
closeTouchStartX,
closeTouchStartY,
isGesturing,
mobileVisualPanel,
mobilePanelState,
windowWidth,
translateX,
backdropOpacity,
@@ -11,12 +11,7 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -41,18 +36,8 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
null,
);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
}
if (mobileView === "file-explorer") {
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
}
return MOBILE_VISUAL_PANEL_AGENT;
}
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { mobileVisualPanel } = useSidebarAnimation();
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
@@ -84,6 +69,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange =
previousMobileView === "file-explorer" || mobileView === "file-explorer";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
@@ -102,17 +90,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("file-explorer");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("file-explorer");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
@@ -122,6 +142,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
@@ -130,32 +153,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("file-explorer");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("file-explorer");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity]);
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent");
translateX.value = withTiming(
windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth]);
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
@@ -15,6 +15,17 @@ import { isNative } from "@/constants/platform";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
@@ -30,8 +41,11 @@ interface SidebarAnimationContextValue {
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -49,6 +63,16 @@ function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explore
return MOBILE_VISUAL_PANEL_AGENT;
}
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
if (mobileView === "file-explorer") {
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
return MOBILE_PANEL_STATE_AGENT;
}
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
@@ -63,6 +87,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
@@ -72,6 +98,71 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
const startMobilePanelTransition = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
return;
}
if (nextMobileView === "file-explorer") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
return;
}
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
return;
}
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
return;
}
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobilePanelState, mobilePanelTarget],
);
const settleMobilePanel = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_AGENT_LIST,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
return;
}
if (nextMobileView === "file-explorer") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
return;
}
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
@@ -82,6 +173,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
@@ -108,17 +201,49 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("agent-list");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("agent-list");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
@@ -128,6 +253,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
@@ -137,31 +265,50 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
isGesturing,
isCompactLayout,
mobileVisualPanel,
mobilePanelState,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent-list");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent-list");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity]);
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent");
translateX.value = withTiming(
-windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth]);
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
@@ -170,8 +317,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
@@ -182,8 +332,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
@@ -2,12 +2,9 @@ import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
interface UseExplorerOpenGestureParams {
enabled: boolean;
@@ -28,7 +25,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
openGestureRef,
} = useExplorerSidebarAnimation();
const {
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
openGestureRef: leftOpenGestureRef,
} = useSidebarAnimation();
@@ -68,7 +65,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
@@ -117,11 +114,9 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -133,7 +128,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
isGesturing,
@@ -655,21 +655,6 @@ function buildComposerConfig(input: {
};
}
function computeWorkspaceTitle(
workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | null,
displayName: string,
sourceDirectory: string | null,
): string {
const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null;
return (
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
fallbackDirectoryName ||
"Choose project"
);
}
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
@@ -801,7 +786,6 @@ export function NewWorkspaceScreen({
projects,
selectedProject,
selectedSourceDirectory,
selectedDisplayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId,
@@ -1091,12 +1075,6 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, serverId, toast],
);
const workspaceTitle = computeWorkspaceTitle(
workspace,
selectedDisplayName,
selectedSourceDirectory,
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
@@ -1308,30 +1286,18 @@ export function NewWorkspaceScreen({
triggerLabel,
],
);
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
New workspace
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
</>
}
leftStyle={styles.headerLeft}
borderless
/>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<View style={styles.centered}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>New workspace</Text>
</View>
<Composer
agentId={draftKey}
serverId={serverId}
@@ -1385,29 +1351,15 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
headerLeft: {
gap: theme.spacing[2],
composerTitleContainer: {
marginBottom: theme.spacing[8],
paddingLeft: theme.spacing[6],
paddingRight: theme.spacing[4],
},
headerTitleContainer: {
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
composerTitle: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 0,
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
flexShrink: 1,
},
errorText: {
fontSize: theme.fontSize.sm,
@@ -26,6 +26,7 @@ import {
RotateCw,
Rows2,
Globe,
Plus,
SquarePen,
SquareTerminal,
X,
@@ -48,6 +49,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
WorkspaceTabPresentationResolver,
@@ -66,6 +68,7 @@ import { RenderProfile } from "@/utils/render-profiler";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedX = withUnistyles(X);
@@ -80,6 +83,7 @@ const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -88,6 +92,51 @@ function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackTyp
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event: LayoutChangeEvent) {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}
interface WorkspaceInlineAddTabButtonProps {
shortcutKeys: ShortcutKey[][] | null;
onPress: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceInlineAddTabButton({
shortcutKeys,
onPress,
onLayout,
}: WorkspaceInlineAddTabButtonProps) {
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab-inline"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={16} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
function TabContextMenuItem({
entry,
}: {
@@ -490,21 +539,27 @@ export function WorkspaceDesktopTabsRow({
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
updateMeasuredWidth(setTabsContainerWidth, event);
}, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
updateMeasuredWidth(setTabsActionsWidth, event);
}, []);
const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setInlineAddButtonWidth, event);
}, []);
const layoutMetrics = useMemo(
() => ({
rowHorizontalInset: 0,
actionsReservedWidth: Math.max(0, tabsActionsWidth),
actionsReservedWidth: Math.max(
0,
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH),
),
rowPaddingHorizontal: 0,
tabGap: 0,
maxTabWidth: 200,
@@ -513,7 +568,7 @@ export function WorkspaceDesktopTabsRow({
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[tabsActionsWidth],
[inlineAddButtonWidth, tabsActionsWidth],
);
const tabLabelLengths = useMemo(
@@ -672,6 +727,11 @@ export function WorkspaceDesktopTabsRow({
getItemData={getTabDragData}
renderItem={renderTab}
/>
<WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys}
onPress={handleCreateAgentTab}
onLayout={handleInlineAddButtonLayout}
/>
</ScrollView>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -928,6 +988,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingHorizontal: theme.spacing[2],
},
inlineAddButton: {
paddingHorizontal: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -1028,6 +1093,13 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
inlineAddActionButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
newTabActionButtonDisabled: {
opacity: 0.5,
},
@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import {
canCloseLeftSidebarGesture,
canCloseRightSidebarGesture,
canOpenLeftSidebarGesture,
canOpenRightSidebarGesture,
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
@@ -41,4 +56,56 @@ describe("sidebar-animation-state", () => {
backdropOpacity: 0,
});
});
it("allows the left open gesture only after the app is settled on the agent panel", () => {
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false);
});
it("allows the left close gesture only while the left sidebar is settled open", () => {
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("allows the right open gesture only after the app is settled on the agent panel", () => {
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false);
});
it("allows the right close gesture only while the right sidebar is settled open", () => {
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("rejects stale settle callbacks from a panel that is no longer the destination", () => {
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_AGENT_LIST,
),
).toBe(true);
expect(
shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT),
).toBe(false);
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
),
).toBe(false);
});
});
@@ -15,6 +15,20 @@ interface SidebarAnimationTargets {
backdropOpacity: number;
}
export const MOBILE_PANEL_STATE_AGENT = 0;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
export const MOBILE_PANEL_TARGET_AGENT = 0;
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
const CLOSED_POSITION_TOLERANCE = 1;
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
@@ -38,3 +52,45 @@ export function getRightSidebarAnimationTargets(
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function canOpenLeftSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
);
}
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
export function canOpenRightSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
);
}
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
export function shouldSettleMobilePanelTransition(
activeTarget: number,
settledTarget: number,
): boolean {
"worklet";
return activeTarget === settledTarget;
}