diff --git a/packages/app/e2e/regression/patch-sticky-header.spec.ts b/packages/app/e2e/regression/patch-sticky-header.spec.ts index c4548e0b40c..e302b5a2217 100644 --- a/packages/app/e2e/regression/patch-sticky-header.spec.ts +++ b/packages/app/e2e/regression/patch-sticky-header.spec.ts @@ -148,6 +148,7 @@ const scenarios = [ for (const scenario of scenarios) { for (const [width, direction] of [ [1400, "ltr"], + [900, "ltr"], [390, "rtl"], ] as const) { test(`${scenario.name}: file headers stay flush at ${width}px in ${direction}`, async ({ page }, info) => { @@ -176,7 +177,7 @@ for (const scenario of scenarios) { const scroller = page.locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport') const toolTitle = scroller.locator('[data-slot="collapsible-trigger"][data-locked]') await expect(toolTitle).toHaveCount(scenario.title ? 1 : 0) - await expect(scroller.locator("[data-session-title]")).toHaveCount(width === 1400 ? 1 : 0) + await expect(scroller.locator("[data-session-title]")).toHaveCount(width >= 768 ? 1 : 0) for (const file of scenario.files) { const name = new RegExp(`${file}\\.ts`) diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index 31b4c2d36e0..09b320be9ae 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -9,6 +9,7 @@ const tabs = (page: Page) => page.locator('[data-slot="titlebar-tabs"] a') for (const direction of ["ltr", "rtl"] as const) { test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) await mockStressTimeline(page) await seed(page, { tabs: [fixture.sourceID, fixture.targetID], @@ -65,6 +66,36 @@ for (const direction of ["ltr", "rtl"] as const) { await header.getByRole("textbox").press("Escape") await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + for (const width of [900, 1440]) { + await page.setViewportSize({ width, height: 900 }) + await expect + .poll(async () => { + const [location, title, menu, summary, sidebar] = await Promise.all( + [project, header.getByRole("heading"), more, details, review].map((control) => control.boundingBox()), + ) + + if (!location || !title || !menu || !summary || !sidebar) return false + const gap = direction === "ltr" ? menu.x - title.x - title.width : title.x - menu.x - menu.width + + const aligned = + Math.abs(menu.y + menu.height / 2 - summary.y - summary.height / 2) <= 0.5 && + Math.abs(menu.y + menu.height / 2 - sidebar.y - sidebar.height / 2) <= 0.5 + + return ( + Math.abs(gap - 2) <= 0.5 && + aligned && + (width === 900 + ? Math.abs(location.y - menu.y - menu.height - 8) <= 0.5 + : Math.abs(location.y + location.height / 2 - menu.y - menu.height / 2) <= 0.5) + ) + }) + .toBe(true) + + const screenshot = test.info().outputPath(`header-${width}.png`) + await page.screenshot({ path: screenshot }) + await test.info().attach(`header-${width}`, { path: screenshot, contentType: "image/png" }) + } + await review.click() await expect(review).toHaveAttribute("aria-expanded", "true") await expect(page.locator("#review-panel")).toBeVisible() diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 49183fc78fd..29e1aa2701f 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -153,14 +153,14 @@ test("keeps the active nested session visible in a narrow desktop header", async const separator = header.locator('[data-slot="session-title-ancestors"] + [data-slot="session-title-separator"]') await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle]) await expect(heading).toBeInViewport() - await expect(separator).toBeInViewport() + await expect(separator).toBeHidden() const bounds = await heading.boundingBox() expect(bounds).not.toBeNull() expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(820) await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl")) await expect(heading).toBeInViewport() - await expect(separator).toBeInViewport() + await expect(separator).toBeHidden() const root = header.locator(`[data-slot="session-title-ancestor"][data-session-id="${parentID}"]`) await root.focus() diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css index 51ec5bec854..ceec2591670 100644 --- a/packages/app/src/session/session-identity-header.css +++ b/packages/app/src/session/session-identity-header.css @@ -1,3 +1,91 @@ .session-options-menu [data-component="menu-v2-item"] { padding-inline-end: 30px; } + +.session-header-layout { + display: flex; + align-items: center; + gap: 2px; + inline-size: 100%; + block-size: 48px; +} + +.session-header-location { + display: flex; + align-items: center; + gap: 2px; + min-inline-size: 0; + max-inline-size: 60%; +} + +.session-header-location > [data-slot="session-location-trigger"] { + max-inline-size: 100%; +} + +.session-header-title { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-inline-size: 0; +} + +.session-header-title > [data-slot="session-title-child"] { + min-inline-size: 0; + max-inline-size: 100%; + flex-shrink: 1; +} + +.session-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-inline-start: 6px; +} + +.session-header-fade { + top: 48px; +} + +@container (max-width: 1000px) { + .session-header-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + block-size: 80px; + padding-block: 10px; + } + + .session-header-title { + grid-column: 1; + grid-row: 1; + block-size: 28px; + } + + .session-header-actions { + grid-column: 2; + grid-row: 1; + margin-inline-start: 0; + } + + .session-header-location { + grid-column: 1 / -1; + grid-row: 2; + max-inline-size: 100%; + block-size: 24px; + } + + .session-header-location > [data-slot="session-title-separator"]:last-child, + .session-header-location > div > [data-slot="session-title-separator"] { + display: none; + } + + .session-header-fade { + top: 80px; + } + + [data-slot="session-timeline-scroll"] { + --session-header-height: 80px; + } +} diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 241b7276579..b4be1cf1953 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -8,7 +8,7 @@ import { ProjectAvatar, } from "@opencode/ui/project-avatar" import { useNavigate } from "@solidjs/router" -import { createMemo, For, Show, type ComponentProps, type ParentProps } from "solid-js" +import { createMemo, For, Show, type ComponentProps, type JSX, type ParentProps } from "solid-js" import { containsDirectory, getFilename } from "@opencode/util/path" import { useServer } from "@/runtime/server/current" import { ServerConnection, useServers } from "@/runtime/server/registry" @@ -42,12 +42,24 @@ export function SessionTitleHeader(props: ParentProps) { {/* Stuck timeline headers stack above this fade, so it only fades content scrolling under the bar. */}
> ) } +export function SessionHeaderLayout(props: ParentProps<{ location: JSX.Element; actions?: JSX.Element }>) { + return ( +