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.
This commit is contained in:
Mohamed Boudra
2026-11-09 15:59:39 +01:00
parent b10647cae3
commit bee9f94a49
3 changed files with 87 additions and 4 deletions
@@ -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.");
});
});
@@ -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<void> };
};
export async function withPluginHoldingNewWorkspaceComposer(
page: Page,
run: (author: {
openNewWorkspace(): Promise<void>;
openSettings(): Promise<void>;
expectHeldComposerFocusResult(result: string): Promise<void>;
}) => Promise<void>,
) {
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();
}
}
@@ -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<View | null>(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")}