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
18 changes: 18 additions & 0 deletions src/mobile/fullscreenScreenPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* True for routes that render a fullscreen overlay screen with their own
* chrome and no `.m-main` (RootLayout's "fullscreen" layout). These carry the
* `m-screen` view-transition name; navigations into/out of them add the
* `screen` transition type so the page chrome holds steady under the slide.
*
* Kept dependency-free (no store/renderer imports) so the pure navigation
* predicates in lightweightThreadListPop can share it while staying testable
* in a plain node environment.
*/
export function isFullscreenScreenPath(path: string): boolean {
return (
path.startsWith("/workspace/") ||
path.startsWith("/notes/") ||
path.startsWith("/pr/") ||
path.startsWith("/terminal/")
);
}
77 changes: 77 additions & 0 deletions src/mobile/lightweightThreadListPop.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
shouldUseLightweightFullscreenPop,
shouldUseLightweightFullscreenPush,
shouldUseLightweightSubAgentPop,
shouldUseLightweightSubAgentPush,
shouldUseLightweightThreadListPop,
Expand Down Expand Up @@ -72,3 +74,78 @@ describe("shouldUseLightweightSubAgentPop", () => {
).toBe(false);
});
});

describe("shouldUseLightweightFullscreenPush", () => {
it("uses the lightweight push only for an iOS web page opening a fullscreen screen", () => {
const runtime = { platform: "ios", nativeApp: false } as const;
expect(
shouldUseLightweightFullscreenPush("/thread/thread-1", "/workspace/thread-1", runtime),
).toBe(true);
expect(shouldUseLightweightFullscreenPush("/threads", "/terminal/project-1", runtime)).toBe(
true,
);
expect(shouldUseLightweightFullscreenPush("/thread/thread-1", "/notes/thread-1", runtime)).toBe(
true,
);
});

it("keeps fullscreen-to-fullscreen and ordinary navigations on the View Transition path", () => {
const runtime = { platform: "ios", nativeApp: false } as const;
// Two overlays have nothing mounted beneath them, so the slide stays a VT.
expect(shouldUseLightweightFullscreenPush("/workspace/thread-1", "/pr/123", runtime)).toBe(
false,
);
expect(shouldUseLightweightFullscreenPush("/thread/thread-1", "/threads", runtime)).toBe(false);
expect(shouldUseLightweightFullscreenPush(undefined, "/workspace/thread-1", runtime)).toBe(
false,
);
});

it("stays off outside iOS web", () => {
expect(
shouldUseLightweightFullscreenPush("/thread/thread-1", "/workspace/thread-1", {
platform: "ios",
nativeApp: true,
}),
).toBe(false);
expect(
shouldUseLightweightFullscreenPush("/thread/thread-1", "/workspace/thread-1", {
platform: "android",
nativeApp: false,
}),
).toBe(false);
});
});

describe("shouldUseLightweightFullscreenPop", () => {
it("uses the lightweight pop only for an iOS web fullscreen screen closing to a page", () => {
const runtime = { platform: "ios", nativeApp: false } as const;
expect(
shouldUseLightweightFullscreenPop("/workspace/thread-1", "/thread/thread-1", runtime),
).toBe(true);
expect(shouldUseLightweightFullscreenPop("/terminal/project-1", "/threads", runtime)).toBe(
true,
);
expect(shouldUseLightweightFullscreenPop("/notes/thread-1", "/thread/thread-1", runtime)).toBe(
true,
);
});

it("keeps fullscreen-to-fullscreen and ordinary navigations on the View Transition path", () => {
const runtime = { platform: "ios", nativeApp: false } as const;
expect(shouldUseLightweightFullscreenPop("/pr/123", "/workspace/thread-1", runtime)).toBe(
false,
);
expect(shouldUseLightweightFullscreenPop("/thread/thread-1", "/threads", runtime)).toBe(false);
expect(shouldUseLightweightFullscreenPop(undefined, "/thread/thread-1", runtime)).toBe(false);
});

it("stays off outside iOS web", () => {
expect(
shouldUseLightweightFullscreenPop("/workspace/thread-1", "/thread/thread-1", {
platform: "android",
nativeApp: false,
}),
).toBe(false);
});
});
41 changes: 41 additions & 0 deletions src/mobile/lightweightThreadListPop.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isFullscreenScreenPath } from "./fullscreenScreenPath";
import { getMobileRuntimePlatform, type MobileRuntimePlatform } from "./mobilePlatform";
import { isNativeApp } from "./pwaInstall";

Expand All @@ -7,6 +8,10 @@ export const LIGHTWEIGHT_SUBAGENT_PUSH_CLASS = "m-shell--lightweight-subagent-pu
export const LIGHTWEIGHT_SUBAGENT_PUSH_ANIMATION = "m-lightweight-subagent-push";
export const LIGHTWEIGHT_SUBAGENT_POP_CLASS = "m-shell--lightweight-subagent-pop";
export const LIGHTWEIGHT_SUBAGENT_POP_ANIMATION = "m-lightweight-subagent-pop";
export const LIGHTWEIGHT_FULLSCREEN_PUSH_CLASS = "m-shell--lightweight-fullscreen-push";
export const LIGHTWEIGHT_FULLSCREEN_PUSH_ANIMATION = "m-lightweight-fullscreen-push";
export const LIGHTWEIGHT_FULLSCREEN_POP_CLASS = "m-shell--lightweight-fullscreen-pop";
export const LIGHTWEIGHT_FULLSCREEN_POP_ANIMATION = "m-lightweight-fullscreen-pop";
export const LIGHTWEIGHT_THREAD_LIST_POP_DURATION_MS = 320;

interface MobileTransitionRuntime {
Expand Down Expand Up @@ -64,3 +69,39 @@ export function shouldUseLightweightSubAgentPop(
const threadMatch = /^\/thread\/([^/]+)$/.exec(toPath);
return subAgentMatch?.[1] !== undefined && subAgentMatch[1] === threadMatch?.[1];
}

/**
* iOS Safari's View Transition capture stalls on the heavy outgoing screen of a
* fullscreen hand-off — a tall virtualized transcript when pushing into the
* overlay, a diff/terminal/PR when popping out of it. For those, skip the
* snapshot entirely: a push slides only the incoming opaque overlay in over the
* shell, a pop slides only the incoming shell in while the outgoing overlay is
* removed un-snapshotted. Native Capacitor and other browsers keep the paired
* snapshot slide. Fullscreen -> fullscreen (e.g. workspace -> PR) keeps the View
* Transition too: nothing is mounted beneath two overlays, so there is no
* lightweight incoming layer to animate.
*/
export function shouldUseLightweightFullscreenPush(
fromPath: string | undefined,
toPath: string,
runtime: MobileTransitionRuntime = {
platform: getMobileRuntimePlatform(),
nativeApp: isNativeApp(),
},
): boolean {
if (runtime.platform !== "ios" || runtime.nativeApp || !fromPath) return false;
return !isFullscreenScreenPath(fromPath) && isFullscreenScreenPath(toPath);
}

/** Match a fullscreen overlay closing back to an ordinary (non-fullscreen) page. */
export function shouldUseLightweightFullscreenPop(
fromPath: string | undefined,
toPath: string,
runtime: MobileTransitionRuntime = {
platform: getMobileRuntimePlatform(),
nativeApp: isNativeApp(),
},
): boolean {
if (runtime.platform !== "ios" || runtime.nativeApp || !fromPath) return false;
return isFullscreenScreenPath(fromPath) && !isFullscreenScreenPath(toPath);
}
20 changes: 5 additions & 15 deletions src/mobile/navHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getBasename } from "@/shared/pathUtils";
import { buildWorktreeLocation } from "@/shared/worktree";
import { useAppStore } from "@/renderer/state/appStore";
import type { DraftStartInput } from "@/renderer/components/thread/ThreadDraftComposerArea";
import { isFullscreenScreenPath } from "./fullscreenScreenPath";
import { useGitSummariesStore } from "./gitSummaries";
import type { RemoteSession } from "./remoteContext";
import { isDesktopSettingsSection } from "./settingsSectionIds";
Expand Down Expand Up @@ -101,6 +102,10 @@ export function threadIdFromPath(pathname: string): string | null {
return match?.[1] ? decodeURIComponent(match[1]) : null;
}

// Defined in fullscreenScreenPath (dependency-free) so the lightweight
// transition predicates can share it; re-exported here for existing importers.
export { isFullscreenScreenPath };

/**
* Depth of a screen in the phone navigation stack. Higher = deeper. Drives the
* direction of the native view transition: deeper is a forward push, shallower
Expand All @@ -110,21 +115,6 @@ export function threadIdFromPath(pathname: string): string | null {
* calling `history.back()`, so history index can't tell us the direction —
* comparing depth gives the correct visual direction regardless.
*/
/**
* True for routes that render a fullscreen overlay screen with their own
* chrome and no `.m-main` (RootLayout's "fullscreen" layout). These carry the
* `m-screen` view-transition name; navigations into/out of them add the
* `screen` transition type so the page chrome holds steady under the slide.
*/
export function isFullscreenScreenPath(path: string): boolean {
return (
path.startsWith("/workspace/") ||
path.startsWith("/notes/") ||
path.startsWith("/pr/") ||
path.startsWith("/terminal/")
);
}

export function screenDepth(path: string): number {
if (path.startsWith("/subagent/")) return 2;
if (path.startsWith("/thread/")) return 1;
Expand Down
13 changes: 9 additions & 4 deletions src/mobile/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { isNativeApp } from "./pwaInstall";
import { migrateLegacyBrowserRoute, mobileRouterBasePath } from "./routing";
import { isFullscreenScreenPath, navigationTransitionType } from "./navHelpers";
import {
shouldUseLightweightFullscreenPop,
shouldUseLightweightFullscreenPush,
shouldUseLightweightSubAgentPop,
shouldUseLightweightSubAgentPush,
shouldUseLightweightThreadListPop,
Expand Down Expand Up @@ -272,13 +274,16 @@ function navigationTransitionTypes(fromPath: string | undefined, toPath: string)
if (window.matchMedia(WIDE_SHELL_QUERY).matches) return false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
}
// iOS Safari can block while snapshotting a long virtualized transcript.
// NarrowShell animates only the lightweight incoming list/subagent layer for
// these routes, so skip the expensive snapshot here.
// iOS Safari can block while snapshotting a long virtualized transcript or a
// heavy fullscreen overlay (diff / terminal / PR). NarrowShell animates only
// the lightweight incoming layer for these routes, so skip the expensive
// snapshot here.
if (
shouldUseLightweightThreadListPop(fromPath, toPath) ||
shouldUseLightweightSubAgentPush(fromPath, toPath) ||
shouldUseLightweightSubAgentPop(fromPath, toPath)
shouldUseLightweightSubAgentPop(fromPath, toPath) ||
shouldUseLightweightFullscreenPush(fromPath, toPath) ||
shouldUseLightweightFullscreenPop(fromPath, toPath)
) {
return false;
}
Expand Down
37 changes: 37 additions & 0 deletions src/mobile/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5857,6 +5857,43 @@ html[data-mobile-platform="ios"] .m-shell--lightweight-subagent-pop > :is(.m-top
}
}

/* Push into a fullscreen overlay (workspace / PR / terminal / notes) from an
ordinary page. iOS Safari stalls snapshotting the outgoing transcript before
the View Transition starts, so skip it and slide only the incoming opaque
overlay in over the shell on compositor transforms. */
html[data-mobile-platform="ios"]
.m-shell--lightweight-fullscreen-push
:is(.m-workspace, .m-git-overlay, .m-notes-screen, .m-screen-loading) {
animation: m-lightweight-fullscreen-push 0.32s cubic-bezier(0.32, 0.72, 0, 1) both;
will-change: transform;
}

@keyframes m-lightweight-fullscreen-push {
from {
transform: translate3d(100%, 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}

/* Back out of a fullscreen overlay to the page beneath. Animate only the
incoming shell; the outgoing overlay (diff / terminal / PR) is removed
without a Safari snapshot. */
html[data-mobile-platform="ios"] .m-shell--lightweight-fullscreen-pop > :is(.m-topbar, .m-main) {
animation: m-lightweight-fullscreen-pop 0.32s cubic-bezier(0.32, 0.72, 0, 1) both;
will-change: transform;
}

@keyframes m-lightweight-fullscreen-pop {
from {
transform: translate3d(-25%, 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}

/* ── Native screen transitions (View Transitions API) ──────────────
*
* The phone layout navigates like a native app. The router (router.tsx) tags
Expand Down
34 changes: 34 additions & 0 deletions src/mobile/useLightweightThreadListPop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
LIGHTWEIGHT_SUBAGENT_PUSH_CLASS,
LIGHTWEIGHT_SUBAGENT_POP_ANIMATION,
LIGHTWEIGHT_SUBAGENT_POP_CLASS,
LIGHTWEIGHT_FULLSCREEN_PUSH_ANIMATION,
LIGHTWEIGHT_FULLSCREEN_PUSH_CLASS,
LIGHTWEIGHT_FULLSCREEN_POP_ANIMATION,
LIGHTWEIGHT_FULLSCREEN_POP_CLASS,
} from "./lightweightThreadListPop";
import { useLightweightThreadListPop } from "./useLightweightThreadListPop";

Expand Down Expand Up @@ -115,4 +119,34 @@ describe("useLightweightThreadListPop", () => {
fireEvent(shell, animationEnd);
expect(shell).not.toHaveClass(LIGHTWEIGHT_SUBAGENT_POP_CLASS);
});

it("animates only the incoming overlay on a thread-to-fullscreen commit", () => {
const view = render(<Harness pathname="/thread/thread-1" />);
const shell = view.getByTestId("shell");

view.rerender(<Harness pathname="/workspace/thread-1" />);
expect(shell).toHaveClass(LIGHTWEIGHT_FULLSCREEN_PUSH_CLASS);

const animationEnd = new Event("animationend", { bubbles: true });
Object.defineProperty(animationEnd, "animationName", {
value: LIGHTWEIGHT_FULLSCREEN_PUSH_ANIMATION,
});
fireEvent(shell, animationEnd);
expect(shell).not.toHaveClass(LIGHTWEIGHT_FULLSCREEN_PUSH_CLASS);
});

it("animates only the incoming shell on a fullscreen-to-thread commit", () => {
const view = render(<Harness pathname="/workspace/thread-1" />);
const shell = view.getByTestId("shell");

view.rerender(<Harness pathname="/thread/thread-1" />);
expect(shell).toHaveClass(LIGHTWEIGHT_FULLSCREEN_POP_CLASS);

const animationEnd = new Event("animationend", { bubbles: true });
Object.defineProperty(animationEnd, "animationName", {
value: LIGHTWEIGHT_FULLSCREEN_POP_ANIMATION,
});
fireEvent(shell, animationEnd);
expect(shell).not.toHaveClass(LIGHTWEIGHT_FULLSCREEN_POP_CLASS);
});
});
Loading