mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a663316b |
@@ -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 },
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user