From b92a34ab3dff320a2177d0a283c9af96b1eefc8d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 22:49:53 +0700 Subject: [PATCH] Keep reconnecting and updating status visible in native chats (#8568) * fix(app): keep chat status toasts visible below floating headers Compact native chats extend beneath the floating header. Panel toasts need measured header clearance and a layer above its fade; placing them below the fade leaves an excessive gap. Keep dock overlays outside keyboard translation so opening the keyboard cannot move them offscreen. * test(app): verify rendered chat status spacing on native devices Measure the real header and toast bounds with the software keyboard closed and open. The former arithmetic helper test passed even when the viewport stopped consuming header clearance; the native regression fails against that broken production wiring. * test(app): exercise toast placement through host disconnection Observe the public reconnecting state with host connectivity interrupted externally. Keep native bounds and software keyboard assertions while removing runtime evaluation and private timeline-owner mutations. --- .../e2e/mobile/timeline-status/placement.mjs | 54 +++++++++++++++++++ .../src/components/floating-header/index.tsx | 48 +++++++++++++++-- packages/app/src/components/toast-host.tsx | 10 +++- .../app/src/composer/dock/index.native.tsx | 4 +- .../composer/dock/overlay-placement.test.ts | 31 +++++++++++ 5 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/mobile/timeline-status/placement.mjs create mode 100644 packages/app/src/composer/dock/overlay-placement.test.ts 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); +});