Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 156462e74f fix(app): keep the Changes diff on screen when its panel reopens
Hiding the Changes panel releases its diff subscription. Reopening it starts a
new stream, and since #8907 a new stream first publishes a loading snapshot
(entries, no bodies) before its first revision commits. That snapshot replaced
the settled diff in the query cache, so the panel showed the full loading
spinner for about a second and then repainted the same diff.

A loading snapshot now only counts on the first load. Once a diff has settled,
shareCheckoutDiff keeps it until the new stream's ready revision lands, which
replaces it in one step when the content changed.
2026-11-10 02:25:35 +01:00
paseo-bot[bot] ab64bebe14 test(terminal): prepare replay input before signaling readiness (#9211)
The replay fixture used its last history row as readiness before installing readline. Register the input handler first so the parent cannot send the pre-cut command during that setup gap.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-09 21:01:43 +00:00
Mohamed BoudraandElsa Granger e0f545fed4 fix(app): use the iPhone Duo top band beside the status region (#8901)
On the iPhone Duo inner screen in portrait, the workspace header, the left
sidebar's first row and the explorer tab rail now use the top band beside
the status region. The status region is published as the window's top-right
obstruction, the same slot desktop window controls use, so the row owning
that corner pads past it. Content gains 56pt.

Refs #7924

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-09 22:03:21 +07:00
16 changed files with 553 additions and 40 deletions
@@ -306,6 +306,24 @@ test("streamed Changes shows the tree before completion and applies a one-file r
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
});
test("reopening compact Changes keeps the painted diff while it resubscribes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
const stream = await holdDiffCompletion(page);
const explorer = await openCompactChanges(page, workspace);
stream.finish();
const firstFile = explorer.getByTestId("diff-file-0");
await expect(firstFile).toBeVisible({ timeout: 30_000 });
const painted = await firstFile.getAttribute("aria-label");
await explorer.getByTestId("workspace-tab-files").click();
stream.hold();
await explorer.getByTestId("workspace-tab-changes_tree").click();
await expect.poll(() => stream.held, { timeout: 30_000 }).toBeGreaterThan(0);
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
await expect(firstFile).toHaveAccessibleName(painted ?? "");
stream.finish();
});
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithManyTinyDiffs(252);
await Promise.all(
@@ -371,6 +389,12 @@ async function holdDiffCompletion(page: Page) {
get completed() {
return completed;
},
get held() {
return completions.length;
},
hold: () => {
holding = true;
},
finish: () => {
holding = false;
completions.splice(0).forEach((send) => send());
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var topBarGuide: UILayoutGuide?
private var occlusions: [CGRect] = []
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
// Frames already include the system's interactive margins.
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if topBarGuide == nil {
// Where a top bar rests. Beside a partial status region it can start above the
// rectangular safe area; content still keeps out of the occlusions.
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
var topBar: Any = NSNull()
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
topBar = rect(frame)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"topBar": topBar,
"occlusions": occlusions.map(rect),
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
@@ -11,6 +11,7 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
opacity: { 50: 0.5 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
@@ -12,8 +12,13 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useTopBand } from "@/window-layout";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
@@ -39,7 +44,8 @@ interface CompactHeaderProps {
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
* contrast). Its height matches the old flat header, so anything
* offset by the header height still lines up.
* offset by the header height still lines up. In a window's top band it takes the top bar's
* place, like the full header. The column hosting it reserves any window-chrome corner it owns.
*/
export function CompactHeader({
navigation,
@@ -51,22 +57,23 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const inTopBand = useTopBand() !== null;
const rootStyle = useMemo(
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
[inTopBand, insets.top, insets.left, insets.right],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={inTopBand ? styles.topBandRow : styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
@@ -148,6 +155,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
topBandRow: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
+6 -4
View File
@@ -31,7 +31,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -807,9 +807,11 @@ function DesktopSidebar({
],
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
);
// Below the window controls' row, or in the top band's place, the first row starts flush.
const inTopBand = useTopBand() !== null;
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
() => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush],
[ownsTopLeft, inTopBand],
);
return (
<MobilePanelOverlay
@@ -938,7 +940,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sidebarHeaderGroupBelowChrome: {
sidebarHeaderGroupFlush: {
paddingTop: 0,
},
// The nav block caps at half the sidebar; a long item list scrolls inside the
+200
View File
@@ -2,6 +2,8 @@ import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpas
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
import { shareCheckoutDiff } from "@/git/diff-sharing";
import { checkoutDiffQueryKey } from "@/git/query-keys";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { daemonConfigQueryKey } from "@/data/daemon-config";
@@ -34,6 +36,7 @@ type RouterMessage =
type RouterMessageType = RouterMessage["type"];
type RouterHandler = (message: RouterMessage) => void;
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
type DiffFile = SubscribeCheckoutDiffResponseMessage["payload"]["files"][number];
const daemonConfig: MutableDaemonConfig = {
relay: { enabled: false },
@@ -233,6 +236,49 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
};
}
/** Matches the real diff observation: nothing is delivered until the stream publishes. */
type SubscribedDiffState = DiffState & { subscriptionId: string };
function createDiffStreamClient() {
const subscriptions: Array<{ publish(state: DiffState): void }> = [];
const noSubscription = {
subscriptionId: "none",
ready: Promise.resolve(undefined as never),
subscribe: () => () => {},
release: async () => {},
};
const client: RouterClient = {
async getProvidersSnapshot() {
throw new Error("Unexpected snapshot pull");
},
observeEvents: () => noSubscription,
observeTerminals: () => noSubscription,
observeDiff() {
const observers = new Set<SubscriptionObserver<SubscribedDiffState>>();
let released = false;
const subscriptionId = `diff-${subscriptions.length + 1}`;
subscriptions.push({
publish: (state) => {
if (released) return;
for (const observer of observers) observer.snapshot({ ...state, subscriptionId });
},
});
return {
subscriptionId,
ready: new Promise<never>(() => {}),
subscribe: (observer: SubscriptionObserver<SubscribedDiffState>) => {
observers.add(observer);
return () => observers.delete(observer);
},
release: async () => {
released = true;
},
};
},
};
return { client, subscriptions, latest: () => subscriptions[subscriptions.length - 1]! };
}
function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
return {
type: "providers_snapshot_update",
@@ -388,6 +434,160 @@ describe("server data push router", () => {
}
});
describe("a diff reopened from a retained panel", () => {
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false);
function diffFile(content: string): DiffFile {
return {
path: "README.md",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content, tokens: [{ text: content, style: null }] }],
},
],
};
}
function loadingState(revision: number): DiffState {
return {
phase: "loading",
revision,
entries: [
{
path: "README.md",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 0,
},
],
};
}
function readyState(revision: number, files: DiffFile[]): DiffState {
return {
phase: "ready",
revision,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files,
diffTooLarge: false,
};
}
function mountChangesPanel(fake: ReturnType<typeof createDiffStreamClient>) {
const queryClient = new QueryClient();
const route = (enabled: boolean) => ({
queryKey,
queryFn: skipToken as typeof skipToken,
gcTime: Infinity,
structuralSharing: shareCheckoutDiff,
meta: checkoutDiffPushRoute({
enabled,
serverId,
cwd,
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
compare: { mode: "uncommitted", ignoreWhitespace: false },
}),
});
const observer = new QueryObserver(queryClient, route(true));
const unsubscribeObserver = observer.subscribe(() => {});
const unmountRouter = mountServerDataPushRouter({
queryClient,
client: fake.client,
serverId,
});
return {
hide: () => observer.setOptions(route(false)),
show: () => observer.setOptions(route(true)),
// What the Changes pane renders from.
diff: () =>
deriveCheckoutDiffResult(
queryClient.getQueryData<CheckoutDiffSnapshot>(queryKey) ?? null,
),
unmount: () => {
unsubscribeObserver();
unmountRouter();
queryClient.clear();
},
};
}
it("keeps the painted diff through the reload when the daemon returns the same content", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
fake.latest().publish(readyState(1, [diffFile("same")]));
const painted = panel.diff().files;
expect(fake.subscriptions).toHaveLength(1);
panel.hide();
panel.show();
expect(fake.subscriptions).toHaveLength(2);
fake.latest().publish(loadingState(1));
expect(panel.diff()).toMatchObject({ isLoading: false, entries: null });
expect(panel.diff().files).toBe(painted);
fake.latest().publish(readyState(1, [diffFile("same")]));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toBe(painted);
} finally {
panel.unmount();
}
});
it("replaces the painted diff in one step when the daemon returns changed content", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
fake.latest().publish(readyState(1, [diffFile("before")]));
const painted = panel.diff().files;
panel.hide();
panel.show();
fake.latest().publish(loadingState(1));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toBe(painted);
const changed = [diffFile("after")];
fake.latest().publish(readyState(1, changed));
expect(panel.diff().isLoading).toBe(false);
expect(panel.diff().files).toEqual(changed);
} finally {
panel.unmount();
}
});
it("reports loading with the announced entries when no content has been painted", () => {
const fake = createDiffStreamClient();
const panel = mountChangesPanel(fake);
try {
expect(panel.diff().isLoading).toBe(true);
fake.latest().publish(loadingState(1));
expect(panel.diff()).toMatchObject({ isLoading: true, files: [] });
expect(panel.diff().entries).toHaveLength(1);
fake.latest().publish(readyState(1, [diffFile("first")]));
expect(panel.diff()).toMatchObject({ isLoading: false, entries: null });
expect(panel.diff().files).toHaveLength(1);
} finally {
panel.unmount();
}
});
});
it("routes provider snapshot and daemon config payloads until detached", async () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
+8
View File
@@ -25,6 +25,10 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
const next = incoming as DiffPayload;
const old = previous as DiffPayload | undefined;
if (!old || old === next) return next;
// A new subscription announces its entries before the bodies arrive. That is
// progress on the first load only: once a diff has settled it stays on screen
// until the next revision commits, so an unchanged diff never repaints.
if (isLoadingSnapshot(next) && !isLoadingSnapshot(old)) return old;
const files = retainDiffFiles(old.files, next.files);
const error = replaceEqualDeep(old.error, next.error);
return files === old.files &&
@@ -37,6 +41,10 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
: { ...next, files, error };
}
function isLoadingSnapshot(payload: DiffPayload): boolean {
return payload.entries !== undefined;
}
export function shareCommitFileDiff(
previous: unknown,
incoming: unknown,
@@ -164,7 +164,11 @@ import {
WorkspaceExplorerSidebarToggle,
WorkspaceHeaderExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { useHasWindowChromeObstruction } from "@/utils/desktop-window";
import {
useHasWindowChromeObstruction,
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import {
resolveWorkspaceHeaderRenderState,
type WorkspaceHeaderCheckoutState,
@@ -1029,6 +1033,23 @@ function useLastMainPane(input: {
return lastMainPaneRef;
}
/** The docked explorer beside the center column takes the top-right window-chrome corner. */
function DesktopCenterColumnChrome({
explorerOpen,
focusModeEnabled,
children,
}: {
explorerOpen: boolean;
focusModeEnabled: boolean;
children: ReactNode;
}) {
return (
<WindowChromeRegion corners={explorerOpen && !focusModeEnabled ? "top-left" : "both"}>
<View style={styles.centerColumn}>{children}</View>
</WindowChromeRegion>
);
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -3516,6 +3537,11 @@ function WorkspaceScreenContent({
},
};
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
// SplitContainer reserves the corner around its own compact headers; this is the column's.
const centerColumnCompactHeader = useMemo(
() => <WindowChromeSafeArea placement="inline">{compactWorkspaceHeader}</WindowChromeSafeArea>,
[compactWorkspaceHeader],
);
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
@@ -3639,9 +3665,12 @@ function WorkspaceScreenContent({
<SheetTabs onCloseTab={handleCloseTabById} />
</View>
) : (
<View style={styles.centerColumn}>
<DesktopCenterColumnChrome
explorerOpen={isExplorerSidebarShowing}
focusModeEnabled={desktopFocusModeEnabled}
>
{rendersDesktopSplitContent ? null : (
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
<AdaptiveWorkspaceChrome compactHeader={centerColumnCompactHeader}>
{fullWorkspaceActions}
{renderWorkspaceScreenHeader()}
@@ -3678,7 +3707,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
<View style={styles.content}>{desktopContent}</View>
</View>
</View>
</DesktopCenterColumnChrome>
);
const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0);
+38 -7
View File
@@ -9,30 +9,61 @@ import {
describe("window chrome", () => {
it("has no corner obstruction outside Electron or in fullscreen", () => {
expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }),
).toEqual({
topLeft: null,
topRight: null,
});
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }),
).toEqual({
topLeft: null,
topRight: null,
});
});
it("places native controls in their physical top corner", () => {
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }),
).toEqual({
topLeft: { width: 78, height: 45 },
topRight: null,
});
expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual(
{ topLeft: null, topRight: { width: 138, height: 36 } },
);
expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({
expect(
resolveWindowChromeObstruction({
mode: "custom-windows",
isFullscreen: false,
topBand: null,
}),
).toEqual({ topLeft: null, topRight: { width: 138, height: 36 } });
expect(
resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false, topBand: null }),
).toEqual({
topLeft: null,
topRight: { width: 108, height: 36 },
});
});
// Measured: iPhone Duo inner screen, portrait. The top bar ends at x 525, where the status
// region (0..82) starts; the band starts at y 26.
it("treats the status region beside a native top band as top-right window controls", () => {
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: { width: 144, height: 56 } },
}),
).toEqual({ topLeft: null, topRight: { width: 144, height: 56 } });
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: null },
}),
).toEqual({ topLeft: null, topRight: null });
});
it("insets and reserves only claimed corners", () => {
const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } };
expect(
+9 -1
View File
@@ -12,6 +12,7 @@ import {
getDesktopWindowControlsWidth,
} from "@/desktop/window-chrome-presentation";
import { isNative } from "@/constants/platform";
import { useTopBand, type TopBand } from "@/window-layout";
export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both";
type WindowChromeSafeAreaPlacement = "inline" | "below";
@@ -105,10 +106,15 @@ export function removeWindowChromeCorner(
return corner === "top-left" ? "top-right" : "top-left";
}
/** Desktop window controls, or the system region a native top band gives way to. */
export function resolveWindowChromeObstruction(input: {
mode: DesktopWindowChromeMode | null;
isFullscreen: boolean;
topBand: TopBand | null;
}): WindowChromeObstruction {
if (input.topBand?.trailingObstruction) {
return { topLeft: null, topRight: input.topBand.trailingObstruction };
}
if (!input.mode || input.isFullscreen) return EMPTY_OBSTRUCTION;
if (input.mode === "native-mac") {
return {
@@ -217,13 +223,15 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) {
};
}, []);
const topBand = useTopBand();
const obstruction = useMemo(
() =>
resolveWindowChromeObstruction({
mode: isElectronReady ? getDesktopWindowChromeMode() : null,
isFullscreen: windowState.isFullscreen,
topBand,
}),
[isElectronReady, windowState.isFullscreen],
[isElectronReady, windowState.isFullscreen, topBand],
);
const desktopWindowChrome = useMemo(
() => ({
+9 -1
View File
@@ -1,9 +1,12 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
@@ -13,3 +16,8 @@ export function useWindowLayout(): WindowLayout | null {
? layout
: null;
}
/** The top band beside the system's status region, or null where the window has none. */
export function useTopBand(): TopBand | null {
return useContext(TopBandContext);
}
+17 -5
View File
@@ -2,18 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
const GeometryProbe = requireNativeViewManager<
ViewProps & {
@@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band that `WindowChromeProvider` reads through `useTopBand()`.
*/
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+2 -1
View File
@@ -3,12 +3,13 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
+126 -6
View File
@@ -1,35 +1,155 @@
import { describe, expect, it } from "vitest";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import {
resolveSafeAreaInsets,
resolveTopBand,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(bottomClearance: number | null | undefined): WindowLayout {
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
return {
bounds: { x: 0, y: 0, width: 402, height: 874 },
verticalBarEdge: "none",
verticalBar: null,
bottomClearance,
divisions: [],
...input,
};
}
describe("resolveSafeAreaInsets", () => {
it("replaces only the bottom with the measured bar clearance", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
});
it("leaves system insets alone before the native layout arrives", () => {
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
});
it("starts the top where the top bar rests when it sits above the system top", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
);
expect(insets).toEqual({ ...systemInsets, top: 12 });
});
it("keeps the system top when the top bar rests at or below it", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
);
expect(insets).toBe(systemInsets);
});
});
// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in
// DeviceHub.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
};
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
return layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: duo.topBar,
occlusions,
});
}
describe("resolveTopBand", () => {
it("publishes the top bar's start and the status region past its trailing end", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
trailingObstruction: { width: 144, height: 56 },
});
});
it("reaches as low as the deepest occlusion beside the bar", () => {
const deeper = { x: 600, y: 0, width: 69, height: 96 };
expect(
resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion, deeper]))?.trailingObstruction,
).toEqual({ width: 144, height: 70 });
});
it("rounds a fractional obstruction up so controls never touch the status region", () => {
const fractional = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 16, y: 26, width: 509.25, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 143.75, height: 82.5 }],
});
expect(resolveTopBand(duo.systemTop, fractional)?.trailingObstruction).toEqual({
width: 144,
height: 57,
});
});
it("has no trailing obstruction when the bar reaches the window's trailing edge", () => {
const fullWidth = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 0, y: 26, width: 669, height: 44 },
});
expect(resolveTopBand(duo.systemTop, fullWidth)).toEqual({
top: 26,
trailingObstruction: null,
});
});
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
});
it("ignores occlusions when the top bar guide sits below the status region", () => {
const below = layout({
topBar: { x: 16, y: 82, width: 637, height: 44 },
occlusions: [duo.statusRegion],
});
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
});
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
const iphone = layout({
topBar: { x: 18, y: 62, width: 366, height: 44 },
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
});
expect(resolveTopBand(62, iphone)).toBeNull();
});
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
// column, which the vertical bar layout already owns.
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
const outer = layout({
bounds: { x: 0, y: 0, width: 466, height: 678 },
verticalBarEdge: "trailing",
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
occlusions: [
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 },
],
});
expect(resolveTopBand(0, outer)).toBeNull();
});
it("is null before the native layout arrives", () => {
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
});
});
+43 -5
View File
@@ -14,18 +14,56 @@ export interface WindowLayout {
* the system has no bottom bar guide. Zero is a measured value.
*/
bottomClearance?: number | null;
/** Where a top bar rests, or null until the system resolves its top bar guide. */
topBar?: WindowLayoutRect | null;
/** Active system occlusions (status region, camera, Live Activities), margins included. */
occlusions?: WindowLayoutRect[];
divisions: WindowLayoutRect[];
}
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
/**
* The band above the rectangular safe area that a top bar may use because the system
* occupies only its trailing end, like window controls on a desktop title bar.
*/
export interface TopBand {
/** Where the top bar starts, in window points. */
top: number;
/**
* The system region past the bar's trailing end, measured from the band's top, or null
* when the bar reaches the window's trailing edge.
*/
trailingObstruction: { width: number; height: number } | null;
}
/** Null unless the top bar guide starts above the system's rectangular safe area. */
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
const bar = layout?.topBar;
if (!layout || !bar || bar.y >= systemTop) return null;
const top = Math.max(0, bar.y);
const barEnd = bar.x + bar.width;
const width = Math.ceil(layout.bounds.width - barEnd);
if (width <= 0) return { top, trailingObstruction: null };
const beside = (layout.occlusions ?? []).filter(
(frame) => frame.x + frame.width > barEnd && frame.y < bar.y + bar.height,
);
const bottom = Math.max(bar.y + bar.height, ...beside.map((frame) => frame.y + frame.height));
return { top, trailingObstruction: { width, height: Math.ceil(bottom - top) } };
}
/**
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
* and the top bar's start when it sits above the system top.
*/
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
return top === systemInsets.top && bottom === systemInsets.bottom
? systemInsets
: { ...systemInsets, top, bottom };
}
export interface DesktopDivisionLayout {
@@ -1796,7 +1796,6 @@ it("replays bounded ANSI chunks independently of live output and reconstructs th
rows: 8,
cols: 120,
...nodeTerminalCommand(`
for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n");
require("node:readline").createInterface({ input: process.stdin }).on("line", line => {
if (line !== "before") { process.stdout.write("LIVE-AFTER-REPLAY\\r\\n"); return; }
process.stdout.write("PRE-CUT");
@@ -1806,6 +1805,9 @@ it("replays bounded ANSI chunks independently of live output and reconstructs th
process.stdout.write("\\r\\n");
}, 5);
});
// The last history row is the parent's readiness signal. Install input
// handling first so observing that row means the fixture can answer.
for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n");
setInterval(() => {}, 1000);
`),
}),