Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -178,6 +178,7 @@ export type LocalSettings = {
tabAttentionStyle: TabAttentionStyle
attentionDismiss: AttentionDismiss
sessionOpenMode: SessionOpenMode
multirowTabs: boolean
}
sidebar: {
sortMode: SidebarSortMode
Expand Down Expand Up @@ -435,6 +436,9 @@ function normalizeExtractedLocalSeed(patch: Record<string, unknown>): 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
}
Expand Down Expand Up @@ -723,6 +727,7 @@ export const defaultLocalSettings: LocalSettings = {
tabAttentionStyle: 'highlight',
attentionDismiss: 'click',
sessionOpenMode: 'tab',
multirowTabs: false,
},
sidebar: {
sortMode: 'activity',
Expand Down
105 changes: 86 additions & 19 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,6 +17,7 @@ import { TabSwitcher } from './TabSwitcher'
import {
DndContext,
closestCenter,
rectIntersection,
KeyboardSensor,
PointerSensor,
TouchSensor,
Expand All @@ -30,16 +31,24 @@ 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'
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
Expand Down Expand Up @@ -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',
}

Expand Down Expand Up @@ -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(), [])
Expand Down Expand Up @@ -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<HTMLDivElement | null>(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

Expand All @@ -395,22 +441,44 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp
}

return (
<div className="relative z-20 h-12 md:h-10 shrink-0 flex items-end px-2 bg-background" data-context={ContextIds.Global}>
<div className={cn(
"relative z-20 shrink-0 flex items-end px-2 bg-background",
multirowTabs ? "h-auto" : "h-12 md:h-10"
)} data-context={ContextIds.Global}>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-muted-foreground/45"
aria-hidden="true"
/>
{sidebarCollapsed && onToggleSidebar && (
<div
className={cn(
"flex-shrink-0 w-10 flex items-end justify-center pb-1",
!multirowTabs && "h-full"
)}
data-testid="desktop-sidebar-reopen-slot"
>
<button
className="p-1 min-h-11 min-w-11 md:h-8 md:w-8 md:min-h-0 md:min-w-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
title="Show sidebar"
aria-label="Show sidebar"
onClick={onToggleSidebar}
>
<PanelLeft className="h-3.5 w-3.5" />
</button>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
collisionDetection={multirowTabs ? rectIntersection : closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t: Tab) => t.id)}
strategy={horizontalListSortingStrategy}
strategy={multirowTabs ? rectSortingStrategy : horizontalListSortingStrategy}
>
{/* Left scroll arrow -- flex sibling alongside the scroll container */}
{!multirowTabs && (
<button
className={cn(
'flex-shrink-0 w-7 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-all duration-150',
Expand All @@ -427,26 +495,24 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp
>
<ChevronLeft className="h-4 w-4" />
</button>
)}

{/* Scrollable tab strip */}
<div
ref={callbackRef}
className="flex items-end gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-none pt-px flex-1 min-w-0"
>
{sidebarCollapsed && onToggleSidebar && (
<button
className="flex-shrink-0 mb-1 p-1 min-h-11 min-w-11 md:min-h-0 md:min-w-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
title="Show sidebar"
aria-label="Show sidebar"
onClick={onToggleSidebar}
>
<PanelLeft className="h-3.5 w-3.5" />
</button>
ref={combinedRef}
data-testid="tab-strip"
className={cn(
"flex items-end gap-0.5 pt-px flex-1 min-w-0",
multirowTabs
? "flex-wrap max-h-32 overflow-y-auto"
: "overflow-x-auto overflow-y-hidden scrollbar-none"
)}
>
{tabs.map(renderSortableTab)}
</div>

{/* Right scroll arrow -- flex sibling alongside the scroll container */}
{!multirowTabs && (
<button
className={cn(
'flex-shrink-0 w-7 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-all duration-150',
Expand All @@ -463,6 +529,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp
>
<ChevronRight className="h-4 w-4" />
</button>
)}
</SortableContext>

{/* Pinned + button -- outside the scrollable area */}
Expand Down
9 changes: 9 additions & 0 deletions src/components/settings/WorkspaceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ export default function WorkspaceSettings({
/>
</SettingsRow>

<SettingsRow label="Multi-row tabs" description="Show tabs in multiple rows instead of a single scrollable row.">
<Toggle
checked={settings.panes?.multirowTabs ?? false}
onChange={(checked) => {
applyLocalSetting({ panes: { multirowTabs: checked } })
}}
/>
</SettingsRow>

<SettingsRow label="Tab completion indicator">
<SegmentedControl
value={settings.panes?.tabAttentionStyle ?? 'highlight'}
Expand Down
21 changes: 13 additions & 8 deletions src/hooks/useTabBarScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface TabBarScrollResult extends TabBarScrollState {
const SCROLL_THRESHOLD = 2 // px tolerance for scroll boundary detection
const HOLD_SCROLL_SPEED = 4 // px per frame (~240px/s at 60fps)

export function useTabBarScroll(activeTabId: string | null, tabCount: number): TabBarScrollResult {
export function useTabBarScroll(activeTabId: string | null, tabCount: number, disabled = false): TabBarScrollResult {
const nodeRef = useRef<HTMLDivElement | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const holdRafRef = useRef<number | null>(null)
Expand All @@ -34,15 +34,20 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T

const updateOverflow = useCallback((el: HTMLDivElement | null) => {
if (!el) {
setOverflow({ canScrollLeft: false, canScrollRight: false })
setOverflow(prev =>
(prev.canScrollLeft === false && prev.canScrollRight === false) ? prev : { canScrollLeft: false, canScrollRight: false }
)
return
}

const { scrollLeft, scrollWidth, clientWidth } = el
setOverflow({
canScrollLeft: scrollLeft > SCROLL_THRESHOLD,
canScrollRight: scrollLeft + clientWidth < scrollWidth - SCROLL_THRESHOLD,
})
const canScrollLeft = scrollLeft > SCROLL_THRESHOLD
const canScrollRight = scrollLeft + clientWidth < scrollWidth - SCROLL_THRESHOLD
setOverflow(prev =>
(prev.canScrollLeft === canScrollLeft && prev.canScrollRight === canScrollRight)
? prev
: { canScrollLeft, canScrollRight }
)
}, [])

const callbackRef = useCallback((node: HTMLDivElement | null) => {
Expand All @@ -54,7 +59,7 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T

nodeRef.current = node

if (!node) {
if (!node || disabled) {
updateOverflow(null)
return
}
Expand Down Expand Up @@ -83,7 +88,7 @@ export function useTabBarScroll(activeTabId: string | null, tabCount: number): T

// Initial overflow check
updateOverflow(node)
}, [updateOverflow])
}, [updateOverflow, disabled])

// Clean up on unmount
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions src/store/browserPreferencesPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function buildLocalSettingsPatch(localSettings: LocalSettings): LocalSett
assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'tabAttentionStyle')
assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'attentionDismiss')
assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'sessionOpenMode')
assignChangedScalar(panes, localSettings.panes, defaultLocalSettings.panes, 'multirowTabs')
if (Object.keys(panes).length > 0) {
patch.panes = panes
}
Expand Down
39 changes: 39 additions & 0 deletions test/e2e-browser/specs/multirow-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '../helpers/fixtures.js'

test.describe('Multi-row tabs', () => {
async function openSettings(page: any) {
await page.getByRole('button', { name: /settings/i }).click()
await expect(page.getByRole('tab', { name: /^Appearance$/i })).toBeVisible({ timeout: 10_000 })
}

test('enables multi-row tabs via settings toggle', async ({ freshellPage: page }) => {
await openSettings(page)

const toggle = page.getByRole('switch', { name: /multi-row tabs/i })
await expect(toggle).toBeVisible({ timeout: 5_000 })
await expect(toggle).not.toBeChecked()
await toggle.click()
await expect(toggle).toBeChecked()
})

test('multi-row mode applies flex-wrap to tab strip', async ({ freshellPage: page }) => {
await page.evaluate(() => {
window.__FRESHELL_TEST_HARNESS__?.dispatch({
type: 'settings/updateSettingsLocal',
payload: { panes: { multirowTabs: true } },
})
})

const tabStrip = page.getByTestId('tab-strip')
await expect(tabStrip).toBeVisible({ timeout: 5_000 })
await expect(tabStrip).toHaveClass(/flex-wrap/)
await expect(tabStrip).toHaveClass(/max-h-32/)
})

test('single-row mode uses overflow-x-auto', async ({ freshellPage: page }) => {
const tabStrip = page.getByTestId('tab-strip')
await expect(tabStrip).toBeVisible({ timeout: 5_000 })
await expect(tabStrip).toHaveClass(/overflow-x-auto/)
await expect(tabStrip).not.toHaveClass(/flex-wrap/)
})
})
Loading
Loading