mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
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.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureExplorerSidebar,
|
||||
openFilesPanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
dragTabIntoExplorer,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragTabIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
focusSubagent(): Promise<void>;
|
||||
expectSubagentTabReadyToType(): Promise<void>;
|
||||
keepParentInExplorerOnCompactScreen(): Promise<void>;
|
||||
focusParent(): Promise<void>;
|
||||
expectParentReadyToTypeInExplorer(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
|
||||
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||
}
|
||||
|
||||
export async function dragTabIntoExplorer(page: Page, tabId: string): Promise<void> {
|
||||
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<void> {
|
||||
if ((await tab.getAttribute("aria-selected")) !== "true") {
|
||||
// The close action overlays the chip's trailing edge on hover. Click the
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ComposerKeyboardScopeProvider
|
||||
|
||||
@@ -74,6 +74,11 @@ 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";
|
||||
}
|
||||
|
||||
export function usePaneFocus(): PaneFocusContextValue {
|
||||
const value = useContext(PaneFocusContext);
|
||||
invariant(value, "PaneFocusContext is required");
|
||||
|
||||
@@ -145,6 +145,22 @@ describe("workspace navigation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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(),
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface NavigateToWorkspaceInput {
|
||||
target?: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
asTab?: boolean;
|
||||
}
|
||||
|
||||
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user