From c097d51135865a200dc3d96d12ddeedb799d4c84 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:39:59 +0800 Subject: [PATCH] feat(app): improve titlebar tab layout (#33914) --- .../app/src/components/titlebar-tab-drag.ts | 4 +- .../app/src/components/titlebar-tab-nav.css | 30 +- .../app/src/components/titlebar-tab-nav.tsx | 294 ++++++++++-------- .../app/src/components/titlebar-tab-strip.tsx | 102 +++--- packages/app/src/components/titlebar.tsx | 1 + .../src/v2/components/project-avatar-v2.css | 1 - 6 files changed, 219 insertions(+), 213 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts index 22feedb148..b0d75750e0 100644 --- a/packages/app/src/components/titlebar-tab-drag.ts +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -22,7 +22,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) { for (const slot of slots) { const id = slot.dataset.tabKey if (!id) continue - const tab = slot.querySelector("[data-titlebar-tab]") + const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector("[data-titlebar-tab]") if (!tab) continue tabWidthById.set(id, tab.getBoundingClientRect().width) } @@ -33,7 +33,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) { const secondId = order[1] for (const slot of slots) { if (slot.dataset.tabKey !== secondId) continue - const tab = slot.querySelector("[data-titlebar-tab]") + const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector("[data-titlebar-tab]") if (!tab) break const style = getComputedStyle(slot) dividerWidth = diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 1ba4893a4a..07a6d1d052 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -1,20 +1,16 @@ [data-titlebar-tab] [data-slot="tab-close"] { position: absolute; - top: 0; - right: 0; + top: 4px; + right: 4px; display: flex; - height: 28px; - width: 28px; + height: 20px; + width: 20px; align-items: center; justify-content: center; } -[data-slot="titlebar-tabs"] { - container-type: inline-size; -} - [data-titlebar-tab-list] { - gap: calc(min(12px, 2cqw) + 1.5px); + gap: 13.5px; } [data-titlebar-tab-slot] { @@ -25,7 +21,7 @@ content: ""; position: absolute; top: 8px; - left: calc(0px - min(6px, 1cqw) - 0.75px); + left: -6.75px; width: 1.5px; height: 12px; border-radius: 9999px; @@ -54,19 +50,11 @@ right: 100%; } -[data-titlebar-tab] [data-slot="tab-link"] { - padding-right: 22px; -} - [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] { padding-right: 0; } -[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] { - padding-right: 22px; -} - [data-titlebar-tab] [data-slot="tab-title"] { min-width: 0; } @@ -95,4 +83,10 @@ [data-titlebar-tab-title] { display: none; } + + [data-slot="tab-close"] { + right: auto; + left: 50%; + transform: translateX(-50%); + } } diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 762a3c1541..03196a4006 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -29,6 +29,9 @@ export function TabNavItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean + tabKey: string + dragActive: boolean + onPointerDown: (event: PointerEvent) => void }) { const language = useLanguage() const [editing, setEditing] = createSignal(false) @@ -170,105 +173,112 @@ export function TabNavItem(props: { return (
{ - tabRoot = el - forwardTabRef(props.ref, el) - }} - data-titlebar-tab - data-slot="titlebar-tab-item" - data-title-overflow={titleOverflowing()} - data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" - classList={{ invisible: props.hidden }} - data-active={props.active} - data-dragging={props.dragging} - data-pressed={props.pressed} - onMouseDown={(event) => { - if (event.button !== 1) return - closeTab(event) - }} + data-titlebar-tab-slot + data-tab-key={props.tabKey} + class="relative flex w-56 min-w-7 max-w-56 flex-shrink" + classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }} + onPointerDown={props.onPointerDown} > - - {(session) => { - return ( - { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.preventDefault() - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = session().title - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return +
{ + tabRoot = el + forwardTabRef(props.ref, el) + }} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-title-overflow={titleOverflowing()} + data-editing={editing()} + 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] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) + }} + > + + {(session) => { + return ( + { event.preventDefault() - titleEl.textContent = session().title - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return event.stopPropagation() }} onClick={(event) => { - if (!editing()) return event.preventDefault() + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() }} - /> - - ) - }} - - -
- { - event.preventDefault() - event.stopPropagation() + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = session().title + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return + event.preventDefault() + titleEl.textContent = session().title + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() + }} + onClick={(event) => { + if (!editing()) return + event.preventDefault() + }} + /> + + ) }} - onClick={closeTab} - icon={} - /> + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + /> +
) @@ -285,6 +295,9 @@ export function DraftTabItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean + tabKey: string + dragActive: boolean + onPointerDown: (event: PointerEvent) => void }) { const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -293,58 +306,69 @@ export function DraftTabItem(props: { } return (
forwardTabRef(props.ref, el)} - data-titlebar-tab - data-slot="titlebar-tab-item" - data-active={props.active} - data-dragging={props.dragging} - data-pressed={props.pressed} - class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]" - classList={{ invisible: props.hidden }} - onMouseDown={(event) => { - if (event.button !== 1) return - closeTab(event) - }} + data-titlebar-tab-slot + data-tab-key={props.tabKey} + class="relative flex w-56 min-w-7 max-w-56 flex-shrink" + classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }} + onPointerDown={props.onPointerDown} > - { - event.preventDefault() - event.stopPropagation() +
forwardTabRef(props.ref, el)} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + 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] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) }} - onClick={(event) => { - event.preventDefault() - if (props.suppressNavigation?.()) return - props.onNavigate() - }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]" > - - - - - {props.title} - - -
- { + { event.preventDefault() event.stopPropagation() }} - onMouseDown={(event) => { + onClick={(event) => { event.preventDefault() - event.stopPropagation() + if (props.suppressNavigation?.()) return + props.onNavigate() }} - onClick={closeTab} - icon={} - aria-label="Close tab" - /> + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + + {props.title} + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" + onClick={closeTab} + icon={} + aria-label="Close tab" + /> +
) diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 3a8cf86e01..e6188fe8b5 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -100,41 +100,33 @@ function SessionTabSlot(props: { }) return ( -
- { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onTitleChangeFailed={(title) => { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onNavigate={() => props.onNavigate(ref)} - onClose={props.onClose} - active={props.active()} - activeServer={props.tab.server === props.activeServerKey} - forceTruncate={props.forceTruncate} - suppressNavigation={props.suppressNavigation} - pressed={props.pressed()} - hidden={props.dragged()} - /> -
+ ref={ref} + href={tabHref(props.tab)} + server={props.tab.server} + session={session} + onTitleChange={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onTitleChangeFailed={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + activeServer={props.tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + suppressNavigation={props.suppressNavigation} + pressed={props.pressed()} + hidden={props.dragged() || !session()} + /> ) } @@ -336,7 +328,10 @@ export function TitlebarTabStrip(props: { if (event.button !== 0 || drag.active) return if (!canStartTabDrag(event.pointerType)) return if (isTabCloseTarget(event.target)) return - const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-titlebar-tab]") + const target = event.currentTarget as HTMLDivElement + const tabEl = target.matches("[data-titlebar-tab]") + ? target + : target.querySelector("[data-titlebar-tab]") if (!tabEl) return if (!tabEl.querySelector('[data-slot="tab-link"]')) return const tab = props.tabs.find((item) => tabKey(item) === id) @@ -459,13 +454,13 @@ export function TitlebarTabStrip(props: { return ( <> -
+
-
+
{(tab, index) => { const id = tabKey(tab) @@ -503,30 +498,23 @@ export function TitlebarTabStrip(props: { } return ( -
{ if (dragged()) return onPointerDown(id, event) }} - > - props.onNavigate(tab, ref)} - onClose={() => props.onClose(tab)} - suppressNavigation={() => suppressNavigation()} - active={props.currentTab() === tab} - pressed={pressedId() === id} - hidden={dragged()} - /> -
+ ref={ref} + href={tabHref(tab)} + title={language.t("command.session.new")} + onNavigate={() => props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + suppressNavigation={() => suppressNavigation()} + active={props.currentTab() === tab} + pressed={pressedId() === id} + hidden={dragged()} + /> ) }}
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 193224f5c6..56427ae0eb 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -481,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { /> +
diff --git a/packages/ui/src/v2/components/project-avatar-v2.css b/packages/ui/src/v2/components/project-avatar-v2.css index d7ec963994..c724744654 100644 --- a/packages/ui/src/v2/components/project-avatar-v2.css +++ b/packages/ui/src/v2/components/project-avatar-v2.css @@ -87,7 +87,6 @@ [data-slot="project-avatar-surface"] [data-slot="project-avatar-image"] { position: relative; - z-index: 1; display: block; width: 100%; height: 100%;