mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 03:46:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2881143287 |
@@ -2,6 +2,131 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
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 page.goto(stressSessionHref(fixture.targetID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
if (direction === "rtl") {
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
}
|
||||
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) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
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"
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
@@ -1384,6 +1385,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<Show when={!!session.identity.params.id}>
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={props.open ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("session.summary.tooltip")}
|
||||
<Show when={keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={props.open ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
|
||||
|
||||
@@ -546,7 +546,7 @@ function MessageTimelineView(
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
|
||||
<SummaryPopover active={props.active} open={summaryOpen()} onOpenChange={setSummary}>
|
||||
<Suspense>
|
||||
<SessionSummaryPanel
|
||||
shown={summaryOpen()}
|
||||
|
||||
Reference in New Issue
Block a user