From 338dc73e962e893e7ca128897c908c7f762c5804 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 19:24:17 +0300 Subject: [PATCH] fix(header): one session-tab menu, hidden scrollbar, right-click, control order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip now owns a single dropdown per tab, fed by the header with items bound to that tab's session — rename (activates the tab first), copy id, share/copy link/unshare, export and move-to-worktree (active tab only, they need the loaded directory), close other tabs, archive and delete with the confirm dialog targeting the right session. The separate inactive-tab menu is gone, and there is no Close item — the tab's close button covers it, now placed after the menu button. Right-click opens that menu without activating the tab. The menu's anchor overlay stays mounted until the close animation finishes, which removes the popup flashing in the top-left corner on close. The scroller hides its scrollbar via a dedicated CSS class (the bar was shifting the header content vertically). --- packages/ui/src/components/layout/Header.tsx | 194 ++++++------ .../components/layout/SessionTabsStrip.tsx | 280 ++++++++---------- packages/ui/src/index.css | 12 + 3 files changed, 235 insertions(+), 251 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 526f6ee3..97259ea1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -49,7 +49,7 @@ import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; -import { SessionTabsStrip } from './SessionTabsStrip'; +import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts'; import { @@ -928,7 +928,7 @@ export const Header: React.FC = () => { const [isHeaderSessionMenuOpen, setIsHeaderSessionMenuOpen] = React.useState(false); const pendingHeaderRenameRef = React.useRef(false); const [headerSessionTitleDraft, setHeaderSessionTitleDraft] = React.useState(''); - const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<'archive' | 'delete' | null>(null); + const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<{ action: 'archive' | 'delete'; sessionId: string } | null>(null); const headerRenameFormRef = React.useRef(null); React.useEffect(() => { @@ -966,18 +966,18 @@ export const Header: React.FC = () => { return () => document.removeEventListener('mousedown', handleDocumentMouseDown); }, [isRenamingHeaderSession, saveHeaderSessionRename]); - const copyCurrentSessionId = React.useCallback(() => { - if (!currentSessionId) return; - void copyTextToClipboard(currentSessionId).then((result) => { + const copySessionIdFor = React.useCallback((sessionId: string) => { + if (!sessionId) return; + void copyTextToClipboard(sessionId).then((result) => { toast[result.ok ? 'success' : 'error'](t(result.ok ? 'sessions.sidebar.session.copyId.success' : 'sessions.sidebar.session.copyId.error')); }).catch(() => toast.error(t('sessions.sidebar.session.copyId.error'))); - }, [currentSessionId, t]); + }, [t]); - const shareCurrentSession = React.useCallback(async () => { - if (!currentSessionId) return; - const result = await shareSession(currentSessionId); + const shareSessionFor = React.useCallback(async (sessionId: string) => { + if (!sessionId) return; + const result = await shareSession(sessionId); if (result?.share?.url) { const copied = await copyTextToClipboard(result.share.url); toast[copied.ok ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), { @@ -988,25 +988,24 @@ export const Header: React.FC = () => { return; } toast.error(t('sessions.sidebar.session.share.error')); - }, [currentSessionId, shareSession, t]); + }, [shareSession, t]); - const copyCurrentSessionShareUrl = React.useCallback(() => { - const shareUrl = currentSession?.shareUrl; + const copySessionShareUrl = React.useCallback((shareUrl: string | null | undefined) => { if (!shareUrl) return; void copyTextToClipboard(shareUrl).then((result) => { toast[result.ok ? 'success' : 'error'](t(result.ok ? 'sessions.sidebar.session.menu.copied' : 'sessions.sidebar.session.share.copyUrlError')); }).catch(() => toast.error(t('sessions.sidebar.session.share.copyUrlError'))); - }, [currentSession?.shareUrl, t]); + }, [t]); - const unshareCurrentSession = React.useCallback(async () => { - if (!currentSessionId) return; - const result = await unshareSession(currentSessionId); + const unshareSessionFor = React.useCallback(async (sessionId: string) => { + if (!sessionId) return; + const result = await unshareSession(sessionId); toast[result ? 'success' : 'error'](t(result ? 'sessions.sidebar.session.unshare.success' : 'sessions.sidebar.session.unshare.error')); - }, [currentSessionId, t, unshareSession]); + }, [t, unshareSession]); const exportCurrentSession = React.useCallback(async () => { if (!currentSessionId || !openDirectory) { @@ -1059,9 +1058,9 @@ export const Header: React.FC = () => { }, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]); const confirmHeaderRetentionAction = React.useCallback(async () => { - if (!currentSessionId || !pendingHeaderRetentionAction) return; + if (!pendingHeaderRetentionAction) return; const sessions = useGlobalSessionsStore.getState().activeSessions; - const ids = [currentSessionId]; + const ids = [pendingHeaderRetentionAction.sessionId]; for (let index = 0; index < ids.length; index += 1) { const parentId = ids[index]; for (const session of sessions) { @@ -1070,7 +1069,7 @@ export const Header: React.FC = () => { } } } - const action = pendingHeaderRetentionAction; + const action = pendingHeaderRetentionAction.action; setPendingHeaderRetentionAction(null); const result = action === 'archive' ? await archiveSessions(ids) : await deleteSessions(ids); const failedIds = result.failedIds; @@ -1083,7 +1082,7 @@ export const Header: React.FC = () => { toast.success(t(action === 'archive' ? 'sessions.sidebar.session.archive.success' : 'sessions.sidebar.session.delete.success')); - }, [archiveSessions, currentSessionId, deleteSessions, pendingHeaderRetentionAction, t]); + }, [archiveSessions, deleteSessions, pendingHeaderRetentionAction, t]); // Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the // chat area; while one is open the header shows the surface identity @@ -1531,6 +1530,75 @@ export const Header: React.FC = () => { const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId)); + const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs }: SessionTabMenuArgs) => { + const shareUrl = session.share?.url ?? null; + const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; + return ( + <> + { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> + {t('sessions.sidebar.session.menu.rename')} + + copySessionIdFor(session.id)}> + {t('sessions.sidebar.session.menu.copyId')} + + + {shareUrl ? ( + <> + copySessionShareUrl(shareUrl)}> + {t('sessions.sidebar.session.menu.copyLink')} + + void unshareSessionFor(session.id)}> + {t('sessions.sidebar.session.menu.unshare')} + + + ) : ( + void shareSessionFor(session.id)}> + {t('sessions.sidebar.session.menu.share')} + + )} + {isActive ? ( + void exportCurrentSession()}> + {t('sessions.sidebar.session.menu.exportMarkdown')} + + ) : null} + {canMoveToWorktree ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} + + + {t('header.sessionTabs.closeOtherTabs')} + + + setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> + {t('sessions.sidebar.bulkActions.archive')} + + setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> + {t('sessions.sidebar.bulkActions.delete')} + + + ); + }, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]); + const renderDesktop = () => (
{ { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} - {t('sessions.sidebar.session.menu.copyId')} + currentSessionId && copySessionIdFor(currentSessionId)}>{t('sessions.sidebar.session.menu.copyId')} {currentSession?.shareUrl ? ( <> - {t('sessions.sidebar.session.menu.copyLink')} - void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + copySessionShareUrl(currentSession?.shareUrl)}>{t('sessions.sidebar.session.menu.copyLink')} + { if (currentSessionId) void unshareSessionFor(currentSessionId); }}>{t('sessions.sidebar.session.menu.unshare')} ) : ( - void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + { if (currentSessionId) void shareSessionFor(currentSessionId); }}>{t('sessions.sidebar.session.menu.share')} )} void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( @@ -1713,8 +1781,8 @@ export const Header: React.FC = () => { ) : null} - setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} - setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'archive', sessionId: currentSessionId }); }}>{t('sessions.sidebar.bulkActions.archive')} + { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'delete', sessionId: currentSessionId }); }}>{t('sessions.sidebar.bulkActions.delete')} ) : null} @@ -1734,65 +1802,13 @@ export const Header: React.FC = () => { ) : null} { - if (!open && pendingHeaderRenameRef.current) { - pendingHeaderRenameRef.current = false; - beginHeaderSessionRename(); - } - }} - > - - - - - { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} - {t('sessions.sidebar.session.menu.copyId')} - - {currentSession?.shareUrl ? ( - <> - {t('sessions.sidebar.session.menu.copyLink')} - void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} - - ) : ( - void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} - )} - void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} - {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( - - - - - - {t('sessions.sidebar.session.menu.moveToWorktree')} - - - - - {isCurrentSessionMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isCurrentSessionActive - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltip')} - - - ) : null} - - setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} - setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} - - - ) : null} + renderMenu={renderSessionTabMenu} + onMenuOpenChangeComplete={(open) => { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} >
{isRenamingHeaderSession ? ( @@ -1933,10 +1949,10 @@ export const Header: React.FC = () => { { if (!open) setPendingHeaderRetentionAction(null); }}> - {pendingHeaderRetentionAction === 'delete' + {pendingHeaderRetentionAction?.action === 'delete' ? t('sessions.sidebar.dialogs.deleteSession.title') : t('sessions.sidebar.dialogs.archiveSession.title')} - {pendingHeaderRetentionAction === 'delete' + {pendingHeaderRetentionAction?.action === 'delete' ? t('sessions.sidebar.dialogs.deleteSession.single', { sessionTitle: currentSessionTitle }) : t('sessions.sidebar.dialogs.archiveSession.single', { sessionTitle: currentSessionTitle })} @@ -1945,7 +1961,7 @@ export const Header: React.FC = () => { {t('sessions.sidebar.dialogs.cancel')} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 51df891e..e75f0dc4 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -20,113 +20,75 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { copyTextToClipboard } from '@/lib/clipboard'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); +type SessionTab = { id: string; session: Session }; + +export type SessionTabMenuArgs = { + session: Session; + isActive: boolean; + select: () => void; + closeOtherTabs: () => void; +}; + /** - * 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. The close button and the - * session menu (passed down from the header) reveal on hover, exactly like on - * inactive tabs. + * One tab, active or not. The tab drags to reorder; the menu and close + * controls sit in a hover-revealed overlay at the tab's end (menu first, + * close after it). The single session menu is supplied by the header via + * `renderMenu`, bound to this tab's session; right-click opens it without + * changing which tab is active. The overlay stays visible until the menu's + * close animation completes, so the popup never loses its anchor mid-flight + * (that was the top-left corner flash). */ -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 }); +const SessionTabItem: React.FC<{ + tab: SessionTab; + isActive: boolean; + onSelect: (tab: SessionTab) => void; + onClose: (id: string) => void; + renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; + closeOtherTabs: (id: string) => void; + onMenuOpenChangeComplete?: (open: boolean) => void; + children?: React.ReactNode; +}> = ({ tab, isActive, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { + const { t } = useI18n(); + const [menuOpen, setMenuOpen] = React.useState(false); + // Keeps the overlay (the menu's anchor) mounted through the close animation. + const [menuVisible, setMenuVisible] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); + + const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); + const overlayVisible = menuOpen || menuVisible; + + const openMenu = React.useCallback(() => { + setMenuVisible(true); + setMenuOpen(true); + }, []); + return (
-
- {children} -
-
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', - )} - > - - {menu} -
-
-
- ); -}; - -type SessionTab = { id: string; session: Session }; - -/** - * One inactive tab: a soft pill with the session title. The "..." menu trigger - * has no reserved footprint — it appears at the tab's end on hover (or while - * its menu is open), nudging the title, mirroring the sidebar row mechanic. - * The reveal itself is opacity-only; the layout change is instant. - */ -const InactiveSessionTab: React.FC<{ - tab: SessionTab; - onSelect: (tab: SessionTab) => void; - onClose: (id: string) => void; - onCloseOthers: (id: string) => void; -}> = ({ tab, onSelect, onClose, onCloseOthers }) => { - const { t } = useI18n(); - const [menuOpen, setMenuOpen] = React.useState(false); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); - - const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); - - return ( -
-
onSelect(tab)} - onKeyDown={(event) => { + aria-selected={isActive} + tabIndex={isActive ? undefined : 0} + onClick={isActive ? undefined : () => onSelect(tab)} + onKeyDown={isActive ? undefined : (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(tab); @@ -138,22 +100,27 @@ const InactiveSessionTab: React.FC<{ onClose(tab.id); } }} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + openMenu(); + }} className={cn( - 'group/session-tab relative flex h-7 w-full min-w-0 cursor-pointer touch-none select-none items-center rounded-md px-2', - 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', - menuOpen && 'bg-interactive-hover text-foreground', + 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', + isActive + ? 'bg-interactive-selection' + : cn( + 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', + overlayVisible && 'bg-interactive-hover text-foreground', + ), )} - title={title} + title={isActive ? undefined : title} > - + {isActive ? children : ( + {title} )} - > - {title} - +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} @@ -161,9 +128,38 @@ const InactiveSessionTab: React.FC<{ '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', + overlayVisible && 'flex opacity-100', )} > + { + setMenuOpen(open); + if (open) setMenuVisible(true); + }} + onOpenChangeComplete={(open) => { + if (!open) setMenuVisible(false); + onMenuOpenChangeComplete?.(open); + }} + > + + + + + {renderMenu({ + session: tab.session, + isActive, + select: () => onSelect(tab), + closeOtherTabs: () => closeOtherTabs(tab.id), + })} + + - - - - - - onClose(tab.id)}> - - {t('header.sessionTabs.closeTab')} - - onCloseOthers(tab.id)}> - - {t('header.sessionTabs.closeOtherTabs')} - - - void copyTextToClipboard(tab.id)}> - - {t('sessions.sidebar.session.menu.copyId')} - - -
@@ -208,20 +178,19 @@ const InactiveSessionTab: React.FC<{ * The header's horizontal working set of sessions (web/desktop only). * * Every session the user opens joins the strip once; the tab whose session is - * current renders `children` — the header's existing title block with rename, - * meta row and the full session menu — inside a softly selected pill. Closing - * a tab only removes it from the strip; closing the active one activates its - * neighbour. Ids whose session has not loaded (or was archived/deleted) stay - * in the store but do not render, so a partial session list never destroys - * the working set. + * current renders `children` — the header's title/rename block — inside a + * selected pill. Closing a tab only removes it from the strip; closing the + * active one activates its neighbour. Ids whose session has not loaded (or + * was archived/deleted) stay in the store but do not render, so a partial + * session list never destroys the working set. */ 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; + /** Menu items for one tab's session, supplied by the header. */ + renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; + /** Fires when a tab menu finishes opening/closing (deferred rename hook). */ + onMenuOpenChangeComplete?: (open: boolean) => void; children: React.ReactNode; -}> = ({ menu = null, menuOpen = false, children }) => { +}> = ({ renderMenu, onMenuOpenChangeComplete, children }) => { const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); @@ -330,32 +299,6 @@ export const SessionTabsStrip: React.FC<{ const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]); - const renderTab = (tab: SessionTab) => { - if (tab.id === currentSessionId) { - return ( - handleClose(tab.id)} - closeLabel={t('header.sessionTabs.closeTab')} - > - {children} - - ); - } - return ( - - ); - }; - // A brand-new draft (no session yet) shows as a transient active pill after // the tabs; it becomes a real tab once the first message creates the session. const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId); @@ -365,7 +308,7 @@ export const SessionTabsStrip: React.FC<{
- {tabs.map(renderTab)} + {tabs.map((tab) => ( + + {tab.id === currentSessionId ? children : null} + + ))} {showDraftPill ? ( diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d37798cc..1cec5c79 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1803,3 +1803,15 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { .session-tab-slot:not([data-active='true']):hover + .session-tab-slot::before { display: none; } + +/* Header session tabs scroller: never show a scrollbar (it shifts the header + content vertically); overflow is communicated by the edge fades. */ +.session-tabs-scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} +.session-tabs-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +}