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, }); }