mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
fix(app): judge compact composer coverage by where it is hosted
The Explorer dock always reported its pane as focused, so a workspace sheet over an Explorer composer still counted as on screen and focus() put the cursor behind the sheet. It now suspends pane focus like the main panes do. A composer on a screen with no panes counted as a main pane, so an Explorer selection left over from the workspace marked New workspace as covered and focus() rejected on a visible input. Only the agent list can cover those screens, because the Explorer lives in the workspace.
This commit is contained in:
@@ -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'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
@@ -406,6 +414,7 @@ export async function withPluginHoldingNewWorkspaceComposer(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openNewWorkspace(): Promise<void>;
|
||||
leaveCompactExplorerForNewWorkspace(): Promise<void>;
|
||||
openSettings(): Promise<void>;
|
||||
expectHeldComposerFocusResult(result: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
@@ -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<void>;
|
||||
focusParent(): Promise<void>;
|
||||
expectParentReadyToTypeInExplorer(): Promise<void>;
|
||||
coverExplorerComposerWithFileSheet(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
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, {
|
||||
|
||||
@@ -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'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ExplorerSidebarDockProps extends Pick<
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
paneFocusSuspended: boolean;
|
||||
closingTabIds: Set<string>;
|
||||
onSelectTab: (paneId: string, tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | 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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user