diff --git a/packages/app/e2e/regression/session-summary-layout.spec.ts b/packages/app/e2e/regression/session-summary-layout.spec.ts index 54e713c4b02..0b7619ceea6 100644 --- a/packages/app/e2e/regression/session-summary-layout.spec.ts +++ b/packages/app/e2e/regression/session-summary-layout.spec.ts @@ -1,6 +1,128 @@ import { expect, test } from "@playwright/test" import { fixture } from "../performance/timeline/session-timeline-stress.fixture" import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers" +import { openWithDirection } from "../utils/direction" + +for (const direction of ["ltr", "rtl"] as const) { + test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => { + await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") }) + await page.setViewportSize({ width: 1440, height: 900 }) + await mockStressTimeline(page) + await openWithDirection(page, stressSessionHref(fixture.targetID), direction) + const trigger = page.getByRole("button", { name: "Session details", exact: true }) + await expect(trigger).toBeEnabled() + await expect(page.locator("html")).toHaveAttribute("dir", direction) + await expect(page.locator("html")).toHaveAttribute("lang", "en") + const row = page.locator( + `[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`, + ) + const content = page.locator("[data-timeline-virtual-content]") + const composer = page.locator('[data-component="session-composer-dock"] > div') + const panel = page.locator('[data-slot="session-chat-panel"]') + const summary = page.getByRole("dialog", { name: "Session details", exact: true }) + await expect(row).toBeInViewport() + await expect(row).toHaveCSS("width", "1000px") + const before = await row.boundingBox() + const dock = await composer.boundingBox() + expect(before).not.toBeNull() + expect(dock).not.toBeNull() + await content.evaluate((element) => { + element.setAttribute("data-summary-motion", "") + for (const type of ["transitionrun", "transitionend"]) { + element.addEventListener(type, (event) => { + if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return + element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`) + }) + } + }) + await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" }) + await trigger.click() + await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,") + await expect + .poll(async () => { + const message = await row.boundingBox() + const details = await summary.boundingBox() + const chat = await panel.boundingBox() + if (!message || !details || !chat) return false + return ( + message.x >= chat.x && + message.x + message.width <= chat.x + chat.width && + (direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width) + ) + }) + .toBe(true) + await expect(row).toHaveCSS("width", "1000px") + await expect + .poll(async () => { + const message = await row.boundingBox() + const input = await composer.boundingBox() + if (!message || !input || !before || !dock) return Infinity + return Math.abs(message.x - before.x - (input.x - dock.x)) + }) + .toBeLessThan(1) + await expect + .poll(() => + content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth), + ) + .toBe(0) + await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" }) + + await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z")) + const shifted = await content.evaluate((element) => getComputedStyle(element).translate) + await content.evaluate((element) => element.setAttribute("data-summary-motion", "")) + // Keep issuing resize events before the idle timer expires, including crossing the width cutoff. + for (const width of [1520, 1280, 1600]) { + await page.setViewportSize({ width, height: 900 }) + await expect(panel).toHaveAttribute("data-summary-resizing", "true") + await page.clock.runFor(100) + await expect(content).toHaveCSS("translate", shifted) + await expect(composer).toHaveCSS("translate", shifted) + await expect(content).toHaveAttribute("data-summary-motion", "") + } + await page.clock.resume() + await expect(panel).toHaveAttribute("data-summary-resizing", "false") + await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,") + await expect(content).not.toHaveCSS("translate", shifted) + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(content).toHaveCSS("translate", shifted) + + await content.evaluate((element) => element.setAttribute("data-summary-motion", "")) + await page.keyboard.press("Escape") + await expect(summary).toBeHidden() + await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,") + await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1) + await expect(trigger).toBeFocused() + + await trigger.click() + await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible() + // Cross the actual chat-panel breakpoint, including any surrounding shell width. + const shell = 1440 - (await panel.boundingBox())!.width + await page.setViewportSize({ width: 1320 + shell, height: 900 }) + await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320) + await expect + .poll(async () => { + const message = (await row.boundingBox())! + const details = (await summary.boundingBox())! + return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width + }) + .toBeGreaterThan(0) + await page.setViewportSize({ width: 1319 + shell, height: 900 }) + await expect(content).toHaveCSS("translate", "none") + await expect(row).toHaveCSS("width", "1000px") + await expect(summary).toBeVisible() + + await page.setViewportSize({ width: 1800, height: 900 }) + await expect(content).toHaveCSS("translate", "0px") + await expect(summary).toBeVisible() + + await page.emulateMedia({ reducedMotion: "reduce" }) + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(content).toHaveCSS("transition-duration", "0s") + await page.keyboard.press("Escape") + await expect(content).toHaveCSS("translate", "none") + await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1) + }) +} for (const theme of ["light", "dark"] as const) { test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => { diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 0dc94112d6c..613f9988376 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -1,8 +1,58 @@ import { expect, test } from "@playwright/test" import { fixture } from "../performance/timeline/session-timeline-stress.fixture" -import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers" +import { + installStressSessionTabs, + mockStressTimeline, + stressSessionHref, +} from "../performance/timeline/timeline-test-helpers" import { openWithDirection } from "../utils/direction" +for (const custom of [false, true]) { + test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => { + await mockStressTimeline(page) + await installStressSessionTabs(page) + if (custom) { + await page.addInitScript(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }), + ) + }) + } + await page.goto(stressSessionHref(fixture.sourceID)) + const trigger = page.getByRole("button", { name: "Session details", exact: true }) + const summary = page.getByRole("dialog", { name: "Session details", exact: true }) + await expect(trigger).toBeEnabled() + await trigger.hover() + const tooltip = page.getByRole("tooltip") + await expect(tooltip).toBeVisible() + await expect(tooltip).toContainText("Summary") + const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)) + const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y" + await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText( + custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"], + ) + for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) { + await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click() + await expect( + page.locator( + `[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`, + ), + ).toBeInViewport() + await page.keyboard.press(shortcut) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible() + await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true) + await expect(tooltip).toBeHidden() + await page.keyboard.press(shortcut) + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(summary).toBeHidden() + await expect(trigger).toBeFocused() + } + }) +} + for (const layout of ["horizontal", "vertical"] as const) { test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => { await mockStressTimeline(page) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e134076958f..8d6c9f7b2f4 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -38,6 +38,38 @@ } @layer components { + [data-slot="session-chat-panel"] { + container-name: session-chat; + } + + [data-slot="session-chat-panel"] + :is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) { + translate: var(--session-summary-resize-translate, var(--session-summary-translate, none)); + transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1); + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + [data-slot="session-chat-panel"][data-summary-resizing="true"] + :is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) { + transition: none; + } + + /* Keep the 1000px conversation intact, with 320px for the summary and its gutters. + Move only as far as needed; wider panels already have enough space in the margin. */ + @container session-chat (min-width: 1320px) { + [data-slot="session-chat-panel"][data-summary-open="true"] + :is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) { + --session-summary-translate: min(0px, calc(50cqi - 820px)); + + &:dir(rtl) { + --session-summary-translate: max(0px, calc(820px - 50cqi)); + } + } + } + [data-slot="session-chat-panel"][data-scrollbar-hidden="true"] [data-slot="session-timeline-scroll"] > .scroll-view__thumb { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 91e9142ae05..ff37e2f911f 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -116,6 +116,7 @@ export const dict = { "command.terminal.toggle": "Toggle terminal", "command.fileTree.toggle": "Toggle file tree", "command.review.toggle": "Toggle review", + "command.session.summary.toggle": "Toggle summary", "command.terminal.new": "New terminal", "command.terminal.new.description": "Create a new terminal tab", "command.steps.toggle": "Toggle steps", @@ -1427,6 +1428,7 @@ export const dict = { "workspace.lifecycle.moving": "Moving to worktree", "workspace.lifecycle.set": "Worktree set", "session.summary.title": "Session details", + "session.summary.tooltip": "Summary", "session.summary.noBranch": "No branch", "session.summary.basedOn": "Based on {{branch}}", "session.summary.server": "Server", diff --git a/packages/app/src/session/review/model.ts b/packages/app/src/session/review/model.ts index 4c8f3f2601a..d06135114c8 100644 --- a/packages/app/src/session/review/model.ts +++ b/packages/app/src/session/review/model.ts @@ -396,6 +396,7 @@ export function createSessionReview(input: { deferRender: input.deferRender, details: { diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined), + open: () => state.detailsOpen, setOpen: (open: boolean) => setState("detailsOpen", open), }, diffVersion: () => vcsQuery.dataUpdatedAt, diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index b449f40c4e8..f5886d94dc6 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -9,8 +9,11 @@ import { createEffect, createComputed, on, + onMount, } from "solid-js" import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { debounce } from "@solid-primitives/scheduled" import { ResizeHandle } from "@opencode/ui/resize-handle" import { MessageTimeline } from "@/session/timeline/message-timeline" import { useServer } from "@/runtime/server/current" @@ -75,11 +78,24 @@ export function SessionScreen(props: { session: SessionModel }) { sideTerminalPresent: false, mobileTerminalCached: false, mobileMoveDismissed: false, + summaryResizeTranslate: undefined as string | undefined, }) const [elements, setElements] = createStore<{ + chat?: HTMLDivElement side?: HTMLDivElement bottomTerminal?: HTMLDivElement }>({}) + const finishWindowResize = debounce(() => setStore("summaryResizeTranslate", undefined), 150) + onMount(() => { + makeEventListener(window, "resize", () => { + if (store.summaryResizeTranslate === undefined) { + const content = elements.chat?.querySelector("[data-timeline-virtual-content]") + // Freeze the painted offset, including an in-flight slide, until resizing settles. + setStore("summaryResizeTranslate", content ? getComputedStyle(content).translate : "none") + } + finishWindowResize() + }) + }) const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible) const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open()) const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom()) @@ -338,6 +354,9 @@ export function SessionScreen(props: { session: SessionModel }) { "transition-none": screen.size.active() || !sidePresence.animate(), }} data-slot="session-chat-panel" + ref={(element) => setElements("chat", element)} + data-summary-open={isDesktop() && review.details.open()} + data-summary-resizing={store.summaryResizeTranslate !== undefined} data-width-animating={store.sideWidthMotion} data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion} onPointerMove={revealTimelineScrollbar} @@ -349,6 +368,7 @@ export function SessionScreen(props: { session: SessionModel }) { onTransitionCancel={trackSideWidthMotion} style={{ width: screen.panel.width(), + "--session-summary-resize-translate": store.summaryResizeTranslate, }} > diff --git a/packages/app/src/session/summary/popover.tsx b/packages/app/src/session/summary/popover.tsx index 3704cacf2eb..ac1d8b14867 100644 --- a/packages/app/src/session/summary/popover.tsx +++ b/packages/app/src/session/summary/popover.tsx @@ -1,23 +1,56 @@ import { Popover } from "@kobalte/core/popover" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" -import type { ParentProps } from "solid-js" +import { Keybind } from "@opencode/ui/keybind" +import { Tooltip } from "@opencode/ui/tooltip" +import { Show, type ParentProps } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" +import { useCommand } from "@/shell/commands/command" -export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) { +export function SummaryPopover( + props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>, +) { const language = useLanguage() + const command = useCommand() + // Cached timelines remain mounted; only the visible summary owns the command. + command.register(() => + props.active === false + ? [] + : [ + { + id: "session.summary.toggle", + title: language.t("command.session.summary.toggle"), + category: language.t("command.category.view"), + keybind: "mod+shift+y", + onSelect: () => props.onOpenChange(!props.open), + }, + ], + ) + const keybind = () => command.keybindParts("session.summary.toggle") return (