From 3d52e83b3b814e5fa0011caee467618f209f8c31 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 15:35:19 -0400 Subject: [PATCH] fix(app): drag the desktop window from the session header gap (#57867) --- .../app/e2e/regression/session-header.spec.ts | 70 ++++++++++++++++ .../app/e2e/utils/windows-menu.fixture.tsx | 5 +- packages/app/e2e/utils/windows-menu.html | 3 +- packages/app/src/session/header/drag-area.tsx | 79 +++++++++++++++++++ packages/app/src/session/screen.tsx | 16 ++++ .../src/session/timeline/message-timeline.tsx | 6 ++ packages/app/src/shell/shell.tsx | 13 ++- 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/session/header/drag-area.tsx diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index 9c6ab38cbb63..fda461702fd6 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -118,6 +118,76 @@ for (const direction of ["ltr", "rtl"] as const) { }) } +// A frameless desktop window moves when its titlebar row is dragged. The session header continues that row through one +// area over the empty gap between the title and the actions, so no control sits under it. +for (const direction of ["ltr", "rtl"] as const) { + test(`the desktop session header drags the window from the gap after the title in ${direction}`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await mockStressTimeline(page) + await page.goto( + `/e2e/utils/windows-menu.html?${new URLSearchParams({ server: fixture.serverKey, path: sessionHref(fixture.targetID, "sidecar") })}`, + ) + const header = page.locator("[data-session-title]") + const area = page.locator('[data-slot="session-header-drag"]') + const options = header.getByRole("button", { name: "More options", exact: true }) + const actions = header.locator(".session-header-actions") + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction) + await expect(area).toHaveCSS("app-region", "drag") + await expect(page.locator('[data-slot="shell-drag-inset"]')).toHaveCSS("app-region", "drag") + + const gap = header.locator('[data-slot="session-header-gap"]') + const aligned = async () => { + const [box, space, end, start, row] = await Promise.all([ + area.boundingBox(), + gap.boundingBox(), + options.boundingBox(), + actions.boundingBox(), + header.locator(".session-header-layout").boundingBox(), + ]) + + if (!box || !space || !end || !start || !row) return false + const between = + direction === "ltr" + ? space.x >= end.x + end.width && space.x + space.width <= start.x + : space.x + space.width <= end.x && space.x >= start.x + start.width + + return ( + between && + Math.abs(box.x - space.x) <= 0.5 && + Math.abs(box.width - space.width) <= 0.5 && + Math.abs(box.y - row.y) <= 0.5 && + Math.abs(box.height - row.height) <= 0.5 + ) + } + + await expect.poll(aligned).toBe(true) + + // The area lives outside the timeline's scroller, so scrolling leaves it over the header. + const timeline = page.locator('[data-slot="session-timeline-scroll"]') + await timeline.hover() + await page.mouse.wheel(0, -2000) + await expect.poll(aligned).toBe(true) + + // The rename field takes the gap's place, so the area steps aside until the title is back. + await header.getByRole("heading").click() + await expect(header.getByRole("textbox")).toBeFocused() + await expect(area).toBeHidden() + await header.getByRole("textbox").press("Escape") + await expect(area).toBeVisible() + }) +} + +test("the web session header leaves window dragging to the browser", async ({ page }) => { + await mockStressTimeline(page) + await seed(page, { tabs: [fixture.targetID] }) + await page.goto(sessionHref(fixture.targetID)) + const header = page.locator("[data-session-title]") + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + await expect(page.locator('[data-slot="session-header-drag"]')).toHaveCount(0) + await expect(page.locator('[data-slot="shell-drag-inset"]')).toHaveCount(0) +}) + test.describe("location", () => { const root = "C:/OpenCode/Location" const worktree = "C:/OpenCode/Worktrees/location/quiet-falcon" diff --git a/packages/app/e2e/utils/windows-menu.fixture.tsx b/packages/app/e2e/utils/windows-menu.fixture.tsx index 4b69c465d688..408f30fd7a74 100644 --- a/packages/app/e2e/utils/windows-menu.fixture.tsx +++ b/packages/app/e2e/utils/windows-menu.fixture.tsx @@ -6,11 +6,14 @@ import { AppBaseProviders, AppInterface } from "../../src/app" import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform" // The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output. -export function mount(input: { server: string }) { +// `path` opens a route other than Home. +export function mount(input: { server: string; path?: string }) { const root = document.getElementById("root") if (!root) throw new Error("Missing fixture root") const history = createMemoryHistory() + + if (input.path) history.set({ value: input.path, replace: true }) render(() => { const [store, setStore] = createStore<{ actions: string[] }>({ actions: [] }) diff --git a/packages/app/e2e/utils/windows-menu.html b/packages/app/e2e/utils/windows-menu.html index 08b7fcf0f967..766f5adc1ea9 100644 --- a/packages/app/e2e/utils/windows-menu.html +++ b/packages/app/e2e/utils/windows-menu.html @@ -7,7 +7,8 @@
diff --git a/packages/app/src/session/header/drag-area.tsx b/packages/app/src/session/header/drag-area.tsx new file mode 100644 index 000000000000..25724c195da3 --- /dev/null +++ b/packages/app/src/session/header/drag-area.tsx @@ -0,0 +1,79 @@ +import { createEffect, createSignal, onCleanup, Show } from "solid-js" +import type { Platform } from "@/runtime/platform/platform" + +// Narrower gaps leave nothing worth grabbing between the title and the actions. +const minWidth = 24 + +/** Whether this window hides its native titlebar and moves from app-drawn drag regions. */ +export function windowDragEnabled(platform: Platform) { + return platform.platform === "desktop" && (platform.os === "macos" || platform.os === "windows") +} + +/** Fills the session header between the title and the actions; the active timeline hands it to the pane's drag area. */ +export function SessionHeaderGap(props: { active: boolean; register?: (gap: HTMLElement) => () => void }) { + let gap!: HTMLDivElement + + createEffect(() => { + if (props.active && props.register) onCleanup(props.register(gap)) + }) + + return