Compare commits

...
4 changed files with 285 additions and 43 deletions

No files matched your search

+166 -32
View File
@@ -56,6 +56,71 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
await expectPath(page, sessionHref(b.id))
})
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
page,
}) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
sessionPermissions: {
[b.id]: [
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
const dot = tabB.locator('[data-slot="tab-unread-dot"]')
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(close).toHaveCSS("opacity", "0")
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
await expect
.poll(() =>
dot.evaluate((element) => {
const dot = element.getBoundingClientRect()
const slot = element.parentElement!.getBoundingClientRect()
return (
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
)
}),
)
.toBeLessThan(1)
await tabB.hover()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await page.mouse.move(0, 0)
await tabB.locator("[data-titlebar-tab-link]").focus()
await page.keyboard.press("Tab")
await expect(close).toBeFocused()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await close.blur()
await expect(dot).toBeVisible()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB).toHaveAttribute("data-active", "true")
await expect(dot).toHaveCount(0)
await tabA.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await tabB.hover()
await close.click()
await expect(tabB).toHaveCount(0)
await expectPath(page, sessionHref(a.id))
})
test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
await page.goto(sessionHref(a.id))
@@ -98,43 +163,112 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
})
for (const row of [
{ tabLayout: "horizontal", width: 360 },
{ tabLayout: "vertical", width: 390 },
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
]) {
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
test.describe(`mobile drawer (${row.tabLayout})`, () => {
test.use({ hasTouch: row.hasTouch })
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[b.id]: [
{
id: "permission-mobile-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
})
await page.goto(sessionHref(a.id))
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("width", "20px")
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("height", "20px")
await expect
.poll(() =>
tabB.evaluate((element) => {
const dot = element.querySelector('[data-slot="tab-unread-slot"]')!.getBoundingClientRect()
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
return dot.right <= close.left || dot.left >= close.right
}),
)
.toBe(true)
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
await page.goto(sessionHref(a.id))
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
}
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
page,
}) => {
await mockWorkspace(page, {
name: "CompactTabs",
sessions: [
a,
b,
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
],
sessionPermissions: {
[b.id]: [
{
id: "permission-compact-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
})
await page.goto(sessionHref(a.id))
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.hover()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
})
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 480 })
const directory = "C:/OpenCode/Tabs"
+90 -6
View File
@@ -9,6 +9,45 @@
justify-content: center;
}
[data-titlebar-tab] :is([data-slot="tab-unread-dot"], [data-slot="tab-avatar-unread-dot"]) {
position: absolute;
width: 7px;
height: 7px;
border-radius: 9999px;
background: var(--v2-background-bg-accent);
pointer-events: none;
}
[data-titlebar-tab] [data-slot="project-avatar-slot"] {
position: relative;
}
[data-titlebar-tab] [data-slot="tab-avatar-unread-dot"] {
display: none;
inset-inline-end: -3px;
top: -3px;
}
[data-titlebar-tab] [data-slot="tab-unread-slot"] {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: flex;
margin-block: auto;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
pointer-events: none;
}
@media (hover: hover) {
[data-titlebar-tab]:not(:where([data-slot="mobile-tabs-drawer"] *)):is(:hover, :has(:focus-visible))
[data-slot="tab-unread-slot"] {
display: none;
}
}
[data-titlebar-tab],
[data-titlebar-tab-action] {
--tab-base: var(--v2-background-bg-deep);
@@ -77,7 +116,8 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-state="pressed"], [data-unread="true"])
[data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
@@ -132,7 +172,10 @@
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
[data-editing="true"]
)
[data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
@@ -151,7 +194,10 @@
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
[data-editing="true"]
):dir(rtl)
[data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
@@ -168,7 +214,10 @@
);
}
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
[data-titlebar-tab]:is(:hover, :has(:focus-visible), [data-active="true"], [data-unread="true"]):not(
[data-editing="true"]
)
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
@@ -196,6 +245,10 @@
}
@media (hover: none) {
[data-titlebar-tab] [data-slot="tab-unread-slot"] {
inset-inline-end: 20px;
}
[data-titlebar-tab] [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
@@ -223,22 +276,53 @@
[data-titlebar-tab]:dir(rtl) [data-slot="tab-link"] {
--tab-title-fade-direction: to left;
}
[data-titlebar-tab][data-unread="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 44px;
}
}
@container (44px < width <= 64px) {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(
:hover,
:has(:focus-visible),
[data-active="true"],
[data-unread="true"]
)
[data-slot="tab-link"] {
-webkit-mask-image: none;
mask-image: none;
}
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(
:hover,
:has(:focus-visible),
[data-active="true"],
[data-unread="true"]
)
[data-titlebar-tab-title] {
visibility: hidden;
}
}
@container (max-width: 44px) {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-avatar-unread-dot"] {
display: block;
width: 6px;
height: 6px;
}
[data-titlebar-tab][data-unread="true"]:not([data-orientation="vertical"]) [data-slot="project-avatar-surface"] {
-webkit-mask-image: radial-gradient(circle 4.5px at 100% 0, transparent 4px, black 5px);
mask-image: radial-gradient(circle 4.5px at 100% 0, transparent 4px, black 5px);
}
[data-titlebar-tab][data-unread="true"]:not([data-orientation="vertical"]):dir(rtl)
[data-slot="project-avatar-surface"] {
-webkit-mask-image: radial-gradient(circle 4.5px at 0 0, transparent 4px, black 5px);
mask-image: radial-gradient(circle 4.5px at 0 0, transparent 4px, black 5px);
}
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
gap: 0;
+21 -5
View File
@@ -11,7 +11,8 @@ import { useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode/client/promise"
import { sessionTabTitle } from "./tab-title"
@@ -55,6 +56,12 @@ export function TabNavItem(props: {
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.session?.id ?? "",
() => true,
)
const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading()
const project = createMemo(() => {
const session = props.session
@@ -232,6 +239,7 @@ export function TabNavItem(props: {
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-unread={unread()}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => {
@@ -298,14 +306,17 @@ export function TabNavItem(props: {
}
>
{(session) => (
<SessionTabAvatar
<SessionTabAvatarView
project={project()}
directory={session.location.directory}
sessionId={session.id}
server={props.server}
unread={false}
loading={avatar.loading()}
/>
)}
</Show>
<Show when={unread()}>
<span data-slot="tab-avatar-unread-dot" aria-hidden="true" />
</Show>
</span>
<span
ref={(el) => {
@@ -350,10 +361,15 @@ export function TabNavItem(props: {
</Menu.Context.Trigger>
<div data-slot="tab-close">
<Show when={unread()}>
<span data-slot="tab-unread-slot" aria-hidden="true">
<span data-slot="tab-unread-dot" />
</span>
</Show>
<IconButton
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
class="hover-reveal relative z-10 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
@@ -70,12 +70,20 @@
opacity: 1;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-unread-slot"] {
inset-inline-end: 44px;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-link"] {
padding-inline-end: 38px;
-webkit-mask-image: none;
mask-image: none;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab][data-unread="true"] [data-slot="tab-link"] {
padding-inline-end: 58px;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-title"] {
color: var(--v2-text-text-base);
font-weight: 530;