fix(app): drag the desktop window from the session header gap (#57867)

This commit is contained in:
James Long 2026-10-21 15:35:19 -04:00 • committed by GitHub
parent f528e1f1b2
commit 3d52e83b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 189 additions and 3 deletions

View file

@ -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"

View file

@ -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: [] })

View file

@ -7,7 +7,8 @@
<div id="root"></div>
<script type="module">
import { mount } from "./windows-menu.fixture.tsx"
mount({ server: new URLSearchParams(location.search).get("server") })
const params = new URLSearchParams(location.search)
mount({ server: params.get("server"), path: params.get("path") ?? undefined })
</script>
</body>
</html>

View file

@ -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 <div ref={gap} data-slot="session-header-gap" aria-hidden="true" class="flex-1 self-stretch" />
}
/**
* Moves the window from the session header's empty gap, as the hidden titlebar's row would. The area is pinned to the
* pane over the gap rather than placed in the header: the header sticks inside the timeline's scroller, and Chromium
* only recomputes drag regions on layout, so a region inside it would stay behind on scroll.
*/
export function SessionHeaderDrag(props: { pane?: HTMLElement; gap?: HTMLElement }) {
const [rect, setRect] = createSignal<{ x: number; y: number; width: number; height: number }>()
// Rename, truncation, readout updates and pane resizes all resize the gap; nothing here runs while the timeline scrolls.
createEffect(() => {
const pane = props.pane
const gap = props.gap
setRect(undefined)
const row = gap?.closest<HTMLElement>(".session-header-layout")
if (!pane || !gap || !row) return
// The gap spans the row's width between the title and the actions; the area takes the row's full height.
const measure = () => {
const box = gap.getBoundingClientRect()
const bounds = row.getBoundingClientRect()
const origin = pane.getBoundingClientRect()
setRect(
box.width < minWidth
? undefined
: { x: box.left - origin.left, y: bounds.top - origin.top, width: box.width, height: bounds.height },
)
}
const resize = new ResizeObserver(measure)
resize.observe(pane)
resize.observe(gap)
// The row's sections catch moves that keep the gap's size, such as the breadcrumb and the actions trading width.
for (const section of row.children) resize.observe(section)
onCleanup(() => resize.disconnect())
})
return (
<Show when={rect()}>
{(value) => (
<div
data-slot="session-header-drag"
aria-hidden="true"
class="absolute"
style={{
left: `${value().x}px`,
top: `${value().y}px`,
width: `${value().width}px`,
height: `${value().height}px`,
}}
data-tauri-drag-region
/>
)}
</Show>
)
}

View file

@ -7,6 +7,7 @@ import {
Switch,
createMemo,
createEffect,
createSignal,
on,
onCleanup,
untrack,
@ -41,6 +42,8 @@ import { TimelineSearchBar } from "./timeline/search-bar"
import { createTimelineQuote, TimelineQuote } from "./timeline/quote"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader, SessionLocation } from "./session-identity-header"
import { SessionHeaderDrag, windowDragEnabled } from "./header/drag-area"
import { usePlatform } from "@/runtime/platform/platform"
import { useServer } from "@/runtime/server/current"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createTimelineCache } from "./timeline/cache"
@ -233,6 +236,15 @@ function SessionScreenContent(props: {
return <SessionErrorFallback error={cause} sessionID={session.identity.params.id} />
}
const platform = usePlatform()
const [headerGap, setHeaderGap] = createSignal<HTMLElement>()
const registerHeaderGap = (gap: HTMLElement) => {
setHeaderGap(gap)
return () => setHeaderGap((current) => (current === gap ? undefined : current))
}
const timelineView = createTimelineCache(
session,
(source, active) => {
@ -247,6 +259,7 @@ function SessionScreenContent(props: {
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
registerHeaderGap={registerHeaderGap}
session={source}
view={own()}
screen={props.screen}
@ -429,6 +442,9 @@ function SessionScreenContent(props: {
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
</Show>
<Show when={windowDragEnabled(platform)}>
<SessionHeaderDrag pane={chat} gap={headerGap()} />
</Show>
<Show when={screen.panel.resizable()}>
<div onPointerDown={() => screen.size.start()}>

View file

@ -33,6 +33,7 @@ import {
SessionTitleHeader,
} from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionHeaderGap } from "@/session/header/drag-area"
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
@ -67,6 +68,8 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type MessageTimelineProps = {
hideHeader?: boolean
/** Hands the header's empty gap to the pane's window drag area. */
registerHeaderGap?: (gap: HTMLElement) => () => void
active?: boolean
session: TimelineSessionSource
view: MountedSession
@ -652,6 +655,9 @@ function MessageTimelineView(
</Menu>
)}
</Show>
<Show when={!title.editing}>
<SessionHeaderGap active={props.active !== false} register={props.registerHeaderGap} />
</Show>
</SessionHeaderLayout>
</SessionTitleHeader>
</Show>

View file

@ -15,6 +15,7 @@ import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { CollapsedSidebarToggle, paintCollapsedSlots } from "@/shell/sidebar-toggle"
import { createPanelMotion, guardClosingFocus, replayPanelToggle } from "@/shell/panel-motion"
import { windowDragEnabled } from "@/session/header/drag-area"
export default function Layout(props: ParentProps) {
const platform = usePlatform()
@ -75,6 +76,7 @@ export default function Layout(props: ParentProps) {
: [],
)
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
const dragInset = () => windowDragEnabled(platform) && !mobile()
return (
<TitlebarRightProvider>
@ -96,7 +98,16 @@ export default function Layout(props: ParentProps) {
}}
>
<Titlebar verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined} />
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<div class="relative flex flex-1 min-h-0 min-w-0 flex-row">
{/* The empty inset above the panels moves the window, as the titlebar does. */}
<Show when={dragInset()}>
<div
data-slot="shell-drag-inset"
aria-hidden="true"
class="absolute inset-x-0 top-0 h-[var(--shell-top-inset,8px)]"
data-tauri-drag-region
/>
</Show>
<Show when={verticalTabs()}>
<aside
id="vertical-tabs-sidebar"