From bee9f94a49550ab6b39f1a2019c1e69505d5288b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 15:59:39 +0100 Subject: [PATCH] fix(app): stop treating a covered New workspace composer as on screen The screen passed isPaneFocused={true}, so with Settings pushed over it the input still counted as revealed. focus() on a held composer resolved on native with no visible cursor, and rejected with the wrong error on web. The screen now reports its real route focus. --- .../plugin-composers-by-reference.spec.ts | 11 +++ .../helpers/plugin-composers-by-reference.ts | 74 ++++++++++++++++++- .../app/src/screens/new-workspace-screen.tsx | 6 +- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts index 62c4c4cad0..375375c457 100644 --- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -3,6 +3,7 @@ import { CHECKLIST, withComposerToolsExample, withPluginFocusingAComposer, + withPluginHoldingNewWorkspaceComposer, withPluginWritingToAnotherWorkspace, } from "../support/helpers/plugin-composers-by-reference"; @@ -105,3 +106,13 @@ test("focus closes the compact Explorer panel covering the composer", async ({ p await author.expectComposerFocused(); }); }); + +test("focus rejects on a New workspace composer the user has left", async ({ page }) => { + test.setTimeout(120_000); + await withPluginHoldingNewWorkspaceComposer(page, async (author) => { + await author.openNewWorkspace(); + await author.expectHeldComposerFocusResult("focused"); + await author.openSettings(); + await author.expectHeldComposerFocusResult("The composer is not on screen."); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts index 29a9d195e2..c280b448d5 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -5,7 +5,7 @@ import { expect, test, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; import { openAgentRoute } from "./mock-agent"; -import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { seedWorkspace } from "./seed-client"; import { getServerId } from "./server-id"; @@ -260,7 +260,7 @@ export default function contribute(client) { kind: "action", async onPress() { for (const composer of client.composers({ agentId: ${targetId} })) { - await composer.insertText("HELLO"); + composer.insertText("HELLO"); await composer.focus(); } }, @@ -391,3 +391,73 @@ export default function contribute(client) { } export { CHECKLIST }; + +type HeldComposerGlobal = typeof globalThis & { + heldComposer?: { focus(): Promise }; +}; + +export async function withPluginHoldingNewWorkspaceComposer( + page: Page, + run: (author: { + openNewWorkspace(): Promise; + openSettings(): Promise; + expectHeldComposerFocusResult(result: string): Promise; + }) => Promise, +) { + const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-")); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "held-composer", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `import { useComposer } from "@getpaseo/plugin/client"; + +function Holder() { + globalThis.heldComposer = useComposer(); + return null; +} + +export default function contribute(client) { + client.addComposerItem({ id: "holder", Component: Holder }); + return () => {}; +}`, + ); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await run({ + openNewWorkspace: () => + test.step("open the New workspace composer", async () => { + await openGlobalNewWorkspaceComposer(page); + await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer); + }), + openSettings: () => + test.step("push Settings over the New workspace screen", async () => { + await page.getByTestId("sidebar-settings").filter({ visible: true }).click(); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + }), + expectHeldComposerFocusResult: (result) => + test.step(`the held composer's focus() reports ${result}`, async () => { + const actual = await page.evaluate(() => + (globalThis as HeldComposerGlobal).heldComposer?.focus().then( + () => "focused", + (error: Error) => error.message, + ), + ); + expect(actual).toBe(result); + }), + }); + } finally { + await client.removePlugin("held-composer"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); + await workspace.cleanup(); + } +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 900fdad7d7..bab4df8f01 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -8,6 +8,7 @@ import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { useIsFocused } from "expo-router/react-navigation"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -909,6 +910,7 @@ export function NewWorkspaceScreen({ const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); + const isScreenFocused = useIsFocused(); // Launch target: what the composer submits to (chat agent, or a terminal // profile). Mirrors useWorkspaceIsolation's pattern below: the derived @@ -1544,7 +1546,7 @@ export function NewWorkspaceScreen({ submitLabel={terminalSubmitLabel} agentId={draftKey} serverId={selectedServerId} - isPaneFocused={true} + isPaneFocused={isScreenFocused} onSubmitMessage={handleSubmitTerminalLaunch} allowEmptySubmit={true} submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")} @@ -1568,7 +1570,7 @@ export function NewWorkspaceScreen({ editingSession={composerEditing.chat} agentId={draftKey} serverId={selectedServerId} - isPaneFocused={true} + isPaneFocused={isScreenFocused} onSubmitMessage={handleSubmitNewWorkspace} allowEmptySubmit={true} submitButtonAccessibilityLabel={t("newWorkspace.create")}