diff --git a/packages/app/e2e/mobile/timeline-status/placement.mjs b/packages/app/e2e/mobile/timeline-status/placement.mjs new file mode 100644 index 0000000000..8317c9e152 --- /dev/null +++ b/packages/app/e2e/mobile/timeline-status/placement.mjs @@ -0,0 +1,54 @@ +// Open an existing compact chat, with the keyboard closed, then interrupt its host connection. +// Hold that interruption externally (for example, stop a disposable SSH TCP forward). +// Leave the daemon running. This test observes the public reconnecting state. +// node placement.mjs +// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1). +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv; +assert( + session && ["android", "ios"].includes(platform) && composerLabel && artifacts, + "Pass all five arguments", +); +const scale = Number(coordinateScale); +assert(scale > 0, "Pass a positive coordinate scale"); +mkdirSync(artifacts, { recursive: true }); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], { + encoding: "utf8", + timeout: 90_000, + }); +} +function capture(name) { + const snapshot = ad("snapshot", "--raw", "--json"); + writeFileSync(path.join(artifacts, `${name}.json`), snapshot); + ad("screenshot", path.join(artifacts, `${name}.png`)); + const nodes = JSON.parse(snapshot).data.nodes; + const header = nodes.find((node) => node.identifier === "floating-header"); + const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast"); + assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present"); + assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds"); + const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale; + assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`); + return toast.rect.y / scale; +} + +ad("wait", 'id="agent-reconnecting-toast"', "20000"); +const closedTop = capture("keyboard-closed"); +ad("press", `label=${JSON.stringify(composerLabel)}`); +const opened = ad("snapshot", "--raw", "--json"); +const nodes = JSON.parse(opened).data.nodes; +assert( + nodes.some( + (node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""), + ), + "Use a real visible software keyboard; open Android sessions with --no-test-ime", +); +const openTop = capture("keyboard-open"); +assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast"); +console.log( + "PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open", +); diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index 6e4cd525f1..cd59ec4d30 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -1,8 +1,26 @@ -import { useCallback, useState, type ReactNode } from "react"; +import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react"; import { View, type LayoutChangeEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { Portal, PortalHost } from "@gorhom/portal"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { EdgeFade } from "@/components/ui/edge-fade"; +const OverlayClearanceContext = createContext(0); +const OverlayHostContext = createContext(null); + +/** Keep status overlays above the fade without lifting the scrolling content. */ +export function FloatingHeaderOverlay({ children }: { children: ReactNode }) { + const hostName = useContext(OverlayHostContext); + const active = useRetainedPanelActive(); + if (!hostName) return children; + return active ? {children} : null; +} + +/** Clearance for stationary overlays inside this layout, measured to the header bottom. */ +export function useFloatingHeaderOverlayClearance(): number { + return useContext(OverlayClearanceContext); +} + /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ const FADE_OVERHANG = 48; /** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ @@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps { * same tree position in both modes, so toggling `floating` never remounts them. */ export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { + const overlayHostName = useId(); const [headerHeight, setHeaderHeight] = useState(0); const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { setHeaderHeight(Math.round(event.nativeEvent.layout.height)); }, []); + const overlayClearance = floating ? headerHeight : 0; + return ( {floating ? null : header} - {children(floating ? headerHeight : 0)} + + + + {children(floating ? headerHeight : 0)} + + + {floating ? ( - + {header} ) : null} + + + ); } @@ -55,6 +90,13 @@ const styles = StyleSheet.create({ flex: 1, minHeight: 0, }, + statusOverlay: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + }, overlay: { position: "absolute", top: 0, diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index d9c8001f50..62a15bad04 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -1,3 +1,7 @@ +import { + FloatingHeaderOverlay, + useFloatingHeaderOverlayClearance, +} from "@/components/floating-header"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { @@ -190,9 +194,11 @@ export function ToastViewport({ return clearTimer; }, [clearTimer, durationMs, scheduleDismiss, toastId]); + const panelClearance = useFloatingHeaderOverlayClearance(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0; + const topOffset = + placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance; const content = ( {content} : content; } function ToastCard({ diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 4b6407c872..e3720ccbee 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -179,8 +179,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } @@ -214,8 +214,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } diff --git a/packages/app/src/composer/dock/overlay-placement.test.ts b/packages/app/src/composer/dock/overlay-placement.test.ts new file mode 100644 index 0000000000..da4262af12 --- /dev/null +++ b/packages/app/src/composer/dock/overlay-placement.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +it("keeps dock overlays outside keyboard translation in every native layout", () => { + const source = ts.createSourceFile( + "dock.tsx", + readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let surfaces = 0; + function visit(node: ts.Node) { + if ( + ts.isJsxElement(node) && + node.openingElement.tagName.getText(source) === "KeyboardTranslateView" + ) { + surfaces++; + function assertStationary(child: ts.Node) { + if (ts.isJsxExpression(child)) + expect(child.expression?.getText(source)).not.toBe("overlay"); + ts.forEachChild(child, assertStationary); + } + ts.forEachChild(node, assertStationary); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(surfaces).toBe(2); +});