mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
* feat(app): swipe open collapsed tablet navigation Reuse the retained sidebar and mobile panel motion for a partial-width navigation overlay when the wide dock is hidden. Preserve the dock preference, project gestures through the sidebar width, and dismiss the overlay when revealing workspace content. Reset the animated frame width explicitly across dock transitions and keep the resize origin on the UI thread. Validated on Duo portrait and landscape, iPad portrait and landscape, iPhone full-width navigation, and browser sidebar interactions. Typecheck, lint, targeted formatting, and 31 relevant tests passed. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): refine touch navigation overlays Enable navigation swipes only on touch-capable runtimes and reject mouse and pen input per gesture event. Allow swiping from anywhere in the workspace instead of requiring an edge start. Cover the full application window with the backdrop, including safe-area padding, and remove the wide overlay's inline close button. Preserve swipe, backdrop, and destination-selection dismissal. Validated interior swipes on Duo and iPad, mouse exclusion in a browser, and full-window dimming and dismissal in Duo portrait and landscape. Typecheck, lint, and targeted formatting passed. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): let the panel store drop the overlay target on dock Docking the sidebar, or any layout that no longer presents it as an overlay, now drops the transient agent-list target inside the panel store. App chrome publishes one left sidebar presentation (overlay, dock, hidden) instead of an effect in SidebarChrome watching flags. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): answer fine pointer and touch screen from one module Merge use-fine-pointer and use-has-touch-screen into hooks/pointer-capabilities, which reads both answers from the media queries it subscribes to. The touch answer now comes from (any-pointer: coarse) instead of navigator.maxTouchPoints, which that query's change events never reported. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the phone web drawer on its edge swipe Restore the 32px left-edge start for the compact web navigation drawer, so interior rightward swipes over terminals, code blocks, and diffs stay with their horizontal scrollers. The wide navigation overlay still opens from anywhere. The edge rule moves into resolveMobilePanelGestureIntent so both drawers share it and it has a test. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): one sidebar handler per destination Settings, host settings, add host, add project, and every navigation row now use one handler that closes the navigation overlay before it navigates. Closing is idempotent, so the docked sidebar shares it and the overlay ? mobile : desktop handler pairs go away. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): resolve the left panel width in one place MobilePanelsProvider now resolves the left sidebar width (full window on compact, the saved width clamped to the safe viewport on wide) and exposes it through the panel runtime. The open and close gestures, the overlay frame, and the docked sidebar all read that value, so gesture travel and panel width cannot disagree. MobileGestureWrapper no longer recomputes it, and the panelWidth/width parameters go away. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * docs: fold the wide navigation overlay into the mobile panel rules Move the facts the overlay PR appended into the sections that own them: the destinations intro, store ownership of the agent-list target, the projection widths, gesture pointer and edge rules, and the sidebar presentation bullets. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the sidebar pans live across dock and overlay switches On an Android tablet, opening the dock with the header toggle and collapsing it again left the touch swipe unable to open the overlay until the app reloaded. Swipe-to-close from the sidebar also stopped working after the same toggle cycle. Both pans took the dock/overlay state through RNGH's `.enabled()`. On Android, RNGH 2.32 cancels the native handler when `isEnabled` changes and resets its config on every update. After a false -> true flip the handler stayed inert. `.enabled()` now carries only the device capability (touch screen present), which does not change at runtime. The dock/overlay state moves to a UI-thread derived value that fails the gesture in onTouchesDown, next to the existing non-touch pointer check. The wide sidebar's workspace list now always receives the close gesture ref, as the compact sidebar already does. Toggling it between undefined and the ref rewired the list's simultaneous handlers on every dock switch, and that also left the close pan dead after a toggle cycle. This is a native RNGH handler behavior, so there is no unit repro. It was verified on the Android fold emulator: toggle open, toggle closed, swipe opens the overlay, swipe on an empty sidebar strip closes it, no reload. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the workspace full width after collapsing the dock On an Android tablet, opening the dock with the header toggle and collapsing it again could leave the workspace at the docked width, with a blank strip on the right, until the overlay was next opened. Elsa's original head showed the same. The sidebar's panel host flips between an in-flow dock and an absolute overlay. Its width came from a Reanimated style that read the React `overlay` flag, so the width reached the native view on the UI thread after React had already committed the new presentation. Layout logs on the fold emulator show the lag on every toggle-open: the workspace laid out at width 0 while the docked host still had the overlay's full window width, then a second pass laid out the 320dp dock. When the late update was lost on collapse, the workspace kept its dock width. The host no longer has a width of its own: - The dock sizes to the inner sidebar view, which already carries the animated resize width. - The overlay host is absoluteFill, which covers the whole row including the safe-area padding. With no animated layout prop on the host, a presentation switch is one React commit, and the host and the workspace lay out in the same pass. Device evidence on the fold emulator: - Toggle open and collapse cycles: the host and the workspace lay out together (320/521dp, then 841/841dp). - A swipe opens the 320dp overlay with a full-window backdrop, and a swipe on the sidebar closes it. - Dragging the dock edge still resizes the dock and the workspace together. There is no unit repro: the defect is the ordering of Reanimated's UI-thread layout updates against React commits. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): cancel pending sidebar swipes when docking An opening swipe still has the agent target until release. Leaving overlay presentation must advance its revision even then, so motion ownership and queued commits reject the obsolete swipe. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep mouse selection free of the touch-only pan's capture On a touch-capable browser the navigation pan registers pointer listeners over the whole workspace. Gesture Handler's web pointer manager captured the pressed element before the handler saw the pointer, and captured it again on every move, so a mouse drag that the pan rejects stayed captured and the browser's selection stopped at the starting paragraph. The patched manager now captures after pointer-down, and only a pointer the handler still tracks. The resize handle and touch swipes keep capturing. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): drop the compact Explorer target when the layout widens Opening the Explorer on a compact layout selects the `file-explorer` mobile panel target. Widening the window (unfolding a foldable, rotating a tablet) kept that target, because the store only heard the left sidebar presentation, which is `overlay` on both compact layouts and wide layouts with a collapsed dock. The navigation swipe begins only from `agent`, so on the wide layout every swipe was rejected until something else reset the target. App chrome now publishes the whole panel layout (compact or wide, plus the left sidebar presentation) and the store drops any target that layout does not present. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep wide overlay sidebar content inside the left safe area The overlay starts at the window's left edge, over the workspace's safe-area padding, so its content now takes the left inset itself. The dock already sits inside app chrome's padding and stays unchanged. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): return an interrupted swipe to its anchor when docking cancels it Docking mid-swipe bumps the panel revision while keeping the agent target. The command treated the unchanged target as motion already in progress and skipped the animation, leaving the dragged position stranded between anchors. A command that interrupts a drag now always animates to its target. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): synchronize startup panel selection and gate pending swipes A child layout effect can publish panel layout before the motion provider subscribes. Establish the current selection when subscribing, then observe later commands. Recheck presentation eligibility before gesture activation so docking also cancels touches that have not started dragging. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): cover panel subscription through its public contract Verify initial snapshot delivery, later commands, and unsubscription directly in the existing panel-store suite instead of mounting test-only React components. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): reopen compact navigation after leaving settings Destination navigation clears the transient overlay selection. Reopen the compact drawer before asserting its configured footer rather than relying on a stale open target surviving a wide layout. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): open navigation by keyboard in composer focus checks Navigation pans intentionally reject mouse drags. Use the keyboard sidebar command to exercise focus and fullscreen Escape behavior without relying on the former mouse swipe helper. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): complete sidebar integration after main rebase Retain the resize width resolver and format the shared division-width and hybrid touch-sizing integration. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(desktop): give split browser panes room for tab rails After #8740, narrow panes replace tab rails with compact dropdowns. The desktop browser journey counts and selects rail tabs, so widen the real Electron content window before splitting instead of treating missing narrow-pane rails as missing panes. Co-authored-by: Elsa Granger <zeyugao@outlook.com> --------- Co-authored-by: Elsa <zeyugao@outlook.com>
636 lines
26 KiB
TypeScript
636 lines
26 KiB
TypeScript
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import {
|
|
expectNearBottom,
|
|
scrollAgentChatToBottom,
|
|
waitForScrollableChat,
|
|
} from "../support/helpers/agent-bottom-anchor";
|
|
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
|
import {
|
|
attachComposerTestImage,
|
|
pasteComposerTestImage,
|
|
enterComposerFullscreen,
|
|
exitComposerFullscreen,
|
|
resizeComposerViewport,
|
|
selectComposerDraftRange,
|
|
expectComposerDraftSelection,
|
|
expectComposerFullscreenControls,
|
|
sendComposerWithButton,
|
|
queueComposerWithButton,
|
|
configureComposerQueueSend,
|
|
activateRetainedComposerTab,
|
|
switchComposerTabWithKeyboard,
|
|
expectWorkspaceHeaderHidden,
|
|
expectWorkspaceHeaderVisible,
|
|
recordVisibleComposerGeometry,
|
|
expectNoCollapsedVisibleComposer,
|
|
expectAttachmentPill,
|
|
expectQueuedMessageButton,
|
|
startRunningMockAgent,
|
|
composerLocator,
|
|
expectComposerVisible,
|
|
expectComposerFullscreenFitsVisibleViewport,
|
|
} from "../support/helpers/composer";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
import {
|
|
closeMobileAgentSidebar,
|
|
expectMobileAgentSidebarHidden,
|
|
expectMobileAgentSidebarVisible,
|
|
openMobileAgentSidebarWithKeyboard,
|
|
} from "../support/helpers/sidebar";
|
|
|
|
async function composerHeight(page: Page): Promise<number> {
|
|
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
|
}
|
|
|
|
async function expectComposerHeight(page: Page, expected: number): Promise<void> {
|
|
await expect.poll(() => composerHeight(page)).toBe(expected);
|
|
}
|
|
|
|
async function pressComposerKeyAndMeasureNextPaint(page: Page, key: string): Promise<number> {
|
|
const composer = composerLocator(page);
|
|
await installNextComposerPaintProbe(page);
|
|
await composer.press(key);
|
|
return readNextComposerPaintHeight(page);
|
|
}
|
|
|
|
async function typeComposerTextAndMeasureNextPaint(page: Page, text: string): Promise<number> {
|
|
const composer = composerLocator(page);
|
|
await installNextComposerPaintProbe(page);
|
|
await composer.pressSequentially(text);
|
|
return readNextComposerPaintHeight(page);
|
|
}
|
|
|
|
async function installNextComposerPaintProbe(page: Page): Promise<void> {
|
|
await composerLocator(page).evaluate((element) => {
|
|
Reflect.set(
|
|
globalThis,
|
|
"__composerNextPaintHeight",
|
|
new Promise<number>((resolve) => {
|
|
element.addEventListener(
|
|
"input",
|
|
() => {
|
|
requestAnimationFrame(() => resolve(element.getBoundingClientRect().height));
|
|
},
|
|
{ once: true },
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function readNextComposerPaintHeight(page: Page): Promise<number> {
|
|
return page.evaluate(() => Reflect.get(globalThis, "__composerNextPaintHeight"));
|
|
}
|
|
|
|
test("blank composer lines remain present and keep their measured height", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-whitespace-",
|
|
title: "Composer whitespace",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
const composer = composerLocator(page);
|
|
const blankLines = "\n\n\n\n\n";
|
|
const collapsedHeight = await composerHeight(page);
|
|
|
|
await test.step("remeasure the current draft when the window narrows", async () => {
|
|
await page.setViewportSize({ width: 1280, height: 1200 });
|
|
const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8);
|
|
await composer.fill(text);
|
|
await expect(composer).toHaveValue(text);
|
|
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
|
|
const wideHeight = await composerHeight(page);
|
|
await page.setViewportSize({ width: 480, height: 1200 });
|
|
await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight);
|
|
await expect(composer).toHaveValue(text);
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
});
|
|
|
|
await test.step("grow the composer with blank lines followed by text", async () => {
|
|
await composer.fill(`${blankLines}x`);
|
|
await expect(composer).toHaveValue(`${blankLines}x`);
|
|
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
|
|
});
|
|
|
|
await test.step("delete only the text without losing the blank lines or height", async () => {
|
|
const expandedHeight = await composerHeight(page);
|
|
await composer.press("Backspace");
|
|
await expect(composer).toHaveValue(blankLines);
|
|
await expectComposerHeight(page, expandedHeight);
|
|
});
|
|
|
|
await test.step("grow on the first paint of a newline and stay stable when it receives a glyph", async () => {
|
|
await composer.fill("alpha\nbeta");
|
|
const filledLineHeight = await composerHeight(page);
|
|
const trailingLineHeight = await pressComposerKeyAndMeasureNextPaint(page, "Shift+Enter");
|
|
expect(trailingLineHeight).toBeGreaterThan(filledLineHeight);
|
|
|
|
const filledTrailingLineHeight = await typeComposerTextAndMeasureNextPaint(page, "x");
|
|
await expect(composer).toHaveValue("alpha\nbeta\nx");
|
|
expect(filledTrailingLineHeight).toBe(trailingLineHeight);
|
|
});
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-bottom-anchor-",
|
|
title: "Composer bottom anchor",
|
|
initialPrompt: "Produce enough content to make the chat scrollable.",
|
|
model: "ten-second-stream",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await awaitAssistantMessage(page);
|
|
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
|
await scrollAgentChatToBottom(page);
|
|
|
|
const composer = composerLocator(page);
|
|
await composer.fill("alpha");
|
|
for (let line = 0; line < 8; line += 1) {
|
|
await composer.press("Shift+Enter");
|
|
}
|
|
|
|
await expectNearBottom(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
|
|
async function pausePageClock(page: Page): Promise<void> {
|
|
const now = Date.now();
|
|
await page.clock.install({ time: now });
|
|
// The installed clock advances between protocol calls; pause at a future time.
|
|
await page.clock.pauseAt(now + 60_000);
|
|
}
|
|
|
|
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
|
|
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
|
|
const frame = page.getByTestId("composer-fullscreen");
|
|
const alertBox = (await frame.getByRole("alert").boundingBox())!;
|
|
const sendBox = (await page
|
|
.getByRole("button", { name: "Send message", exact: true })
|
|
.boundingBox())!;
|
|
const editorBox = (await composerLocator(page).boundingBox())!;
|
|
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
|
|
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
|
|
}
|
|
|
|
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
|
await composerLocator(page).fill(text);
|
|
await expect(composerLocator(page)).toHaveValue(text);
|
|
}
|
|
|
|
test("compact long drafts stop growing before the header", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-cap-",
|
|
title: "Composer cap",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await writeCompactDraft(page, "A long draft line\n".repeat(40));
|
|
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
|
|
page,
|
|
}) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-fullscreen-",
|
|
title: "Fullscreen composer",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
|
|
await attachComposerTestImage(page);
|
|
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
|
await writeCompactDraft(page, "First line\nSecond line");
|
|
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
|
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
|
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
|
await writeCompactDraft(
|
|
page,
|
|
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
|
4,
|
|
),
|
|
);
|
|
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
|
});
|
|
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
|
|
await selectComposerDraftRange(page, 4, 17);
|
|
await enterComposerFullscreen(page);
|
|
await expectComposerFullscreenControls(page);
|
|
await expectWorkspaceHeaderHidden(page);
|
|
await expectComposerDraftSelection(page, 4, 17);
|
|
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
|
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
|
await selectComposerDraftRange(page, 3, 12);
|
|
});
|
|
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
|
|
await exitComposerFullscreen(page);
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await expect(composerLocator(page)).toHaveValue(
|
|
"Edited fullscreen draft\nSecond line\nThird line",
|
|
);
|
|
await expectComposerDraftSelection(page, 3, 12);
|
|
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
});
|
|
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
|
await writeCompactDraft(page, "Typed after the fullscreen cycle");
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
});
|
|
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
|
|
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
|
await enterComposerFullscreen(page);
|
|
await writeCompactDraft(page, "Short draft");
|
|
await expectComposerFullscreenControls(page);
|
|
await selectComposerDraftRange(page, 0, 5);
|
|
await resizeComposerViewport(page, "landscape");
|
|
await expectComposerFullscreenControls(page);
|
|
await expectComposerDraftSelection(page, 0, 5);
|
|
await expect(composerLocator(page)).toHaveValue("Short draft");
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await resizeComposerViewport(page, "portrait");
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
|
});
|
|
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
|
|
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
|
await enterComposerFullscreen(page);
|
|
await writeCompactDraft(page, "Typed inside fullscreen");
|
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
|
await exitComposerFullscreen(page);
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
|
|
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
await writeCompactDraft(page, "One more inline message");
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
});
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-remount-caret-",
|
|
title: "Remount caret",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await writeCompactDraft(page, "Short draft");
|
|
await selectComposerDraftRange(page, 5, 5);
|
|
// The draft owner publishes web edits after the next paint. A rotation inside that
|
|
// window remounts the editor before the edit has reached the draft store; the paused
|
|
// clock holds the page there instead of racing the frame.
|
|
await pausePageClock(page);
|
|
await composerLocator(page).press("X");
|
|
await resizeComposerViewport(page, "landscape");
|
|
await page.clock.resume();
|
|
await expect(composerLocator(page)).toHaveValue("ShortX draft");
|
|
await expectComposerDraftSelection(page, 6, 6);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await composerLocator(page).press("Y");
|
|
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
|
|
page,
|
|
}) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-fullscreen-retry-",
|
|
title: "Fullscreen retry",
|
|
featureValues: { mockPromptRejections: 1 },
|
|
});
|
|
const draft = "First line\nSecond line\nThird line";
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await writeCompactDraft(page, draft);
|
|
await enterComposerFullscreen(page);
|
|
await sendComposerWithButton(page);
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
|
|
await enterComposerFullscreen(page);
|
|
await expectFullscreenErrorAboveSend(page);
|
|
await expect(composerLocator(page)).toHaveValue(draft);
|
|
await expect(composerLocator(page)).toBeEditable();
|
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
|
await sendComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
await expectWorkspaceHeaderVisible(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
async function blurComposer(page: Page): Promise<void> {
|
|
await composerLocator(page).blur();
|
|
await expect(composerLocator(page)).not.toBeFocused();
|
|
}
|
|
|
|
/** Mid-band at the given height and near the editor's bottom edge. */
|
|
async function reservedBandPoints(
|
|
page: Page,
|
|
bandX: number,
|
|
topY: number,
|
|
): Promise<Array<{ x: number; y: number }>> {
|
|
const editor = (await composerLocator(page).boundingBox())!;
|
|
return [
|
|
{ x: bandX, y: topY },
|
|
{ x: bandX, y: editor.y + editor.height - 6 },
|
|
];
|
|
}
|
|
|
|
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-band-focus-",
|
|
title: "Composer band focus",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
|
|
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
|
|
const longDraft = "Band first line\nBand second line\nBand third line";
|
|
await writeCompactDraft(page, longDraft);
|
|
await expect(fullscreenButton).toBeVisible();
|
|
const button = (await fullscreenButton.boundingBox())!;
|
|
const bandX = button.x + button.width / 2;
|
|
|
|
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
|
|
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
|
|
await blurComposer(page);
|
|
await page.mouse.click(point.x, point.y);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await expect(composerLocator(page)).toHaveValue(longDraft);
|
|
await expect(exitButton).toHaveCount(0);
|
|
}
|
|
});
|
|
|
|
await test.step("the button itself still toggles fullscreen", async () => {
|
|
await fullscreenButton.click();
|
|
await expectComposerFullscreenControls(page);
|
|
await exitComposerFullscreen(page);
|
|
await expect(exitButton).toHaveCount(0);
|
|
await expect(composerLocator(page)).toHaveValue(longDraft);
|
|
});
|
|
|
|
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
|
|
await writeCompactDraft(page, "Short");
|
|
await expect(fullscreenButton).toHaveCount(0);
|
|
const editor = (await composerLocator(page).boundingBox())!;
|
|
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
|
|
await blurComposer(page);
|
|
await page.mouse.click(point.x, point.y);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await expect(composerLocator(page)).toHaveValue("Short");
|
|
await expect(exitButton).toHaveCount(0);
|
|
}
|
|
});
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
|
|
page,
|
|
}) => {
|
|
await configureComposerQueueSend(page);
|
|
await resizeComposerViewport(page, "portrait");
|
|
const agent = await startRunningMockAgent(page, {
|
|
prefix: "composer-fullscreen-queue-",
|
|
model: "one-minute-stream",
|
|
prompt: "Stay running for fullscreen queue test.",
|
|
});
|
|
try {
|
|
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
|
|
await enterComposerFullscreen(page);
|
|
await expectComposerFullscreenControls(page);
|
|
await queueComposerWithButton(page);
|
|
await expect(composerLocator(page)).toHaveValue("");
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await expectQueuedMessageButton(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
|
|
page,
|
|
}) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-paste-remount-",
|
|
title: "Composer paste",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
|
expect(await pasteComposerTestImage(page)).toBe(true);
|
|
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
|
|
await enterComposerFullscreen(page);
|
|
expect(await pasteComposerTestImage(page)).toBe(true);
|
|
await exitComposerFullscreen(page);
|
|
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
|
|
expect(await pasteComposerTestImage(page)).toBe(true);
|
|
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
|
|
try {
|
|
const first = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "First retained chat",
|
|
});
|
|
const second = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Second retained chat",
|
|
});
|
|
await openWorkspaceWithAgents(page, [first, second]);
|
|
await resizeComposerViewport(page, "portrait");
|
|
const draft = "Live first line\nLive second line\nLive third line";
|
|
await writeCompactDraft(page, draft);
|
|
await enterComposerFullscreen(page);
|
|
await expectWorkspaceHeaderHidden(page);
|
|
await selectComposerDraftRange(page, 4, 17);
|
|
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
|
|
// a desktop-width window still has the tab shortcut.
|
|
await resizeComposerViewport(page, "landscape");
|
|
await switchComposerTabWithKeyboard(page, "previous");
|
|
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
|
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
|
|
await composerLocator(page).press("Escape");
|
|
await switchComposerTabWithKeyboard(page, "next");
|
|
await expectComposerFullscreenControls(page);
|
|
await switchComposerTabWithKeyboard(page, "previous");
|
|
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
|
await resizeComposerViewport(page, "portrait");
|
|
await expectWorkspaceHeaderVisible(page);
|
|
await writeCompactDraft(page, "Other tab draft");
|
|
await shrinkVisibleViewport(page);
|
|
await activateRetainedComposerTab(page, second.title);
|
|
await expectComposerFullscreenControls(page);
|
|
await expectWorkspaceHeaderHidden(page);
|
|
await expect(composerLocator(page)).toHaveValue(draft);
|
|
await expectComposerFullscreenFitsVisibleViewport(page);
|
|
await expectComposerDraftSelection(page, 4, 17);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
expect(await pasteComposerTestImage(page)).toBe(true);
|
|
await exitComposerFullscreen(page);
|
|
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
|
|
page,
|
|
}) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-fullscreen-sidebar-",
|
|
title: "Fullscreen under sidebar",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
const draft = "Line one\nLine two\nLine three";
|
|
await writeCompactDraft(page, draft);
|
|
await enterComposerFullscreen(page);
|
|
await expectWorkspaceHeaderHidden(page);
|
|
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
|
|
await openMobileAgentSidebarWithKeyboard(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
// The list covers the pane, so the editor's exit action is not armed.
|
|
await page.keyboard.press("Escape");
|
|
await closeMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
await expectComposerFullscreenControls(page);
|
|
await expect(composerLocator(page)).toHaveValue(draft);
|
|
await exitComposerFullscreen(page);
|
|
await expectWorkspaceHeaderVisible(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
|
|
try {
|
|
const first = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "First capacity chat",
|
|
});
|
|
const second = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Second capacity chat",
|
|
});
|
|
await openWorkspaceWithAgents(page, [first, second]);
|
|
await resizeComposerViewport(page, "portrait");
|
|
const draft = "Retained long line\n".repeat(40);
|
|
await writeCompactDraft(page, draft);
|
|
await recordVisibleComposerGeometry(page);
|
|
await activateRetainedComposerTab(page, first.title);
|
|
await writeCompactDraft(page, "Other visible draft");
|
|
await activateRetainedComposerTab(page, second.title);
|
|
await expect(composerLocator(page)).toHaveValue(draft);
|
|
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
|
|
await expectNoCollapsedVisibleComposer(page);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
async function shrinkVisibleViewport(page: Page): Promise<void> {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
|
|
await cdp.detach();
|
|
}
|
|
|
|
async function panVisibleViewport(page: Page): Promise<void> {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchStart",
|
|
touchPoints: [{ x: 4, y: 300 }],
|
|
});
|
|
for (let y = 290; y >= 100; y -= 10) {
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchMove",
|
|
touchPoints: [{ x: 4, y }],
|
|
});
|
|
}
|
|
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
|
|
await cdp.detach();
|
|
}
|
|
|
|
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-visible-viewport-",
|
|
title: "Visible viewport composer",
|
|
});
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
|
await enterComposerFullscreen(page);
|
|
await expectComposerFullscreenFitsVisibleViewport(page);
|
|
await shrinkVisibleViewport(page);
|
|
await expectComposerFullscreenFitsVisibleViewport(page);
|
|
await panVisibleViewport(page);
|
|
await expectComposerFullscreenFitsVisibleViewport(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|