mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-06 10:10:26 +00:00
fix(app): drag the desktop window from the session header gap (#57867)
This commit is contained in:
parent
f528e1f1b2
commit
3d52e83b3b
7 changed files with 189 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
79
packages/app/src/session/header/drag-area.tsx
Normal file
79
packages/app/src/session/header/drag-area.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue