mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +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>
769 lines
28 KiB
TypeScript
769 lines
28 KiB
TypeScript
import type { BrowserContext, Locator } from "@playwright/test";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
|
|
const CROSS_STRUCTURE_MARKDOWN = [
|
|
"# P1 — High-value UI behavior",
|
|
"",
|
|
"5. Claude model and thinking preferences",
|
|
"",
|
|
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
|
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
|
" - Confirm Opus 5 exposes the Fast toggle.",
|
|
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
|
"",
|
|
"6. Chat, composer, and timeline stability",
|
|
].join("\n");
|
|
|
|
const ASSISTANT_MARKDOWN = [
|
|
CROSS_STRUCTURE_MARKDOWN,
|
|
"",
|
|
"Direct matches:",
|
|
"",
|
|
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
|
"",
|
|
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
|
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
|
"",
|
|
"5. Fifth item",
|
|
"6. Sixth item",
|
|
"7. Seventh item",
|
|
"8. Eighth item",
|
|
"",
|
|
"Nested list:",
|
|
"",
|
|
"3. Outer three",
|
|
"",
|
|
" 7. Inner seven",
|
|
" 8. Inner eight",
|
|
" 9. Inner nine",
|
|
"",
|
|
"A hard break ",
|
|
"stays hard.",
|
|
"",
|
|
"www.example.com stays plain Markdown text.",
|
|
"",
|
|
'[docs](https://docs.example.com "Reference") and <https://autolink.example.com>.',
|
|
"",
|
|
"[workspace file](file:///tmp/paseo%20notes.md#L4)",
|
|
"",
|
|
"`https://example.com/generated` stays code, not a generated link.",
|
|
"",
|
|
"```typescript",
|
|
'const answer = "yes";',
|
|
" return answer;",
|
|
"```",
|
|
"",
|
|
"After code.",
|
|
"",
|
|
"```bash",
|
|
"echo trailing",
|
|
"",
|
|
"```",
|
|
"",
|
|
"````text",
|
|
"before",
|
|
"```",
|
|
"after",
|
|
"````",
|
|
"",
|
|
"| Left | Right | Center |",
|
|
"| :-- | --: | :-: |",
|
|
"| Current \\| active | ~~obsolete~~ | ready |",
|
|
].join("\n");
|
|
|
|
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
|
"www.example.com stays",
|
|
"[www.example.com](http://www.example.com) stays",
|
|
)
|
|
.replace(
|
|
"`https://example.com/generated` stays",
|
|
"[`https://example.com/generated`](https://example.com/generated) stays",
|
|
)
|
|
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
|
|
.replace(
|
|
"<https://autolink.example.com>",
|
|
"[https://autolink.example.com](https://autolink.example.com)",
|
|
)
|
|
.replace(
|
|
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
|
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
|
)
|
|
// Rendering drops the blank line before a closing fence, so copying cannot bring it
|
|
// back. The fence is here to cover a body that ends in more than one newline.
|
|
.replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```");
|
|
|
|
interface ClipboardContent {
|
|
html: string;
|
|
plainText: string;
|
|
}
|
|
|
|
async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
}
|
|
|
|
/**
|
|
* Locate assistant rows by source-message identity.
|
|
*/
|
|
function assistantMessageBlocks(page: Page): Locator {
|
|
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
|
}
|
|
|
|
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
|
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
|
}
|
|
|
|
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
|
const assistantMessage = assistantMessageBlocks(page);
|
|
await assistantMessage.locator(selector).evaluate((element) => {
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Select an element from the end of the text before it. A drag that starts in the gap
|
|
* above a block anchors there, so nothing outside the element is highlighted.
|
|
*/
|
|
async function selectAssistantElementFromEndOf(
|
|
page: Page,
|
|
startText: string,
|
|
selector: string,
|
|
): Promise<void> {
|
|
await assistantMessageBlocks(page).evaluateAll(
|
|
(blocks, selected) => {
|
|
let startNode: Text | null = null;
|
|
for (const block of blocks) {
|
|
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
if (walker.currentNode.textContent?.endsWith(selected.startText)) {
|
|
startNode = walker.currentNode as Text;
|
|
}
|
|
}
|
|
}
|
|
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
|
|
if (!startNode || !end) {
|
|
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
|
|
}
|
|
const range = document.createRange();
|
|
range.setStart(startNode, startNode.length);
|
|
range.setEnd(end, end.childNodes.length);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
},
|
|
{ startText, selector },
|
|
);
|
|
}
|
|
|
|
/** A drag over an image alone selects it inside its rendered frame. */
|
|
async function selectAssistantImage(page: Page): Promise<void> {
|
|
await assistantMessageBlocks(page)
|
|
.locator("img")
|
|
.evaluate((element) => {
|
|
const range = document.createRange();
|
|
range.selectNode(element);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
});
|
|
}
|
|
|
|
/** Press in the gap below the image and drag to the end of `text` in the block after it. */
|
|
async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
|
const blocks = assistantMessageBlocks(page);
|
|
const target = blocks.getByText(text);
|
|
await target.scrollIntoViewIfNeeded();
|
|
const frame = await blocks.locator("img").boundingBox();
|
|
const end = await target.evaluate((element, selectedText) => {
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode as Text;
|
|
const offset = node.textContent?.indexOf(selectedText) ?? -1;
|
|
if (offset >= 0) {
|
|
const range = document.createRange();
|
|
range.setStart(node, offset);
|
|
range.setEnd(node, offset + selectedText.length);
|
|
const box = range.getBoundingClientRect();
|
|
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
|
|
}
|
|
}
|
|
throw new Error(`Could not find ${selectedText}`);
|
|
}, text);
|
|
if (!frame) {
|
|
throw new Error("Expected a rendered image");
|
|
}
|
|
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
|
await page.mouse.move(end.left + 1, end.y - 14);
|
|
await page.mouse.down();
|
|
await page.mouse.move(end.x, end.y, { steps: 10 });
|
|
await page.mouse.up();
|
|
}
|
|
|
|
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
|
await selectAssistantTextRange(page, text, text);
|
|
}
|
|
|
|
async function selectAssistantListItemFromMarker(
|
|
page: Page,
|
|
listTag: "ol" | "ul",
|
|
itemText: string,
|
|
endText: string,
|
|
): Promise<void> {
|
|
const assistantMessage = assistantMessageBlocks(page);
|
|
const item = assistantMessage
|
|
.locator(listTag)
|
|
.filter({ hasText: itemText })
|
|
.locator(":scope > li")
|
|
.filter({ hasText: itemText });
|
|
|
|
await item.evaluate((element, selectedEndText) => {
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
let endNode: Text | null = null;
|
|
let endOffset = -1;
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode;
|
|
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
|
|
if (node instanceof Text && offset >= 0) {
|
|
endNode = node;
|
|
endOffset = offset + selectedEndText.length;
|
|
break;
|
|
}
|
|
}
|
|
if (!endNode) {
|
|
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
|
|
}
|
|
|
|
const range = document.createRange();
|
|
range.setStart(element, 0);
|
|
range.setEnd(endNode, endOffset);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}, endText);
|
|
}
|
|
|
|
async function doubleClickAssistantMarkdownText(
|
|
page: Page,
|
|
tag: "code" | "strong",
|
|
text: string,
|
|
): Promise<void> {
|
|
const assistantMessage = assistantMessageBlocks(page);
|
|
await assistantMessage
|
|
.locator(tag)
|
|
.filter({ hasText: text })
|
|
.dblclick({ position: { x: 5, y: 5 } });
|
|
}
|
|
|
|
async function selectAssistantTextRange(
|
|
page: Page,
|
|
startText: string,
|
|
endText: string,
|
|
): Promise<void> {
|
|
await assistantMessageBlocks(page).evaluateAll(
|
|
(blocks, selectedRange) => {
|
|
// The message's text nodes in reading order, across every block row.
|
|
const nodes: Text[] = [];
|
|
for (const block of blocks) {
|
|
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
let found = blockWalker.nextNode();
|
|
while (found) {
|
|
nodes.push(found as Text);
|
|
found = blockWalker.nextNode();
|
|
}
|
|
}
|
|
const text = nodes.map((node) => node.data).join("");
|
|
const start = text.indexOf(selectedRange.startText);
|
|
const end = text.indexOf(selectedRange.endText, start);
|
|
if (start < 0 || end < 0)
|
|
throw new Error(
|
|
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
|
);
|
|
const boundary = (offset: number, isEnd = false): [Text, number] => {
|
|
for (const node of nodes) {
|
|
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
|
|
offset -= node.length;
|
|
}
|
|
throw new Error("Selection extends beyond message");
|
|
};
|
|
const range = document.createRange();
|
|
range.setStart(...boundary(start));
|
|
range.setEnd(...boundary(end + selectedRange.endText.length, true));
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
},
|
|
{ startText, endText },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Select from `startText` into the middle of the next rendered code line.
|
|
*
|
|
* Highlighted code splits a line across one text node per syntax token and puts the
|
|
* newline in a node of its own, so this is a partial selection that crosses a line
|
|
* break — the shape whose whitespace Turndown would collapse.
|
|
*/
|
|
async function selectAssistantAcrossCodeLines(
|
|
page: Page,
|
|
startText: string,
|
|
endText: string,
|
|
): Promise<void> {
|
|
await assistantMessageBlocks(page).evaluateAll(
|
|
(blocks, selectedRange) => {
|
|
// The message's text nodes in reading order, across every block row.
|
|
const nodes: Text[] = [];
|
|
for (const block of blocks) {
|
|
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
let found = blockWalker.nextNode();
|
|
while (found) {
|
|
nodes.push(found as Text);
|
|
found = blockWalker.nextNode();
|
|
}
|
|
}
|
|
let cursor = 0;
|
|
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
|
let startNode: Node | null = null;
|
|
let startOffset = -1;
|
|
let textNode = walker.nextNode();
|
|
while (textNode) {
|
|
if (!startNode) {
|
|
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
|
if (offset >= 0) {
|
|
startNode = textNode;
|
|
startOffset = offset;
|
|
}
|
|
textNode = walker.nextNode();
|
|
continue;
|
|
}
|
|
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
|
if (endOffset >= 0) {
|
|
const range = document.createRange();
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(textNode, endOffset + selectedRange.endText.length);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
return;
|
|
}
|
|
textNode = walker.nextNode();
|
|
}
|
|
throw new Error(
|
|
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
|
);
|
|
},
|
|
{ startText, endText },
|
|
);
|
|
}
|
|
|
|
async function copySelection(page: Page): Promise<void> {
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
}
|
|
|
|
/** The Copy button writes text/plain only, so the rich reader would throw on it. */
|
|
async function readPlainClipboard(page: Page): Promise<string> {
|
|
return page.evaluate(() => navigator.clipboard.readText());
|
|
}
|
|
|
|
async function readRichClipboard(page: Page): Promise<ClipboardContent> {
|
|
return page.evaluate(async () => {
|
|
const items = await navigator.clipboard.read();
|
|
const item = items[0];
|
|
if (!item) {
|
|
throw new Error("Expected clipboard content");
|
|
}
|
|
return {
|
|
html: await (await item.getType("text/html")).text(),
|
|
plainText: await (await item.getType("text/plain")).text(),
|
|
};
|
|
});
|
|
}
|
|
|
|
test("copying an assistant selection preserves Markdown structure and links", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
|
|
});
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "assistant-selection-copy-",
|
|
title: "Assistant selection copy",
|
|
initialPrompt: "Render the clipboard fixture.",
|
|
featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN },
|
|
});
|
|
|
|
try {
|
|
await allowRichClipboard(context);
|
|
await agent.client.waitForAgentUpsert(
|
|
agent.agentId,
|
|
(snapshot) => snapshot.status === "idle",
|
|
30_000,
|
|
);
|
|
await openAgentRoute(page, agent);
|
|
|
|
const assistantMessage = assistantMessageBlocks(page);
|
|
for (const [tag, text] of [
|
|
["strong", "strong prose"],
|
|
["em", "emphasized prose"],
|
|
["s", "struck prose"],
|
|
]) {
|
|
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
|
|
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
|
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
|
}
|
|
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
|
|
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
|
|
|
|
await selectStructuredClipboardFixture(page);
|
|
await copySelection(page);
|
|
|
|
const clipboard = await readRichClipboard(page);
|
|
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
|
|
expect(clipboard.html).not.toContain("<ul>");
|
|
expect(clipboard.html).not.toContain("<li>");
|
|
expect(clipboard.html).toContain("<div>- ");
|
|
expect(clipboard.html).toContain(
|
|
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
|
);
|
|
expect(clipboard.html).toContain("<code>apply_patch</code>");
|
|
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
|
|
expect(clipboard.html).toContain("A hard break<br>");
|
|
expect(clipboard.html).toContain(
|
|
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="https://docs.example.com/" title="Reference">docs</a>',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
|
);
|
|
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
|
expect(clipboard.html).toContain(
|
|
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
|
);
|
|
expect(clipboard.html).toContain('<code class="language-typescript">');
|
|
expect(clipboard.html).toContain(
|
|
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
|
|
);
|
|
expect(clipboard.html).toContain("<table>");
|
|
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
|
|
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
|
|
expect(clipboard.html).toContain("<s>obsolete</s>");
|
|
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
|
|
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
|
|
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
|
|
|
|
await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
|
|
await copySelection(page);
|
|
|
|
const doubleClickedCodeClipboard = await readRichClipboard(page);
|
|
expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch");
|
|
expect(doubleClickedCodeClipboard.html).not.toContain("<code>");
|
|
|
|
await doubleClickAssistantMarkdownText(page, "strong", "strong prose");
|
|
await copySelection(page);
|
|
|
|
const doubleClickedBoldClipboard = await readRichClipboard(page);
|
|
expect(doubleClickedBoldClipboard.plainText).toBe("strong");
|
|
expect(doubleClickedBoldClipboard.html).not.toContain("<strong>");
|
|
|
|
await selectAssistantTextRange(
|
|
page,
|
|
"P1 — High-value UI behavior",
|
|
"Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
|
);
|
|
await copySelection(page);
|
|
|
|
const headingAndListClipboard = await readRichClipboard(page);
|
|
expect(headingAndListClipboard.plainText).toBe(
|
|
[
|
|
"P1 — High-value UI behavior",
|
|
"===========================",
|
|
"",
|
|
"5. Claude model and thinking preferences",
|
|
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
|
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
|
" - Confirm Opus 5 exposes the Fast toggle.",
|
|
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
|
].join("\n"),
|
|
);
|
|
expect(headingAndListClipboard.html).toMatch(
|
|
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
|
|
);
|
|
|
|
await selectAssistantText(page, "First");
|
|
await copySelection(page);
|
|
|
|
const partialClipboard = await readRichClipboard(page);
|
|
expect(partialClipboard.plainText).toBe("First");
|
|
expect(partialClipboard.html).toContain("<p>First</p>");
|
|
|
|
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
|
|
await copySelection(page);
|
|
|
|
const paragraphAndListClipboard = await readRichClipboard(page);
|
|
expect(paragraphAndListClipboard.plainText).toBe(
|
|
[
|
|
"Direct matches:",
|
|
"",
|
|
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
|
"",
|
|
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
|
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
|
].join("\n"),
|
|
);
|
|
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
|
|
expect(paragraphAndListClipboard.html).not.toContain("<li>");
|
|
expect(paragraphAndListClipboard.html).toContain(
|
|
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
|
);
|
|
|
|
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
|
|
await copySelection(page);
|
|
|
|
const draggedBulletClipboard = await readRichClipboard(page);
|
|
expect(draggedBulletClipboard.plainText).toBe(
|
|
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
|
);
|
|
expect(draggedBulletClipboard.html).toContain(
|
|
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
|
);
|
|
|
|
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
|
|
await copySelection(page);
|
|
|
|
const draggedNumberClipboard = await readRichClipboard(page);
|
|
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
|
|
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
|
|
|
|
await selectAssistantText(page, "docs");
|
|
await copySelection(page);
|
|
|
|
const titledLinkClipboard = await readRichClipboard(page);
|
|
expect(titledLinkClipboard.plainText).toBe("docs");
|
|
expect(titledLinkClipboard.html).not.toContain("<a ");
|
|
|
|
await selectAssistantText(page, "https://autolink.example.com");
|
|
await copySelection(page);
|
|
|
|
const autolinkClipboard = await readRichClipboard(page);
|
|
expect(autolinkClipboard.plainText).toBe("https://autolink.example.com");
|
|
|
|
await selectAssistantText(page, "workspace file");
|
|
await copySelection(page);
|
|
|
|
const fileLinkClipboard = await readRichClipboard(page);
|
|
expect(fileLinkClipboard.plainText).toBe("workspace file");
|
|
expect(fileLinkClipboard.html).not.toContain("<a ");
|
|
|
|
await selectAssistantTextRange(page, "Seventh item", "Eighth item");
|
|
await copySelection(page);
|
|
|
|
const midListClipboard = await readRichClipboard(page);
|
|
expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item");
|
|
expect(midListClipboard.html).toContain("<div>8. Eighth item</div>");
|
|
|
|
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
|
|
await copySelection(page);
|
|
|
|
const nestedListClipboard = await readRichClipboard(page);
|
|
expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine");
|
|
expect(nestedListClipboard.html).toContain("<div>9. Inner nine</div>");
|
|
|
|
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
|
|
await copySelection(page);
|
|
|
|
const crossBlockClipboard = await readRichClipboard(page);
|
|
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
|
|
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
|
|
|
|
await selectAssistantText(page, "Current");
|
|
await copySelection(page);
|
|
|
|
const partialTableClipboard = await readRichClipboard(page);
|
|
expect(partialTableClipboard.plainText).toBe("Current");
|
|
expect(partialTableClipboard.html).toContain("<p>Current</p>");
|
|
expect(partialTableClipboard.html).not.toContain("<table");
|
|
|
|
await selectAssistantTextRange(page, "Right", "ready");
|
|
await copySelection(page);
|
|
|
|
const columnSliceClipboard = await readRichClipboard(page);
|
|
expect(columnSliceClipboard.plainText).toContain("ready");
|
|
expect(columnSliceClipboard.html).not.toContain("<table>");
|
|
expect(columnSliceClipboard.html).toContain("ready");
|
|
expect(columnSliceClipboard.html).toContain("<s>obsolete</s>");
|
|
|
|
// A partial selection loses its `pre` wrapper, so the code reaches Turndown as
|
|
// prose unless it is diverted first: the line break and the indentation go, and
|
|
// Markdown-significant characters get escaped.
|
|
await selectAssistantAcrossCodeLines(page, "const", "return");
|
|
await copySelection(page);
|
|
|
|
const codeLinesClipboard = await readRichClipboard(page);
|
|
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
|
|
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
|
|
|
|
// Selecting inside code expresses an intent to copy code, even when the selection
|
|
// contains every character in the block.
|
|
await selectAssistantElement(
|
|
page,
|
|
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
await copySelection(page);
|
|
|
|
const completeCodeClipboard = await readRichClipboard(page);
|
|
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
|
|
|
// Touching the block before it without selecting any of its text is still a
|
|
// selection inside the code.
|
|
await selectAssistantElementFromEndOf(
|
|
page,
|
|
"not a generated link.",
|
|
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe(
|
|
'const answer = "yes";\n return answer;',
|
|
);
|
|
|
|
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
|
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
|
await copySelection(page);
|
|
|
|
const crossedCodeClipboard = await readRichClipboard(page);
|
|
expect(crossedCodeClipboard.plainText).toBe(
|
|
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
|
);
|
|
|
|
// Selecting a whole line runs off its end into the newline node. A trailing
|
|
// newline auto-executes the line when it is pasted into a terminal.
|
|
await selectAssistantAcrossCodeLines(page, "const", " ");
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
|
|
|
|
// The block's own Copy button had the same hazard: a Markdown fence body ends in
|
|
// a newline, and the button copied it raw.
|
|
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
|
|
// The button is opacity 0 / pointerEvents none until the fence is hovered.
|
|
await typescriptFence.hover();
|
|
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
|
|
|
|
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
|
|
|
|
// A blank line before the closing fence leaves the body ending in two newlines,
|
|
// so stripping only the terminal one still hands the terminal an executable line.
|
|
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
|
|
await bashFence.hover();
|
|
await bashFence.locator("[data-paseo-markdown-ignore]").click();
|
|
|
|
expect(await readPlainClipboard(page)).toBe("echo trailing");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const image =
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "assistant-selection-copy-image-",
|
|
title: "Assistant selection copy image",
|
|
initialPrompt: "Render the image fixture.",
|
|
featureValues: {
|
|
mockAssistantResponse: [
|
|
"Before the image.",
|
|
"",
|
|
``,
|
|
"",
|
|
"After the image.",
|
|
].join("\n"),
|
|
},
|
|
});
|
|
|
|
try {
|
|
await allowRichClipboard(context);
|
|
await agent.client.waitForAgentUpsert(
|
|
agent.agentId,
|
|
(snapshot) => snapshot.status === "idle",
|
|
30_000,
|
|
);
|
|
await openAgentRoute(page, agent);
|
|
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
|
|
|
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe(
|
|
`Before the image.\n\n\n\nAfter the image.`,
|
|
);
|
|
|
|
await selectAssistantImage(page);
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe(``);
|
|
|
|
await dragFromBelowImageTo(page, "After the");
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe("After the");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test.describe("on a touch-capable desktop browser", () => {
|
|
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
|
|
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
|
test.use({ hasTouch: true });
|
|
|
|
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "assistant-selection-drag-",
|
|
title: "Assistant selection drag",
|
|
initialPrompt: "Render three paragraphs.",
|
|
featureValues: {
|
|
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
|
},
|
|
});
|
|
|
|
try {
|
|
await agent.client.waitForAgentUpsert(
|
|
agent.agentId,
|
|
(snapshot) => snapshot.status === "idle",
|
|
30_000,
|
|
);
|
|
await openAgentRoute(page, agent);
|
|
const blocks = assistantMessageBlocks(page);
|
|
const first = await blocks.getByText("First paragraph.").boundingBox();
|
|
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
|
if (!first || !third) {
|
|
throw new Error("Expected rendered paragraphs");
|
|
}
|
|
|
|
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
|
await page.mouse.up();
|
|
|
|
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
|
expect(selected).toContain("First paragraph.");
|
|
expect(selected).toContain("Third paragraph.");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
});
|