From 5a771d12d2b8ac0083886228a3545067a43f509a Mon Sep 17 00:00:00 2001 From: Hako Date: Wed, 14 Oct 2026 04:29:39 +0900 Subject: [PATCH 1/2] fix(split): accept a pane drop whose target node was just redrawn; deflake sticky and Windows tests The pane area accepted a sidebar drag only on dragover. When the node under the pointer is replaced between the last dragover and the drop (xterm redraws a row, the chat transcript renders again), Chromium's drop hit test sends the new node a dragenter that nobody accepts and then refuses the drop (dragenter, dragleave, dragend; no drop). The area now accepts dragenter as well. A new split-view case replaces the node under the pointer right before the drop; it timed out without the fix, as ui-contexts did in CI. sticky-modifiers: herdr's first screen turns bracketed paste on, and it can arrive after the key bar is ready and after the test turned the mode off, so a dropped "z" was wrapped in paste markers. The test's WebSocket route now strips that mode from the stream. windows-plugin: a failing cleanup stop in the finally block hid the test's first failure. The cleanup error is now ignored after an earlier failure. Co-Authored-By: Claude Opus 5.5 --- changelog.d/split-drop-on-redrawn-node.md | 8 ++++++ scripts/split-view-regression.ts | 30 ++++++++++++++++++++++- scripts/sticky-modifiers-regression.ts | 8 +++++- scripts/windows-plugin.test.ts | 7 +++++- src/App.tsx | 5 +++- 5 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 changelog.d/split-drop-on-redrawn-node.md diff --git a/changelog.d/split-drop-on-redrawn-node.md b/changelog.d/split-drop-on-redrawn-node.md new file mode 100644 index 0000000000..3705d2474b --- /dev/null +++ b/changelog.d/split-drop-on-redrawn-node.md @@ -0,0 +1,8 @@ +### Fixed +- A pane dragged from the sidebar onto the pane area opens in a half even when the terminal or + chat under the pointer redraws just before the drop; Chrome refused that drop before. + +### Maintenance +- The sticky-modifiers check keeps herdr's stream from turning bracketed paste back on after the + check turned it off, and the Windows install test no longer hides its first failure behind a + failing cleanup stop. diff --git a/scripts/split-view-regression.ts b/scripts/split-view-regression.ts index 54dc8ad5ad..c17be6e0e0 100644 --- a/scripts/split-view-regression.ts +++ b/scripts/split-view-regression.ts @@ -56,7 +56,7 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< const half = (page: Page, side: string) => page.locator(`.pane-slot[data-side="${side}"]`); const isActive = (page: Page, side: string) => page.waitForFunction((which) => document.querySelector(`.pane-slot[data-side="${which}"]`)?.classList.contains("is-active") === true, side); /** the pane dropped on the right half, which is then the active one */ - const splitRight = async (page: Page, pane: string): Promise => { + const splitRight = async (page: Page, pane: string, options: { newTargetAtDrop?: boolean } = {}): Promise => { const area = page.locator(".pane-split"); const source = page.locator(`.pane-select[title^="${pane} —"]`); await source.waitFor({ state: "visible" }); @@ -77,6 +77,16 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< const guide = document.querySelector(".split-drop"); return guide?.classList.contains("is-visible") === true && guide.getAttribute("data-side") === "right"; }); + if (options.newTargetAtDrop) { + // The node under the pointer is replaced between the last dragover and the drop (xterm redraws + // a row, a transcript renders again): the drop's own hit test then meets a new node and sends + // it a dragenter, and Chromium refuses a drop whose last drag event nobody accepted. The cover + // is the test's own node, so React never sees the swap. + await page.evaluate(() => { + const cover = document.querySelector("#split-drop-cover")!; + cover.replaceWith(cover.cloneNode()); + }); + } await page.mouse.up(); await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 2); await isActive(page, "right"); @@ -522,6 +532,24 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< console.log("PASS an inactive-half lens choice works and survives activation when storage writes fail"); } + // 9b. A drop just after the node under the pointer was replaced still opens the half. + { + const [left, right] = await panesFor("new-target-left", "new-target-right") as [string, string]; + const { page, errors } = await open([left, right], "terminal"); + await page.locator(".pane-split").evaluate((area) => { + const cover = document.createElement("div"); + cover.id = "split-drop-cover"; + const box = area.getBoundingClientRect(); + cover.style.cssText = `position:fixed;left:${box.left}px;top:${box.top}px;width:${box.width}px;height:${box.height}px;z-index:10`; + area.append(cover); + }); + await splitRight(page, right, { newTargetAtDrop: true }); + await page.locator("#split-drop-cover").evaluate((cover) => cover.remove()); + await half(page, "right").locator(".xterm").waitFor(); + assert.deepEqual(errors, []); + console.log("PASS a drop on a node that replaced the hovered one still opens the half"); + } + // 10. The phone's glass header follows the nested pane DOM, but never crosses a notice or tab row. { const [pane] = await panesFor("mobile-glass") as [string]; diff --git a/scripts/sticky-modifiers-regression.ts b/scripts/sticky-modifiers-regression.ts index 75f4484745..8e87cfff90 100644 --- a/scripts/sticky-modifiers-regression.ts +++ b/scripts/sticky-modifiers-regression.ts @@ -50,6 +50,12 @@ try { remote.onMessage((raw) => { const message = JSON.parse(String(raw)); if (message.type === "error") console.log("terminal error", message); + // Herdr's screen stream turns bracketed paste on in its first screen, which can land after the + // key bar is ready and after the reset below: this run checks xterm with it off throughout. + if (message.type === "pty-data" && typeof message.data === "string" && message.data.includes("\x1b[?2004h")) { + socket.send(JSON.stringify({ ...message, data: message.data.replaceAll("\x1b[?2004h", "") })); + return; + } socket.send(raw); }); socket.onMessage((raw) => { frames.push(JSON.parse(String(raw))); remote.send(raw); }); @@ -59,7 +65,7 @@ try { ? "Meta+Shift+Comma" : "Control+Shift+Comma"; const ctrl = page.locator('[data-key="Control"]'); await until(async () => !(await ctrl.isDisabled()), "terminal input ready"); - // Herdr's screen stream enables bracketed paste; exercise xterm with it disabled too. + // Exercise xterm with bracketed paste off (the route above keeps herdr's stream from turning it on). ws!.send(JSON.stringify({ type: "pty-data", pane_id: pane, data: "\x1b[?2004l" })); const input = page.locator(".xterm-helper-textarea"); await input.focus(); diff --git a/scripts/windows-plugin.test.ts b/scripts/windows-plugin.test.ts index 054eae0e0d..4bec9ed2ca 100644 --- a/scripts/windows-plugin.test.ts +++ b/scripts/windows-plugin.test.ts @@ -62,6 +62,7 @@ it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops expect(code, err + out + (existsSync(join(state, "server.log")) ? readFileSync(join(state, "server.log"), "utf8") : "")).toBe(expectedCode); return out; }; + let failed = false; try { await run("start"); // start's process has exited, but the managed server and its children must still answer. @@ -143,8 +144,12 @@ it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops rmSync(join(state, "server.pid")); } } + } catch (error) { + failed = true; + throw error; } finally { - if (existsSync(join(state, "server.pid"))) await run("stop"); + // after a failure, a stop that fails too must not replace the error that says what went wrong + if (existsSync(join(state, "server.pid"))) await run("stop").catch(error => { if (!failed) throw error; }); await new Promise(resolve => peer.close(() => resolve())); rmSync(scratch, { recursive: true, force: true }); } diff --git a/src/App.tsx b/src/App.tsx index d40f559f65..a983980f31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1022,6 +1022,9 @@ export function App() { const inside = !!box && !!point && point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom; showDropTarget(box && point && inside ? dropSide(point.x, box.left, box.width, dropAtRef.current) : null); }, [tabDrag, splitDragOn, showDropTarget]); + // dragenter too: a drop hit-tests once more, and when the node under the pointer was replaced since + // the last dragover (xterm redraws its rows, a transcript renders again) the new node gets a + // dragenter instead. Chromium refuses a drop whose last drag event nobody accepted. const paneDragOver = (event: DragEvent): void => { if (!splitDragOn || !event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return; event.preventDefault(); @@ -1534,7 +1537,7 @@ export function App() { {health?.lan_exposure && { setSettingsLinkPage("devices"); setSettingsOpen(true); }} />} {/* the pane's tab, or two tabs side by side (lib/split.ts): a tab dragged down out of its strip, or a sidebar row dragged over this area, opens its pane in the half it is dropped on */} -
+
{(["a", "b"] as const).map((slot) => { // one pane: the solo slot, or a split's active half where the window is too narrow for both if (!splitOn && slot !== (split ? split.active : soloSlot)) return null; From 72a6e19e1c5e01c9d7c200e6d425c6c6891c1471 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 15 Oct 2026 04:28:25 +0900 Subject: [PATCH 2/2] test(scripts): drop the unproven sticky-modifiers and Windows install test changes --- changelog.d/split-drop-on-redrawn-node.md | 5 ----- scripts/sticky-modifiers-regression.ts | 8 +------- scripts/windows-plugin.test.ts | 7 +------ 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/changelog.d/split-drop-on-redrawn-node.md b/changelog.d/split-drop-on-redrawn-node.md index 3705d2474b..6975ab0801 100644 --- a/changelog.d/split-drop-on-redrawn-node.md +++ b/changelog.d/split-drop-on-redrawn-node.md @@ -1,8 +1,3 @@ ### Fixed - A pane dragged from the sidebar onto the pane area opens in a half even when the terminal or chat under the pointer redraws just before the drop; Chrome refused that drop before. - -### Maintenance -- The sticky-modifiers check keeps herdr's stream from turning bracketed paste back on after the - check turned it off, and the Windows install test no longer hides its first failure behind a - failing cleanup stop. diff --git a/scripts/sticky-modifiers-regression.ts b/scripts/sticky-modifiers-regression.ts index ac26854985..224bee3596 100644 --- a/scripts/sticky-modifiers-regression.ts +++ b/scripts/sticky-modifiers-regression.ts @@ -51,12 +51,6 @@ try { remote.onMessage((raw) => { const message = JSON.parse(String(raw)); if (message.type === "error") console.log("terminal error", message); - // Herdr's screen stream turns bracketed paste on in its first screen, which can land after the - // key bar is ready and after the reset below: this run checks xterm with it off throughout. - if (message.type === "pty-data" && typeof message.data === "string" && message.data.includes("\x1b[?2004h")) { - socket.send(JSON.stringify({ ...message, data: message.data.replaceAll("\x1b[?2004h", "") })); - return; - } socket.send(raw); }); socket.onMessage((raw) => { frames.push(JSON.parse(String(raw))); remote.send(raw); }); @@ -66,7 +60,7 @@ try { ? "Meta+Shift+Comma" : "Control+Shift+Comma"; const ctrl = page.locator('[data-key="Control"]'); await until(async () => !(await ctrl.isDisabled()), "terminal input ready"); - // Exercise xterm with bracketed paste off (the route above keeps herdr's stream from turning it on). + // Herdr's screen stream enables bracketed paste; exercise xterm with it disabled too. ws!.send(JSON.stringify({ type: "pty-data", pane_id: pane, data: "\x1b[?2004l" })); const input = page.locator(".xterm-helper-textarea"); await input.focus(); diff --git a/scripts/windows-plugin.test.ts b/scripts/windows-plugin.test.ts index a91c1f7868..58c1c6e737 100644 --- a/scripts/windows-plugin.test.ts +++ b/scripts/windows-plugin.test.ts @@ -127,7 +127,6 @@ it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops expect(code, err + out + (existsSync(join(state, "server.log")) ? readFileSync(join(state, "server.log"), "utf8") : "")).toBe(expectedCode); return out; }; - let failed = false; try { await run("start"); // start's process has exited, but the managed server and its children must still answer. @@ -219,12 +218,8 @@ it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops rmSync(join(state, "server.pid")); } } - } catch (error) { - failed = true; - throw error; } finally { - // after a failure, a stop that fails too must not replace the error that says what went wrong - if (existsSync(join(state, "server.pid"))) await run("stop").catch(error => { if (!failed) throw error; }); + if (existsSync(join(state, "server.pid"))) await run("stop"); await new Promise(resolve => peer.close(() => resolve())); rmSync(scratch, { recursive: true, force: true }); }