Skip to content

Commit 9f3ba44

Browse files
authored
fix(app): keep summaries clear of the session timeline (#48449)
1 parent c4fe0f6 commit 9f3ba44

8 files changed

Lines changed: 273 additions & 13 deletions

File tree

‎packages/app/e2e/regression/session-summary-layout.spec.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,128 @@
11
import { expect, test } from "@playwright/test"
22
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
33
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
4+
import { openWithDirection } from "../utils/direction"
5+
6+
for (const direction of ["ltr", "rtl"] as const) {
7+
test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => {
8+
await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") })
9+
await page.setViewportSize({ width: 1440, height: 900 })
10+
await mockStressTimeline(page)
11+
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
12+
const trigger = page.getByRole("button", { name: "Session details", exact: true })
13+
await expect(trigger).toBeEnabled()
14+
await expect(page.locator("html")).toHaveAttribute("dir", direction)
15+
await expect(page.locator("html")).toHaveAttribute("lang", "en")
16+
const row = page.locator(
17+
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
18+
)
19+
const content = page.locator("[data-timeline-virtual-content]")
20+
const composer = page.locator('[data-component="session-composer-dock"] > div')
21+
const panel = page.locator('[data-slot="session-chat-panel"]')
22+
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
23+
await expect(row).toBeInViewport()
24+
await expect(row).toHaveCSS("width", "1000px")
25+
const before = await row.boundingBox()
26+
const dock = await composer.boundingBox()
27+
expect(before).not.toBeNull()
28+
expect(dock).not.toBeNull()
29+
await content.evaluate((element) => {
30+
element.setAttribute("data-summary-motion", "")
31+
for (const type of ["transitionrun", "transitionend"]) {
32+
element.addEventListener(type, (event) => {
33+
if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return
34+
element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`)
35+
})
36+
}
37+
})
38+
await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" })
39+
await trigger.click()
40+
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
41+
await expect
42+
.poll(async () => {
43+
const message = await row.boundingBox()
44+
const details = await summary.boundingBox()
45+
const chat = await panel.boundingBox()
46+
if (!message || !details || !chat) return false
47+
return (
48+
message.x >= chat.x &&
49+
message.x + message.width <= chat.x + chat.width &&
50+
(direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width)
51+
)
52+
})
53+
.toBe(true)
54+
await expect(row).toHaveCSS("width", "1000px")
55+
await expect
56+
.poll(async () => {
57+
const message = await row.boundingBox()
58+
const input = await composer.boundingBox()
59+
if (!message || !input || !before || !dock) return Infinity
60+
return Math.abs(message.x - before.x - (input.x - dock.x))
61+
})
62+
.toBeLessThan(1)
63+
await expect
64+
.poll(() =>
65+
content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth),
66+
)
67+
.toBe(0)
68+
await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" })
69+
70+
await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z"))
71+
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
72+
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
73+
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
74+
for (const width of [1520, 1280, 1600]) {
75+
await page.setViewportSize({ width, height: 900 })
76+
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
77+
await page.clock.runFor(100)
78+
await expect(content).toHaveCSS("translate", shifted)
79+
await expect(composer).toHaveCSS("translate", shifted)
80+
await expect(content).toHaveAttribute("data-summary-motion", "")
81+
}
82+
await page.clock.resume()
83+
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
84+
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
85+
await expect(content).not.toHaveCSS("translate", shifted)
86+
await page.setViewportSize({ width: 1440, height: 900 })
87+
await expect(content).toHaveCSS("translate", shifted)
88+
89+
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
90+
await page.keyboard.press("Escape")
91+
await expect(summary).toBeHidden()
92+
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
93+
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
94+
await expect(trigger).toBeFocused()
95+
96+
await trigger.click()
97+
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
98+
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
99+
const shell = 1440 - (await panel.boundingBox())!.width
100+
await page.setViewportSize({ width: 1320 + shell, height: 900 })
101+
await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320)
102+
await expect
103+
.poll(async () => {
104+
const message = (await row.boundingBox())!
105+
const details = (await summary.boundingBox())!
106+
return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width
107+
})
108+
.toBeGreaterThan(0)
109+
await page.setViewportSize({ width: 1319 + shell, height: 900 })
110+
await expect(content).toHaveCSS("translate", "none")
111+
await expect(row).toHaveCSS("width", "1000px")
112+
await expect(summary).toBeVisible()
113+
114+
await page.setViewportSize({ width: 1800, height: 900 })
115+
await expect(content).toHaveCSS("translate", "0px")
116+
await expect(summary).toBeVisible()
117+
118+
await page.emulateMedia({ reducedMotion: "reduce" })
119+
await page.setViewportSize({ width: 1440, height: 900 })
120+
await expect(content).toHaveCSS("transition-duration", "0s")
121+
await page.keyboard.press("Escape")
122+
await expect(content).toHaveCSS("translate", "none")
123+
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
124+
})
125+
}
4126

5127
for (const theme of ["light", "dark"] as const) {
6128
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {

‎packages/app/e2e/regression/session-summary.spec.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,58 @@
11
import { expect, test } from "@playwright/test"
22
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
3-
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
3+
import {
4+
installStressSessionTabs,
5+
mockStressTimeline,
6+
stressSessionHref,
7+
} from "../performance/timeline/timeline-test-helpers"
48
import { openWithDirection } from "../utils/direction"
59

10+
for (const custom of [false, true]) {
11+
test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => {
12+
await mockStressTimeline(page)
13+
await installStressSessionTabs(page)
14+
if (custom) {
15+
await page.addInitScript(() => {
16+
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
17+
localStorage.setItem(
18+
"settings.v3",
19+
JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }),
20+
)
21+
})
22+
}
23+
await page.goto(stressSessionHref(fixture.sourceID))
24+
const trigger = page.getByRole("button", { name: "Session details", exact: true })
25+
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
26+
await expect(trigger).toBeEnabled()
27+
await trigger.hover()
28+
const tooltip = page.getByRole("tooltip")
29+
await expect(tooltip).toBeVisible()
30+
await expect(tooltip).toContainText("Summary")
31+
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
32+
const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y"
33+
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
34+
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
35+
)
36+
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
37+
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click()
38+
await expect(
39+
page.locator(
40+
`[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`,
41+
),
42+
).toBeInViewport()
43+
await page.keyboard.press(shortcut)
44+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
45+
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
46+
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
47+
await expect(tooltip).toBeHidden()
48+
await page.keyboard.press(shortcut)
49+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
50+
await expect(summary).toBeHidden()
51+
await expect(trigger).toBeFocused()
52+
}
53+
})
54+
}
55+
656
for (const layout of ["horizontal", "vertical"] as const) {
757
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
858
await mockStressTimeline(page)

‎packages/app/src/index.css‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,38 @@
3838
}
3939

4040
@layer components {
41+
[data-slot="session-chat-panel"] {
42+
container-name: session-chat;
43+
}
44+
45+
[data-slot="session-chat-panel"]
46+
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
47+
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
48+
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
49+
50+
@media (prefers-reduced-motion: reduce) {
51+
transition: none;
52+
}
53+
}
54+
55+
[data-slot="session-chat-panel"][data-summary-resizing="true"]
56+
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
57+
transition: none;
58+
}
59+
60+
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
61+
Move only as far as needed; wider panels already have enough space in the margin. */
62+
@container session-chat (min-width: 1320px) {
63+
[data-slot="session-chat-panel"][data-summary-open="true"]
64+
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
65+
--session-summary-translate: min(0px, calc(50cqi - 820px));
66+
67+
&:dir(rtl) {
68+
--session-summary-translate: max(0px, calc(820px - 50cqi));
69+
}
70+
}
71+
}
72+
4173
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
4274
[data-slot="session-timeline-scroll"]
4375
> .scroll-view__thumb {

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export const dict = {
116116
"command.terminal.toggle": "Toggle terminal",
117117
"command.fileTree.toggle": "Toggle file tree",
118118
"command.review.toggle": "Toggle review",
119+
"command.session.summary.toggle": "Toggle summary",
119120
"command.terminal.new": "New terminal",
120121
"command.terminal.new.description": "Create a new terminal tab",
121122
"command.steps.toggle": "Toggle steps",
@@ -1427,6 +1428,7 @@ export const dict = {
14271428
"workspace.lifecycle.moving": "Moving to worktree",
14281429
"workspace.lifecycle.set": "Worktree set",
14291430
"session.summary.title": "Session details",
1431+
"session.summary.tooltip": "Summary",
14301432
"session.summary.noBranch": "No branch",
14311433
"session.summary.basedOn": "Based on {{branch}}",
14321434
"session.summary.server": "Server",

‎packages/app/src/session/review/model.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ export function createSessionReview(input: {
396396
deferRender: input.deferRender,
397397
details: {
398398
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
399+
open: () => state.detailsOpen,
399400
setOpen: (open: boolean) => setState("detailsOpen", open),
400401
},
401402
diffVersion: () => vcsQuery.dataUpdatedAt,

‎packages/app/src/session/screen.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import {
99
createEffect,
1010
createComputed,
1111
on,
12+
onMount,
1213
} from "solid-js"
1314
import { createStore } from "solid-js/store"
15+
import { makeEventListener } from "@solid-primitives/event-listener"
16+
import { debounce } from "@solid-primitives/scheduled"
1417
import { ResizeHandle } from "@opencode/ui/resize-handle"
1518
import { MessageTimeline } from "@/session/timeline/message-timeline"
1619
import { useServer } from "@/runtime/server/current"
@@ -75,11 +78,24 @@ export function SessionScreen(props: { session: SessionModel }) {
7578
sideTerminalPresent: false,
7679
mobileTerminalCached: false,
7780
mobileMoveDismissed: false,
81+
summaryResizeTranslate: undefined as string | undefined,
7882
})
7983
const [elements, setElements] = createStore<{
84+
chat?: HTMLDivElement
8085
side?: HTMLDivElement
8186
bottomTerminal?: HTMLDivElement
8287
}>({})
88+
const finishWindowResize = debounce(() => setStore("summaryResizeTranslate", undefined), 150)
89+
onMount(() => {
90+
makeEventListener(window, "resize", () => {
91+
if (store.summaryResizeTranslate === undefined) {
92+
const content = elements.chat?.querySelector("[data-timeline-virtual-content]")
93+
// Freeze the painted offset, including an in-flight slide, until resizing settles.
94+
setStore("summaryResizeTranslate", content ? getComputedStyle(content).translate : "none")
95+
}
96+
finishWindowResize()
97+
})
98+
})
8399
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
84100
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
85101
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
@@ -338,6 +354,9 @@ export function SessionScreen(props: { session: SessionModel }) {
338354
"transition-none": screen.size.active() || !sidePresence.animate(),
339355
}}
340356
data-slot="session-chat-panel"
357+
ref={(element) => setElements("chat", element)}
358+
data-summary-open={isDesktop() && review.details.open()}
359+
data-summary-resizing={store.summaryResizeTranslate !== undefined}
341360
data-width-animating={store.sideWidthMotion}
342361
data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion}
343362
onPointerMove={revealTimelineScrollbar}
@@ -349,6 +368,7 @@ export function SessionScreen(props: { session: SessionModel }) {
349368
onTransitionCancel={trackSideWidthMotion}
350369
style={{
351370
width: screen.panel.width(),
371+
"--session-summary-resize-translate": store.summaryResizeTranslate,
352372
}}
353373
>
354374
<Show when={!!session.identity.params.id}>

‎packages/app/src/session/summary/popover.tsx‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,56 @@
11
import { Popover } from "@kobalte/core/popover"
22
import { Icon } from "@opencode/ui/icon"
33
import { IconButton } from "@opencode/ui/icon-button"
4-
import type { ParentProps } from "solid-js"
4+
import { Keybind } from "@opencode/ui/keybind"
5+
import { Tooltip } from "@opencode/ui/tooltip"
6+
import { Show, type ParentProps } from "solid-js"
57
import { useLanguage } from "@/runtime/i18n/language"
8+
import { useCommand } from "@/shell/commands/command"
69

7-
export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) {
10+
export function SummaryPopover(
11+
props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>,
12+
) {
813
const language = useLanguage()
14+
const command = useCommand()
15+
// Cached timelines remain mounted; only the visible summary owns the command.
16+
command.register(() =>
17+
props.active === false
18+
? []
19+
: [
20+
{
21+
id: "session.summary.toggle",
22+
title: language.t("command.session.summary.toggle"),
23+
category: language.t("command.category.view"),
24+
keybind: "mod+shift+y",
25+
onSelect: () => props.onOpenChange(!props.open),
26+
},
27+
],
28+
)
29+
const keybind = () => command.keybindParts("session.summary.toggle")
930
return (
1031
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
1132
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
12-
<Popover.Trigger
13-
as={IconButton}
14-
icon={<Icon name="window-analytics" />}
15-
variant="ghost-muted"
16-
size="large"
17-
state={props.open ? "pressed" : undefined}
18-
aria-label={language.t("session.summary.title")}
19-
aria-expanded={props.open}
20-
/>
33+
<Tooltip
34+
placement="bottom"
35+
value={
36+
<>
37+
{language.t("session.summary.tooltip")}
38+
<Show when={keybind().length > 0}>
39+
<Keybind keys={keybind()} variant="neutral" />
40+
</Show>
41+
</>
42+
}
43+
>
44+
<Popover.Trigger
45+
as={IconButton}
46+
icon={<Icon name="window-analytics" />}
47+
variant="ghost-muted"
48+
size="large"
49+
state={props.open ? "pressed" : undefined}
50+
aria-label={language.t("session.summary.title")}
51+
aria-expanded={props.open}
52+
/>
53+
</Tooltip>
2154
<Popover.Portal>
2255
<Popover.Content
2356
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"

‎packages/app/src/session/timeline/message-timeline.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ function MessageTimelineView(
546546
<SessionContextUsage placement="bottom" />
547547
<Show when={!parentID() && project()}>
548548
{(project) => (
549-
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
549+
<SummaryPopover active={props.active} open={summaryOpen()} onOpenChange={setSummary}>
550550
<Suspense>
551551
<SessionSummaryPanel
552552
shown={summaryOpen()}

0 commit comments

Comments
 (0)