diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts index 5b68663862..cf64451c45 100644 --- a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -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'.", - "", - "", - `Open \`${target}\` now`, - "", - ].join("\n"); -} - test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({ page, }) => { 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 e04c520054..c40f58589b 100644 --- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -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(); }); }); 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 162176a1ed..9358b80b60 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -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>; @@ -406,6 +414,7 @@ export async function withPluginHoldingNewWorkspaceComposer( page: Page, run: (author: { openNewWorkspace(): Promise; + leaveCompactExplorerForNewWorkspace(): Promise; openSettings(): Promise; expectHeldComposerFocusResult(result: string): Promise; }) => Promise, @@ -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; focusParent(): Promise; expectParentReadyToTypeInExplorer(): Promise; + coverExplorerComposerWithFileSheet(): Promise; }) => Promise, ) { 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, { diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts index 94c33747d0..36dcb61a85 100644 --- a/packages/app/e2e/support/helpers/sheet.ts +++ b/packages/app/e2e/support/helpers/sheet.ts @@ -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'.", + "", + "", + `Open \`${target}\` now`, + "", + ].join("\n"); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a3ca2e8173..d6d94f6c81 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index c78016c69e..956293e6a0 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -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 { diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index e39106b65b..c43e87c45b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -38,6 +38,7 @@ interface ExplorerSidebarDockProps extends Pick< normalizedServerId: string; normalizedWorkspaceId: string; isWorkspaceFocused: boolean; + paneFocusSuspended: boolean; closingTabIds: Set; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | 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} /> diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index c1c375fbae..e2d171be48 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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,