diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index 6b3625a04e5..2ddbf996b00 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -209,42 +209,119 @@ test.describe("location", () => { sessions: [{ id: "ses_location", title: "Location session", directory: worktree }], } - test("only collapses location names when the timeline header is narrow", async ({ page }) => { + test("collapses the worktree name before the project and only then truncates the title", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }) + await page.clock.install() await openSession(page, { ...workspace, sessions: [{ id: "ses_location", title: "Investigate session header layout regression", directory: worktree }], }) const header = page.locator("[data-session-title]") - const names = header.locator('[data-slot="session-location-project"], [data-slot="session-location-worktree"]') + const project = header.locator('[data-slot="session-location-project"]') + const worktreeName = header.locator('[data-slot="session-location-worktree"]') + const location = header.locator('[data-slot="session-location-trigger"]') + const row = header.locator(".session-header-layout") const review = page.getByRole("button", { name: "Toggle review", exact: true }) - await expect(names).toHaveText(["Location", "quiet-falcon"]) + const tooltip = page.getByRole("tooltip") + await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible() + await expect(project).toHaveText("Location") + await expect(worktreeName).toHaveText("quiet-falcon") - for (const panel of [false, true, false]) { - if (panel || (await review.getAttribute("aria-expanded")) === "true") await review.click() - await expect(review).toHaveAttribute("aria-expanded", String(panel)) + // Keep the viewport fixed: a pane can narrow a session independently of the window. + for (const state of [ + { width: 1000, project: true, worktree: true, truncated: false }, + { width: 680, project: true, worktree: false, truncated: false }, + { width: 600, project: false, worktree: false, truncated: false }, + { width: 490, project: false, worktree: false, truncated: true }, + { width: 680, project: true, worktree: false, truncated: false }, + { width: 1000, project: true, worktree: true, truncated: false }, + ]) { + await row.evaluate((element, width) => (element.style.inlineSize = `${width}px`), state.width) + await expect(project).toBeVisible({ visible: state.project }) + await expect(worktreeName).toBeVisible({ visible: state.worktree }) + await expect(header.locator(".session-location-worktree-icon")).toBeVisible() + await expect + .poll(() => header.getByRole("heading").evaluate((element) => element.scrollWidth > element.clientWidth)) + .toBe(state.truncated) + await expect(header.getByRole("button", { name: "More options", exact: true })).toBeInViewport() + await header.getByRole("heading").hover() + await location.hover() + if (state.project && state.worktree) { + // Advance past the shared tooltip delay, so absence is not just its initial waiting period. + await page.clock.fastForward(500) + await expect(tooltip).toBeHidden() + } + if (!state.project || !state.worktree) await expect(tooltip).toHaveText("Location / quiet-falcon") await expect .poll(() => - names.evaluateAll((elements) => elements.every((element) => getComputedStyle(element).clipPath === "none")), + location.evaluate((element) => { + const icon = element.querySelector(".session-location-worktree-icon")?.getBoundingClientRect() + const bounds = element.getBoundingClientRect() + + const separator = element + .closest(".session-header-location") + ?.querySelector(':scope > [data-slot="session-title-separator"]') + ?.getBoundingClientRect() + + const label = element.querySelector('[data-slot="session-location-worktree"]') + + const end = label?.checkVisibility({ checkVisibilityCSS: true }) + ? label.getBoundingClientRect().right + : icon?.right + + return !!end && !!separator && Math.abs(bounds.right - end - 6) <= 0.5 && bounds.right <= separator.left + }), ) - .toBe(!panel) - await expect(header.getByRole("button", { name: "More options", exact: true })).toBeInViewport() - await expect - .poll(() => header.getByRole("heading").evaluate((element) => element.scrollWidth <= element.clientWidth)) .toBe(true) - if (panel) { - const separator = header.locator('[data-slot="session-title-separator"]') - await expect(separator).toBeVisible() - await expect(separator).toHaveText("") - await expect(separator).toHaveAttribute("aria-hidden", "true") - await expect(header.locator(".session-location-worktree-icon")).toBeHidden() - const location = header.locator('[data-slot="session-location-trigger"]') - await location.hover() - await expect(location).toHaveCSS("padding-inline-start", "6px") - await expect(location).toHaveCSS("padding-inline-end", "6px") - expect(await location.boundingBox()).toMatchObject({ width: 28, height: 28 }) - } - await header.screenshot({ path: test.info().outputPath(`location-${panel ? "compact" : "expanded"}.png`) }) + await header.screenshot({ path: test.info().outputPath(`location-${state.width}.png`) }) + } + + await row.evaluate((element) => element.style.removeProperty("inline-size")) + await review.click() + await expect(review).toHaveAttribute("aria-expanded", "true") + await expect(worktreeName).toBeHidden() + await review.click() + await expect(worktreeName).toBeVisible() + + // A title change must refit even when the session container itself never resizes. + await row.evaluate((element) => (element.style.inlineSize = "600px")) + await expect(project).toBeHidden() + await header.getByRole("heading").click() + await header.getByRole("textbox").fill("Short title") + await expect(project).toBeVisible() + await expect(worktreeName).toBeVisible() + await header.getByRole("textbox").press("Escape") + await expect(project).toBeHidden() + await expect(location).toHaveAccessibleName("Location / quiet-falcon") + await expect(location).not.toHaveAttribute("title") + await location.hover() + await expect(tooltip).toHaveText("Location / quiet-falcon") + await page.screenshot({ path: test.info().outputPath("location-tooltip.png") }) + await location.click() + await expect(page.locator('[data-slot="session-location-card"]')).toBeVisible() + await expect(tooltip).toBeHidden() + }) + + test("collapses to the project avatar when there is no worktree", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await openSession(page, { + ...workspace, + sessions: [{ id: "ses_location", title: "Investigate session header layout regression", directory: root }], + }) + const header = page.locator("[data-session-title]") + const location = header.locator('[data-slot="session-location-trigger"]') + const project = location.locator('[data-slot="session-location-project"]') + const row = header.locator(".session-header-layout") + await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible() + await expect(location.locator(".session-location-worktree-icon")).toHaveCount(0) + + for (const width of [1000, 360, 1000]) { + await row.evaluate((element, width) => (element.style.inlineSize = `${width}px`), width) + await expect(project).toBeVisible({ visible: width === 1000 }) + await location.hover() + + if (width === 360) expect(await location.boundingBox()).toMatchObject({ width: 28, height: 28 }) + await header.screenshot({ path: test.info().outputPath(`project-${width}.png`) }) } }) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 481eff6c1b9..f5147cd6ed3 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -170,6 +170,36 @@ test("keeps the active nested session visible in a narrow desktop header", async await expect(page).toHaveURL(sessionHref(parentID)) }) +test("restores the project name after closing review in a nested session", async ({ page }) => { + await page.setViewportSize({ width: 890, height: 900 }) + await mockWorkspace(page, { + name: "Project with a moderately long display name", + directory, + sessions: [ + { id: parentID, title: "Root session outlining repository-wide change analysis and implementation" }, + { id: childID, parentID, title: "Intermediate agent examining architecture and regression coverage" }, + { id: grandchildID, parentID: childID, title: "Review repository" }, + ], + }) + await page.goto(sessionHref(grandchildID)) + await expect(page.locator("[data-timeline-virtual-content]")).toBeAttached() + const header = page.locator("[data-session-title]") + const row = header.locator(".session-header-layout") + const project = header.locator('[data-slot="session-location-project"]') + const review = page.getByRole("button", { name: "Toggle review", exact: true }) + await expect(project).toBeVisible() + const width = await row.evaluate((element) => element.getBoundingClientRect().width) + await header.screenshot({ path: test.info().outputPath("nested-header-before-review.png") }) + + await review.click() + await expect(project).toBeHidden() + await review.click() + await expect.poll(() => row.evaluate((element) => element.getBoundingClientRect().width)).toBe(width) + await expect(project).toBeVisible() + await expect(header.getByRole("heading", { name: "Review repository", exact: true })).toBeVisible() + await header.screenshot({ path: test.info().outputPath("nested-header-restored.png") }) +}) + test("shows parent lineage while the child timeline loads", async ({ page }) => { await setup(page) const requested = Promise.withResolvers() diff --git a/packages/app/src/session/header/fit.ts b/packages/app/src/session/header/fit.ts new file mode 100644 index 00000000000..c14cec87829 --- /dev/null +++ b/packages/app/src/session/header/fit.ts @@ -0,0 +1,100 @@ +import { onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" + +/** Fits the location names before allowing the session title to truncate. */ +export function createSessionHeaderFit() { + const [store, setStore] = createStore({ collapse: 0 }) + let root!: HTMLDivElement + + onMount(() => { + const fit = () => { + const location = root.querySelector(".session-header-location") + const title = root.querySelector(".session-header-title") + const measure = root.querySelector(".session-header-title-measure > span") + const trigger = root.querySelector('[data-slot="session-location-trigger"]') + const project = trigger?.querySelector('[data-slot="session-location-project"]') + const worktree = trigger?.querySelector('[data-slot="session-location-worktree"]') + + if (!location || !title || !measure || !trigger || !project || !root.clientWidth) return + + const gap = Number.parseFloat(getComputedStyle(trigger).columnGap) + const projectWidth = project.getBoundingClientRect().width + gap + const worktreeWidth = worktree ? worktree.getBoundingClientRect().width + gap : 0 + + // Hidden labels retain their intrinsic width, so fitting never depends on a previous truncation. + const expanded = + location.getBoundingClientRect().width + + (store.collapse >= 1 ? worktreeWidth : 0) + + (store.collapse >= 2 ? projectWidth : 0) + + // Ancestors can fill the space a hidden name freed. Apply their cap to each reconstructed candidate. + const max = getComputedStyle(location).maxInlineSize + + const limit = + max === "none" + ? Number.POSITIVE_INFINITY + : Number.parseFloat(max) * (max.endsWith("%") ? root.getBoundingClientRect().width / 100 : 1) + + const children = [...title.children].filter( + (element) => !element.matches(".session-header-title-measure") && getComputedStyle(element).display !== "none", + ) + + const titleWidth = children.reduce( + (width, element) => { + if (element.matches('[data-slot="session-header-gap"]')) return width + + if (element.matches("h1, input")) return width + measure.getBoundingClientRect().width + const style = getComputedStyle(element) + + return ( + width + + element.getBoundingClientRect().width + + Number.parseFloat(style.marginInlineStart) + + Number.parseFloat(style.marginInlineEnd) + ) + }, + Math.max(0, children.length - 1) * Number.parseFloat(getComputedStyle(title).columnGap), + ) + + // Everything outside these two sections is reserved for actions, separators and their spacing. + const available = location.getBoundingClientRect().width + title.getBoundingClientRect().width - titleWidth + setStore( + "collapse", + Math.min(expanded, limit) <= available + ? 0 + : worktree && Math.min(expanded - worktreeWidth, limit) <= available + ? 1 + : 2, + ) + } + + const resize = new ResizeObserver(fit) + + const observe = () => { + resize.disconnect() + resize.observe(root) + root + .querySelectorAll( + ".session-header-location, .session-location-label, .session-header-title > *, .session-header-title-measure > span, .session-header-actions", + ) + .forEach((element) => resize.observe(element)) + fit() + } + + // Conditional controls and the rename field can mount without resizing the container itself. + const mutation = new MutationObserver(observe) + mutation.observe(root, { childList: true, subtree: true }) + observe() + onCleanup(() => { + resize.disconnect() + mutation.disconnect() + }) + }) + + return { + ref: (element: HTMLDivElement) => (root = element), + get collapse() { + return store.collapse + }, + } +} diff --git a/packages/app/src/session/header/separator.tsx b/packages/app/src/session/header/separator.tsx index 652f13e5289..7153297e291 100644 --- a/packages/app/src/session/header/separator.tsx +++ b/packages/app/src/session/header/separator.tsx @@ -72,25 +72,32 @@ motionTuning.subscribe(() => setTuning(read())) * Sits between breadcrumb segments with `gap` of visible space on both sides. `before` and `after` are the space the * neighbouring segments already leave between their ink and this element, so the margins make up the rest. */ -export function SessionHeaderSeparator(props: { before?: number; after?: number }) { +export function SessionHeaderSeparator(props: { + before?: number + after?: number + glyph?: typeof tuning.glyph + gap?: number +}) { + const glyph = () => props.glyph ?? tuning.glyph + // The ink of every glyph fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing. const width = () => { - if (tuning.glyph === "divider") return tuning.weight + if (glyph() === "divider") return tuning.weight - if (tuning.glyph === "dot") return tuning.size / 4 + if (glyph() === "dot") return tuning.size / 4 return tuning.size * 0.45 } - const stroke = () => tuning.weight * (tuning.glyph === "bold" ? 2 : 1) + const stroke = () => tuning.weight * (glyph() === "bold" ? 2 : 1) return (