fix(header): session tabs span to the right controls, close buttons, even padding

The old flex spacer split the header in half, boxing the strip into the
middle while tabs shrank to slivers; the spacer now renders only for
the VS Code and surface-title layouts, tabs keep a fixed width (w-44)
and the strip scrolls behind the right-side buttons. Every tab gains a
hover-revealed close button next to the menu; the active tab's menu is
hover-revealed too (it was always visible) — the header passes it into
the strip so both overlays behave identically. The active tab's inner
padding matches inactive tabs.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 19:08:33 +03:00
parent 447e43eac5
commit 97ddab73a1
2 changed files with 142 additions and 88 deletions
+63 -68
View File
@@ -1733,8 +1733,68 @@ export const Header: React.FC = () => {
</button>
</SessionSwitcherDropdown>
) : null}
<SessionTabsStrip>
<div className="flex min-w-0 flex-col justify-center px-1">
<SessionTabsStrip
menuOpen={isHeaderSessionMenuOpen}
menu={currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? (
<DropdownMenu
open={isHeaderSessionMenuOpen}
onOpenChange={setIsHeaderSessionMenuOpen}
onOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false;
beginHeaderSessionRename();
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="size-5 min-w-0 px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label={t('header.sessionActions.openAria')}>
<Icon name="more" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem onClick={() => { pendingHeaderRenameRef.current = true; }}><Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}</DropdownMenuItem>
<DropdownMenuItem onClick={copyCurrentSessionId}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}</DropdownMenuItem>
<DropdownMenuSeparator />
{currentSession?.shareUrl ? (
<>
<DropdownMenuItem onClick={copyCurrentSessionShareUrl}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => void unshareCurrentSession()}><Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<DropdownMenuItem
disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree}
onClick={moveCurrentSessionToWorktree}
className="w-full"
>
<Icon name="folder-shared" className="mr-2 size-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')}
</DropdownMenuItem>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isCurrentSessionMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isCurrentSessionActive
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
</TooltipContent>
</Tooltip>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPendingHeaderRetentionAction('archive')}><Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction('delete')}><Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
>
<div className="flex min-w-0 flex-col justify-center">
{isRenamingHeaderSession ? (
<form
ref={headerRenameFormRef}
@@ -1782,76 +1842,11 @@ export const Header: React.FC = () => {
</span>
)}
</div>
<div className={cn(
'flex h-[18px] shrink-0 items-center justify-center',
// Top-aligned only when the title has a metadata line under it;
// alone, the title is centred and the button must follow.
'self-center',
)}>
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? (
<DropdownMenu
open={isHeaderSessionMenuOpen}
onOpenChange={setIsHeaderSessionMenuOpen}
onOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false;
beginHeaderSessionRename();
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="h-[18px] w-6 px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label={t('header.sessionActions.openAria')}>
<Icon name="more" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem onClick={() => { pendingHeaderRenameRef.current = true; }}><Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}</DropdownMenuItem>
<DropdownMenuItem onClick={copyCurrentSessionId}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}</DropdownMenuItem>
<DropdownMenuSeparator />
{currentSession?.shareUrl ? (
<>
<DropdownMenuItem onClick={copyCurrentSessionShareUrl}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => void unshareCurrentSession()}><Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<DropdownMenuItem
disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree}
onClick={moveCurrentSessionToWorktree}
className="w-full"
>
<Icon name="folder-shared" className="mr-2 size-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')}
</DropdownMenuItem>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isCurrentSessionMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isCurrentSessionActive
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
</TooltipContent>
</Tooltip>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPendingHeaderRetentionAction('archive')}><Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction('delete')}><Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</SessionTabsStrip>
</div>
)}
<div className="flex-1" />
{activeSurfaceHeader || isVSCode ? <div className="flex-1" /> : null}
<div className="flex shrink-0 items-center gap-1">
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? (
@@ -37,15 +37,24 @@ const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
/**
* Sortable shell for the active tab: the pill itself drags, while the
* interactive content inside (rename form, menu) stops pointer-down so a text
* selection or menu click never starts a drag.
* selection or menu click never starts a drag. The close button and the
* session menu (passed down from the header) reveal on hover, exactly like on
* inactive tabs.
*/
const ActiveTabShell: React.FC<{ id: string; children: React.ReactNode }> = ({ id, children }) => {
const ActiveTabShell: React.FC<{
id: string;
menu: React.ReactNode;
menuOpen: boolean;
onClose: () => void;
closeLabel: string;
children: React.ReactNode;
}> = ({ id, menu, menuOpen, onClose, closeLabel, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: DndCSS.Translate.toString(transform), transition }}
className={cn('session-tab-slot flex h-7 w-56 min-w-16 max-w-56 flex-shrink touch-none', isDragging && 'z-10 opacity-60')}
className={cn('session-tab-slot flex h-7 w-44 shrink-0 touch-none', isDragging && 'z-10 opacity-60')}
data-active-session-tab
data-active="true"
{...attributes}
@@ -54,9 +63,30 @@ const ActiveTabShell: React.FC<{ id: string; children: React.ReactNode }> = ({ i
<div
role="tab"
aria-selected
className="flex h-7 w-full min-w-0 items-center rounded-md bg-interactive-selection px-2"
className="group/session-tab relative flex h-7 w-full min-w-0 items-center rounded-md bg-interactive-selection px-2"
>
{children}
<div className={cn('min-w-0 flex-1', 'group-hover/session-tab:pr-10', menuOpen && 'pr-10')}>
{children}
</div>
<div
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
menuOpen && 'flex opacity-100',
)}
>
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
{menu}
</div>
</div>
</div>
);
@@ -86,7 +116,7 @@ const InactiveSessionTab: React.FC<{
<div
ref={setNodeRef}
style={{ transform: DndCSS.Translate.toString(transform), transition }}
className={cn('session-tab-slot flex h-7 w-56 min-w-10 max-w-56 flex-shrink', isDragging && 'z-10 opacity-60')}
className={cn('session-tab-slot flex h-7 w-44 shrink-0', isDragging && 'z-10 opacity-60')}
data-active="false"
{...attributes}
{...listeners}
@@ -118,25 +148,36 @@ const InactiveSessionTab: React.FC<{
<span
className={cn(
'min-w-0 flex-1 truncate text-[13px] font-medium leading-4',
'group-hover/session-tab:pr-5',
menuOpen && 'pr-5',
'group-hover/session-tab:pr-10',
menuOpen && 'pr-10',
)}
>
{title}
</span>
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
menuOpen && 'flex opacity-100',
)}
>
<button
type="button"
aria-label={t('header.sessionTabs.closeTab')}
onClick={() => onClose(tab.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.sessionTabs.tabMenuAria')}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground',
'opacity-0 transition-opacity duration-150 hover:text-foreground',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
menuOpen && 'flex opacity-100',
)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</button>
@@ -157,6 +198,7 @@ const InactiveSessionTab: React.FC<{
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
@@ -173,7 +215,13 @@ const InactiveSessionTab: React.FC<{
* in the store but do not render, so a partial session list never destroys
* the working set.
*/
export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const SessionTabsStrip: React.FC<{
/** The header's session menu for the active tab (already a DropdownMenu). */
menu?: React.ReactNode;
/** Whether that menu is open, so the hover overlay stays visible. */
menuOpen?: boolean;
children: React.ReactNode;
}> = ({ menu = null, menuOpen = false, children }) => {
const { t } = useI18n();
const tabIds = useSessionTabsStore((state) => state.tabIds);
const ensureTab = useSessionTabsStore((state) => state.ensureTab);
@@ -284,7 +332,18 @@ export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ chil
const renderTab = (tab: SessionTab) => {
if (tab.id === currentSessionId) {
return <ActiveTabShell key={tab.id} id={tab.id}>{children}</ActiveTabShell>;
return (
<ActiveTabShell
key={tab.id}
id={tab.id}
menu={menu}
menuOpen={menuOpen}
onClose={() => handleClose(tab.id)}
closeLabel={t('header.sessionTabs.closeTab')}
>
{children}
</ActiveTabShell>
);
}
return (
<InactiveSessionTab
@@ -323,10 +382,10 @@ export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ chil
<div
role="tab"
aria-selected
className="session-tab-slot flex h-7 w-56 min-w-16 max-w-56 flex-shrink items-center rounded-md bg-interactive-selection px-2"
className="session-tab-slot flex h-7 w-44 shrink-0 items-center rounded-md bg-interactive-selection px-2"
data-active="true"
>
{children}
<div className="min-w-0 flex-1">{children}</div>
</div>
) : null}
</div>