Compare commits

...
Author SHA1 Message Date
David Hill 3134f519f6 fix(app): keep right panel controls aligned 2026-09-03 00:59:42 -06:00
8 changed files with 156 additions and 15 deletions
@@ -0,0 +1,18 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
const component = await mount("ui-split-button--open-in", { globals: { theme } })
const control = component.locator('[data-component="split-button-v2"]')
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).not.toHaveCSS("box-shadow", "none")
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
await component.getByRole("button", { name: "Open options" }).hover()
await expect(control).toHaveCSS("box-shadow", border)
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("box-shadow", border)
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
})
}
@@ -0,0 +1,85 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTogglePosition"
const sessionID = "ses_review_toggle_position"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
for (const width of [1000, 1440]) {
for (const direction of ["ltr", "rtl"] as const) {
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_toggle_position",
worktree: directory,
vcs: "git",
name: "review-toggle-position",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-toggle-position",
projectID: "proj_review_toggle_position",
directory,
title: "Review toggle position",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const header = page.locator("[data-session-title]")
const panel = page.locator("#review-panel")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const closed = await toggle.boundingBox()
if (!closed) throw new Error("Review toggle bounds are unavailable")
const headerBox = await header.boundingBox()
if (!headerBox) throw new Error("Session header bounds are unavailable")
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
await expect
.poll(async () => {
const box = await panel.boundingBox()
if (!box) return false
return (
closed.x >= box.x &&
closed.x + closed.width <= box.x + box.width &&
closed.y >= box.y &&
closed.y + closed.height <= box.y + 52
)
})
.toBe(true)
await expect
.poll(async () => {
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box ? box.y + box.height / 2 : undefined
})
.toBe(closed.y + closed.height / 2)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
})
}
}
@@ -427,11 +427,15 @@ export function SessionSidePanel(props: {
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot h-12 self-start shrink-0 flex items-center gap-2 pe-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show when={reviewOpen()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</div>
</div>
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useCommand } from "@/shell/commands/command"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
export function SessionReviewToggle() {
const command = useCommand()
const language = useLanguage()
const { view } = useSessionLayout()
return (
<SessionHeaderActions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: true,
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}}
/>
)
}
export type SessionHeaderActionsState = {
reviewLabel: string
@@ -1,31 +1,19 @@
import { createMemo, Show } from "solid-js"
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const { view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
return (
<>
<TitlebarRight>
@@ -35,7 +23,9 @@ export function SessionHeader() {
</Tooltip>
</Show>
</TitlebarRight>
<SessionHeaderActions state={actions()} />
<Show when={isDesktop() && !view().reviewPanel.opened()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
)
}
+7
View File
@@ -28,6 +28,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
@@ -274,6 +275,12 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Both headers reserve a slot for this control, outside their width animations. */}
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
<div class="absolute end-3 top-2.5 z-30" data-slot="session-review-toggle">
<SessionReviewToggle />
</div>
</Show>
<div
classList={{
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
@@ -9,6 +9,7 @@
overflow: hidden;
}
[data-component="split-button-v2"].session-review-v2-open-in-app,
[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) {
box-shadow: inset 0 0 0 1px var(--v2-border-border-muted);
}
@@ -1,4 +1,5 @@
import { Icon } from "@opencode-ai/ui/icon"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "./split-button"
export default {
@@ -29,3 +30,16 @@ export const Disabled = {
</SplitButton>
),
}
export const OpenIn = {
render: () => (
<SplitButton class="session-review-v2-open-in-app">
<SplitButtonAction aria-label="Open in Finder">
<AppIcon id="finder" />
</SplitButtonAction>
<SplitButtonMenuTrigger aria-label="Open options">
<Icon name="chevron-down" size="small" />
</SplitButtonMenuTrigger>
</SplitButton>
),
}