From dc7bed417a32f70c540ae4a43c39b3ec088e256f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:08:37 +0200 Subject: [PATCH] Keep late composer pills clear of completed turn actions (#7341) * fix(app): keep composer tracks clear of timeline footers * fix(app): preserve detached scroll on overlay mount * style(app): use object input for overlay anchor policy --------- Co-authored-by: Stephen Holiday Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/mobile/composer-overlay/README.md | 47 +++++++++ .../e2e/mobile/composer-overlay/android.mjs | 95 +++++++++++++++++++ .../composer-overlay/assert-footer-clear.mjs | 21 ++++ packages/app/src/agent-stream/view.tsx | 6 ++ 4 files changed, 169 insertions(+) create mode 100644 packages/app/e2e/mobile/composer-overlay/README.md create mode 100644 packages/app/e2e/mobile/composer-overlay/android.mjs create mode 100644 packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs diff --git a/packages/app/e2e/mobile/composer-overlay/README.md b/packages/app/e2e/mobile/composer-overlay/README.md new file mode 100644 index 0000000000..d7a1ec4b62 --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/README.md @@ -0,0 +1,47 @@ +# Late composer pill clearance + +This regression opens a completed conversation, relaunches the Android app, and +registers a composer pill five seconds after the client plugin loads. It checks +that the completed turn's Copy and Fork buttons remain fully above the pill. +The daemon, plugin API, native app, layout, and accessibility bounds are real; +no provider calls are needed during the test. + +Prerequisites: + +- Build the server and app dependencies with `npm run build:server` and + `npm run build --workspace=@getpaseo/expo-two-way-audio`. +- Run an isolated development daemon on a non-default port. +- Run this checkout's Metro server and open the connected Android dev client + with `agent-device`. The app must already know the isolated daemon. +- Open a completed conversation on a compact portrait Android emulator, at the + latest turn, with no other composer tracks visible. Keep the software keyboard + closed. Note the workspace and agent IDs from the daemon. + +Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the +repository root, substituting the session and ports: + +```sh +PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \ + node packages/app/e2e/mobile/composer-overlay/android.mjs \ + composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193 +``` + +The runner installs and removes its delayed plugin and leaves the conversation +intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory +contains the cold-reopen screenshot and raw accessibility snapshot. Without an +explicit directory, temporary evidence is removed with the fixture. + +To check an existing capture independently: + +```sh +node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \ + .dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill' +``` + +On the failing baseline, the Copy button extends below the pill's top edge. With +the correction, both actions are clear before the composer receives focus. + +Additional checks on the same conversation: opening the keyboard keeps the footer +clear; after scrolling up, a newly registered pill must not jump back to the latest +turn. Native iOS uses a different keyboard implementation and needs its own device +verification. diff --git a/packages/app/e2e/mobile/composer-overlay/android.mjs b/packages/app/e2e/mobile/composer-overlay/android.mjs new file mode 100644 index 0000000000..99f56df41e --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/android.mjs @@ -0,0 +1,95 @@ +// Start on a completed conversation in the connected Android dev client. +// node android.mjs [app-id] [metro-port] +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocket } from "ws"; +import { DaemonClient } from "../../../../client/dist/daemon-client.js"; + +const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] = + process.argv; +assert( + session && daemonUrl && workspaceId && agentId, + "Pass a session, isolated daemon URL, workspace ID, and agent ID", +); +const url = new URL(daemonUrl); +assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon"); +assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon"); +const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-")); +const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence"); +await mkdir(artifacts, { recursive: true }); +const pluginId = "composer-overlay-mobile-repro"; +const pillLabel = "Overlay regression pill"; +const client = new DaemonClient({ + url: daemonUrl, + clientId: "composer-overlay-mobile-regression", + clientType: "cli", + appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version, + webSocketFactory: (address, options) => new WebSocket(address, options), +}); +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 90_000, + }); +} +let installed = false; +let previousConfig; +try { + ad("wait", 'label="Copy turn"', "20000"); + await client.connect(); + previousConfig = (await client.getDaemonConfig()).config; + await client.patchDaemonConfig({ pluginsEnabled: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: pluginId, + requirements: { paseo: ">=0.11.4" }, + }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `export default function contribute(client) { + let pill; + const timer = setTimeout(() => { + pill = client.addComposerPill({ + id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)}, + button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } }, + }); + }, 5000); + return () => { clearTimeout(timer); pill?.remove(); }; + }`, + ); + await client.installDirectoryPlugin(directory); + installed = true; + ad("wait", `label="${pillLabel}"`, "20000"); + // Match the report: reopen a completed conversation, then let the plugin register late. + ad( + "open", + appId, + "--platform", + "android", + "--metro-port", + metroPort, + "--no-test-ime", + "--relaunch", + ); + ad("wait", `label="${pillLabel}"`, "20000"); + const capture = path.join(artifacts, "cold-reopen.json"); + await writeFile(capture, ad("snapshot", "--raw", "--json")); + ad("screenshot", path.join(artifacts, "cold-reopen.png")); + execFileSync( + process.execPath, + [fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel], + { stdio: "inherit" }, + ); +} finally { + if (installed) await client.removePlugin(pluginId); + if (previousConfig) + await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); +} diff --git a/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs b/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs new file mode 100644 index 0000000000..b091093254 --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs @@ -0,0 +1,21 @@ +// Assert geometry from a fresh agent-device snapshot --raw --json capture. +// node assert-footer-clear.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const [, , snapshotPath, pillLabel] = process.argv; +assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label"); +const { data } = JSON.parse(readFileSync(snapshotPath, "utf8")); +const buttons = data.nodes.filter((node) => node.type === "android.widget.Button"); +const pill = buttons.find((node) => node.label === pillLabel); +assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`); +for (const label of ["Copy turn", "Fork chat from here"]) { + const button = buttons.find((node) => node.label === label); + assert(button?.rect, `Completed-turn action '${label}' must be visible`); + const bottom = button.rect.y + button.rect.height; + assert( + bottom <= pill.rect.y, + `${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`, + ); +} +console.log("PASS: completed-turn actions remain fully above the composer pill"); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c1466b3682..da2fd59d36 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1252,6 +1252,12 @@ function agentStreamViewPropsEqual( if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) { reasons.push("isAuthoritativeHistoryReady"); } + if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) { + reasons.push("bottomOverlayTailClearance"); + } + if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) { + reasons.push("bottomOverlayControlClearance"); + } if (left.toast !== right.toast) reasons.push("toast"); if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); if (left.readOnly !== right.readOnly) reasons.push("readOnly");