From f92f610fc75180e1444ba098c90cce1b62c55ba0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 16:50:39 +0100 Subject: [PATCH] fix(app): open focus targets as tabs and focus Explorer composers on compact Promoting a sheet after navigating could not reach an open deferred until the workspace loaded, so the request now asks for a tab up front with asTab, which travels with the deferred open. That removes the post-navigation promotion. A compact composer counted as covered whenever the panel target was not the agent panel, so one hosted in the Explorer could never take focus. Coverage now follows the composer's own host. --- docs/plugins.md | 2 +- .../app/e2e/browser/explorer-sidebar.spec.ts | 21 +------- .../plugin-composers-by-reference.spec.ts | 9 ++++ .../helpers/plugin-composers-by-reference.ts | 52 ++++++++++++++----- .../app/e2e/support/helpers/workspace-tabs.ts | 17 ++++++ .../app/src/composer/agent-composer/index.ts | 4 +- packages/app/src/composer/index.tsx | 6 ++- packages/app/src/panels/pane-context.tsx | 5 ++ .../navigation.test.ts | 16 ++++++ .../navigation.ts | 1 + .../src/stores/workspace-layout-store.test.ts | 18 +++++++ .../app/src/stores/workspace-layout-store.ts | 4 +- .../app/src/utils/navigate-to-agent/index.ts | 40 +++----------- .../utils/navigate-to-agent/resolve.test.ts | 8 +++ .../src/utils/navigate-to-agent/resolve.ts | 3 ++ .../app/src/utils/prepare-workspace-tab.ts | 3 ++ 16 files changed, 139 insertions(+), 70 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 19b961f6bb..03a2c3a81f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -713,7 +713,7 @@ implementations provide it: `DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock. `focus` differs per implementation on purpose. A running agent's model reveals the agent with -`navigateToAgentComposer` when `ComposerInput.isRevealed()` is false, and holds one pending request until an +`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index 06a6994332..b63775ab2b 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -13,6 +13,7 @@ import { ensureExplorerSidebar, openFilesPanel, waitForWorkspaceTabsVisible, + dragTabIntoExplorer, } from "../support/helpers/workspace-tabs"; function explorerSidebar(page: Parameters[0]) { @@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async } }); -async function dragTabIntoExplorer( - page: Parameters[0], - tabId: string, -) { - const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`); - const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); - const sourceBox = await source.boundingBox(); - const destinationBox = await destination.boundingBox(); - if (!sourceBox || !destinationBox) - throw new Error("Tab drag requires visible source and destination"); - const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; - await page.mouse.move(start.x, start.y); - await page.mouse.down(); - await page.mouse.move(start.x + 12, start.y + 4); - await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { - steps: 20, - }); - await page.mouse.up(); -} - test("moves agents into Explorer and keeps the shared Agent launcher available", async ({ page, }, testInfo) => { 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 1138fab09a..e04c520054 100644 --- a/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts +++ b/packages/app/e2e/browser/plugin-composers-by-reference.spec.ts @@ -127,3 +127,12 @@ test("focus opens a compact subagent as a tab with its composer", async ({ page await author.expectSubagentTabReadyToType(); }); }); + +test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => { + test.setTimeout(120_000); + await withPluginFocusingASubagent(page, async (author) => { + await author.keepParentInExplorerOnCompactScreen(); + 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 70f9b3f531..162176a1ed 100644 --- a/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts +++ b/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts @@ -11,7 +11,11 @@ import { seedWorkspace } from "./seed-client"; import { expectNoSheetOpen, expectOpenTabCount } from "./sheet"; import { seedSubagent } from "./subagents"; import { getServerId } from "./server-id"; -import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs"; +import { + createAgentTabFromMenu, + dragTabIntoExplorer, + ensureExplorerSidebar, +} from "./workspace-tabs"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -475,6 +479,9 @@ export async function withPluginFocusingASubagent( openExplorer(): Promise; focusSubagent(): Promise; expectSubagentTabReadyToType(): Promise; + keepParentInExplorerOnCompactScreen(): Promise; + focusParent(): Promise; + expectParentReadyToTypeInExplorer(): Promise; }) => Promise, ) { const workspace = await seedMockAgentWorkspace({ @@ -504,7 +511,38 @@ export async function withPluginFocusingASubagent( await client.installDirectoryPlugin(directory); await page.setViewportSize(COMPACT); await gotoAppShell(page); + const focusAgent = async (agentId: string) => { + const result = await page.evaluate( + (id) => + (globalThis as FocusAgentGlobal).focusAgent?.(id).then( + () => "focused", + (error: Error) => error.message, + ), + agentId, + ); + expect(result).toBe("focused"); + }; await run({ + keepParentInExplorerOnCompactScreen: () => + test.step("move the parent into the Explorer, then narrow the window", async () => { + await page.setViewportSize(WIDE); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent); + const explorer = await ensureExplorerSidebar(page); + await dragTabIntoExplorer(page, `agent_${workspace.agentId}`); + await expect( + explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`), + ).toBeVisible(); + await page.setViewportSize(COMPACT); + }), + 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()), openParentAgent: () => test.step("open the parent agent on a phone", async () => { await openAgentRoute(page, { @@ -520,17 +558,7 @@ export async function withPluginFocusingASubagent( await expect(await ensureExplorerSidebar(page)).toBeVisible(); }), focusSubagent: () => - test.step("focus the subagent, which has no tab", async () => { - const result = await page.evaluate( - (agentId) => - (globalThis as FocusAgentGlobal).focusAgent?.(agentId).then( - () => "focused", - (error: Error) => error.message, - ), - child.id, - ); - expect(result).toBe("focused"); - }), + test.step("focus the subagent, which has no tab", () => focusAgent(child.id)), expectSubagentTabReadyToType: () => test.step("the subagent is a tab with the cursor in its composer", async () => { await expectNoSheetOpen(page); diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 8ddd507563..c124e12df2 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -61,6 +61,23 @@ function explorerSidebar(page: Page) { return visibleTestId(page, "workspace-explorer-sidebar").first(); } +export async function dragTabIntoExplorer(page: Page, tabId: string): Promise { + const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`); + const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); + const sourceBox = await source.boundingBox(); + const destinationBox = await destination.boundingBox(); + if (!sourceBox || !destinationBox) + throw new Error("Tab drag requires visible source and destination"); + const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 12, start.y + 4); + await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { + steps: 20, + }); + await page.mouse.up(); +} + async function selectWorkspaceTab(tab: Locator): Promise { if ((await tab.getAttribute("aria-selected")) !== "true") { // The close action overlays the chip's trailing edge on hover. Click the diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts index 75aa099645..a3eac7a815 100644 --- a/packages/app/src/composer/agent-composer/index.ts +++ b/packages/app/src/composer/agent-composer/index.ts @@ -15,7 +15,7 @@ import { useDraftStore } from "@/stores/draft-store"; import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { UserComposerAttachment } from "@/attachments/types"; -import { navigateToAgentComposer } from "@/utils/navigate-to-agent"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; /** The mounted composer's input. While attached, prompt commands go through it. */ export interface MountedComposerInput { @@ -359,7 +359,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer composer = createAgentComposer(serverId, agentId, { queryClient: singletonQueryClient, onUnheld: releaseReleasableComposers, - reveal: () => navigateToAgentComposer({ serverId, agentId }), + reveal: () => navigateToAgent({ serverId, agentId, asTab: true }), }); composers.set(key, composer); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 242e61adfd..a3ca2e8173 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -138,6 +138,7 @@ import { submitAgentInput } from "@/composer/submit"; 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 { useAppSettings } from "@/hooks/use-settings"; import { RenderProfile } from "@/utils/render-profiler"; import { AfterPaintPublication } from "@/composer/after-paint-publication"; @@ -1222,7 +1223,10 @@ 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 overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); + const hostPanelTarget = usePaneHost() === "explorer" ? "file-explorer" : "agent"; + const overlayPanelRequested = usePanelStore( + (state) => state.mobilePanel.target !== hostPanelTarget, + ); const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( { ]); }); + it("carries a request for a tab through a deferred open", () => { + const { deps, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map() }); + + navigateToWorkspace( + { + serverId: "server-1", + workspaceId: "workspace-a", + target: { kind: "agent", agentId: "agent-1" }, + asTab: true, + }, + deps, + ); + + expect(deferredTabs[0]).toMatchObject({ asTab: true }); + }); + it("defers an agent tab until a missing workspace is recovered", () => { const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map(), diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index 6d94ed81a3..bbc55701d6 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -25,6 +25,7 @@ export interface NavigateToWorkspaceInput { target?: WorkspaceTabTarget; pin?: boolean; placement?: WorkspaceTabPlacement; + asTab?: boolean; } export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 9542e4b3f6..6bb930334a 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4569,6 +4569,24 @@ describe("compact workspace sheet", () => { ); } + it("opens a subagent as a tab when a deferred request asks for one", () => { + const store = createCompactStore(); + seedAgents([ + { id: "parent", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "parent", workspaceId: "sheet-ws" }, + ]); + + store.getState().deferOpenTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + asTab: true, + }); + store.getState().consumePendingOpen(workspaceKey); + + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toContain("agent"); + }); it("reveals a file in the sheet and leaves the tab strip unchanged", () => { const store = createCompactStore(); const before = tabKinds(store); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index f66650c7ca..3e0d14ba6b 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -105,6 +105,8 @@ export interface OpenWorkspaceTabInput { /** Keeps an explicitly opened agent visible even when it is archived. */ pin?: boolean; placement?: WorkspaceTabPlacement; + /** Opens a tab where a compact layout would otherwise use a sheet. */ + asTab?: boolean; insertionPosition?: WorkspaceTabInsertionPosition; parentTabId?: string; state?: JsonValue; @@ -1108,7 +1110,7 @@ export function createWorkspaceLayoutStore( input.parentTabId, ); const sheetPlacement = - input.placement?.mode === "pane" + input.placement?.mode === "pane" || input.asTab ? null : resolveSheetPlacement( get(), diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 5a22ca361c..8ccb75d68d 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -1,48 +1,22 @@ import { router, type Href } from "expo-router"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; export type { NavigateToAgentInput } from "./resolve"; -function readAgentNavTarget({ serverId, agentId }: { serverId: string; agentId: string }) { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); - return { - agentWorkspaceId: agent?.workspaceId, - }; -} - export function navigateToAgent(input: NavigateToAgentInput): string { return resolveNavigateToAgent(input, { - readAgentNavTarget, + readAgentNavTarget: ({ serverId, agentId }) => { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return { + agentWorkspaceId: agent?.workspaceId, + }; + }, navigateToHostAgent: (route) => { router.navigate(route as Href); }, navigateToWorkspace, }); } - -/** - * Navigates to the agent where its composer is. A compact sheet is for looking and has no composer, - * so a subagent that lands in one becomes a tab, as Open as tab does. - */ -export function navigateToAgentComposer(input: NavigateToAgentInput): void { - navigateToAgent(input); - const workspaceId = normalizeWorkspaceOpaqueId( - readAgentNavTarget(input).agentWorkspaceId ?? input.workspaceId, - ); - const workspaceKey = workspaceId - ? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId }) - : null; - if (!workspaceKey) return; - const layout = useWorkspaceLayoutStore.getState(); - const sheet = layout.sheetsByWorkspace[workspaceKey]?.find( - ({ tab }) => tab.target.kind === "agent" && tab.target.agentId === input.agentId, - ); - if (sheet) revealWorkspaceTab(workspaceKey, layout.openTabInFront(workspaceKey, sheet)); -} diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index f768edfce5..c06d20d096 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -40,6 +40,14 @@ function createFakeNavigators(target: AgentNavTarget): { } describe("resolveNavigateToAgent", () => { + it("carries a request for a tab to the workspace navigation", () => { + const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: WORKSPACE_ID }); + + resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, asTab: true }, deps); + + expect(tabNavigations[0]).toMatchObject({ asTab: true }); + }); + it("opens the workspace tab carried by the agent's workspaceId", () => { const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ agentWorkspaceId: WORKSPACE_ID, diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index 842760d793..4cd47ee14b 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -9,6 +9,8 @@ export interface NavigateToAgentInput { // (cold deep-links). Otherwise the workspace is read from the store. workspaceId?: string | null; pin?: boolean; + /** Opens the agent with its composer, where a compact layout would show a sheet without one. */ + asTab?: boolean; } export interface AgentNavTarget { @@ -41,5 +43,6 @@ export function resolveNavigateToAgent( workspaceId, target: { kind: "agent", agentId: input.agentId }, pin: input.pin, + asTab: input.asTab, }); } diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 5d05bff60c..e2787a9806 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -8,6 +8,7 @@ export interface PrepareWorkspaceTabInput { target: WorkspaceTabTarget; pin?: boolean; placement?: WorkspaceTabPlacement; + asTab?: boolean; } export interface PrepareWorkspaceTabDeps { @@ -17,6 +18,7 @@ export interface PrepareWorkspaceTabDeps { intent: "reveal"; pin?: boolean; placement?: WorkspaceTabPlacement; + asTab?: boolean; }) => string | null; } @@ -44,5 +46,6 @@ export function prepareWorkspaceTab( intent: "reveal", pin: input.pin === true && target.kind === "agent", placement: input.placement, + asTab: input.asTab, }); }