From f52e924b5be799bfda7212744a66cd3c0cb6965b Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Mon, 18 May 2026 03:23:06 -0700 Subject: [PATCH 1/3] feat: port multi-row tabs setting to main --- docs/index.html | 3 + shared/settings.ts | 7 +- src/components/TabBar.tsx | 141 +++++++--- src/components/settings/WorkspaceSettings.tsx | 9 + src/hooks/useTabBarScroll.ts | 21 +- src/store/browserPreferencesPersistence.ts | 1 + test/e2e-browser/specs/multirow-tabs.spec.ts | 39 +++ .../components/TabBar.multirow.test.tsx | 240 ++++++++++++++++++ .../browserPreferencesPersistence.test.ts | 20 ++ test/unit/shared/settings.test.ts | 42 +++ 10 files changed, 473 insertions(+), 50 deletions(-) create mode 100644 test/e2e-browser/specs/multirow-tabs.spec.ts create mode 100644 test/unit/client/components/TabBar.multirow.test.tsx diff --git a/docs/index.html b/docs/index.html index 90206eb5a..68a6ca177 100644 --- a/docs/index.html +++ b/docs/index.html @@ -97,6 +97,9 @@ background: hsl(var(--background)); flex-shrink: 0; position: relative; z-index: 20; transition: background-color .2s; gap: 2px; } +.tabbar.multirow { + height: auto; max-height: 128px; flex-wrap: wrap; overflow-y: auto; +} .tabbar::after { content: ''; position: absolute; inset: auto 0 0 0; height: 1px; background: hsl(var(--muted-foreground) / .45); z-index: 0; diff --git a/shared/settings.ts b/shared/settings.ts index 71ce9532d..928c4c1a3 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -53,7 +53,7 @@ const TERMINAL_LOCAL_KEYS = [ 'osc52Clipboard', 'renderer', ] as const -const PANES_LOCAL_KEYS = ['snapThreshold', 'iconsOnTabs', 'tabAttentionStyle', 'attentionDismiss', 'sessionOpenMode'] as const +const PANES_LOCAL_KEYS = ['snapThreshold', 'iconsOnTabs', 'tabAttentionStyle', 'attentionDismiss', 'sessionOpenMode', 'multirowTabs'] as const const SIDEBAR_LOCAL_KEYS = [ 'sortMode', 'worktreeGrouping', @@ -178,6 +178,7 @@ export type LocalSettings = { tabAttentionStyle: TabAttentionStyle attentionDismiss: AttentionDismiss sessionOpenMode: SessionOpenMode + multirowTabs: boolean } sidebar: { sortMode: SidebarSortMode @@ -435,6 +436,9 @@ function normalizeExtractedLocalSeed(patch: Record): LocalSetti if (SessionOpenModeSchema.safeParse(patch.panes.sessionOpenMode).success) { panes.sessionOpenMode = patch.panes.sessionOpenMode as SessionOpenMode } + if (typeof patch.panes.multirowTabs === 'boolean') { + panes.multirowTabs = patch.panes.multirowTabs as boolean + } if (Object.keys(panes).length > 0) { normalized.panes = panes } @@ -723,6 +727,7 @@ export const defaultLocalSettings: LocalSettings = { tabAttentionStyle: 'highlight', attentionDismiss: 'click', sessionOpenMode: 'tab', + multirowTabs: false, }, sidebar: { sortMode: 'activity', diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 2d170f67b..e68386ec6 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -7,7 +7,7 @@ import { getWsClient } from '@/lib/ws-client' import { getTabDisplayTitle } from '@/lib/tab-title' import { collectPaneEntries, collectTerminalIds } from '@/lib/pane-utils' import { getBusyPaneIdsForTab } from '@/lib/pane-activity' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTabBarScroll } from '@/hooks/useTabBarScroll' import TabItem from './TabItem' import { cancelCodingCliRequest } from '@/store/codingCliSlice' @@ -17,6 +17,7 @@ import { TabSwitcher } from './TabSwitcher' import { DndContext, closestCenter, + rectIntersection, KeyboardSensor, PointerSensor, TouchSensor, @@ -30,9 +31,10 @@ import { SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, + rectSortingStrategy, useSortable, } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' +import { CSS as DndCSS } from '@dnd-kit/utilities' import type { Tab, TabAttentionStyle } from '@/store/types' import type { PaneContent, PaneNode } from '@/store/paneTypes' import type { ChatSessionState } from '@/store/agentChatTypes' @@ -40,6 +42,13 @@ import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice import { ContextIds } from '@/components/context-menu/context-menu-constants' import { applyTabRename } from '@/store/titleSync' +function escapeSelector(id: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(id) + } + return id.replace(/(["\\])/g, '\\$1') +} + interface SortableTabProps { tab: Tab displayTitle: string @@ -90,7 +99,7 @@ function SortableTab({ } = useSortable({ id: tab.id }) const style = { - transform: CSS.Transform.toString(transform), + transform: DndCSS.Transform.toString(transform), transition: transition || 'transform 150ms ease', } @@ -161,6 +170,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp const attentionDismiss = useAppSelector((s) => s.settings?.settings?.panes?.attentionDismiss ?? 'click') const iconsOnTabs = useAppSelector((s) => s.settings?.settings?.panes?.iconsOnTabs ?? true) const tabAttentionStyle = useAppSelector((s) => s.settings?.settings?.panes?.tabAttentionStyle ?? 'highlight') + const multirowTabs = useAppSelector((s) => s.settings?.settings?.panes?.multirowTabs ?? false) const extensions = useAppSelector((s) => s.extensions?.entries) const ws = useMemo(() => getWsClient(), []) @@ -369,11 +379,47 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp callbackRef, canScrollLeft, canScrollRight, + scrollToTab, handleArrowClick, startHoldScroll, stopHoldScroll, cancelHoldScroll, - } = useTabBarScroll(activeTabId, tabs.length) + } = useTabBarScroll(activeTabId, tabs.length, multirowTabs) + + // Container ref for multirow auto-scroll (scoped, not global DOM query) + const multirowContainerRef = useRef(null) + const combinedRef = useCallback((node: HTMLDivElement | null) => { + callbackRef(node) + multirowContainerRef.current = node + }, [callbackRef]) + + // Container-scoped scroll for active tab in multirow mode (vertical) + useEffect(() => { + if (!multirowTabs || !activeTabId) return + const container = multirowContainerRef.current + if (!container) return + const tabEl = container.querySelector(`[data-tab-id="${escapeSelector(activeTabId)}"]`) as HTMLElement | null + if (!tabEl) return + const containerRect = container.getBoundingClientRect() + const tabRect = tabEl.getBoundingClientRect() + // Only scroll if tab is outside the visible area + if (tabRect.top < containerRect.top || tabRect.bottom > containerRect.bottom) { + const offset = tabRect.top - containerRect.top - (containerRect.height / 2) + (tabRect.height / 2) + container.scrollBy({ top: offset, behavior: 'smooth' }) + } + }, [activeTabId, multirowTabs]) + + // Re-fire horizontal scroll when transitioning from multirow to single-row + const prevMultirowRef = useRef(multirowTabs) + useEffect(() => { + let raf: number | null = null + if (prevMultirowRef.current && !multirowTabs && activeTabId) { + // Defer to next frame so the DOM has re-rendered with single-row layout + raf = requestAnimationFrame(() => scrollToTab(activeTabId)) + } + prevMultirowRef.current = multirowTabs + return () => { if (raf !== null) cancelAnimationFrame(raf) } + }, [multirowTabs, activeTabId, scrollToTab]) const activeTab = activeId ? tabs.find((t: Tab) => t.id === activeId) : null @@ -395,43 +441,54 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp } return ( -
+