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..99beb86c8a --- /dev/null +++ b/packages/app/e2e/mobile/timeline-status/placement.mjs @@ -0,0 +1,86 @@ +// Open an existing connected compact chat in a dev client, with the keyboard closed. +// 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, target, serverId, coordinateScale, composerLabel, artifacts] = + process.argv; +assert( + session && + ["android", "ios"].includes(platform) && + target && + serverId && + composerLabel && + artifacts, + "Pass all seven 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 evaluate(expression) { + const result = ad("cdp", "runtime", "eval", "--expr", expression); + assert(!result.includes("Runtime evaluation failed"), result); +} +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-updating-toast"); + assert(header?.rect && toast?.rect, "Measured header and updating 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", `label=${JSON.stringify(composerLabel)}`, "20000"); +ad("cdp", "target", "select", target); +try { + // Hold completion of the real timeline response. No response data is synthesized. + evaluate(` + globalThis.__statusPlacement = {}; + (() => { + const module = name => __r(Array.from(__r.getModules()).find(([,v]) => v.verboseName === name)[0]); + const store = module("src/runtime/host-runtime.ts").getHostRuntimeStore(); + const owner = module("src/stores/session-store.ts").useSessionStore.getState().sessions[${JSON.stringify(serverId)}].viewedTimelineSync; + const original = store.fetchAgentTimeline; + const releases = []; + let held = true; + store.fetchAgentTimeline = async function(...args) { + const response = await original.apply(this, args); + if (held) await new Promise(resolve => releases.push(resolve)); + return response; + }; + __statusPlacement.restore = () => { held = false; store.fetchAgentTimeline = original; releases.forEach(resolve => resolve()); }; + owner.setActive(false); + owner.setActive(true); + })(); + `); + ad("wait", 'id="agent-updating-toast"', "15000"); + 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 toast stays12 points below measured header, keyboard closed/open"); +} finally { + evaluate("globalThis.__statusPlacement?.restore?.(); delete globalThis.__statusPlacement;"); +} diff --git a/packages/app/src/components/floating-header/clearance.test.ts b/packages/app/src/components/floating-header/clearance.test.ts deleted file mode 100644 index f23cdcefa8..0000000000 --- a/packages/app/src/components/floating-header/clearance.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { floatingHeaderOverlayClearance } from "./clearance"; - -describe("floating header overlay clearance", () => { - it("keeps stationary status overlays 12 points below the measured header", () => { - for (const headerHeight of [72, 108, 132]) { - const toastTop = floatingHeaderOverlayClearance(true, headerHeight) + 12; - expect(toastTop).toBe(headerHeight + 12); - } - }); - - it("adds no clearance when the header is in flow or hidden for fullscreen", () => { - expect(floatingHeaderOverlayClearance(false, 108)).toBe(0); - expect(floatingHeaderOverlayClearance(false, 0)).toBe(0); - }); -}); diff --git a/packages/app/src/components/floating-header/clearance.ts b/packages/app/src/components/floating-header/clearance.ts deleted file mode 100644 index 59339c0fe9..0000000000 --- a/packages/app/src/components/floating-header/clearance.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Content fades beneath the header; status overlays render above that fade. */ -export const FLOATING_HEADER_FADE_OVERHANG = 48; - -export function floatingHeaderOverlayClearance(floating: boolean, headerHeight: number): number { - return floating ? headerHeight : 0; -} diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index d0266975a7..cd59ec4d30 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -5,8 +5,6 @@ import { Portal, PortalHost } from "@gorhom/portal"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { EdgeFade } from "@/components/ui/edge-fade"; -import { FLOATING_HEADER_FADE_OVERHANG, floatingHeaderOverlayClearance } from "./clearance"; - const OverlayClearanceContext = createContext(0); const OverlayHostContext = createContext(null); @@ -24,7 +22,7 @@ export function useFloatingHeaderOverlayClearance(): number { } /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ -const FADE_OVERHANG = FLOATING_HEADER_FADE_OVERHANG; +const FADE_OVERHANG = 48; /** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ const FADE_SOLID_FRACTION = 0.57; @@ -46,7 +44,7 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea setHeaderHeight(Math.round(event.nativeEvent.layout.height)); }, []); - const overlayClearance = floatingHeaderOverlayClearance(floating, headerHeight); + const overlayClearance = floating ? headerHeight : 0; return ( @@ -66,7 +64,12 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea backdrop="surfaceWorkspace" style={styles.fade} /> - + {header}