fix(app): judge compact composer coverage by where it is hosted

The Explorer dock always reported its pane as focused, so a workspace sheet
over an Explorer composer still counted as on screen and focus() put the
cursor behind the sheet. It now suspends pane focus like the main panes do.

A composer on a screen with no panes counted as a main pane, so an Explorer
selection left over from the workspace marked New workspace as covered and
focus() rejected on a visible input. Only the agent list can cover those
screens, because the Explorer lives in the workspace.
This commit is contained in:
Mohamed Boudra
2026-11-10 01:53:10 +01:00
parent f92f610fc7
commit d8372eb25d
8 changed files with 84 additions and 22 deletions
@@ -19,6 +19,7 @@ import {
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
promptMentioning,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
@@ -28,17 +29,6 @@ import {
const FILE_LINK = "linked.ts:3";
function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
@@ -118,6 +118,14 @@ test("focus rejects on a New workspace composer the user has left", async ({ pag
});
});
test("focus reaches a New workspace composer opened from a compact Explorer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.leaveCompactExplorerForNewWorkspace();
await author.expectHeldComposerFocusResult("focused");
});
});
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
@@ -134,5 +142,8 @@ test("focus reaches an agent kept in the Explorer on a compact screen", async ({
await author.keepParentInExplorerOnCompactScreen();
await author.focusParent();
await author.expectParentReadyToTypeInExplorer();
await author.coverExplorerComposerWithFileSheet();
await author.focusParent();
await author.expectParentReadyToTypeInExplorer();
});
});
@@ -3,12 +3,19 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { openCommandCenter } from "./command-center";
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import { expectNoSheetOpen, expectOpenTabCount } from "./sheet";
import {
expectNoSheetOpen,
expectOpenTabCount,
promptMentioning,
sheet,
tapSheetBackdrop,
} from "./sheet";
import { seedSubagent } from "./subagents";
import { getServerId } from "./server-id";
import {
@@ -20,6 +27,7 @@ import {
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
const FILE_LINK = "linked.ts:3";
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
@@ -406,6 +414,7 @@ export async function withPluginHoldingNewWorkspaceComposer(
page: Page,
run: (author: {
openNewWorkspace(): Promise<void>;
leaveCompactExplorerForNewWorkspace(): Promise<void>;
openSettings(): Promise<void>;
expectHeldComposerFocusResult(result: string): Promise<void>;
}) => Promise<void>,
@@ -443,6 +452,22 @@ export default function contribute(client) {
await openGlobalNewWorkspaceComposer(page);
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
}),
leaveCompactExplorerForNewWorkspace: () =>
test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize(COMPACT);
await expect(await ensureExplorerSidebar(page)).toBeVisible();
await page.evaluate(() => {
(globalThis as HeldComposerGlobal).heldComposer = undefined;
});
await page.keyboard.press("ControlOrMeta+n");
await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
// A compact composer mounts plugin items inside its controls sheet.
await page.getByRole("button", { name: /^Select model/ }).click();
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
await tapSheetBackdrop(page);
await expectNoSheetOpen(page);
}),
openSettings: () =>
test.step("push Settings over the New workspace screen", async () => {
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
@@ -482,12 +507,15 @@ export async function withPluginFocusingASubagent(
keepParentInExplorerOnCompactScreen(): Promise<void>;
focusParent(): Promise<void>;
expectParentReadyToTypeInExplorer(): Promise<void>;
coverExplorerComposerWithFileSheet(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "focus-subagent-",
title: "Focus parent",
initialPrompt: promptMentioning(FILE_LINK),
});
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Focus child" },
@@ -531,6 +559,8 @@ export async function withPluginFocusingASubagent(
agentId: workspace.agentId,
});
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
// The seeded turn must finish first, so the drag isn't racing its updates to the tab.
await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
const explorer = await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
await expect(
@@ -541,8 +571,15 @@ export async function withPluginFocusingASubagent(
focusParent: () =>
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
expectParentReadyToTypeInExplorer: () =>
test.step("the cursor is in the Explorer's composer", () =>
expect(prompt(page).filter({ visible: true })).toBeFocused()),
test.step("the cursor is in the Explorer's composer", async () => {
await expectNoSheetOpen(page);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
coverExplorerComposerWithFileSheet: () =>
test.step("open a file's sheet over the Explorer's composer", async () => {
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
await expect(sheet(page, "linked.ts")).toBeVisible();
}),
openParentAgent: () =>
test.step("open the parent agent on a phone", async () => {
await openAgentRoute(page, {
+12
View File
@@ -112,3 +112,15 @@ export async function expectDeclinedDiscardPrompt(
await close();
expect(await prompt).toContain("discard the draft");
}
/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
export function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
+12 -4
View File
@@ -43,7 +43,7 @@ import {
toolbarSlot,
} from "@/components/ui/control-geometry";
import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails";
import { usePanelStore } from "@/stores/panel-store";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
import {
@@ -139,6 +139,7 @@ import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { usePaneHost } from "@/panels/pane-context";
import type { PaneHost } from "@/panels/panel-manifest";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
@@ -1216,6 +1217,13 @@ function ComposerVoiceModeButton({
);
}
/** Only the agent list can cover a screen outside a workspace: the Explorer lives in the workspace. */
function isMobilePanelCoveringHost(target: MobilePanelView, host: PaneHost | null): boolean {
if (target === "agent-list") return true;
if (host === null) return false;
return target !== (host === "explorer" ? "file-explorer" : "agent");
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
@@ -1223,9 +1231,9 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const hostPanelTarget = usePaneHost() === "explorer" ? "file-explorer" : "agent";
const overlayPanelRequested = usePanelStore(
(state) => state.mobilePanel.target !== hostPanelTarget,
const paneHost = usePaneHost();
const overlayPanelRequested = usePanelStore((state) =>
isMobilePanelCoveringHost(state.mobilePanel.target, paneHost),
);
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
+3 -3
View File
@@ -74,9 +74,9 @@ export function usePaneContext(): PaneContextValue {
return value;
}
/** The host of the pane around this component; a screen with no panes counts as the main host. */
export function usePaneHost(): PaneHost {
return useContext(PaneContext)?.host ?? "main";
/** The host of the pane around this component, or null on a screen with no panes. */
export function usePaneHost(): PaneHost | null {
return useContext(PaneContext)?.host ?? null;
}
export function usePaneFocus(): PaneFocusContextValue {
@@ -38,6 +38,7 @@ interface ExplorerSidebarDockProps extends Pick<
normalizedServerId: string;
normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
paneFocusSuspended: boolean;
closingTabIds: Set<string>;
onSelectTab: (paneId: string, tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -56,6 +57,7 @@ export function ExplorerSidebarDock({
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
paneFocusSuspended,
closingTabIds,
onSelectTab,
onCloseTab,
@@ -167,7 +169,7 @@ export function ExplorerSidebarDock({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused
isPaneFocused={!paneFocusSuspended}
buildPaneContentModel={buildPaneContentModel}
/>
</View>
@@ -3697,6 +3697,7 @@ function WorkspaceScreenContent({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
paneFocusSuspended={paneFocusSuspended}
closingTabIds={closingTabIds}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -3738,6 +3739,7 @@ function WorkspaceScreenContent({
normalizedServerId,
normalizedWorkspaceId,
isRouteFocused,
paneFocusSuspended,
isExplorerSidebarShowing,
closingTabIds,
hoveredCloseTabKey,