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,