mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
perf(tab-bar): a change to one tab no longer re-renders every tab (#24261)
With many tabs open, a change to any one tab (a retitle, an agent finishing, a tab switch, a git status write, or a browser tab update on SSH and web clients) re-rendered every tab in the strip, so the strip stuttered. Each tab is now a memoized row that re-renders only when its own values change, with stable handlers, a stable drag id list and stable drag sensor options. Editor tabs get their own git status, and mirrored browser tabs keep their page-id list while the ids don't change. Part of #24241: opening, closing or reordering a tab still re-renders every tab once.
This commit is contained in:
@@ -260,7 +260,7 @@ async function renderEditorFileTab(
|
||||
hasTabsToRight: false,
|
||||
hasTabsToLeft: false,
|
||||
tabCount: 1,
|
||||
statusByRelativePath: new Map(),
|
||||
gitStatus: null,
|
||||
onActivate,
|
||||
onClose: () => {},
|
||||
onCloseOthers: () => {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable'
|
||||
import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { basename, normalizeRelativePath } from '@/lib/path'
|
||||
import { basename } from '@/lib/path'
|
||||
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
|
||||
import { renameFileOnDisk } from '@/lib/rename-file'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
@@ -40,7 +40,7 @@ export default function EditorFileTab({
|
||||
hasTabsToRight,
|
||||
hasTabsToLeft,
|
||||
tabCount,
|
||||
statusByRelativePath,
|
||||
gitStatus: tabStatus,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
@@ -59,7 +59,7 @@ export default function EditorFileTab({
|
||||
hasTabsToRight: boolean
|
||||
hasTabsToLeft: boolean
|
||||
tabCount: number
|
||||
statusByRelativePath: Map<string, GitFileStatus>
|
||||
gitStatus: GitFileStatus | null
|
||||
onActivate: () => void
|
||||
onClose: () => void
|
||||
onCloseOthers: () => void
|
||||
@@ -196,10 +196,6 @@ export default function EditorFileTab({
|
||||
[file.filePath]
|
||||
)
|
||||
|
||||
const tabStatus =
|
||||
file.relativePath === 'All Changes'
|
||||
? null
|
||||
: (statusByRelativePath.get(normalizeRelativePath(file.relativePath)) ?? null)
|
||||
const tabStatusColor = tabStatus ? STATUS_COLORS[tabStatus] : undefined
|
||||
const tabLabel = getEditorDisplayLabel(file)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ async function renderTerminalStrip(): Promise<Record<string, unknown> | null> {
|
||||
onSetTabColor: () => {},
|
||||
onTogglePaneExpand: () => {}
|
||||
}),
|
||||
'SortableTab'
|
||||
'TabBarItemRow'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ const useAppStoreMock = vi.fn(
|
||||
|
||||
vi.mock('react', async () => await stubHeadlessReact())
|
||||
vi.mock('zustand/react/shallow', () => stubShallowSelector())
|
||||
// The headless React stub has no dispatcher for the hook each tab row subscribes to language changes with.
|
||||
vi.mock('react-i18next', async () => ({
|
||||
...(await vi.importActual<Record<string, unknown>>('react-i18next')),
|
||||
useTranslation: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', async () => (await import('./lucide-icon-stub-fixture')).stubEveryIcon())
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTabBarRuntimeModel } from './use-tab-bar-runtime-model'
|
||||
import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
|
||||
import { useTabBarItemProjection } from './use-tab-bar-item-projection'
|
||||
import { renderTabBarSurface } from './tab-bar-surface'
|
||||
import { useTabBarItemActions } from './use-tab-bar-item-actions'
|
||||
import { useActiveClientHostedBrowserRowId } from '@/lib/pane-manager/client-hosted-browser-row-state'
|
||||
|
||||
function TabBarInner(props: TabBarProps): React.JSX.Element {
|
||||
@@ -64,6 +65,11 @@ function TabBarInner(props: TabBarProps): React.JSX.Element {
|
||||
}
|
||||
runtime.pinTab(item.unifiedTabId)
|
||||
}
|
||||
const itemActions = useTabBarItemActions({
|
||||
props,
|
||||
togglePinned,
|
||||
toggleTabViewMode: runtime.toggleTabViewMode
|
||||
})
|
||||
// Read here, not just where the rows render: the real tabs have to know when a row took over.
|
||||
const activeClientHostedBrowserRowId = useActiveClientHostedBrowserRowId({
|
||||
worktreeId,
|
||||
@@ -92,7 +98,7 @@ function TabBarInner(props: TabBarProps): React.JSX.Element {
|
||||
tabStripNavigation,
|
||||
tabStripDragScroll,
|
||||
activeClientHostedBrowserRowId,
|
||||
togglePinned
|
||||
itemActions
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, startTransition, Suspense, use } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearClientHostedBrowserRowSelection,
|
||||
getClientHostedBrowserRowSelection,
|
||||
selectClientHostedBrowserRow
|
||||
} from '@/lib/pane-manager/client-hosted-browser-row-state'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import type { TabBarItem } from './tab-bar-item-model'
|
||||
import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types'
|
||||
import {
|
||||
renderTabBarItems,
|
||||
type TabBarItemSurfaceProps,
|
||||
type TabBarItemSurfaceRuntime
|
||||
} from './tab-bar-item-surface'
|
||||
import { useTabBarItemActions } from './use-tab-bar-item-actions'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type TabProps = {
|
||||
tab?: { id: string; title: string }
|
||||
isActive: boolean
|
||||
isPinned: boolean
|
||||
onActivate: (id: string) => void
|
||||
onDuplicate?: () => void
|
||||
gitStatus?: string | null
|
||||
}
|
||||
|
||||
// Every render of every tab, in order, keyed by the id the strip shows it under.
|
||||
const tabRenders: { id: string; props: TabProps }[] = []
|
||||
|
||||
vi.mock('./SortableTab', () => ({
|
||||
default: (props: TabProps) => {
|
||||
tabRenders.push({ id: props.tab!.id, props })
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./BrowserTab', () => ({
|
||||
default: (props: TabProps) => {
|
||||
tabRenders.push({ id: props.tab!.id, props })
|
||||
return null
|
||||
},
|
||||
getBrowserTabLabel: () => ''
|
||||
}))
|
||||
vi.mock('./EditorFileTab', () => ({
|
||||
default: (props: TabProps & { file: { id: string } }) => {
|
||||
tabRenders.push({ id: props.file.id, props })
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
/** Rebuilt on every call, like the strip's projections: equal content, never the same objects. */
|
||||
function buildItems({ terminalPinned = false, browserTitle = 'Example' } = {}): TabBarItem[] {
|
||||
return [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal-1',
|
||||
unifiedTabId: 'unified-terminal-1',
|
||||
isPinned: terminalPinned,
|
||||
data: {
|
||||
id: 'terminal-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'zsh',
|
||||
generatedTitle: 'Fix the login bug',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'browser',
|
||||
id: 'browser-1',
|
||||
unifiedTabId: 'unified-browser-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.test',
|
||||
title: browserTitle,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'editor',
|
||||
id: 'file-1',
|
||||
unifiedTabId: 'unified-file-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'file-1',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
isPreview: false,
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'session-1',
|
||||
unifiedTabId: 'session-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'session-1',
|
||||
entityId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'agent-session',
|
||||
label: 'Session',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 3,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
type StripInputs = {
|
||||
onActivate?: (id: string) => void
|
||||
generatedTabTitlesEnabled?: boolean
|
||||
managedBrowserCreationEnabled?: boolean
|
||||
terminalPinned?: boolean
|
||||
browserTitle?: string
|
||||
activeTabType?: WorkspaceVisibleTabType
|
||||
activeClientHostedBrowserRowId?: string | null
|
||||
statusByRelativePath?: TabBarItemSurfaceRuntime['statusByRelativePath']
|
||||
}
|
||||
|
||||
function Strip({
|
||||
onActivate = NOOP,
|
||||
generatedTabTitlesEnabled = false,
|
||||
managedBrowserCreationEnabled = false,
|
||||
terminalPinned,
|
||||
browserTitle,
|
||||
activeTabType = 'terminal',
|
||||
activeClientHostedBrowserRowId = null,
|
||||
statusByRelativePath = STATUS_BY_RELATIVE_PATH
|
||||
}: StripInputs): React.JSX.Element {
|
||||
const props: TabBarItemSurfaceProps = {
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'terminal-1',
|
||||
activeFileId: 'file-1',
|
||||
activeBrowserTabId: 'browser-1',
|
||||
activeSimulatorTabId: null,
|
||||
activeTabType,
|
||||
expandedPaneByTabId: {}
|
||||
}
|
||||
const runtime: TabBarItemSurfaceRuntime = {
|
||||
resolvedGroupId: 'group-1',
|
||||
generatedTabTitlesEnabled,
|
||||
unifiedTabByVisibleId: new Map(),
|
||||
nativeChatEnabled: false,
|
||||
tabAgentTypesByTabId: {},
|
||||
nativeChatTabWideFallbackUnsafeTabsById: {},
|
||||
nativeChatTranscriptIsLocalReadable: false,
|
||||
managedBrowserCreationEnabled,
|
||||
statusByRelativePath
|
||||
}
|
||||
const actions = useTabBarItemActions({
|
||||
props: {
|
||||
onActivate,
|
||||
onActivateFile: onActivate,
|
||||
onActivateBrowserTab: onActivate,
|
||||
onActivateAgentSession: onActivate,
|
||||
onClose: NOOP,
|
||||
onCloseOthers: NOOP,
|
||||
onCloseToRight: NOOP,
|
||||
onCloseToLeft: NOOP,
|
||||
onSetCustomTitle: NOOP,
|
||||
onSetTabColor: NOOP,
|
||||
onTogglePaneExpand: NOOP
|
||||
},
|
||||
togglePinned: NOOP,
|
||||
toggleTabViewMode: NOOP
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{renderTabBarItems({
|
||||
items: buildItems({ terminalPinned, browserTitle }),
|
||||
props,
|
||||
runtime,
|
||||
actions,
|
||||
dropIndicatorByVisibleId: new Map(),
|
||||
includeTopTabBorder: true,
|
||||
activeClientHostedBrowserRowId
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const NOOP = (): void => {}
|
||||
const STATUS_BY_RELATIVE_PATH: TabBarItemSurfaceRuntime['statusByRelativePath'] = new Map()
|
||||
const TAB_IDS = ['terminal-1', 'browser-1', 'file-1', 'session-1']
|
||||
let root: Root | null = null
|
||||
|
||||
const NEVER_RESOLVES = new Promise<never>(() => {})
|
||||
|
||||
function Suspended(): null {
|
||||
use(NEVER_RESOLVES)
|
||||
return null
|
||||
}
|
||||
|
||||
function stripTree(inputs: StripInputs, suspended = false): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Strip {...inputs} />
|
||||
{suspended ? <Suspended /> : null}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStrip(inputs: StripInputs = {}): void {
|
||||
if (!root) {
|
||||
root = createRoot(document.createElement('div'))
|
||||
}
|
||||
act(() => root!.render(stripTree(inputs)))
|
||||
}
|
||||
|
||||
function lastRender(tabId: string): TabProps {
|
||||
return tabRenders.findLast((render) => render.id === tabId)!.props
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
tabRenders.length = 0
|
||||
clearClientHostedBrowserRowSelection()
|
||||
})
|
||||
|
||||
describe('tab strip rows', () => {
|
||||
it('skips every tab when the strip re-renders with equal tab content', () => {
|
||||
renderStrip()
|
||||
expect(tabRenders.map((render) => render.id)).toEqual(TAB_IDS)
|
||||
|
||||
renderStrip({ onActivate: vi.fn() })
|
||||
|
||||
expect(tabRenders).toHaveLength(TAB_IDS.length)
|
||||
})
|
||||
|
||||
it('calls the handler the strip has now, from a tab that skipped its render', () => {
|
||||
const first = vi.fn()
|
||||
const current = vi.fn()
|
||||
renderStrip({ onActivate: first })
|
||||
renderStrip({ onActivate: current })
|
||||
|
||||
lastRender('terminal-1').onActivate('terminal-1')
|
||||
|
||||
expect(current).toHaveBeenCalledWith('terminal-1')
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the committed handler when React abandons a render', async () => {
|
||||
const committed = vi.fn()
|
||||
const abandoned = vi.fn()
|
||||
renderStrip({ onActivate: committed })
|
||||
await act(async () =>
|
||||
startTransition(() => root!.render(stripTree({ onActivate: abandoned }, true)))
|
||||
)
|
||||
|
||||
lastRender('terminal-1').onActivate('terminal-1')
|
||||
|
||||
expect(committed).toHaveBeenCalledWith('terminal-1')
|
||||
expect(abandoned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-renders every tab when the language is re-applied, as a language-pack reload does', async () => {
|
||||
renderStrip()
|
||||
|
||||
await act(() => i18n.changeLanguage(i18n.language))
|
||||
|
||||
expect(tabRenders.map((render) => render.id)).toEqual([...TAB_IDS, ...TAB_IDS])
|
||||
})
|
||||
|
||||
it('re-renders a terminal tab when the generated-titles setting changes its title', () => {
|
||||
renderStrip()
|
||||
expect(lastRender('terminal-1').tab?.title).toBe('zsh')
|
||||
|
||||
renderStrip({ generatedTabTitlesEnabled: true })
|
||||
|
||||
expect(lastRender('terminal-1').tab?.title).toBe('Fix the login bug')
|
||||
})
|
||||
|
||||
it('re-renders a browser tab when duplicating becomes available', () => {
|
||||
renderStrip()
|
||||
expect(lastRender('browser-1').onDuplicate).toBeUndefined()
|
||||
|
||||
renderStrip({ managedBrowserCreationEnabled: true })
|
||||
|
||||
expect(lastRender('browser-1').onDuplicate).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('re-renders a tab when it is pinned', () => {
|
||||
renderStrip()
|
||||
expect(lastRender('terminal-1').isPinned).toBe(false)
|
||||
|
||||
renderStrip({ terminalPinned: true })
|
||||
|
||||
expect(lastRender('terminal-1').isPinned).toBe(true)
|
||||
})
|
||||
|
||||
it('re-renders only the two tabs a switch moves the active state between', () => {
|
||||
renderStrip({ activeTabType: 'terminal' })
|
||||
tabRenders.length = 0
|
||||
|
||||
renderStrip({ activeTabType: 'browser' })
|
||||
|
||||
expect(tabRenders.map((render) => render.id)).toEqual(['terminal-1', 'browser-1'])
|
||||
})
|
||||
|
||||
it('re-renders a tab when its own data changes', () => {
|
||||
renderStrip()
|
||||
|
||||
renderStrip({ browserTitle: 'Renamed page' })
|
||||
|
||||
expect(lastRender('browser-1').tab?.title).toBe('Renamed page')
|
||||
expect(tabRenders).toHaveLength(TAB_IDS.length + 1)
|
||||
})
|
||||
|
||||
it('re-renders an editor tab only when a git status write changes its own status', () => {
|
||||
renderStrip()
|
||||
|
||||
renderStrip({ statusByRelativePath: new Map([['other.md', 'modified']]) })
|
||||
expect(tabRenders).toHaveLength(TAB_IDS.length)
|
||||
|
||||
renderStrip({ statusByRelativePath: new Map([['notes.md', 'modified']]) })
|
||||
expect(lastRender('file-1').gitStatus).toBe('modified')
|
||||
expect(tabRenders).toHaveLength(TAB_IDS.length + 1)
|
||||
})
|
||||
|
||||
it.each(TAB_IDS)('retires a client-hosted row selection when %s is activated', (tabId) => {
|
||||
renderStrip()
|
||||
selectClientHostedBrowserRow({
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: 'page-1',
|
||||
groupId: 'group-1',
|
||||
groupActiveTabIdAtSelection: 'unified-terminal-1'
|
||||
})
|
||||
|
||||
lastRender(tabId).onActivate(tabId)
|
||||
|
||||
expect(getClientHostedBrowserRowSelection()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A client-hosted row covers the pane without moving the group's `activeTabId`, so the strip's two
|
||||
* halves would each keep painting an underline — the reported double highlight.
|
||||
*/
|
||||
describe('real tabs while a client-hosted row is selected', () => {
|
||||
const activeFlags = (): boolean[] => TAB_IDS.map((tabId) => lastRender(tabId).isActive)
|
||||
|
||||
it.each<WorkspaceVisibleTabType>(['terminal', 'browser', 'editor'])(
|
||||
'underlines the %s tab the group is actually showing when no row is selected',
|
||||
(activeTabType) => {
|
||||
renderStrip({ activeTabType })
|
||||
expect(activeFlags().filter(Boolean)).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each<WorkspaceVisibleTabType>(['terminal', 'browser', 'editor'])(
|
||||
'renders the %s tab inactive while a row owns the pane',
|
||||
(activeTabType) => {
|
||||
renderStrip({ activeTabType, activeClientHostedBrowserRowId: 'page-1' })
|
||||
expect(activeFlags()).toEqual([false, false, false, false])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import SortableTab from './SortableTab'
|
||||
import EditorFileTab from './EditorFileTab'
|
||||
import BrowserTab from './BrowserTab'
|
||||
import type { DropIndicator } from './drop-indicator'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import { getTabDragLabel, resolveTerminalItemTab, type TabBarItem } from './tab-bar-item-model'
|
||||
import type { TabBarItemActions } from './use-tab-bar-item-actions'
|
||||
|
||||
// Why only values and `actions`: anything a tab draws must be a compared prop, or a skipped render shows it stale.
|
||||
type TabBarItemRowProps = {
|
||||
item: TabBarItem
|
||||
actions: TabBarItemActions
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
generatedTabTitlesEnabled: boolean
|
||||
tabCount: number
|
||||
hasTabsToLeft: boolean
|
||||
hasTabsToRight: boolean
|
||||
isActive: boolean
|
||||
isExpanded: boolean
|
||||
dropIndicator: DropIndicator
|
||||
includeTopTabBorder: boolean
|
||||
canToggleViewMode: boolean
|
||||
isChatView: boolean
|
||||
/** Unified tab whose view mode the terminal tab toggles; absent when it has none. */
|
||||
viewModeTabId: string | undefined
|
||||
canDuplicate: boolean
|
||||
/** This editor tab's own status, so a git status write re-renders only the tabs it changed. */
|
||||
gitStatus: GitFileStatus | null
|
||||
}
|
||||
|
||||
function TabBarItemRow({
|
||||
item,
|
||||
actions,
|
||||
worktreeId,
|
||||
groupId,
|
||||
generatedTabTitlesEnabled,
|
||||
tabCount,
|
||||
hasTabsToLeft,
|
||||
hasTabsToRight,
|
||||
isActive,
|
||||
isExpanded,
|
||||
dropIndicator,
|
||||
includeTopTabBorder,
|
||||
canToggleViewMode,
|
||||
isChatView,
|
||||
viewModeTabId,
|
||||
canDuplicate,
|
||||
gitStatus
|
||||
}: TabBarItemRowProps): React.JSX.Element {
|
||||
// Why: the tabs' labels come from `translate()`, which a skipped render would leave in the old language.
|
||||
useTranslation()
|
||||
const dragData: TabDragItemData = {
|
||||
kind: 'tab',
|
||||
worktreeId,
|
||||
groupId,
|
||||
unifiedTabId: item.unifiedTabId,
|
||||
visibleTabId: item.id,
|
||||
tabType: item.type,
|
||||
label: getTabDragLabel(item, generatedTabTitlesEnabled),
|
||||
iconPath: item.type === 'editor' ? item.data.filePath : undefined,
|
||||
color: item.type === 'terminal' ? (item.data.color ?? null) : null
|
||||
}
|
||||
const shared = {
|
||||
isActive,
|
||||
isPinned: item.isPinned,
|
||||
hasTabsToRight,
|
||||
hasTabsToLeft,
|
||||
tabCount,
|
||||
onTogglePin: () => actions.togglePinned(item),
|
||||
dragData,
|
||||
dropIndicator,
|
||||
includeTopTabBorder
|
||||
}
|
||||
const sortableTabProps = {
|
||||
...shared,
|
||||
unifiedTabId: item.unifiedTabId,
|
||||
groupId,
|
||||
onClose: actions.close,
|
||||
onCloseOthers: actions.closeOthers,
|
||||
onCloseToRight: actions.closeToRight,
|
||||
onCloseToLeft: actions.closeToLeft,
|
||||
onSetCustomTitle: actions.setCustomTitle,
|
||||
onSetTabColor: actions.setTabColor
|
||||
}
|
||||
if (item.type === 'terminal') {
|
||||
return (
|
||||
<SortableTab
|
||||
{...sortableTabProps}
|
||||
tab={resolveTerminalItemTab(item.data, generatedTabTitlesEnabled)}
|
||||
canToggleViewMode={canToggleViewMode}
|
||||
isChatView={isChatView}
|
||||
onToggleViewMode={viewModeTabId ? () => actions.toggleViewMode(viewModeTabId) : undefined}
|
||||
isExpanded={isExpanded}
|
||||
onActivate={actions.activateTerminal}
|
||||
onToggleExpand={actions.togglePaneExpand}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'agent-session') {
|
||||
const structuredTab: TerminalTab = {
|
||||
id: item.id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: item.data.label,
|
||||
customTitle: item.data.customLabel,
|
||||
color: item.data.color,
|
||||
sortOrder: item.data.sortOrder,
|
||||
createdAt: item.data.createdAt,
|
||||
...(isAgentSessionHandleProvider(item.data.agentSessionAgent)
|
||||
? { launchAgent: item.data.agentSessionAgent as TuiAgent }
|
||||
: {})
|
||||
}
|
||||
return (
|
||||
<SortableTab
|
||||
{...sortableTabProps}
|
||||
tab={structuredTab}
|
||||
isExpanded={false}
|
||||
onActivate={actions.activateAgentSession}
|
||||
onToggleExpand={() => {}}
|
||||
canSplitTerminal={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const closeScope = {
|
||||
onCloseOthers: () => actions.closeOthers(item.id),
|
||||
onCloseToRight: () => actions.closeToRight(item.id),
|
||||
onCloseToLeft: () => actions.closeToLeft(item.id)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return (
|
||||
<BrowserTab
|
||||
{...shared}
|
||||
{...closeScope}
|
||||
tab={item.data}
|
||||
onActivate={() => actions.activateBrowserTab(item.id)}
|
||||
onClose={() => actions.closeBrowserTab(item.id)}
|
||||
onDuplicate={
|
||||
canDuplicate ? () => actions.duplicateBrowserTab(item.id, item.unifiedTabId) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const fileTabProps = {
|
||||
...shared,
|
||||
...closeScope,
|
||||
gitStatus,
|
||||
onActivate: () => actions.activateFile(item.id),
|
||||
onClose: () => actions.closeFile(item.id),
|
||||
onCloseAll: actions.closeAllFiles
|
||||
}
|
||||
if (item.type === 'simulator') {
|
||||
const simulatorLabel = item.data.label || 'Mobile Emulator'
|
||||
const simulatorFile: OpenFile & { tabId: string } = {
|
||||
id: item.id,
|
||||
tabId: item.id,
|
||||
filePath: simulatorLabel,
|
||||
relativePath: simulatorLabel,
|
||||
worktreeId,
|
||||
language: 'simulator',
|
||||
isPreview: false,
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
return <EditorFileTab {...fileTabProps} file={simulatorFile} onMakePermanent={() => {}} />
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
{...fileTabProps}
|
||||
file={item.data}
|
||||
onMakePermanent={() => actions.makePreviewFilePermanent(item.data.id, item.data.tabId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Why field-by-field: the strip rebuilds every `item` and its `data` on any tab write, so identity never survives.
|
||||
function sameTabBarItemRowProps(previous: TabBarItemRowProps, next: TabBarItemRowProps): boolean {
|
||||
const { item: previousItem, ...previousRest } = previous
|
||||
const { item: nextItem, ...nextRest } = next
|
||||
const { data: previousData, ...previousIdentity } = previousItem
|
||||
const { data: nextData, ...nextIdentity } = nextItem
|
||||
return (
|
||||
shallow(previousRest, nextRest) &&
|
||||
shallow(previousIdentity, nextIdentity) &&
|
||||
shallow(previousData, nextData)
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TabBarItemRow, sameTabBarItemRowProps)
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types'
|
||||
import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import type { Tab, WorkspaceVisibleTabType } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
|
||||
@@ -48,6 +49,24 @@ export type TabBarItem =
|
||||
data: Tab & { contentType: 'agent-session' }
|
||||
}
|
||||
|
||||
/** The terminal tab as the strip shows it: its title resolved against the generated-titles setting. */
|
||||
export function resolveTerminalItemTab(
|
||||
tab: TerminalTab & { unifiedTabId?: string },
|
||||
generatedTitlesEnabled: boolean
|
||||
): TerminalTab & { unifiedTabId?: string } {
|
||||
return { ...tab, title: resolveTerminalTabTitle(tab, generatedTitlesEnabled, tab.title) }
|
||||
}
|
||||
|
||||
export function resolveEditorTabGitStatus(
|
||||
relativePath: string,
|
||||
statusByRelativePath: Map<string, GitFileStatus>
|
||||
): GitFileStatus | null {
|
||||
if (relativePath === 'All Changes') {
|
||||
return null
|
||||
}
|
||||
return statusByRelativePath.get(normalizeRelativePath(relativePath)) ?? null
|
||||
}
|
||||
|
||||
export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string {
|
||||
if (item.type === 'terminal') {
|
||||
return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title)
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type React from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types'
|
||||
import {
|
||||
clearClientHostedBrowserRowSelection,
|
||||
getClientHostedBrowserRowSelection,
|
||||
selectClientHostedBrowserRow
|
||||
} from '@/lib/pane-manager/client-hosted-browser-row-state'
|
||||
import type { TabBarItem } from './tab-bar-item-model'
|
||||
import type { TabBarProps } from './tab-bar-props'
|
||||
import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model'
|
||||
import { renderTabBarItems } from './tab-bar-item-surface'
|
||||
|
||||
const ITEMS: TabBarItem[] = [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal-1',
|
||||
unifiedTabId: 'unified-terminal-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'terminal-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Setup',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'browser',
|
||||
id: 'browser-1',
|
||||
unifiedTabId: 'unified-browser-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.test/',
|
||||
title: 'Example Domain',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'editor',
|
||||
id: 'file-1',
|
||||
unifiedTabId: 'unified-file-1',
|
||||
isPinned: false,
|
||||
data: {
|
||||
id: 'file-1',
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
isPreview: false,
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Only the fields renderTabBarItems reads; nothing else reaches an isActive decision.
|
||||
const RUNTIME = {
|
||||
resolvedGroupId: 'group-1',
|
||||
generatedTabTitlesEnabled: false,
|
||||
unifiedTabByVisibleId: new Map(),
|
||||
nativeChatEnabled: false,
|
||||
tabAgentTypesByTabId: {},
|
||||
nativeChatTabWideFallbackUnsafeTabsById: {},
|
||||
nativeChatTranscriptIsLocalReadable: false,
|
||||
managedBrowserCreationEnabled: false,
|
||||
toggleTabViewMode: () => {},
|
||||
statusByRelativePath: new Map()
|
||||
} as unknown as TabBarRuntimeModel
|
||||
|
||||
function makeProps(activeTabType: WorkspaceVisibleTabType): TabBarProps {
|
||||
return {
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'terminal-1',
|
||||
activeFileId: 'file-1',
|
||||
activeBrowserTabId: 'browser-1',
|
||||
activeSimulatorTabId: null,
|
||||
activeTabType,
|
||||
groupActiveTabId: 'unified-terminal-1',
|
||||
expandedPaneByTabId: {}
|
||||
} as unknown as TabBarProps
|
||||
}
|
||||
|
||||
function activeFlags(
|
||||
activeTabType: WorkspaceVisibleTabType,
|
||||
activeClientHostedBrowserRowId: string | null
|
||||
): boolean[] {
|
||||
const rendered = renderTabBarItems({
|
||||
items: ITEMS,
|
||||
props: makeProps(activeTabType),
|
||||
runtime: RUNTIME,
|
||||
dropIndicatorByVisibleId: new Map(),
|
||||
includeTopTabBorder: true,
|
||||
activeClientHostedBrowserRowId,
|
||||
togglePinned: () => {}
|
||||
})
|
||||
return rendered.map(
|
||||
(node) => (node as React.ReactElement<{ isActive: boolean }>).props.isActive === true
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearClientHostedBrowserRowSelection()
|
||||
})
|
||||
|
||||
/**
|
||||
* A client-hosted row covers the pane without moving the group's `activeTabId`, so the strip's two
|
||||
* halves would each keep painting an underline — the reported double highlight.
|
||||
*/
|
||||
describe('real tabs while a client-hosted row is selected', () => {
|
||||
it.each<WorkspaceVisibleTabType>(['terminal', 'browser', 'editor'])(
|
||||
'underlines the %s tab the group is actually showing when no row is selected',
|
||||
(activeTabType) => {
|
||||
expect(activeFlags(activeTabType, null).filter(Boolean)).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each<WorkspaceVisibleTabType>(['terminal', 'browser', 'editor'])(
|
||||
'renders the %s tab inactive while a row owns the pane',
|
||||
(activeTabType) => {
|
||||
expect(activeFlags(activeTabType, 'page-1')).toEqual([false, false, false])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('client-hosted row while a real tab is activated', () => {
|
||||
it.each(ITEMS.map((item, index) => [item.type, index] as const))(
|
||||
'retires the row selection when the %s tab is clicked',
|
||||
(_type, index) => {
|
||||
selectClientHostedBrowserRow({
|
||||
worktreeId: 'wt-1',
|
||||
browserPageId: 'page-1',
|
||||
groupId: 'group-1',
|
||||
groupActiveTabIdAtSelection: 'unified-terminal-1'
|
||||
})
|
||||
const rendered = renderTabBarItems({
|
||||
items: ITEMS,
|
||||
props: makeProps('terminal'),
|
||||
runtime: RUNTIME,
|
||||
dropIndicatorByVisibleId: new Map(),
|
||||
includeTopTabBorder: true,
|
||||
activeClientHostedBrowserRowId: 'page-1',
|
||||
togglePinned: () => {}
|
||||
})
|
||||
|
||||
const clicked = rendered[index] as React.ReactElement<{
|
||||
onActivate: (id: string) => void
|
||||
}>
|
||||
clicked.props.onActivate('terminal-1')
|
||||
|
||||
expect(getClientHostedBrowserRowSelection()).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,37 +1,57 @@
|
||||
import React from 'react'
|
||||
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import { canToggleNativeChat } from '../native-chat/native-chat-availability'
|
||||
import { resolveNativeChatTabAgentEvidence } from './native-chat-tab-agent-evidence'
|
||||
import SortableTab from './SortableTab'
|
||||
import EditorFileTab from './EditorFileTab'
|
||||
import BrowserTab from './BrowserTab'
|
||||
import type { DropIndicator } from './drop-indicator'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import { getTabDragLabel, type TabBarItem } from './tab-bar-item-model'
|
||||
import {
|
||||
resolveEditorTabGitStatus,
|
||||
resolveTerminalItemTab,
|
||||
type TabBarItem
|
||||
} from './tab-bar-item-model'
|
||||
import type { TabBarProps } from './tab-bar-props'
|
||||
import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model'
|
||||
import { clearClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state'
|
||||
import type { TabBarItemActions } from './use-tab-bar-item-actions'
|
||||
import TabBarItemRow from './TabBarItemRow'
|
||||
|
||||
export type TabBarItemSurfaceProps = Pick<
|
||||
TabBarProps,
|
||||
| 'worktreeId'
|
||||
| 'activeTabId'
|
||||
| 'activeFileId'
|
||||
| 'activeBrowserTabId'
|
||||
| 'activeSimulatorTabId'
|
||||
| 'activeTabType'
|
||||
| 'expandedPaneByTabId'
|
||||
>
|
||||
|
||||
export type TabBarItemSurfaceRuntime = Pick<
|
||||
TabBarRuntimeModel,
|
||||
| 'resolvedGroupId'
|
||||
| 'generatedTabTitlesEnabled'
|
||||
| 'unifiedTabByVisibleId'
|
||||
| 'nativeChatEnabled'
|
||||
| 'tabAgentTypesByTabId'
|
||||
| 'nativeChatTabWideFallbackUnsafeTabsById'
|
||||
| 'nativeChatTranscriptIsLocalReadable'
|
||||
| 'managedBrowserCreationEnabled'
|
||||
| 'statusByRelativePath'
|
||||
>
|
||||
|
||||
export function renderTabBarItems({
|
||||
items,
|
||||
props,
|
||||
runtime,
|
||||
actions,
|
||||
dropIndicatorByVisibleId,
|
||||
includeTopTabBorder,
|
||||
activeClientHostedBrowserRowId,
|
||||
togglePinned
|
||||
activeClientHostedBrowserRowId
|
||||
}: {
|
||||
items: TabBarItem[]
|
||||
props: TabBarProps
|
||||
runtime: TabBarRuntimeModel
|
||||
props: TabBarItemSurfaceProps
|
||||
runtime: TabBarItemSurfaceRuntime
|
||||
actions: TabBarItemActions
|
||||
dropIndicatorByVisibleId: Map<string, DropIndicator>
|
||||
includeTopTabBorder: boolean
|
||||
activeClientHostedBrowserRowId: string | null
|
||||
togglePinned: (item: TabBarItem) => void
|
||||
}): React.ReactNode[] {
|
||||
const {
|
||||
worktreeId,
|
||||
@@ -40,23 +60,7 @@ export function renderTabBarItems({
|
||||
activeBrowserTabId,
|
||||
activeSimulatorTabId,
|
||||
activeTabType,
|
||||
expandedPaneByTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onCloseToLeft,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor,
|
||||
onTogglePaneExpand,
|
||||
onActivateFile,
|
||||
onCloseFile,
|
||||
onActivateBrowserTab,
|
||||
onActivateAgentSession,
|
||||
onCloseBrowserTab,
|
||||
onDuplicateBrowserTab,
|
||||
onCloseAllFiles,
|
||||
onMakePreviewFilePermanent
|
||||
expandedPaneByTabId
|
||||
} = props
|
||||
const {
|
||||
resolvedGroupId,
|
||||
@@ -66,7 +70,7 @@ export function renderTabBarItems({
|
||||
tabAgentTypesByTabId,
|
||||
nativeChatTabWideFallbackUnsafeTabsById,
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
toggleTabViewMode,
|
||||
managedBrowserCreationEnabled,
|
||||
statusByRelativePath
|
||||
} = runtime
|
||||
|
||||
@@ -74,40 +78,40 @@ export function renderTabBarItems({
|
||||
// the group's own activeTabId never moves for it, and two underlines would show at once.
|
||||
const clientHostedRowOwnsActiveState = activeClientHostedBrowserRowId !== null
|
||||
|
||||
// Why: this is the strip's single activation fan-out, so retiring a client-hosted placeholder
|
||||
// here covers every row kind — including re-clicking the tab that was already active, which the
|
||||
// group's activeTabId never moves for.
|
||||
function activateRealTab<TArg>(activate: ((arg: TArg) => void) | undefined): (arg: TArg) => void {
|
||||
return (arg) => {
|
||||
clearClientHostedBrowserRowSelection()
|
||||
activate?.(arg)
|
||||
function isActiveItem(item: TabBarItem): boolean {
|
||||
if (clientHostedRowOwnsActiveState) {
|
||||
return false
|
||||
}
|
||||
if (item.type === 'terminal') {
|
||||
return (
|
||||
(activeTabType === 'terminal' || activeTabType === 'simulator') && item.id === activeTabId
|
||||
)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return activeTabType === 'browser' && activeBrowserTabId === item.id
|
||||
}
|
||||
if (item.type === 'simulator') {
|
||||
return activeTabType === 'simulator' && item.id === activeSimulatorTabId
|
||||
}
|
||||
if (item.type === 'agent-session') {
|
||||
return activeTabType === 'agent-session' && item.id === activeTabId
|
||||
}
|
||||
return (activeTabType === 'editor' || activeTabType === 'simulator') && activeFileId === item.id
|
||||
}
|
||||
|
||||
return items.map((item, index) => {
|
||||
const dragData: TabDragItemData = {
|
||||
kind: 'tab',
|
||||
worktreeId,
|
||||
groupId: resolvedGroupId,
|
||||
unifiedTabId: item.unifiedTabId,
|
||||
visibleTabId: item.id,
|
||||
tabType: item.type,
|
||||
label: getTabDragLabel(item, generatedTabTitlesEnabled),
|
||||
iconPath: item.type === 'editor' ? item.data.filePath : undefined,
|
||||
color: item.type === 'terminal' ? (item.data.color ?? null) : null
|
||||
}
|
||||
let canToggleViewMode = false
|
||||
let isChatView = false
|
||||
let viewModeTabId: string | undefined
|
||||
if (item.type === 'terminal') {
|
||||
const terminalTab = {
|
||||
...item.data,
|
||||
title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title)
|
||||
}
|
||||
const terminalTab = resolveTerminalItemTab(item.data, generatedTabTitlesEnabled)
|
||||
const unifiedTabForItem = unifiedTabByVisibleId.get(item.id)
|
||||
// Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok.
|
||||
const resolvedAgent = resolveNativeChatTabAgentEvidence(terminalTab, unifiedTabForItem)
|
||||
// Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id.
|
||||
const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null
|
||||
const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true
|
||||
const canToggleViewMode =
|
||||
canToggleViewMode =
|
||||
unifiedTabForItem !== undefined &&
|
||||
canToggleNativeChat({
|
||||
experimentalNativeChatEnabled: nativeChatEnabled,
|
||||
@@ -118,185 +122,33 @@ export function renderTabBarItems({
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
isChatViewMode: unifiedTabForItem.viewMode === 'chat'
|
||||
})
|
||||
return (
|
||||
<SortableTab
|
||||
key={item.id}
|
||||
tab={terminalTab}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
groupId={resolvedGroupId}
|
||||
tabCount={items.length}
|
||||
canToggleViewMode={canToggleViewMode}
|
||||
isChatView={nativeChatEnabled && unifiedTabForItem?.viewMode === 'chat'}
|
||||
onToggleViewMode={
|
||||
unifiedTabForItem ? () => toggleTabViewMode(unifiedTabForItem.id) : undefined
|
||||
}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
hasTabsToLeft={index > 0}
|
||||
isActive={
|
||||
!clientHostedRowOwnsActiveState &&
|
||||
(activeTabType === 'terminal' || activeTabType === 'simulator') &&
|
||||
item.id === activeTabId
|
||||
}
|
||||
isPinned={item.isPinned}
|
||||
isExpanded={expandedPaneByTabId[item.id] === true}
|
||||
onActivate={activateRealTab(onActivate)}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onCloseToLeft={onCloseToLeft}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
onToggleExpand={onTogglePaneExpand}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return (
|
||||
<BrowserTab
|
||||
key={item.id}
|
||||
tab={item.data}
|
||||
isActive={
|
||||
!clientHostedRowOwnsActiveState &&
|
||||
activeTabType === 'browser' &&
|
||||
activeBrowserTabId === item.id
|
||||
}
|
||||
isPinned={item.isPinned}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
hasTabsToLeft={index > 0}
|
||||
tabCount={items.length}
|
||||
onActivate={() => activateRealTab(onActivateBrowserTab)(item.id)}
|
||||
onClose={() => onCloseBrowserTab?.(item.id)}
|
||||
onCloseOthers={() => onCloseOthers(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onCloseToLeft={() => onCloseToLeft(item.id)}
|
||||
onDuplicate={
|
||||
runtime.managedBrowserCreationEnabled
|
||||
? () => onDuplicateBrowserTab?.(item.id, item.unifiedTabId)
|
||||
: undefined
|
||||
}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'simulator') {
|
||||
const simulatorLabel = item.data.label || 'Mobile Emulator'
|
||||
const simulatorFile: OpenFile & { tabId: string } = {
|
||||
id: item.id,
|
||||
tabId: item.id,
|
||||
filePath: simulatorLabel,
|
||||
relativePath: simulatorLabel,
|
||||
worktreeId,
|
||||
language: 'simulator',
|
||||
isPreview: false,
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
key={item.id}
|
||||
file={simulatorFile}
|
||||
isActive={
|
||||
!clientHostedRowOwnsActiveState &&
|
||||
activeTabType === 'simulator' &&
|
||||
item.id === activeSimulatorTabId
|
||||
}
|
||||
isPinned={item.isPinned}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
hasTabsToLeft={index > 0}
|
||||
tabCount={items.length}
|
||||
statusByRelativePath={statusByRelativePath}
|
||||
onActivate={() => activateRealTab(onActivateFile)(item.id)}
|
||||
onClose={() => onCloseFile?.(item.id)}
|
||||
onCloseOthers={() => onCloseOthers(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onCloseToLeft={() => onCloseToLeft(item.id)}
|
||||
onCloseAll={() => onCloseAllFiles?.()}
|
||||
onMakePermanent={() => {}}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'agent-session') {
|
||||
const structuredTab: TerminalTab = {
|
||||
id: item.id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: item.data.label,
|
||||
customTitle: item.data.customLabel,
|
||||
color: item.data.color,
|
||||
sortOrder: item.data.sortOrder,
|
||||
createdAt: item.data.createdAt,
|
||||
...(isAgentSessionHandleProvider(item.data.agentSessionAgent)
|
||||
? { launchAgent: item.data.agentSessionAgent as TuiAgent }
|
||||
: {})
|
||||
}
|
||||
return (
|
||||
<SortableTab
|
||||
key={item.id}
|
||||
tab={structuredTab}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
groupId={resolvedGroupId}
|
||||
tabCount={items.length}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
hasTabsToLeft={index > 0}
|
||||
isActive={
|
||||
!clientHostedRowOwnsActiveState &&
|
||||
activeTabType === 'agent-session' &&
|
||||
item.id === activeTabId
|
||||
}
|
||||
isPinned={item.isPinned}
|
||||
isExpanded={false}
|
||||
onActivate={() => activateRealTab(onActivateAgentSession)(item.id)}
|
||||
onClose={() => onClose(item.id)}
|
||||
onCloseOthers={() => onCloseOthers(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onCloseToLeft={() => onCloseToLeft(item.id)}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
onToggleExpand={() => {}}
|
||||
canSplitTerminal={false}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
/>
|
||||
)
|
||||
isChatView = nativeChatEnabled && unifiedTabForItem?.viewMode === 'chat'
|
||||
viewModeTabId = unifiedTabForItem?.id
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
<TabBarItemRow
|
||||
key={item.id}
|
||||
file={item.data}
|
||||
isActive={
|
||||
!clientHostedRowOwnsActiveState &&
|
||||
(activeTabType === 'editor' || activeTabType === 'simulator') &&
|
||||
activeFileId === item.id
|
||||
}
|
||||
isPinned={item.isPinned}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
hasTabsToLeft={index > 0}
|
||||
item={item}
|
||||
actions={actions}
|
||||
worktreeId={worktreeId}
|
||||
groupId={resolvedGroupId}
|
||||
generatedTabTitlesEnabled={generatedTabTitlesEnabled}
|
||||
tabCount={items.length}
|
||||
statusByRelativePath={statusByRelativePath}
|
||||
onActivate={() => activateRealTab(onActivateFile)(item.id)}
|
||||
onClose={() => onCloseFile?.(item.id)}
|
||||
onCloseOthers={() => onCloseOthers(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onCloseToLeft={() => onCloseToLeft(item.id)}
|
||||
onCloseAll={() => onCloseAllFiles?.()}
|
||||
onMakePermanent={() => onMakePreviewFilePermanent?.(item.data.id, item.data.tabId)}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
dragData={dragData}
|
||||
hasTabsToLeft={index > 0}
|
||||
hasTabsToRight={index < items.length - 1}
|
||||
isActive={isActiveItem(item)}
|
||||
isExpanded={item.type === 'terminal' && expandedPaneByTabId[item.id] === true}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
canToggleViewMode={canToggleViewMode}
|
||||
isChatView={isChatView}
|
||||
viewModeTabId={viewModeTabId}
|
||||
canDuplicate={item.type === 'browser' && managedBrowserCreationEnabled}
|
||||
gitStatus={
|
||||
item.type === 'editor'
|
||||
? resolveEditorTabGitStatus(item.data.relativePath, statusByRelativePath)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TabBarProps } from './tab-bar-props'
|
||||
import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model'
|
||||
import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
|
||||
import type { TabBarItemProjection } from './use-tab-bar-item-projection'
|
||||
import type { TabBarItem } from './tab-bar-item-model'
|
||||
import type { TabBarItemActions } from './use-tab-bar-item-actions'
|
||||
import { renderTabBarItems } from './tab-bar-item-surface'
|
||||
import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu'
|
||||
import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows'
|
||||
@@ -36,7 +36,7 @@ export function renderTabBarSurface({
|
||||
tabStripNavigation,
|
||||
tabStripDragScroll,
|
||||
activeClientHostedBrowserRowId,
|
||||
togglePinned
|
||||
itemActions
|
||||
}: {
|
||||
props: TabBarProps
|
||||
runtime: TabBarRuntimeModel
|
||||
@@ -45,7 +45,7 @@ export function renderTabBarSurface({
|
||||
tabStripNavigation: ReturnType<typeof useTabStripOverflowNavigation>
|
||||
tabStripDragScroll: ReturnType<typeof useTabStripDragScrollHandlers>
|
||||
activeClientHostedBrowserRowId: string | null
|
||||
togglePinned: (item: TabBarItem) => void
|
||||
itemActions: TabBarItemActions
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
worktreeId,
|
||||
@@ -95,10 +95,10 @@ export function renderTabBarSurface({
|
||||
items: orderedItems,
|
||||
props,
|
||||
runtime,
|
||||
actions: itemActions,
|
||||
dropIndicatorByVisibleId,
|
||||
includeTopTabBorder,
|
||||
activeClientHostedBrowserRowId,
|
||||
togglePinned
|
||||
activeClientHostedBrowserRowId
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types'
|
||||
import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
@@ -361,7 +360,7 @@ describe('tab title tooltips', () => {
|
||||
hasTabsToRight={false}
|
||||
hasTabsToLeft={false}
|
||||
tabCount={1}
|
||||
statusByRelativePath={new Map<string, GitFileStatus>()}
|
||||
gitStatus={null}
|
||||
onActivate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCloseOthers={vi.fn()}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { clearClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state'
|
||||
import type { TabBarItem } from './tab-bar-item-model'
|
||||
import type { TabBarProps } from './tab-bar-props'
|
||||
|
||||
type TabBarItemActionSource = {
|
||||
props: Pick<
|
||||
TabBarProps,
|
||||
| 'onActivate'
|
||||
| 'onActivateFile'
|
||||
| 'onActivateBrowserTab'
|
||||
| 'onActivateAgentSession'
|
||||
| 'onClose'
|
||||
| 'onCloseFile'
|
||||
| 'onCloseBrowserTab'
|
||||
| 'onCloseOthers'
|
||||
| 'onCloseToRight'
|
||||
| 'onCloseToLeft'
|
||||
| 'onCloseAllFiles'
|
||||
| 'onSetCustomTitle'
|
||||
| 'onSetTabColor'
|
||||
| 'onTogglePaneExpand'
|
||||
| 'onDuplicateBrowserTab'
|
||||
| 'onMakePreviewFilePermanent'
|
||||
>
|
||||
togglePinned: (item: TabBarItem) => void
|
||||
toggleTabViewMode: (tabId: string) => void
|
||||
}
|
||||
|
||||
export type TabBarItemActions = {
|
||||
activateTerminal: (tabId: string) => void
|
||||
activateFile: (fileId: string) => void
|
||||
activateBrowserTab: (tabId: string) => void
|
||||
activateAgentSession: (tabId: string) => void
|
||||
close: (tabId: string) => void
|
||||
closeFile: (fileId: string) => void
|
||||
closeBrowserTab: (tabId: string) => void
|
||||
closeOthers: (tabId: string) => void
|
||||
closeToRight: (tabId: string) => void
|
||||
closeToLeft: (tabId: string) => void
|
||||
closeAllFiles: () => void
|
||||
setCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
togglePaneExpand: (tabId: string) => void
|
||||
duplicateBrowserTab: (tabId: string, unifiedTabId: string) => void
|
||||
makePreviewFilePermanent: (fileId: string, tabId?: string) => void
|
||||
togglePinned: (item: TabBarItem) => void
|
||||
toggleViewMode: (tabId: string) => void
|
||||
}
|
||||
|
||||
/** Actions that read the strip's handlers when called, so their own identity never has to change. */
|
||||
export function useTabBarItemActions(source: TabBarItemActionSource): TabBarItemActions {
|
||||
const latest = useRef(source)
|
||||
// Why an effect: a render React abandons must not hand its handlers to the tabs already on screen.
|
||||
useLayoutEffect(() => {
|
||||
latest.current = source
|
||||
})
|
||||
return useMemo(() => {
|
||||
// Why: this is the strip's single activation fan-out, so retiring a client-hosted placeholder
|
||||
// here covers every row kind — including re-clicking the tab that was already active, which the
|
||||
// group's activeTabId never moves for.
|
||||
function activateRealTab(activate: () => void): void {
|
||||
clearClientHostedBrowserRowSelection()
|
||||
activate()
|
||||
}
|
||||
return {
|
||||
activateTerminal: (tabId) => activateRealTab(() => latest.current.props.onActivate(tabId)),
|
||||
activateFile: (fileId) =>
|
||||
activateRealTab(() => latest.current.props.onActivateFile?.(fileId)),
|
||||
activateBrowserTab: (tabId) =>
|
||||
activateRealTab(() => latest.current.props.onActivateBrowserTab?.(tabId)),
|
||||
activateAgentSession: (tabId) =>
|
||||
activateRealTab(() => latest.current.props.onActivateAgentSession?.(tabId)),
|
||||
close: (tabId) => latest.current.props.onClose(tabId),
|
||||
closeFile: (fileId) => latest.current.props.onCloseFile?.(fileId),
|
||||
closeBrowserTab: (tabId) => latest.current.props.onCloseBrowserTab?.(tabId),
|
||||
closeOthers: (tabId) => latest.current.props.onCloseOthers(tabId),
|
||||
closeToRight: (tabId) => latest.current.props.onCloseToRight(tabId),
|
||||
closeToLeft: (tabId) => latest.current.props.onCloseToLeft(tabId),
|
||||
closeAllFiles: () => latest.current.props.onCloseAllFiles?.(),
|
||||
setCustomTitle: (tabId, title) => latest.current.props.onSetCustomTitle(tabId, title),
|
||||
setTabColor: (tabId, color) => latest.current.props.onSetTabColor(tabId, color),
|
||||
togglePaneExpand: (tabId) => latest.current.props.onTogglePaneExpand(tabId),
|
||||
duplicateBrowserTab: (tabId, unifiedTabId) =>
|
||||
latest.current.props.onDuplicateBrowserTab?.(tabId, unifiedTabId),
|
||||
makePreviewFilePermanent: (fileId, tabId) =>
|
||||
latest.current.props.onMakePreviewFilePermanent?.(fileId, tabId),
|
||||
togglePinned: (item) => latest.current.togglePinned(item),
|
||||
toggleViewMode: (tabId) => latest.current.toggleTabViewMode(tabId)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TabBarProps } from './tab-bar-props'
|
||||
import { useTabBarItemProjection } from './use-tab-bar-item-projection'
|
||||
|
||||
const NOOP = (): void => {}
|
||||
|
||||
function terminalTab(id: string, sortOrder: number): TerminalTab & { unifiedTabId?: string } {
|
||||
return {
|
||||
id,
|
||||
unifiedTabId: `unified-${id}`,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'zsh',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilt per call, like the store's tab list: equal content, never the same objects. */
|
||||
function buildProps(ids: string[]): TabBarProps {
|
||||
return {
|
||||
tabs: ids.map((id, index) => terminalTab(id, index)),
|
||||
tabBarOrder: [...ids],
|
||||
activeTabId: ids[0] ?? null,
|
||||
activeTabType: 'terminal',
|
||||
worktreeId: 'wt-1',
|
||||
expandedPaneByTabId: {},
|
||||
onActivate: NOOP,
|
||||
onClose: NOOP,
|
||||
onCloseOthers: NOOP,
|
||||
onCloseToRight: NOOP,
|
||||
onCloseToLeft: NOOP,
|
||||
onNewTerminalTab: NOOP,
|
||||
onNewBrowserTab: NOOP,
|
||||
onSetCustomTitle: NOOP,
|
||||
onSetTabColor: NOOP,
|
||||
onTogglePaneExpand: NOOP
|
||||
}
|
||||
}
|
||||
|
||||
function renderProjection(initialIds: string[]): {
|
||||
sortableIds: () => string[]
|
||||
rerenderWith: (ids: string[]) => void
|
||||
} {
|
||||
const { result, rerender } = renderHook(
|
||||
(ids: string[]) =>
|
||||
useTabBarItemProjection({
|
||||
props: buildProps(ids),
|
||||
resolvedGroupId: 'group-1',
|
||||
unifiedTabs: [],
|
||||
unifiedTabByVisibleId: new Map(),
|
||||
generatedTabTitlesEnabled: false,
|
||||
statusByRelativePath: new Map()
|
||||
}),
|
||||
{ initialProps: initialIds }
|
||||
)
|
||||
return {
|
||||
sortableIds: () => result.current.sortableIds,
|
||||
rerenderWith: (ids) => rerender(ids)
|
||||
}
|
||||
}
|
||||
|
||||
describe('tab strip sortable ids', () => {
|
||||
it('hands dnd-kit the same array while the ids are unchanged', () => {
|
||||
const projection = renderProjection(['term-1', 'term-2'])
|
||||
const first = projection.sortableIds()
|
||||
|
||||
projection.rerenderWith(['term-1', 'term-2'])
|
||||
|
||||
// A new array here re-renders every tab through dnd-kit's context, even with identical ids.
|
||||
expect(projection.sortableIds()).toBe(first)
|
||||
})
|
||||
|
||||
it('hands dnd-kit a new array when a tab is added', () => {
|
||||
const projection = renderProjection(['term-1', 'term-2'])
|
||||
|
||||
projection.rerenderWith(['term-1', 'term-2', 'term-3'])
|
||||
|
||||
expect(projection.sortableIds()).toEqual(['term-1', 'term-2', 'term-3'])
|
||||
})
|
||||
|
||||
it('hands dnd-kit a new array when the tabs are reordered', () => {
|
||||
const projection = renderProjection(['term-1', 'term-2'])
|
||||
|
||||
projection.rerenderWith(['term-2', 'term-1'])
|
||||
|
||||
expect(projection.sortableIds()).toEqual(['term-2', 'term-1'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { TabBarProps } from './tab-bar-props'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type TabBarItem
|
||||
} from './tab-bar-item-model'
|
||||
import type { DropIndicator } from './drop-indicator'
|
||||
import { sameStringArray } from '@/runtime/web-session-tabs-sync/state-equality-core'
|
||||
|
||||
export type TabBarItemProjection = {
|
||||
orderedItems: TabBarItem[]
|
||||
@@ -107,7 +108,12 @@ export function useTabBarItemProjection({
|
||||
unifiedTabByVisibleId
|
||||
]
|
||||
)
|
||||
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
|
||||
const orderedIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
|
||||
// Why: dnd-kit re-renders every tab when this array's identity changes, and the items rebuild on any tab write.
|
||||
const [sortableIds, setSortableIds] = useState(orderedIds)
|
||||
if (!sameStringArray(sortableIds, orderedIds)) {
|
||||
setSortableIds(orderedIds)
|
||||
}
|
||||
const activeIndicator =
|
||||
hoveredTabInsertion?.groupId === resolvedGroupId ? hoveredTabInsertion : null
|
||||
const dropIndicatorByVisibleId = useMemo(
|
||||
|
||||
@@ -271,6 +271,26 @@ describe('canDropTabIntoPaneBody', () => {
|
||||
})
|
||||
|
||||
describe('useTabDragSplit', () => {
|
||||
it('keeps the drag sensors across re-renders until enablement changes', () => {
|
||||
const sensorsByRender: ReturnType<typeof useTabDragSplit>['sensors'][] = []
|
||||
function Probe({ enabled }: { enabled: boolean }): null {
|
||||
sensorsByRender.push(useTabDragSplit({ worktreeId: WT, enabled }).sensors)
|
||||
return null
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mounted.push({ container, root })
|
||||
|
||||
act(() => root.render(createElement(Probe, { enabled: true })))
|
||||
act(() => root.render(createElement(Probe, { enabled: true })))
|
||||
// Why: new sensors rebuild every tab's drag listeners, which re-renders every tab.
|
||||
expect(sensorsByRender.at(-1)).toBe(sensorsByRender.at(-2))
|
||||
|
||||
act(() => root.render(createElement(Probe, { enabled: false })))
|
||||
expect(sensorsByRender.at(-1)).not.toBe(sensorsByRender.at(-2))
|
||||
})
|
||||
|
||||
it.each(['split', 'insertion'])(
|
||||
'does not restore a %s preview after blur cleared the drag',
|
||||
async (preview) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState, type RefObject } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, type RefObject } from 'react'
|
||||
import {
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
@@ -131,9 +131,13 @@ export function useTabDragSplit({
|
||||
// useSensors(ptr) / useSensors(), because dnd-kit internally spreads
|
||||
// the sensors array into a useEffect dependency list — changing its
|
||||
// length between renders violates React's rules of hooks.
|
||||
const pointerSensor = useSensor(TabDragPointerSensor, {
|
||||
activationConstraint: { distance: getTabDragActivationDistance(enabled) }
|
||||
})
|
||||
const activationDistance = getTabDragActivationDistance(enabled)
|
||||
// Why memoized: fresh options rebuild every tab's drag listeners and wake every tab through dnd-kit's context.
|
||||
const pointerSensorOptions = useMemo(
|
||||
() => ({ activationConstraint: { distance: activationDistance } }),
|
||||
[activationDistance]
|
||||
)
|
||||
const pointerSensor = useSensor(TabDragPointerSensor, pointerSensorOptions)
|
||||
const sensors = useSensors(pointerSensor)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import type {
|
||||
@@ -304,6 +305,37 @@ describe('remote mirror resource identity', () => {
|
||||
expect(next.browserCertificateFailuresByPageId).toBe(state.browserCertificateFailuresByPageId)
|
||||
})
|
||||
|
||||
it('keeps an unchanged browser tab record when a sibling browser tab changes', () => {
|
||||
const browserTab = (index: number, title: string) => ({
|
||||
type: 'browser' as const,
|
||||
id: `host-browser-tab-${index}`,
|
||||
browserWorkspaceId: `host-browser-workspace-${index}`,
|
||||
browserPageId: `host-browser-page-${index}`,
|
||||
title,
|
||||
url: `https://example.com/${index}`,
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
certificateFailure: null,
|
||||
isActive: index === 1
|
||||
})
|
||||
const state = applySnapshot(
|
||||
makeState(),
|
||||
makeSnapshot(WORKTREE_A, [browserTab(1, 'One'), browserTab(2, 'Two')], 'browser')
|
||||
)
|
||||
const next = applySnapshot(
|
||||
state,
|
||||
makeSnapshot(WORKTREE_A, [browserTab(1, 'One'), browserTab(2, 'Renamed')], 'browser'),
|
||||
NOW + 1
|
||||
)
|
||||
|
||||
const [unchanged, renamed] = next.browserTabsByWorktree[WORKTREE_A]!
|
||||
const [previous] = state.browserTabsByWorktree[WORKTREE_A]!
|
||||
expect(renamed!.title).toBe('Renamed')
|
||||
// Why: the tab strip compares each record field by field, so a rebuilt page list re-renders the tab.
|
||||
expect(shallow(unchanged, previous)).toBe(true)
|
||||
})
|
||||
|
||||
it('clears a same-page certificate failure without replacing its page or handle', () => {
|
||||
const state = applySnapshot(makeState(), makeBrowserSnapshot())
|
||||
const next = applySnapshot(state, makeBrowserSnapshot({ certificateFailure: null }), NOW + 1)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state'
|
||||
import { readBrowserClientHostId } from '../browser-client-host-identity'
|
||||
import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement'
|
||||
import { browserPageEqual } from './state-equality-tabs'
|
||||
import { sameStringArray } from './state-equality-core'
|
||||
import { collectLayoutGroupIds } from './tab-group-layout-tree'
|
||||
import { buildBrowserUnifiedTab } from './tab-builders'
|
||||
import { isReadyBrowserTab } from './terminal-surfaces'
|
||||
@@ -201,13 +202,18 @@ export function buildMirroredBrowserTabs(
|
||||
// Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace
|
||||
// page-list cleanup gates on page.workspaceId matching this entry's workspace.id.
|
||||
const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage
|
||||
const existingPageIds = existing?.workspace.pageIds
|
||||
const workspace: BrowserWorkspace = {
|
||||
id: workspaceId,
|
||||
worktreeId: snapshot.worktree,
|
||||
label: existing?.workspace.label,
|
||||
sessionProfileId: existing?.workspace.sessionProfileId ?? null,
|
||||
activePageId: page.id,
|
||||
pageIds: [page.id],
|
||||
// Why: the tab strip skips a tab only while every field keeps its identity.
|
||||
pageIds:
|
||||
existingPageIds && sameStringArray(existingPageIds, [page.id])
|
||||
? existingPageIds
|
||||
: [page.id],
|
||||
url: page.url,
|
||||
title: page.title,
|
||||
loading: page.loading,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types'
|
||||
import type { TerminalTab } from '../../../src/shared/terminal-tab-types'
|
||||
import type { Worktree } from '../../../src/shared/worktree/types'
|
||||
import type { DictationMeterState } from '../../../src/renderer/src/components/dictation/dictation-audio-meter'
|
||||
import type { ReactCommitHook } from './tab-render-recorder'
|
||||
|
||||
// Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi.
|
||||
export type AppStore = {
|
||||
@@ -68,6 +69,8 @@ declare global {
|
||||
__store?: AppStore
|
||||
__dictationMeterE2E?: { publish(meter: DictationMeterState): void }
|
||||
__paneManagers?: Map<string, PaneManagerLike>
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactCommitHook
|
||||
__tabsRenderedPerCommit?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Counts how many tab-strip tabs each React commit re-renders, the way React DevTools highlights
|
||||
* updates: through the commit hook the renderer always installs.
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
type Fiber = {
|
||||
tag: number
|
||||
flags: number
|
||||
child: Fiber | null
|
||||
sibling: Fiber | null
|
||||
stateNode: unknown
|
||||
}
|
||||
|
||||
export type ReactCommitHook = {
|
||||
onCommitFiberRoot?: (rendererId: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown
|
||||
}
|
||||
|
||||
/** Commits that render no tab (the sidebar, terminals starting up) are left out. */
|
||||
export async function startRecordingTabRenders(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
// Function, class, forwardRef and memo components; bit 1 is React's PerformedWork flag.
|
||||
const componentTags = new Set([0, 1, 11, 14, 15])
|
||||
const performedWork = 1
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__
|
||||
if (!hook) {
|
||||
throw new Error('React commit hook is not installed')
|
||||
}
|
||||
const original = hook.onCommitFiberRoot
|
||||
// A subtree React skipped keeps last commit's fiber objects, whose flags are stale. Per root: the renderer mounts several.
|
||||
const previousFibersByRoot = new WeakMap<object, Set<Fiber>>()
|
||||
const recorded: number[] = []
|
||||
window.__tabsRenderedPerCommit = recorded
|
||||
hook.onCommitFiberRoot = function (rendererId, root, ...rest) {
|
||||
const previousFibers = previousFibersByRoot.get(root)
|
||||
const fibers = new Set<Fiber>()
|
||||
const renderedTabIds = new Set<string>()
|
||||
const stack: Fiber[] = [root.current]
|
||||
while (stack.length > 0) {
|
||||
const fiber = stack.pop()!
|
||||
fibers.add(fiber)
|
||||
if (
|
||||
componentTags.has(fiber.tag) &&
|
||||
(fiber.flags & performedWork) === performedWork &&
|
||||
!previousFibers?.has(fiber)
|
||||
) {
|
||||
let host: Fiber | null = fiber
|
||||
while (host && host.tag !== 5) {
|
||||
host = host.child
|
||||
}
|
||||
const element = host?.stateNode
|
||||
const tabId =
|
||||
element instanceof Element
|
||||
? element.closest('[data-tab-strip-slot]')?.getAttribute('data-tab-strip-slot')
|
||||
: undefined
|
||||
if (tabId) {
|
||||
renderedTabIds.add(tabId)
|
||||
}
|
||||
}
|
||||
if (fiber.sibling) {
|
||||
stack.push(fiber.sibling)
|
||||
}
|
||||
if (fiber.child) {
|
||||
stack.push(fiber.child)
|
||||
}
|
||||
}
|
||||
previousFibersByRoot.set(root, fibers)
|
||||
if (renderedTabIds.size > 0) {
|
||||
recorded.push(renderedTabIds.size)
|
||||
}
|
||||
return original?.call(this, rendererId, root, ...rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Tabs re-rendered by each commit since the last call. */
|
||||
export async function takeTabRenders(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => window.__tabsRenderedPerCommit?.splice(0) ?? [])
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* E2E test for a long tab strip: a change to one tab must re-render that tab, not the strip.
|
||||
*
|
||||
* Why E2E: only the whole app shows every React commit a tab change causes — the store write, the
|
||||
* strip's projections, and the drag-and-drop context every tab reads.
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store'
|
||||
import { waitForActivePanePtyId } from './helpers/terminal'
|
||||
import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal'
|
||||
import { startRecordingTabRenders, takeTabRenders } from './helpers/tab-render-recorder'
|
||||
|
||||
const BACKGROUND_TABS = 30
|
||||
|
||||
// New terminals retitle their tabs for a few seconds after opening; wait for the strip to go quiet.
|
||||
async function waitForQuietStrip(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await takeTabRenders(page)
|
||||
await page.waitForTimeout(500)
|
||||
return (await takeTabRenders(page)).length
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
test.describe('Tab strip tab render isolation', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
})
|
||||
|
||||
test('a title change or a tab switch re-renders only the tabs involved', async ({ orcaPage }) => {
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const tabIds = await orcaPage.evaluate(
|
||||
({ wId, count }) => {
|
||||
const ids: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(
|
||||
window.__store!.getState().createTab(wId, undefined, undefined, { activate: false }).id
|
||||
)
|
||||
}
|
||||
return ids
|
||||
},
|
||||
{ wId: worktreeId, count: BACKGROUND_TABS }
|
||||
)
|
||||
const tab = (tabId: string) =>
|
||||
orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
|
||||
await expect(tab(tabIds[BACKGROUND_TABS - 1])).toBeAttached()
|
||||
await startRecordingTabRenders(orcaPage)
|
||||
|
||||
await waitForQuietStrip(orcaPage)
|
||||
await orcaPage.evaluate(
|
||||
(tabId) => window.__store!.getState().updateTabTitle(tabId, 'retitled in background'),
|
||||
tabIds[5]
|
||||
)
|
||||
await expect(tab(tabIds[5])).toHaveAttribute('data-tab-title', 'retitled in background')
|
||||
const backgroundRetitle = await takeTabRenders(orcaPage)
|
||||
// Control: the recorder sees the one tab that did change.
|
||||
expect(backgroundRetitle.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...backgroundRetitle)).toBe(1)
|
||||
|
||||
await waitForQuietStrip(orcaPage)
|
||||
// A node script, so the title is emitted the same way under PowerShell, cmd and POSIX shells.
|
||||
// It stays alive until the title is read; a shell prompt would retitle the tab straight back.
|
||||
const retitle = await runNodeScriptInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
`process.stdout.write('\\x1b]0;retitled by its terminal\\x07'); setTimeout(() => {}, 3000)`
|
||||
)
|
||||
try {
|
||||
await expect(
|
||||
orcaPage.locator('[data-testid="sortable-tab"][data-active="true"]')
|
||||
).toHaveAttribute('data-tab-title', 'retitled by its terminal')
|
||||
const terminalRetitle = await takeTabRenders(orcaPage)
|
||||
expect(terminalRetitle.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...terminalRetitle)).toBe(1)
|
||||
} finally {
|
||||
retitle.cleanup()
|
||||
}
|
||||
|
||||
await waitForQuietStrip(orcaPage)
|
||||
await tab(tabIds[1]).click()
|
||||
await expect(tab(tabIds[1])).toHaveAttribute('data-active', 'true')
|
||||
await orcaPage.waitForTimeout(500)
|
||||
const tabSwitch = await takeTabRenders(orcaPage)
|
||||
expect(tabSwitch.length).toBeGreaterThan(0)
|
||||
// The tab that lost the active state and the one that gained it.
|
||||
expect(Math.max(...tabSwitch)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user