diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index e67186257..22add5bde 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -334,6 +334,17 @@ export async function openRightPanel(page: Page) { return panel } +/** + * Returns the right-panel shell tab strip. The Tabs.List is portalled into the + * titlebar (see #pawwork-titlebar-tabs) so queries scoped to the + * complementary right panel no longer find it. Use this helper instead of + * `rightPanel.getByRole("tablist")` — it stays correct whether the tabs render + * portalled (desktop) or inline. + */ +export function rightPanelTabList(page: Page) { + return page.locator('[data-scope="right-panel"][data-component="tabs"]').getByRole("tablist").first() +} + export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index caaecc921..0e479c40b 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { cleanupSession, openSidebar } from "../actions" +import { cleanupSession, openSidebar, rightPanelTabList } from "../actions" import { pawworkSessionNewSelector, promptSelector, titlebarRightSelector } from "../selectors" import { modKey } from "../utils" @@ -14,7 +14,7 @@ test("desktop right-panel tabs switch between review and files within a unified await rightToggle.click() await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const reviewTab = shellTabList.getByRole("tab", { name: "Review", exact: true }) const filesTab = shellTabList.getByRole("tab", { name: "Files", exact: true }) await expect(shellTabList.getByRole("tab", { name: "Status", exact: true })).toBeVisible() @@ -121,7 +121,7 @@ test("desktop session keeps a single right-panel toggle and icon-first utility t await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) await expect(shellTabList.locator('[data-component="icon"]')).toHaveCount(4) const widths = await shellTabList.locator('[data-slot="tabs-trigger"]').evaluateAll((els) => @@ -139,7 +139,7 @@ test("desktop right-panel shell tabs keep the sidepanel chrome contract", async await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const statusWrapper = shellTabList.locator('[data-slot="tabs-trigger-wrapper"]').first() const wrapperStyles = await statusWrapper.evaluate((el) => { @@ -185,7 +185,7 @@ test("desktop right-panel uses the design icon set for utility tabs", async ({ p await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const icons = await shellTabList.locator('[data-slot="tabs-trigger"] [data-slot="icon-svg"]').evaluateAll((els) => els.map((el) => el.innerHTML), ) @@ -203,12 +203,16 @@ test("desktop review root shows a simple toolbar before opening files", async ({ await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const reviewTab = shellTabList.getByRole("tab", { name: "Review", exact: true }) await reviewTab.click() await expect(reviewTab).toHaveAttribute("aria-selected", "true") - await expect(rightPanel.getByRole("tablist")).toHaveCount(2) + // Shell tabs are now portalled into the titlebar; only the inner review + // tablist remains inside the complementary panel region. + await expect(rightPanel.getByRole("tablist")).toHaveCount(1) + // The portalled shell tablist is still present and visible. + await expect(shellTabList).toBeVisible() const openFile = rightPanel.getByRole("button", { name: /^Open file$/i }).first() await expect(openFile).toBeVisible() @@ -222,7 +226,12 @@ test("desktop right-panel collapses shell tab labels below the compact threshold await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const shellTabList = rightPanel.getByRole("tablist").first() + // Shell tabs are portalled into the titlebar; the slot's width — and thus the + // label-collapse container queries — is driven by --right-panel-width set on + // [data-component="desktop-shell"] (layout.tsx). documentElement-level + // overrides lose the cascade to the inline style on desktop-shell, so set the + // var directly on that element. + const shellTabList = rightPanelTabList(page) const tabLabels = () => shellTabList .locator('[data-slot="tabs-trigger"]') @@ -230,14 +239,16 @@ test("desktop right-panel collapses shell tab labels below the compact threshold await expect.poll(tabLabels).toEqual(["", "", "", ""]) - await rightPanel.evaluate((el) => { - ;(el as HTMLElement).style.width = "400px" + await page.evaluate(() => { + const shell = document.querySelector('[data-component="desktop-shell"]') as HTMLElement | null + shell?.style.setProperty("--right-panel-width", "400px") }) await expect.poll(tabLabels).toEqual(["Status", "Files", "Review", "Terminal"]) - await rightPanel.evaluate((el) => { - ;(el as HTMLElement).style.width = "320px" + await page.evaluate(() => { + const shell = document.querySelector('[data-component="desktop-shell"]') as HTMLElement | null + shell?.style.setProperty("--right-panel-width", "320px") }) await expect.poll(tabLabels).toEqual(["", "", "", ""]) @@ -291,7 +302,7 @@ test("legacy changes side-panel state restores into the review tab", async ({ pa await gotoSession() const rightPanel = page.locator("#right-panel") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") await expect(shellTabList.getByRole("tab", { name: "Review", exact: true })).toHaveAttribute("aria-selected", "true") diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts index 5a29b8494..04c3d3766 100644 --- a/packages/app/e2e/files/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openRightPanel, withSession } from "../actions" +import { openRightPanel, rightPanelTabList, withSession } from "../actions" // Historical context: before the right-panel-polish PR (#52), the Review tab // carried a sibling vertical file-tree pane (#file-tree-panel) that surfaced @@ -13,8 +13,10 @@ test("@smoke review tab no longer renders the legacy file-tree sub-panel", async await withSession(project.sdk, `e2e review layout smoke ${Date.now()}`, async (session) => { await project.gotoSession(session.id) - const rightPanel = await openRightPanel(page) - const shellTabList = rightPanel.getByRole("tablist") + await openRightPanel(page) + // Tabs.List is portalled into the titlebar — use the scope-aware helper instead + // of scoping to the complementary region. + const shellTabList = rightPanelTabList(page) await shellTabList.locator("button").last().click() await page.getByRole("menuitem", { name: "Review" }).click() diff --git a/packages/app/e2e/inputs/select-review-filter.spec.ts b/packages/app/e2e/inputs/select-review-filter.spec.ts index fac311f95..be50d1e09 100644 --- a/packages/app/e2e/inputs/select-review-filter.spec.ts +++ b/packages/app/e2e/inputs/select-review-filter.spec.ts @@ -6,13 +6,16 @@ * IconButtons toggle aria-pressed correctly. */ import type { Page } from "@playwright/test" -import { openRightPanel, withSession } from "../actions" +import { openRightPanel, rightPanelTabList, withSession } from "../actions" import { test, expect } from "../fixtures" import { bodyText } from "../prompt/mock" async function openReviewPanel(page: Page) { - const panel = await openRightPanel(page) - const tabList = panel.getByRole("tablist").first() + await openRightPanel(page) + // Tabs.List is portalled into the titlebar (see #pawwork-titlebar-tabs), so it + // no longer lives inside the complementary right-panel region — query via the + // data-scope helper instead of `panel.getByRole("tablist")`. + const tabList = rightPanelTabList(page) const reviewTab = tabList.getByRole("tab", { name: "Review", exact: true }) if (await reviewTab.isVisible().catch(() => false)) { diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts index 826dd0543..6e29bb28f 100644 --- a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { runPromptSlash, waitTerminalFocusIdle } from "../actions" +import { runPromptSlash, waitTerminalFocusIdle, rightPanelTabList } from "../actions" import { promptSelector, terminalSelector } from "../selectors" test("/terminal opens the right-panel terminal tab", async ({ page, gotoSession }) => { @@ -8,7 +8,7 @@ test("/terminal opens the right-panel terminal tab", async ({ page, gotoSession const prompt = page.locator(promptSelector) const terminal = page.locator(terminalSelector) const rightPanel = page.locator("#right-panel") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const terminalTab = shellTabList.getByRole("tab", { name: "Terminal", exact: true }) const embeddedTerminalTabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 65b5d5f3b..1684d8847 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -56,6 +56,11 @@ export const projectWorkspacesToggleSelector = (slug: string) => `[data-action="project-workspaces-toggle"][data-project="${slug}"]` export const titlebarRightSelector = "#pawwork-titlebar-right" +// Right-panel shell tabs are portalled into the titlebar so the tab strip reads +// as window chrome instead of a second toolbar (see #pawwork-titlebar-tabs). +// Scoping by data-scope (stamped on the slot) keeps test queries resilient to +// portal-vs-inline rendering. +export const rightPanelTabsScopeSelector = '[data-scope="right-panel"]' export const sidebarNavMobileSelector = '[data-component="sidebar-nav-mobile"]' export const popoverBodySelector = '[data-slot="popover-body"]' diff --git a/packages/app/e2e/session/session-artifacts.spec.ts b/packages/app/e2e/session/session-artifacts.spec.ts index a389cf834..9676aea0d 100644 --- a/packages/app/e2e/session/session-artifacts.spec.ts +++ b/packages/app/e2e/session/session-artifacts.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openRightPanel, waitSessionIdle } from "../actions" +import { openRightPanel, rightPanelTabList, waitSessionIdle } from "../actions" import { bodyText } from "../prompt/mock" test("added files stay quiet until the user opens the Files tab", async ({ page, llm, project }) => { @@ -50,11 +50,11 @@ test("added files stay quiet until the user opens the Files tab", async ({ page, const rightPanel = page.locator('[data-component="right-panel"]') await expect(rightPanel).toHaveAttribute("aria-hidden", "true") - const panel = await openRightPanel(page) - await panel.getByRole("button", { name: "Add tab" }).click() + await openRightPanel(page) + const shellTabList = rightPanelTabList(page) + await shellTabList.getByRole("button", { name: "Add tab" }).click() await page.getByRole("menuitem", { name: "Files" }).click() - const shellTabList = panel.getByRole("tablist").first() const filesTab = shellTabList.getByRole("tab", { name: "Files", exact: true }) await expect(filesTab).toHaveAttribute("aria-selected", "true") await expect(page.locator('[data-artifact-file="artifact-report.md"]')).toBeVisible({ timeout: 30000 }) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 38305eccc..68a2bcd7c 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -7,6 +7,7 @@ import { closeSettingsPanel, openSettings, openRightPanel, + rightPanelTabList, seedSessionQuestion, } from "../actions" import { @@ -951,9 +952,10 @@ test("todo updates do not switch an open right panel to status", async ({ page, const rightPanel = await openRightPanel(page) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - await rightPanel.getByRole("button", { name: "Add tab" }).click() + const shellTabList = rightPanelTabList(page) + await shellTabList.getByRole("button", { name: "Add tab" }).click() await page.getByRole("menuitem", { name: "Files" }).click() - const filesTab = rightPanel.getByRole("tab", { name: "Files", exact: true }).first() + const filesTab = shellTabList.getByRole("tab", { name: "Files", exact: true }) await expect(filesTab).toHaveAttribute("aria-selected", "true") await e2eUpdateTodos( @@ -991,7 +993,7 @@ test("todo updates remain visible in the status panel", async ({ page, project } const rightPanel = await openRightPanel(page) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - const statusTab = rightPanel.getByRole("tab", { name: "Status", exact: true }).first() + const statusTab = rightPanelTabList(page).getByRole("tab", { name: "Status", exact: true }) await statusTab.click() await expect(statusTab).toHaveAttribute("aria-selected", "true") diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 1568c8781..8eaa980a5 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises" -import { waitSessionIdle, withSession } from "../actions" +import { waitSessionIdle, withSession, rightPanelTabList } from "../actions" import { test, expect } from "../fixtures" import { bodyText } from "../prompt/mock" import { titlebarRightSelector } from "../selectors" @@ -108,14 +108,14 @@ async function patchWithMock( async function show(page: Parameters[0]["page"]) { const rightToggle = page.locator(`${titlebarRightSelector} button`).first() const rightPanel = page.locator("#right-panel") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) const reviewTab = shellTabList.getByRole("tab", { name: "Review", exact: true }) await expect(rightToggle).toBeVisible() if ((await rightPanel.getAttribute("aria-hidden")) === "true") await rightToggle.click() await expect(rightPanel).toHaveAttribute("aria-hidden", "false") if ((await reviewTab.count()) === 0) { - await rightPanel.getByRole("button", { name: "Add tab" }).click() + await shellTabList.getByRole("button", { name: "Add tab" }).click() await page.getByRole("menuitem", { name: /Review/ }).click() } await reviewTab.click() 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/e2e/snap/todo-status-only.snap.ts b/packages/app/e2e/snap/todo-status-only.snap.ts index 1b9b10361..10934e137 100644 --- a/packages/app/e2e/snap/todo-status-only.snap.ts +++ b/packages/app/e2e/snap/todo-status-only.snap.ts @@ -1,6 +1,6 @@ import { expect, type Page } from "@playwright/test" import type { Todo } from "@opencode-ai/sdk/v2/client" -import { openRightPanel, withSession } from "../actions" +import { openRightPanel, rightPanelTabList, withSession } from "../actions" import { test } from "../fixtures" import { promptSelector } from "../selectors" import { composeGrid, snapOutputPath, type Shot } from "./_compose" @@ -62,7 +62,7 @@ test("todo-status-only", async ({ page, project }) => { ) const rightPanel = await openRightPanel(page) - const statusTab = rightPanel.getByRole("tab", { name: "Status", exact: true }).first() + const statusTab = rightPanelTabList(page).getByRole("tab", { name: "Status", exact: true }) await statusTab.click() await expect(statusTab).toHaveAttribute("aria-selected", "true") diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts index e30f20bbc..bfa9abf44 100644 --- a/packages/app/e2e/status/status-popover.spec.ts +++ b/packages/app/e2e/status/status-popover.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../fixtures" +import { rightPanelTabList } from "../actions" import { titlebarRightSelector } from "../selectors" test("desktop right-panel toggle opens the status tab by default", async ({ page, gotoSession }) => { @@ -6,7 +7,7 @@ test("desktop right-panel toggle opens the status tab by default", async ({ page const rightToggle = page.locator(`${titlebarRightSelector} button`).first() const rightPanel = page.locator("#right-panel") - const shellTabList = rightPanel.getByRole("tablist").first() + const shellTabList = rightPanelTabList(page) await expect(rightPanel).toHaveAttribute("aria-hidden", "true") @@ -14,13 +15,16 @@ test("desktop right-panel toggle opens the status tab by default", async ({ page await expect(rightPanel).toHaveAttribute("aria-hidden", "false") await expect(shellTabList.getByRole("tab", { name: "Status", exact: true })).toHaveAttribute("aria-selected", "true") - await expect(rightPanel.getByRole("tab", { name: /servers/i })).toBeVisible() - await expect(rightPanel.getByRole("tab", { name: /mcp/i })).toBeVisible() - await expect(rightPanel.getByRole("tab", { name: /lsp/i })).toBeVisible() - await expect(rightPanel.getByRole("tab", { name: /plugins/i })).toBeVisible() + // Servers/MCP/LSP/Plugins render as collapsible SectionRow ` diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 48aba89fe..cb52273bf 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 type { SessionTodoItem } from "@/pages/session/todos/todo-model" import type { CanonicalTodoSnapshot } from "@/pages/session/todos/todo-source" 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}
) @@ -70,8 +74,12 @@ export function SessionStatusSummary(props: { const todos = createMemo(() => snapshot().items) 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={}> @@ -89,6 +97,6 @@ export function SessionStatusSummary(props: {
-
+ ) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index ba275a722..118104616 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,50 @@ 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). */} + {/* `pointer-events-none` on the slot mirrors `#pawwork-titlebar-left` — only + the portalled tab buttons (which carry their own `pointer-events-auto` + via the sidepanel CSS variant) should swallow clicks. Otherwise the + slot's z-10 box covers the Right utility panel toggle in + `#pawwork-titlebar-right` whenever the panel is open, making the toggle + unclickable (caught by perf-probe-baseline's session-streaming-long + run on PR #878). */} +
) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3d0edd1ad..d2b81a14b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2369,8 +2369,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[] }) {