From 4a7ddb49e3f0841efa124ec3454a17049ada33cb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 23 May 2026 20:56:45 +0800 Subject: [PATCH 1/7] refactor(app): right-panel visual reshape & portal tabs into titlebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns Status / Files / Review tabs with docs/design/preview/right-pane.html and removes the seam between the titlebar and the right panel. Visual deltas (per docs/design/scratch/2026-05-23-area-b-*.html): - Status / Connections section headers: text-h3 uppercase → text-caption - Status sections separated by 24px breathing room only (no hairline, no chip background, no left indicator bar). Apply Occam's razor — three sections distinguish themselves by content shape alone. - Connections SectionRow loses border-b (header dot + count carry grouping) - Files row metadata drops middle-dot separator, switches to gap-3 - Files action Buttons gain leading icons (open-file / folder) - Review empty state removes the Mark watermark - Context breakdown swaps the syntax-* token rainbow for a brand-primary alpha ladder (system .30 / user 1.0 / assistant .70 / tool .45 / other .18) — syntax-* is reserved for code highlighting per DESIGN.md Tab strip — direction D (per scratch 2026-05-23-area-b-titlebar-tab-strip.html): - Every ShellTab renders 'icon + label' at rest, identical shape regardless of closable. On a closable tab, hovering swaps the leading icon for a close-small × in the same 14×14 slot — no separate close button, no layout shift. Width-at-rest is determined only by label length. - Active marker is weight + color shift only: fg-strong + font-weight 500 vs. fg-weak resting and fg-base on hover. No chip background, no brand underline; DESIGN.md keeps brand orange as a small accent and the titlebar tab strip is too prominent a surface to carry it. - ShellTab API takes an 'icon' prop instead of children; callers stop passing the icon + label as JSX children. Structural change: - Right-panel portals into a new pawwork-titlebar-tabs slot in . The slot is anchored to the viewport right edge, width = var(--right-panel-width), so its border-left sits exactly on the right-panel-body border-left below — one continuous 1px from titlebar top to viewport bottom. Portal preserves the virtual tree so Tabs, SortableProvider, and DragDropProvider contexts still flow to the moved Tabs.List. - Tabs.css uses descendant selectors like '[data-component="tabs"] [data-slot="tabs-list"]'; portalling Tabs.List out of would drop all sidepanel styling (no flex, no hover, no selected state). The titlebar slot mirrors the same data attributes so existing CSS re-matches without forking the stylesheet. - The sidepanel tabs-list height was hard-coded to 44px; replaced with height: 100% so the slot drives the height (macOS 40px chrome / Windows 44px) and tabs never overflow the titlebar bottom edge. Snap: packages/app/e2e/snap/right-panel-titlebar.snap.ts seeds five todos across every status (completed / in_progress / pending / cancelled) and captures the chrome/body seam plus the multi-tab layout (Status + Files + Review) in light + dark, so future regressions to the resting view are caught. --- .../app/e2e/snap/right-panel-titlebar.snap.ts | 145 ++++++++++++++++ .../session/session-context-tab.tsx | 14 +- .../session/session-sortable-shell-tab.tsx | 77 ++++++--- .../session/session-status-connections.tsx | 11 +- .../session/session-status-summary.tsx | 16 +- packages/app/src/components/titlebar.tsx | 39 ++++- packages/app/src/pages/layout.tsx | 4 +- packages/app/src/pages/session/files-tab.tsx | 5 +- .../src/pages/session/session-side-panel.tsx | 157 ++++++++++-------- packages/ui/src/components/tabs.css | 34 +++- 10 files changed, 387 insertions(+), 115 deletions(-) create mode 100644 packages/app/e2e/snap/right-panel-titlebar.snap.ts diff --git a/packages/app/e2e/snap/right-panel-titlebar.snap.ts b/packages/app/e2e/snap/right-panel-titlebar.snap.ts new file mode 100644 index 000000000..bc69c764e --- /dev/null +++ b/packages/app/e2e/snap/right-panel-titlebar.snap.ts @@ -0,0 +1,145 @@ +import { expect, type Page } from "@playwright/test" +import type { Todo } from "@opencode-ai/sdk/v2/client" +import { openRightPanel, openSidebar } from "../actions" +import { test } from "../fixtures" +import { sessionItemSelector } from "../selectors" +import { applyDarkModeForTests } from "../utils" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +// Seed four todos covering every status — completed / in_progress / pending / +// cancelled — so the Status tab shows real content (not the "No todos yet" +// empty state). Same approach as status-summary-todos.snap.ts. +async function updateTodos(input: { + url: string + directory: string + sessionID: string + todos: Array> +}) { + const response = await fetch( + `${input.url}/session/__e2e/update-todos?directory=${encodeURIComponent(input.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: input.sessionID, todos: input.todos }), + }, + ) + if (response.status !== 204) { + throw new Error(`updateTodos failed: ${response.status} ${await response.text()}`) + } +} + +// Right-panel + titlebar shell composition. This target exists to verify the +// visual contract between the titlebar's right edge and the right panel: +// +// 1) The right-panel tab row (Status / Files / …) lives INSIDE the titlebar, +// portalled from into 's `pawwork-titlebar-tabs` +// slot, so the tabs read as window chrome instead of a second toolbar. +// 2) The titlebar's `border-l border-border-weaker` at the tab slot's left +// edge must align pixel-for-pixel with the panel body's `border-l` below it +// — one continuous 1px separator from top of titlebar to bottom of viewport. +// +// We capture full-viewport (fullPage: false) so the seam between chrome and +// body is visible in one frame. Component-level crops would hide exactly the +// alignment we are checking. Both light and dark are captured because the seam +// is most fragile in dark mode where `--border-weaker` is only a hair lighter +// than `--bg-base`. + +// reducedMotion: "reduce" trips the layout shell's `motion-reduce:transition-none`, +// which kills the 240ms --right-panel-width transition. Without this, Playwright's +// stability check keeps blocking clicks on tabs/buttons sitting on the moving slot. +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, reducedMotion: "reduce" }) + +// Open Files and Review in the right-panel tab strip via the "+" dropdown. +// We want the snap to capture the multi-tab layout (active indicator, gap +// between tabs, alignment with the body) rather than just the single Status +// pill. Status is non-closable so always present; the other two cover the +// closable + active-state variants. +async function openExtraTabs(page: Page) { + // Use the registered command keybinds rather than clicking the "+" dropdown. + // The dropdown trigger lives in the portalled titlebar tab slot, where + // Playwright's hit-test mis-attributes pointer-events to the right-panel + // body below (z-stacking false positive). Keybinds bypass the issue entirely + // and are stable across platforms via ControlOrMeta. + // fileTree.toggle → mod+\ + // review.toggle → mod+shift+r + // Registered in packages/app/src/pages/session/use-session-commands.tsx. + // Focus the main app region first so the global keybind dispatcher receives + // the events (Playwright otherwise can dispatch from the document root before + // any element is focused). + await page.locator("main").first().click() + await page.keyboard.press("ControlOrMeta+\\") + await page.keyboard.press("ControlOrMeta+Shift+R") + // Wait for the openTabs side-effect to propagate before we click Status — + // otherwise the snap can race the tab list update and capture a single-tab + // strip when we expect three. + await expect.poll(() => page.getByRole("tab").count(), { timeout: 5_000 }).toBe(3) + // Click Status so the snap captures Status-active (the default landing tab) + // rather than whichever extra tab opened last. + await page.getByRole("tab", { name: "Status" }).click() +} + +async function captureRightPanelShell( + page: Page, + label: "light" | "dark", + project: { url: string; directory: string }, + sessionID: string, + todos: Array>, +): Promise { + // Sidebar → click session item: same navigation as status-summary-todos.snap.ts. + // Direct route navigation is fragile because session routes carry directory state + // that the sidebar entry already encodes. + await openSidebar(page) + await page.locator(sessionItemSelector(sessionID)).click() + await openRightPanel(page) + await openExtraTabs(page) + // Re-seed todos every capture. applyDarkModeForTests calls page.reload, which + // wipes the in-memory sync cache; re-posting is cheaper and more deterministic + // than waiting for the session_todo stream to re-hydrate after reload. + await updateTodos({ url: project.url, directory: project.directory, sessionID, todos }) + await expect + .poll(() => page.locator('[data-slot="status-summary-todo"]').count(), { timeout: 15_000 }) + .toBe(todos.length) + // Move the pointer to a neutral spot so no hover/tooltip is captured on top + // of the tab strip (the openRightPanel button otherwise leaves a tooltip). + await page.mouse.move(0, 0) + // animations: "disabled" freezes the right-panel width transition so width is + // stable when we snapshot, otherwise the tab portal's `right: var(--right-panel-width)` + // can capture mid-tween. + return { name: label, buf: await page.screenshot({ fullPage: false, animations: "disabled" }) } +} + +test("right-panel-titlebar", async ({ page, project }) => { + test.setTimeout(180_000) + + let sessionID: string | undefined + await project.open({ + beforeGoto: async ({ sdk }) => { + const session = await sdk.session.create({ title: "snap right panel titlebar" }).then((res) => res.data) + sessionID = session?.id + }, + }) + if (!sessionID) throw new Error("Session create did not return an id") + project.trackSession(sessionID) + + // Realistic Progress content — five todos across every marker variant. Picked + // from a believable PR cleanup session so the snap reads as a real moment of + // work, not lorem-ipsum placeholders. + const todos: Array> = [ + { content: "Audit session-status-summary tokens against DESIGN.md", status: "completed", priority: "high" }, + { content: "Wire portal slot in titlebar for right-panel tabs", status: "completed", priority: "high" }, + { content: "Verify hairline alignment across mac & windows chrome", status: "in_progress", priority: "high" }, + { content: "Sweep stale 'No connections' empty-state copy", status: "pending", priority: "medium" }, + { content: "Drop the brand underline on active tab", status: "cancelled", priority: "low" }, + ] + + const shots: Shot[] = [] + + shots.push(await captureRightPanelShell(page, "light", project, sessionID, todos)) + + await applyDarkModeForTests(page) + shots.push(await captureRightPanelShell(page, "dark", project, sessionID, todos)) + + const out = snapOutputPath("right-panel-titlebar") + await composeGrid(shots, out) + process.stdout.write(`\n[snap] right-panel-titlebar grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 7ff00299a..2d025f8d8 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -19,12 +19,16 @@ import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" +// brand-primary alpha ladder: user gets the full brand color (focus), assistant/tool/system/other +// recede via decreasing alpha. Hardcoded rgba (not color-mix(..., transparent)) per PawWork pierre +// convention — Safari's non-premultiplied interpolation can shift the apparent hue. +// Previous syntax-* tokens violated DESIGN.md (those are reserved for code highlighting). const BREAKDOWN_COLOR: Record = { - system: "var(--syntax-info)", - user: "var(--syntax-success)", - assistant: "var(--syntax-property)", - tool: "var(--syntax-warning)", - other: "var(--syntax-comment)", + system: "rgba(255, 89, 16, 0.30)", + user: "var(--brand-primary)", + assistant: "rgba(255, 89, 16, 0.70)", + tool: "rgba(255, 89, 16, 0.45)", + other: "rgba(255, 89, 16, 0.18)", } function Stat(props: { label: string; value: JSX.Element }) { diff --git a/packages/app/src/components/session/session-sortable-shell-tab.tsx b/packages/app/src/components/session/session-sortable-shell-tab.tsx index 43f5875d9..2a382a7a3 100644 --- a/packages/app/src/components/session/session-sortable-shell-tab.tsx +++ b/packages/app/src/components/session/session-sortable-shell-tab.tsx @@ -1,17 +1,34 @@ -import type { JSX } from "solid-js" +import { Show, type JSX } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" -import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon } from "@opencode-ai/ui/icon" import { Tabs } from "@opencode-ai/ui/tabs" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useLanguage } from "@/context/language" import type { RightPanelTab } from "@/pages/session/right-panel-tabs" +/** + * Right-panel shell tab. Renders as `icon + label`; on a closable tab, hovering + * the leading icon swaps it in place for a close-small × — same 14×14 slot, no + * layout shift, so closable and non-closable tabs share an identical resting + * width. The active tab is marked by a 2px brand underline (handled in + * packages/ui/src/components/tabs.css for `data-variant="sidepanel"`) — chip + * backgrounds are intentionally absent so the strip reads as titlebar chrome + * rather than a competing toolbar. + * + * Click target rules: + * - Click on the icon area: + * • closable + hovered → close (stopPropagation, so Tabs.Trigger doesn't + * also fire the value-change). + * • otherwise → falls through to Tabs.Trigger, selecting the tab. + * - Click anywhere else on the tab → Tabs.Trigger handles the selection. + * - Middle-click anywhere → close (existing onMiddleClick contract). + */ export function ShellTab(props: { value: RightPanelTab label: string closable: boolean onClose: (tab: RightPanelTab) => void - children: JSX.Element + icon: JSX.Element }): JSX.Element { const language = useLanguage() const close = () => { @@ -19,33 +36,53 @@ export function ShellTab(props: { props.onClose(props.value) } + const swap = ( + { + if (!props.closable) return + event.stopPropagation() + event.preventDefault() + close() + }} + > + + {props.icon} + + + + + + + + ) + return (
- - - ) : undefined - } - hideCloseButton onMiddleClick={close} aria-label={props.label} > - {props.children} + + + {swap} + + + {props.label}
) @@ -56,7 +93,7 @@ export function SortableShellTab(props: { label: string closable: boolean onClose: (tab: RightPanelTab) => void - children: JSX.Element + icon: JSX.Element }): JSX.Element { const sortable = createSortable(props.value) diff --git a/packages/app/src/components/session/session-status-connections.tsx b/packages/app/src/components/session/session-status-connections.tsx index 666039a54..d402c4270 100644 --- a/packages/app/src/components/session/session-status-connections.tsx +++ b/packages/app/src/components/session/session-status-connections.tsx @@ -30,7 +30,7 @@ function SectionRow(props: { return "bg-border-weak" } return ( -
+
diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index a44031efc..9fd5853a9 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -8,9 +8,13 @@ import { selectSessionTodos } from "@/pages/session/session-todos" import type { SessionTodoItem } from "@/pages/session/todos/todo-model" function Section(props: { title: string; children: JSX.Element }) { + // No divider — sections are separated by 24px of breathing room only. + // Hairlines felt too "boxed in" against the warm-neutral surface; the + // generous py-6 (24px top + 24px bottom = 48px between sections) reads + // as a calm pause without enclosing each section in chrome. return ( -
-
{props.title}
+
+
{props.title}
{props.children}
) @@ -65,8 +69,12 @@ export function SessionStatusSummary(props: { ) const sources = createMemo(() => extractSources(props.parts())) + // No outer wrapper — Section components attach directly to SessionStatusPanel's + // scroll container, so the first:border-t-0 selector correctly drops the leading + // hairline regardless of whether SessionStatusSummary's siblings (e.g. + // SessionStatusConnections below) come first or last in the DOM. return ( -
+ <>
0} fallback={}>
@@ -82,6 +90,6 @@ export function SessionStatusSummary(props: {
-
+ ) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index ba275a722..b9c016484 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -24,7 +24,7 @@ export function Titlebar() { const windows = createMemo(() => isWindowsShell(platform)) const zoom = () => platform.webviewZoom?.() ?? 1 const currentTitlebarHeight = () => - mac() ? "var(--shell-titlebar-current-height, var(--shell-titlebar-height, 40px))" : undefined + mac() ? "var(--shell-titlebar-current-height, var(--shell-titlebar-height, 44px))" : undefined const leftPortalStyle = () => ({ left: "max(172px, calc(var(--sidebar-width, 0px) + 16px))", right: "calc(var(--right-panel-width, 0px) + 52px)", @@ -161,6 +161,43 @@ export function Titlebar() { class="flex items-center gap-1 shrink-0 justify-end" />
+ + {/* Portal slot for the right-panel tab bar. Lives inside the titlebar so the + tabs read as part of the window chrome rather than a second toolbar + beneath it. The slot sits directly above the right-panel body — same + width (`var(--right-panel-width)`) and anchored to the viewport's right + edge (`right: 0`). `border-l` puts the 1px on the slot's left edge, + which is the same x as `right-panel-body`'s `border-l` immediately + below it, so the two read as one continuous separator from titlebar + top to viewport bottom. + + The `data-component="tabs"` + `data-variant="sidepanel"` + `data-scope` + + `data-orientation` attributes mirror what + renders on its root. Portalling Tabs.List takes it out of that ancestor, + so the CSS in packages/ui/src/components/tabs.css (which uses descendant + selectors like `[data-component="tabs"] [data-slot="tabs-list"]`) would + otherwise miss it — no flex, no height, no sidepanel hover/selected + colors. Stamping the same data attrs here lets all existing selectors + re-match without forking the stylesheet. + + `flex-row` is intentional and not redundant: the same `[data-component="tabs"]` + rule that we are inheriting also sets `flex-direction: column` on the host + (it expects to wrap Tabs.List + Tabs.Content vertically). Without an explicit + override, the slot ends up as a column flex container and `items-center` would + align its single child horizontally instead of vertically, leaving the tabs + glued to the top of the titlebar. + + Only populated when the right panel is open (SessionSidePanel guards its Portal). */} +
) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 324765d74..f9bd5ea2f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2515,8 +2515,8 @@ export default function Layout(props: ParentProps) { style={{ "--shell-titlebar-current-height": isMacShell(platform) - ? `calc(var(--shell-titlebar-height, 40px) / ${platform.webviewZoom?.() ?? 1})` - : "var(--shell-titlebar-height, 40px)", + ? `calc(var(--shell-titlebar-height, 44px) / ${platform.webviewZoom?.() ?? 1})` + : "var(--shell-titlebar-height, 44px)", "--sidebar-width": layout.sidebar.opened() ? `${side()}px` : "0px", "--right-panel-width": layout.rightPanel.opened() ? `${layout.rightPanel.width()}px` : "0px", "--right-panel-divider": layout.rightPanel.opened() ? "var(--border-weaker)" : "transparent", diff --git a/packages/app/src/pages/session/files-tab.tsx b/packages/app/src/pages/session/files-tab.tsx index fc43bc1b4..296d45091 100644 --- a/packages/app/src/pages/session/files-tab.tsx +++ b/packages/app/src/pages/session/files-tab.tsx @@ -131,13 +131,12 @@ export function FilesTab(props: { files: FilesTabEntry[] }) {
{entry.file}
-
+
{entry.kind === "added" ? language.t("session.files.status.added") : language.t("session.files.status.updated")} - {meta().exists ? formatSize(meta().size) : language.t("session.files.missing")}
@@ -153,6 +152,7 @@ export function FilesTab(props: { files: FilesTabEntry[] }) {