Compare commits

...
Author SHA1 Message Date
David Hill 58a663316b feat(app): add collapsible vertical tabs and hover navigation 2026-09-03 02:07:03 -06:00
19 changed files with 949 additions and 144 deletions
@@ -93,6 +93,71 @@ test("keeps review visibility per tab and the pane mounted across tab switches",
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
})
test("compact shell headers preserve equal outer gaps and aligned controls", async ({ page }) => {
await setup(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
// Exercise the desktop's compact header metric with the real shell and panel components.
await page.locator('[data-slot="shell-layout"]').evaluate((element) => {
element.style.setProperty("--shell-header-height", "40px")
})
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const file = page.locator('#review-panel button[aria-label="Open file"]')
await expect(file).toBeVisible()
const frame = await page.locator('[data-slot="shell-layout"]').boundingBox()
if (!frame) throw new Error("shell has no bounding box")
const bounds = await toggle.boundingBox()
expect(bounds).toMatchObject({ y: frame.y + 14, height: 28 })
for (const opened of [true, false]) {
if (!opened) await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(async () => {
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
const button = await review.boundingBox()
if (!panel || !button) return undefined
return { top: button.y - panel.y, right: panel.x + panel.width - button.x - button.width }
})
.toEqual({ top: 6, right: 6 })
await expect
.poll(async () => {
const button = await review.boundingBox()
return button ? button.y + button.height / 2 : undefined
})
.toBe(frame.y + 28)
await expect
.poll(async () => {
const button = await file.boundingBox()
return button ? button.y + button.height / 2 : undefined
})
.toBe(frame.y + 28)
await expect
.poll(() =>
page.locator('[data-slot="session-chat-panel"], #review-panel').evaluateAll(
(panels, frame) =>
panels.map((panel) => {
const bounds = panel.getBoundingClientRect()
return { top: bounds.top - frame.y, bottom: frame.y + frame.height - bounds.bottom }
}),
frame,
),
)
.toEqual([
{ top: 8, bottom: 8 },
{ top: 8, bottom: 8 },
])
}
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function switchTab(page: Page, title: string) {
@@ -67,3 +67,66 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(mcp).toBeHidden()
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`collapsed vertical navigation stays in the populated timeline header in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
await page.locator('[data-slot="shell-layout"]').evaluate((element) => {
element.style.setProperty("--shell-header-height", "40px")
element.style.setProperty("--tabs-native-inset", "70px")
})
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const host = header.locator('[data-slot="titlebar-navigation-start"]')
await expect(host).toBeAttached()
await host.evaluate((element) => element.setAttribute("data-retained-host", "true"))
for (const width of [1440, 900]) {
await page.setViewportSize({ width, height: 900 })
await toggle.click()
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect(host).toHaveAttribute("data-retained-host", "true")
await expect(page.locator('[data-slot="collapsed-tabs-toolbar"]')).toBeHidden()
await expect
.poll(async () => {
const boxes = await Promise.all([
toggle.boundingBox(),
header.getByRole("button", { name: "Home", exact: true }).boundingBox(),
header.getByRole("button", { name: "New session", exact: true }).boundingBox(),
header.getByRole("heading").boundingBox(),
header.getByRole("button", { name: "Toggle review", exact: true }).boundingBox(),
])
if (boxes.some((box) => !box)) return Infinity
const centers = boxes.map((box) => box!.y + box!.height / 2)
return Math.max(...centers) - Math.min(...centers)
})
.toBeLessThanOrEqual(1)
await expect
.poll(async () => {
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
const button = await header.getByRole("button", { name: "Toggle review", exact: true }).boundingBox()
if (!panel || !button) return undefined
return {
top: button.y - panel.y,
end: direction === "ltr" ? panel.x + panel.width - button.x - button.width : button.x - panel.x,
}
})
.toEqual({ top: 6, end: direction === "ltr" ? 6 : 76 })
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(host).toHaveAttribute("data-retained-host", "true")
}
})
}
@@ -231,6 +231,127 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
for (const direction of ["ltr", "rtl"] as const) {
test(`vertical tabs toggle stays anchored and exposes header navigation (${direction})`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar.locator(toggle)).toHaveAttribute("aria-expanded", "true")
const badge = page.locator('[data-slot="channel-indicator"]')
await expect(badge).toHaveCount(1)
await expect(badge).toHaveText((process.env.OPENCODE_CHANNEL ?? "dev").toUpperCase())
await expect(sidebar.locator('[data-slot="vertical-tabs-controls"]').locator(badge)).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]').locator(badge)).toHaveCount(0)
const badgeBounds = await badge.boundingBox()
const bounds = await toggle.boundingBox()
expect(bounds).not.toBeNull()
if (!badgeBounds || !bounds) throw new Error("tab controls have no bounding box")
expect(
direction === "ltr" ? bounds.x - badgeBounds.x - badgeBounds.width : badgeBounds.x - bounds.x - bounds.width,
).toBe(12)
expect(badgeBounds.y + badgeBounds.height / 2).toBe(bounds.y + bounds.height / 2)
await toggle.click()
await expect(sidebar).toHaveCount(0)
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect(header.locator('[data-slot="vertical-tabs-controls"]').locator(badge)).toBeVisible()
await expect.poll(() => badge.boundingBox()).toEqual(badgeBounds)
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(header.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect(header.getByRole("button", { name: "New session", exact: true })).toBeVisible()
await expect(header.getByRole("button", { name: "Status", exact: true })).toBeVisible()
await expect(header.locator('[data-slot="titlebar-navigation-start"] button')).toHaveCount(3)
expect(
await header
.locator('[data-slot="titlebar-navigation-start"] button')
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label"))),
).toEqual(["Toggle vertical tabs", "Home", "New session"])
await testInfo.attach(`collapsed-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await toggle.press("Enter")
await expect(sidebar.locator(toggle)).toHaveAttribute("aria-expanded", "true")
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(sidebar).toHaveCSS("width", "260px")
await expect(header.getByRole("button", { name: "Home", exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 800, height: 720 })
const narrow = await toggle.boundingBox()
await toggle.click()
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(narrow)
await expect(header.getByRole("button", { name: "New session", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 1280, height: 720 })
await header.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
const toolbar = page.locator('[data-slot="collapsed-tabs-toolbar"]')
const home = page.locator('[data-slot="home-panel"]')
await expect(home.locator(toggle)).toBeVisible()
await expect(toolbar).toBeHidden()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(() =>
home.evaluate((panel) => {
const shell = panel.closest('[data-slot="shell-layout"]')!.getBoundingClientRect()
const bounds = panel.getBoundingClientRect()
return {
top: bounds.top - shell.top,
bottom: shell.bottom - bounds.bottom,
left: bounds.left - shell.left,
right: shell.right - bounds.right,
}
}),
)
.toEqual({ top: 8, bottom: 8, left: 8, right: 8 })
await home.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`${href}$`))
await expect(header.locator(toggle)).toBeVisible()
await header.getByRole("button", { name: "New session", exact: true }).click()
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
const draft = page.locator('[data-component="new-session"]')
await expect(draft.locator(toggle)).toBeVisible()
await expect(toolbar).toBeHidden()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(() =>
draft.evaluate((panel) => {
const shell = panel.closest('[data-slot="shell-layout"]')!.getBoundingClientRect()
const bounds = panel.getBoundingClientRect()
return {
top: bounds.top - shell.top,
bottom: shell.bottom - bounds.bottom,
left: bounds.left - shell.left,
right: shell.right - bounds.right,
}
}),
)
.toEqual({ top: 8, bottom: 8, left: 8, right: 8 })
await toggle.click()
await expect(sidebar.getByRole("button", { name: "New session", exact: true })).toBeVisible()
})
}
test("appearance experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
@@ -314,6 +435,239 @@ test("appearance experimental settings control vertical tab details", async ({ p
await expect(layout).toContainText("Vertical")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`collapsed tabs toggle previews every open tab on hover (${direction})`, async ({ page }, testInfo) => {
await mockServer(page)
await page.route(`${server}/api/session/active*`, (route) =>
json(route, { data: { [sessionC.id]: { type: "running" } } }),
)
await page.route(`${server}/api/event*`, (route) =>
route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
}),
)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "hover-draft" },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
localStorage.setItem(
"opencode.global.dat:notification",
JSON.stringify({
list: [{ type: "turn-complete", session: sessionB, directory, time: Date.now(), viewed: false }],
}),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id, directory: sessionA.directory },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionA.title)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar.locator("[data-titlebar-tab-title]")).toHaveText([
sessionA.title,
"Session",
sessionB.title,
sessionC.title,
])
const appearance = await sidebar.locator("[data-titlebar-tab]").evaluateAll((rows) =>
rows.map((row) => {
const style = getComputedStyle(row)
return { height: style.height, radius: style.borderRadius, background: style.backgroundImage }
}),
)
await page.clock.install()
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toHaveAttribute("data-hover-blocked", "true")
await toggle.hover()
await page.clock.runFor(1000)
await expect(popup).toHaveCount(0)
await page.locator("[data-session-title] h1").hover()
await expect(toggle).toHaveAttribute("data-hover-blocked", "false")
await toggle.hover()
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([
sessionA.title,
"Session",
sessionB.title,
sessionC.title,
])
await expect(popup.getByRole("button", { name: "New session", exact: true })).toHaveCount(0)
await expect(popup.getByRole("button", { name: "Close tab", exact: true })).toHaveCount(4)
await expect(popup.locator("[data-titlebar-tab-list]")).toHaveCSS("gap", "4px")
await expect(popup.locator('[data-slot="project-avatar-slot"]')).toHaveCount(3)
await expect(popup).toHaveCSS("padding", "4px")
await expect
.poll(async () => {
const button = await toggle.boundingBox()
if (!button) return Infinity
const centers = await popup.locator('[data-slot="project-avatar-slot"]').evaluateAll((icons) =>
icons.map((icon) => {
const bounds = icon.getBoundingClientRect()
return bounds.x + bounds.width / 2
}),
)
return Math.max(...centers.map((center) => Math.abs(center - button.x - button.width / 2)))
})
.toBeLessThanOrEqual(1)
await expect(
popup.locator(`[data-titlebar-tab-link][href="${hrefB}"] [data-slot="project-avatar-unread-dot"]`),
).toBeVisible()
await expect(
popup.locator(`[data-titlebar-tab-link][href="${hrefC}"] [data-component="session-progress-indicator-v2"]`),
).toBeVisible()
expect(
await popup.locator("[data-titlebar-tab]").evaluateAll((rows) =>
rows.map((row) => {
const style = getComputedStyle(row)
return { height: style.height, radius: style.borderRadius, background: style.backgroundImage }
}),
),
).toEqual(appearance)
await popup.screenshot({ path: testInfo.outputPath("open-tabs.png") })
await expect(popup.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')).toHaveText(
sessionA.title,
)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
const bounds = await toggle.boundingBox()
await popup.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).hover()
await expect(popup).toBeVisible()
await expect(toggle).toHaveAttribute("data-state", "hover")
await popup.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionB.title)
await expect(popup).toBeVisible()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(popup.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')).toHaveText(
sessionB.title,
)
const closing = popup
.locator("[data-titlebar-tab-slot]")
.filter({ has: page.locator(`[data-titlebar-tab-link][href="${hrefC}"]`) })
await closing.hover()
await closing.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await page.keyboard.press("Escape")
await expect(popup).toBeVisible()
await page.locator("[data-session-title] h1").hover()
await expect(popup).toBeHidden()
await expect(toggle).not.toHaveAttribute("data-state", "hover")
await toggle.click()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
await page.locator("[data-session-title] h1").hover()
await toggle.hover()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(popup).toHaveCount(0)
})
}
test("closing the active and final dropdown tabs keeps it open", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionA.title)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await page.locator("[data-session-title] h1").hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
const active = popup.locator('[data-titlebar-tab-slot][data-active="true"]')
await expect(active.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
await active.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([sessionB.title])
await expect(active.locator("[data-titlebar-tab-title]")).toHaveText(sessionB.title)
await active.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await expect(popup).toHaveText("No open tabs")
await expect(toggle).toHaveAttribute("data-state", "hover")
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await expect(popup).toBeHidden()
})
test("collapsed tabs hover list handles an empty tab list", async ({ page }) => {
await mockServer(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto("/")
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
await expect(popup).toHaveText("No open tabs")
await expect(popup.getByRole("button")).toHaveCount(0)
})
test("collapsed tabs dropdown scrolls the sidebar rows", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: 20 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `hover-${index}`,
})),
),
)
},
{ server, directory: sessionA.directory },
)
await page.goto("/")
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
await expect(popup.locator("[data-titlebar-tab-slot]")).toHaveCount(20)
const scroll = popup.locator('[data-slot="vertical-tabs-scroll"]')
await expect.poll(() => scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
const tab = popup.locator('[data-tab-key="draft:hover-19"] [data-titlebar-tab-link]')
await tab.scrollIntoViewIfNeeded()
await expect(tab).toBeInViewport()
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
})
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
@@ -392,7 +746,11 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
+3
View File
@@ -9,6 +9,7 @@ import { createHomeScrollController } from "./scroll"
import { createHomeSessionSearchController } from "./sessions/search"
import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
import { TitlebarNavigationHeader } from "@/shell/titlebar/navigation-slot"
export function Home() {
const mobile = createMediaQuery("(max-width: 767px)")
@@ -19,11 +20,13 @@ export function Home() {
const scroll = createHomeScrollController(sessions.data.groups)
return (
<div
data-slot="home-panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<TitlebarNavigationHeader />
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
+2
View File
@@ -15,6 +15,7 @@ import {
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { TitlebarNavigationHeader } from "@/shell/titlebar/navigation-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -54,6 +55,7 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<TitlebarNavigationHeader />
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
+3
View File
@@ -808,6 +808,9 @@ export const dict = {
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.tabs.toggle": "Toggle vertical tabs",
"titlebar.tabs.open": "Open tabs",
"titlebar.tabs.empty": "No open tabs",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -289,9 +289,14 @@ export function SessionSidePanel(props: {
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<Tabs
value={activeTab()}
onChange={activateTab}
style={{ "--tabs-bar-height": "var(--shell-header-height,48px)" }}
>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
style={{ height: "var(--shell-header-height,52px)" }}
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
@@ -13,14 +13,23 @@ import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "./title"
import { TitlebarNavigationMount } from "@/shell/titlebar/navigation-slot"
export function SessionTitleHeader(props: ParentProps) {
export function SessionTitleHeader(props: ParentProps<{ navigationEnd?: boolean }>) {
return (
<div
data-session-title
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
class="sticky top-0 z-30 flex w-full items-start bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_var(--shell-header-height,48px),transparent)] pb-4 ps-2.5"
style={{
// Match the 28px action buttons' top inset, retaining physical macOS control clearance in RTL.
"padding-inline-end": "calc((var(--shell-header-height, 48px) - 28px) / 2 + var(--tabs-trailing-inset, 0px))",
}}
>
{props.children}
<TitlebarNavigationMount side="start" />
<div class="min-w-0 flex-1">{props.children}</div>
<Show when={props.navigationEnd !== false}>
<TitlebarNavigationMount side="end" />
</Show>
</div>
)
}
@@ -93,7 +102,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
return (
<Show when={title() || parentTitle() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex h-[var(--shell-header-height,48px)] w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center">
<span
@@ -33,6 +33,7 @@ import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
import { TitlebarNavigationMount } from "@/shell/titlebar/navigation-slot"
type BackgroundTask = {
id: string
@@ -644,8 +645,8 @@ function MessageTimelineView(
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
<Show when={!props.hideHeader}>
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<SessionTitleHeader navigationEnd={false}>
<div class="h-[var(--shell-header-height,48px)] w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center min-w-0 flex-1 w-full">
<Show
@@ -795,6 +796,8 @@ function MessageTimelineView(
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{/* Auxiliary chrome precedes the actions so the review toggle stays at the panel edge. */}
<TitlebarNavigationMount side="end" />
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
@@ -540,7 +540,7 @@ export function createTimelineVirtualizer(input: Input) {
onScroll={handleListScroll}
onClick={input.onSelectionInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
style={{ "--sticky-accordion-top": input.showHeader() ? "var(--shell-header-height,48px)" : "0px" }}
>
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
{props.header}
+104 -59
View File
@@ -8,6 +8,7 @@ import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { createTitlebarNavigationSlot, TitlebarNavigationProvider } from "@/shell/titlebar/navigation-slot"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -19,9 +20,13 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({
debugTools: false,
tabsWidth: 260,
tabsOpened: true,
tabsMount: undefined as HTMLElement | undefined,
navigationStart: undefined as HTMLElement | undefined,
navigationEnd: undefined as HTMLElement | undefined,
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const navigation = createTitlebarNavigationSlot(() => verticalTabs() && !state.tabsOpened)
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
const update: TitlebarUpdate = {
@@ -38,70 +43,110 @@ export default function Layout(props: ParentProps) {
return (
<TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
}}
>
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
<TitlebarNavigationProvider value={navigation}>
<div
data-slot="shell-layout"
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
// Native macOS controls stay on the physical left, regardless of writing direction.
"--tabs-native-inset":
platform.platform === "desktop" && platform.os === "macos" && !platform.windowFullscreen?.()
? `max(0px, calc(${88 / (platform.webviewZoom?.() ?? 1)}px - 18px))`
: "0px",
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
// Keep equal outer gaps while using compact header rows on macOS.
"--shell-header-height":
platform.platform === "desktop" && platform.os === "macos" && verticalTabs() ? "40px" : undefined,
}}
>
<Titlebar
update={update}
verticalTabs={
verticalTabs()
? {
opened: state.tabsOpened,
toggle: () => setState("tabsOpened", (value) => !value),
mount: state.tabsMount,
start: navigation.mount("start") ?? state.navigationStart,
end: navigation.mount("end") ?? state.navigationEnd,
}
: undefined
}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs() && state.tabsOpened}>
<aside
id="vertical-tabs-sidebar"
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
}}
>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
</aside>
</Show>
{/* Size containment collapses percentage-height descendants in WebKit. */}
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
</aside>
<Show when={navigation.collapsed()}>
<div
data-slot="collapsed-tabs-toolbar"
class="mx-2 flex h-[var(--shell-header-height,48px)] shrink-0 self-stretch items-center justify-between px-2.5 mt-[var(--shell-top-inset,8px)]"
style={{ display: navigation.mount("start") ? "none" : undefined }}
>
<div
ref={(element) => setState("navigationStart", element)}
class="flex items-center"
style={{ "padding-inline-start": "var(--tabs-control-inset)" }}
/>
<div
ref={(element) => setState("navigationEnd", element)}
class="flex items-center"
style={{ "padding-inline-end": "var(--tabs-trailing-inset)" }}
/>
</div>
</Show>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
</div>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
{/* Size containment collapses percentage-height descendants in WebKit. */}
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
</div>
</main>
<ToastRegion />
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
</TitlebarNavigationProvider>
</TitlebarRightProvider>
)
}
@@ -9,6 +9,7 @@ import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
import { useTitlebarNavigationSlot } from "@/shell/titlebar/navigation-slot"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -20,6 +21,8 @@ export function StatusPopover() {
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const navigation = useTitlebarNavigationSlot()
const footer = () => desktop() && settings.appearance.tabLayout() === "vertical" && !navigation?.collapsed()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
@@ -41,8 +44,8 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
placement: footer() ? "top-start" : "bottom-end",
shift: footer() ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -85,8 +88,12 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: props.state.placement,
shift: props.state.shift,
get placement() {
return props.state.placement
},
get shift() {
return props.state.shift
},
}
return (
@@ -0,0 +1,63 @@
import { createContext, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
export function createTitlebarNavigationSlot(collapsed: () => boolean) {
const [store, setStore] = createStore<{ start: HTMLElement[]; end: HTMLElement[] }>({ start: [], end: [] })
return {
collapsed,
mount: (side: "start" | "end") => store[side].at(-1),
register(side: "start" | "end", element: HTMLElement) {
setStore(side, (items) => [...items, element])
onCleanup(() => setStore(side, (items) => items.filter((item) => item !== element)))
},
}
}
const TitlebarNavigationContext = createContext<ReturnType<typeof createTitlebarNavigationSlot>>()
export function TitlebarNavigationProvider(
props: ParentProps<{ value: ReturnType<typeof createTitlebarNavigationSlot> }>,
) {
return <TitlebarNavigationContext.Provider value={props.value}>{props.children}</TitlebarNavigationContext.Provider>
}
export function useTitlebarNavigationSlot() {
return useContext(TitlebarNavigationContext)
}
export function TitlebarNavigationHeader() {
const slot = useTitlebarNavigationSlot()
if (!slot) return null
return (
<div
data-slot="panel-navigation-header"
class="flex shrink-0 items-center justify-between ps-2.5"
style={{
display: slot.collapsed() ? undefined : "none",
"padding-inline-end": "calc((var(--shell-header-height, 48px) - 28px) / 2 + var(--tabs-trailing-inset, 0px))",
}}
>
<TitlebarNavigationMount side="start" />
<TitlebarNavigationMount side="end" />
</div>
)
}
export function TitlebarNavigationMount(props: { side: "start" | "end" }) {
const slot = useContext(TitlebarNavigationContext)
if (!slot) return null
let mount!: HTMLDivElement
// The header owns its hosts for its entire lifetime, not just while tabs are collapsed.
// Otherwise a host remount can temporarily route navigation into the shell's separate toolbar.
onMount(() => slot.register(props.side, mount))
return (
<div
ref={mount}
data-slot={`titlebar-navigation-${props.side}`}
class="flex h-[var(--shell-header-height,48px)] shrink-0 items-center"
style={{
display: slot.collapsed() ? undefined : "none",
"padding-inline-start": props.side === "start" ? "var(--tabs-control-inset, 0px)" : undefined,
}}
/>
)
}
@@ -0,0 +1,80 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { Show, type Accessor, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Tab } from "@/shell/tabs/tabs"
import { TitlebarTabStrip } from "./tab-strip"
export function OpenTabsPopover(props: {
trigger: (open: Accessor<boolean>) => JSX.Element
tabs: Tab[]
currentTab: Tab | undefined
open: boolean
onOpenChange: (open: boolean) => void
blocked: boolean
onHoverExit: () => void
onSelect: (tab: Tab) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
}) {
const language = useLanguage()
let trigger!: HTMLDivElement
return (
<HoverCard
open={props.open && !props.blocked}
onOpenChange={(open) => props.onOpenChange(open && !props.blocked)}
openDelay={300}
closeDelay={200}
placement="bottom-start"
gutter={6}
// Center the 16px favicon after 4px popup + 6px row padding on the 28px toggle.
shift={-4}
>
<HoverCard.Trigger
ref={trigger}
as="div"
role="presentation"
tabIndex={-1}
class="flex shrink-0"
onPointerLeave={props.onHoverExit}
>
{props.trigger(() => props.open && !props.blocked)}
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
as="nav"
// HoverCard forwards these to its dismissable layer, but omits them from its public prop types.
{...{
onEscapeKeyDown: (event: KeyboardEvent) => event.preventDefault(),
onPointerDownOutside: (event: Event) => event.preventDefault(),
}}
ref={(element) => {
const theme = trigger.closest("[data-theme]")?.getAttribute("data-theme")
if (theme) element.setAttribute("data-theme", theme)
}}
data-slot="open-tabs-popover"
aria-label={language.t("titlebar.tabs.open")}
class="z-50 flex w-[300px] max-w-[calc(100dvw-24px)] max-h-[min(400px,calc(100dvh-80px))] flex-col overflow-hidden rounded-[8px] bg-v2-background-bg-deep p-1 shadow-[var(--v2-elevation-floating)] outline-none [app-region:no-drag]"
>
<Show
when={props.tabs.length}
fallback={
<div class="px-2 py-1.5 text-[13px] leading-4 text-v2-text-text-muted">
{language.t("titlebar.tabs.empty")}
</div>
}
>
<TitlebarTabStrip
orientation="vertical"
tabs={props.tabs}
currentTab={props.currentTab}
onNavigate={props.onSelect}
onClose={props.onClose}
onReorder={props.onReorder}
/>
</Show>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard>
)
}
+2 -1
View File
@@ -86,7 +86,8 @@
gap: 2px;
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-list][data-orientation="vertical"] {
:is([data-slot="vertical-tabs-sidebar"], [data-slot="open-tabs-popover"])
[data-titlebar-tab-list][data-orientation="vertical"] {
gap: 4px;
}
@@ -279,14 +279,14 @@ export function TitlebarTabStrip(props: {
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
data-orientation={vertical() ? "vertical" : "horizontal"}
class="relative min-w-0"
classList={{ "min-h-0 overflow-hidden": vertical() }}
classList={{ "flex min-h-0 flex-col overflow-hidden": vertical() }}
>
<div
data-slot={vertical() ? "vertical-tabs-scroll" : "titlebar-tabs-scroll"}
class="flex min-w-0 no-scrollbar [app-region:no-drag]"
classList={{
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
"min-h-0 flex-col overflow-y-auto overflow-x-hidden": vertical(),
}}
>
<DragDropProvider
@@ -1,3 +1,13 @@
[data-slot="shell-layout"] {
--tabs-control-inset: var(--tabs-native-inset, 0px);
--tabs-trailing-inset: 0px;
}
[data-slot="shell-layout"]:dir(rtl) {
--tabs-control-inset: 0px;
--tabs-trailing-inset: var(--tabs-native-inset, 0px);
}
[data-slot="titlebar-tab-item"] {
user-select: none;
}
+155 -68
View File
@@ -31,13 +31,14 @@ import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
import { OpenTabsPopover } from "./open-tabs-popover"
const titlebarHeight = 36
// The horizontal macOS bar includes 8px top padding: its controls center at y=28.
const titlebarHeight = 48
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
const macTrafficLightsTopClearance = 28
const macTrafficLightsBaseWidth = 88
export type TitlebarUpdate = {
version: string | undefined
@@ -48,7 +49,7 @@ export type TitlebarUpdate = {
export function Titlebar(props: {
update?: TitlebarUpdate
debugTools?: { visible: boolean; toggle: () => void }
verticalTabs?: { mount?: HTMLElement }
verticalTabs?: { opened: boolean; toggle: () => void; mount?: HTMLElement; start?: HTMLElement; end?: HTMLElement }
}) {
const platform = usePlatform()
const command = useCommand()
@@ -64,7 +65,6 @@ export function Titlebar(props: {
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const macVerticalTabs = createMemo(() => mac() && !!props.verticalTabs)
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
@@ -316,6 +316,62 @@ export function Titlebar(props: {
}
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
// Keep the dropdown open across session changes that replace its header/portal host.
const [tabsPreview, setTabsPreview] = createStore({ blocked: false, open: false })
let tabsToggle: HTMLButtonElement | undefined
const toggleIcon = (hover?: () => boolean) => (
<IconButton
ref={(element) => (tabsToggle = element)}
type="button"
data-action="toggle-vertical-tabs"
data-hover-blocked={tabsPreview.blocked}
variant="ghost-muted"
size="large"
state={hover?.() ? "hover" : undefined}
class="shrink-0 [app-region:no-drag]"
icon={<Icon name="sidebar-right" class="-scale-x-100 rtl:scale-x-100" />}
aria-label={language.t("titlebar.tabs.toggle")}
aria-expanded={props.verticalTabs?.opened}
aria-controls="vertical-tabs-sidebar"
onClick={() => {
setTabsPreview({ blocked: props.verticalTabs?.opened === true, open: false })
props.verticalTabs?.toggle()
// The control moves between portal hosts; retain keyboard focus.
queueMicrotask(() => tabsToggle?.focus({ preventScroll: true }))
}}
/>
)
const toggleButton = (preview = false) => (
<div data-slot="vertical-tabs-controls" class="flex shrink-0 items-center gap-3">
<Show when={!windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show
when={preview}
fallback={
<Tooltip placement="bottom" value={language.t("titlebar.tabs.toggle")}>
{toggleIcon()}
</Tooltip>
}
>
<OpenTabsPopover
trigger={toggleIcon}
tabs={tabsStore}
currentTab={currentTab()}
open={tabsPreview.open}
onOpenChange={(open) => setTabsPreview("open", open)}
blocked={tabsPreview.blocked}
onHoverExit={() => setTabsPreview("blocked", false)}
onSelect={(tab) => tabs.select(tab)}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</Show>
</div>
)
const homeButton = (vertical = false) => (
<Show
when={vertical}
@@ -334,7 +390,7 @@ export function Titlebar(props: {
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
class={props.verticalTabs ? "shrink-0" : "!w-9 shrink-0"}
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
@@ -617,78 +673,107 @@ export function Titlebar(props: {
}
>
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
<Show when={macVerticalTabs()}>
<div
class="relative mb-2 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
<>
<Show when={!vertical().opened && vertical().start} keyed>
{(mount) => (
<Portal mount={mount}>
<div class="flex shrink-0 items-center gap-1 pe-6 [app-region:no-drag]">
{toggleButton(true)}
{homeButton()}
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
icon={<Icon name="edit" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
</div>
</Portal>
)}
</Show>
<Show when={!vertical().opened && vertical().end} keyed>
{(mount) => (
<Portal mount={mount}>
<div class="flex items-center gap-1.5 ps-2">
<TitlebarRight state={rightState()} />
</div>
</Portal>
)}
</Show>
<Show when={vertical().opened}>
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
data-slot="vertical-tabs-toolbar"
class="mb-3 flex h-[var(--shell-header-height,48px)] shrink-0 items-center ms-2"
style={{ "padding-inline-start": "var(--tabs-control-inset)" }}
data-tauri-drag-region
>
<ChannelIndicator debugTools={props.debugTools} />
{toggleButton()}
</div>
</div>
</Show>
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
{language.t("command.session.new")}
</button>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
>
<TitlebarRightMount />
<Show when={!macVerticalTabs() && !windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
</div>
</Portal>
)}
</Show>
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
{language.t("command.session.new")}
</button>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
>
<TitlebarRightMount />
</div>
</Portal>
)}
</Show>
</Show>
</>
)}
</Show>
</Show>
<Show when={!mobile()}>
<div class="flex-1" />
</Show>
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
<Show when={!props.verticalTabs || props.verticalTabs.opened}>
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
</Show>
</div>
)
}}
@@ -764,6 +849,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<button
type="button"
data-slot="channel-indicator"
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
style={style()}
onClick={props.debugTools.toggle}
@@ -780,6 +866,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
<Show when={label}>
{(value) => (
<div
data-slot="channel-indicator"
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
style={style()}
>
@@ -32,7 +32,8 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 14, y: 14 },
// Native buttons are 16px tall; center them on the header controls at y=28.
trafficLightPosition: { x: 18, y: 20 },
}
: {}),
...(process.platform === "win32"