Remove directional tab-close actions and tracking (#31599)

* Remove directional tab-close menu actions

* Remove retired directional-close translations

* Remove retired directional-close PostHog tracking

* Preserve legacy app-open schema compatibility without emitting samples
This commit is contained in:
Jinjing
2026-10-21 12:01:27 -07:00
committed by GitHub
parent dafed6b345
commit b3f1607c84
25 changed files with 59 additions and 710 deletions
@@ -1,10 +1,6 @@
import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import {
BULK_TAB_CLOSE_ACTIONS,
selectBulkCloseTabs,
type BulkTabCloseMode
} from './mobile-tab-close-selection'
import { selectBulkCloseTabs } from './mobile-tab-close-selection'
/** Session-route state the bulk close orchestration reads and drives. */
type BulkCloseSheetDeps = {
@@ -16,14 +12,14 @@ type BulkCloseSheetDeps = {
}
/**
* Builds the long-press bulk-close entries (Close Others / Left) shared
* Builds the long-press bulk-close entries (Close Others) shared
* by every session tab sheet. Lives outside the session route to keep the
* orchestration out of its max-lines budget; anchors are passed by tab id so
* sheets never need the full tab object.
*/
export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) {
const selectClosable = (anchorTabId: string, mode: BulkTabCloseMode) =>
selectBulkCloseTabs(deps.sessionTabsRef.current, anchorTabId, mode).filter((candidate) => {
const selectClosable = (anchorTabId: string) =>
selectBulkCloseTabs(deps.sessionTabsRef.current, anchorTabId).filter((candidate) => {
if (candidate.type !== 'markdown') {
return true
}
@@ -33,8 +29,8 @@ export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) {
return !(doc?.status === 'ready' && doc.isDirty)
})
const bulkClose = async (anchor: MobileSessionTab, mode: BulkTabCloseMode) => {
const targets = selectClosable(anchor.id, mode)
const bulkClose = async (anchor: MobileSessionTab) => {
const targets = selectClosable(anchor.id)
const activeWasTargeted = targets.some(
(candidate) => candidate.id === deps.activeSessionTabIdRef.current
)
@@ -53,19 +49,19 @@ export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) {
anchorTabId == null
? undefined
: deps.sessionTabsRef.current.find((candidate) => candidate.id === anchorTabId)
if (!anchor) {
if (!anchor || selectClosable(anchor.id).length === 0) {
return []
}
return BULK_TAB_CLOSE_ACTIONS.filter(
({ mode }) => selectClosable(anchor.id, mode).length > 0
).map(({ mode, label }) => ({
label,
destructive: true,
onPress: () => {
dismiss()
void bulkClose(anchor, mode)
return [
{
label: 'Close Other Tabs',
destructive: true,
onPress: () => {
dismiss()
void bulkClose(anchor)
}
}
}))
]
}
}
@@ -10,31 +10,21 @@ const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
describe('selectBulkCloseTabs', () => {
const tabs = [tab('a'), tab('b'), tab('c'), tab('d')]
it('selects every tab except the anchor for mode "others"', () => {
expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd'])
})
it('selects tabs before the anchor for mode "left"', () => {
expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b'])
})
it('returns empty when the anchor is at the left edge', () => {
expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([])
it('selects every tab except the anchor', () => {
expect(selectBulkCloseTabs(tabs, 'b').map((t) => t.id)).toEqual(['a', 'c', 'd'])
})
it('returns empty when the anchor is not in the list', () => {
expect(selectBulkCloseTabs(tabs, 'missing', 'others')).toEqual([])
expect(selectBulkCloseTabs(tabs, 'missing')).toEqual([])
})
it('skips dirty tabs so unsaved edits survive a bulk close', () => {
const withDirty = [tab('a', true), tab('b'), tab('c', false), tab('d')]
expect(selectBulkCloseTabs(withDirty, 'd', 'left').map((t) => t.id)).toEqual(['b', 'c'])
expect(selectBulkCloseTabs(withDirty, 'b', 'others').map((t) => t.id)).toEqual(['c', 'd'])
expect(selectBulkCloseTabs(withDirty, 'b').map((t) => t.id)).toEqual(['c', 'd'])
})
it('skips pinned tabs', () => {
const withPinned = [tab('a', undefined, true), tab('b'), tab('c', undefined, true), tab('d')]
expect(selectBulkCloseTabs(withPinned, 'd', 'left').map((t) => t.id)).toEqual(['b'])
expect(selectBulkCloseTabs(withPinned, 'b', 'others').map((t) => t.id)).toEqual(['d'])
expect(selectBulkCloseTabs(withPinned, 'b').map((t) => t.id)).toEqual(['d'])
})
})
@@ -1,11 +1,3 @@
export type BulkTabCloseMode = 'others' | 'left'
/** Long-press sheet entries, in display order. */
export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [
{ mode: 'others', label: 'Close Other Tabs' },
{ mode: 'left', label: 'Close Tabs to the Left' }
]
type BulkClosableTab = {
id: string
isDirty?: boolean
@@ -13,23 +5,18 @@ type BulkClosableTab = {
}
/**
* Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to
* the Left") should target, in strip order relative to the pressed tab.
* Pick the other tabs a long-press bulk close should target, in strip order.
* Dirty documents are skipped — mobile has no save prompt on close, so bulk
* closing must never silently discard unsaved edits.
*/
export function selectBulkCloseTabs<T extends BulkClosableTab>(
tabs: readonly T[],
anchorTabId: string,
mode: BulkTabCloseMode
anchorTabId: string
): T[] {
const anchorIndex = tabs.findIndex((tab) => tab.id === anchorTabId)
if (anchorIndex === -1) {
return []
}
const candidates =
mode === 'others'
? tabs.filter((_, index) => index !== anchorIndex)
: tabs.slice(0, anchorIndex)
const candidates = tabs.filter((_, index) => index !== anchorIndex)
return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true)
}
@@ -1,181 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventProps } from '../../shared/telemetry-events'
import {
_setCommonPropsForTests,
initTelemetry,
setOptIn,
track,
trackAppOpenedOnce
} from './client'
import {
BASE_COMMON,
cleanupTelemetryClientTest,
setupTelemetryClientTest,
type TelemetryClientTestState
} from './client-test-harness'
const left: EventProps<'tabs_directional_close_requested'> = {
direction: 'left',
tab_type: 'terminal',
surface: 'workspace',
eligible_tab_count: 3
}
const right: EventProps<'tabs_directional_close_requested'> = { ...left, direction: 'right' }
describe('directional tab close event budget', () => {
let state: TelemetryClientTestState
beforeEach(() => {
state = setupTelemetryClientTest()
_setCommonPropsForTests({
...BASE_COMMON,
install_id: '00000000-0000-4000-8000-000000000013'
})
})
afterEach(() => {
cleanupTelemetryClientTest(state.envStash)
vi.useRealTimers()
})
it('captures at most two events across tab types, surfaces, and token-bucket refills', () => {
vi.useFakeTimers()
expect(track('tabs_directional_close_requested', left)).toBe(true)
expect(track('tabs_directional_close_requested', right)).toBe(true)
for (let index = 0; index < 100; index++) {
vi.advanceTimersByTime(60_000)
expect(
track('tabs_directional_close_requested', {
...left,
tab_type: 'browser',
surface: 'floating'
})
).toBe(false)
expect(track('tabs_directional_close_requested', right)).toBe(false)
}
expect(state.mock.capture).toHaveBeenCalledTimes(2)
})
it('does not let repeat clicks exhaust the other direction or the global event budget', () => {
track('tabs_directional_close_requested', left)
for (let index = 0; index < 1500; index++) {
track('tabs_directional_close_requested', left)
}
expect(track('tabs_directional_close_requested', right)).toBe(true)
expect(track('app_opened', {})).toBe(true)
expect(state.mock.capture).toHaveBeenCalledTimes(3)
})
it('allows each direction again after a new app session starts', () => {
track('tabs_directional_close_requested', left)
track('tabs_directional_close_requested', right)
initTelemetry(state.store)
expect(track('tabs_directional_close_requested', left)).toBe(true)
expect(track('tabs_directional_close_requested', right)).toBe(true)
expect(state.mock.capture).toHaveBeenCalledTimes(4)
})
it('does not spend the direction allowance while telemetry is disabled', () => {
state.settings.telemetry!.optedIn = false
expect(track('tabs_directional_close_requested', left)).toBe(false)
state.settings.telemetry!.optedIn = true
expect(track('tabs_directional_close_requested', left)).toBe(true)
expect(state.mock.capture).toHaveBeenCalledTimes(1)
})
it('does not spend the direction allowance on an invalid payload', () => {
expect(track('tabs_directional_close_requested', { ...left, eligible_tab_count: -1 })).toBe(
false
)
expect(track('tabs_directional_close_requested', left)).toBe(true)
expect(state.mock.capture).toHaveBeenCalledTimes(1)
})
it.each([true, false])(
'includes settings opt-in in the denominator (sampled: %s)',
async (sampled) => {
if (!sampled) {
_setCommonPropsForTests(BASE_COMMON)
}
state.settings.telemetry!.optedIn = false
trackAppOpenedOnce()
expect(track('tabs_directional_close_requested', left)).toBe(false)
expect(state.mock.capture).not.toHaveBeenCalled()
await setOptIn('settings', true)
expect(track('tabs_directional_close_requested', left)).toBe(sampled)
await setOptIn('settings', false)
await setOptIn('settings', true)
trackAppOpenedOnce()
expect(track('tabs_directional_close_requested', left)).toBe(false)
const captures = state.mock.capture.mock.calls.map(([capture]) => capture)
const opened = captures.filter((capture) => capture.event === 'app_opened')
expect(opened).toHaveLength(1)
expect(opened[0].properties).toMatchObject({
tab_close_sampled: sampled,
tab_close_sample_rate: 0.1,
session_id: BASE_COMMON.session_id
})
expect(captures[0]).toEqual(opened[0])
const closes = captures.filter(
(capture) => capture.event === 'tabs_directional_close_requested'
)
expect(closes).toHaveLength(sampled ? 1 : 0)
if (sampled) {
expect(closes[0].properties.session_id).toBe(opened[0].properties.session_id)
}
}
)
it('does not consume the denominator while an environment consent override blocks opt-in', async () => {
process.env.DO_NOT_TRACK = '1'
await setOptIn('settings', true)
expect(track('tabs_directional_close_requested', left)).toBe(false)
expect(state.mock.capture).not.toHaveBeenCalled()
delete process.env.DO_NOT_TRACK
await setOptIn('settings', true)
expect(track('tabs_directional_close_requested', left)).toBe(true)
expect(state.mock.capture.mock.calls.map(([capture]) => capture.event)).toEqual([
'app_opened',
'telemetry_opted_in',
'tabs_directional_close_requested'
])
})
it('marks sampled installs on the existing app-open event without adding an event', () => {
track('app_opened', { tab_close_sampled: false })
track('tabs_directional_close_requested', left)
expect(state.mock.capture).toHaveBeenCalledTimes(2)
expect(state.mock.capture.mock.calls[0]?.[0].properties).toMatchObject({
tab_close_sampled: true,
tab_close_sample_rate: 0.1
})
expect(state.mock.capture.mock.calls[1]?.[0].properties).toMatchObject({ sample_rate: 0.1 })
})
it('excludes unsampled installs while leaving other analytics enabled', () => {
_setCommonPropsForTests(BASE_COMMON)
for (let index = 0; index < 1500; index++) {
expect(track('tabs_directional_close_requested', left)).toBe(false)
expect(track('tabs_directional_close_requested', right)).toBe(false)
}
expect(track('app_opened', { tab_close_sampled: true })).toBe(true)
expect(
track('workspace_created', { source: 'command_palette', from_existing_branch: false })
).toBe(true)
expect(state.mock.capture).toHaveBeenCalledTimes(2)
expect(state.mock.capture.mock.calls[0]?.[0].properties).toMatchObject({
tab_close_sampled: false,
tab_close_sample_rate: 0.1
})
})
})
+4 -5
View File
@@ -2,7 +2,6 @@
// tests pin capture payload drift, shutdown gating, burst caps, consent order,
// and the per-session event ceiling.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CommonProps } from '../../shared/telemetry-events'
import type { Store } from '../persistence'
import { _setShuttingDownForTests, track, trackAppOpenedOnce } from './client'
import {
@@ -34,10 +33,10 @@ describe('track()', () => {
const call = mock.capture.mock.calls[0]![0]
expect(call.event).toBe('app_opened')
expect(call.distinctId).toBe(BASE_COMMON.install_id)
expect(call.properties.$process_person_profile).toBe(false)
for (const key of Object.keys(BASE_COMMON) as (keyof CommonProps)[]) {
expect(call.properties[key]).toBe(BASE_COMMON[key])
}
expect(call.properties).toEqual({
...BASE_COMMON,
$process_person_profile: false
})
})
// Drift-check: the full set of keys on the capture payload is bounded by
-37
View File
@@ -10,12 +10,10 @@ import { arch as osArch, platform as osPlatform, release as osRelease } from 'no
import { getAppEnvironment } from '../../shared/app-environment'
import { PostHog } from 'posthog-node'
import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events'
import { TAB_CLOSE_TELEMETRY_SAMPLE_RATE } from '../../shared/telemetry-tab-event-schemas'
import type { Store } from '../persistence'
import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap'
import { getCohortAtEmit } from './cohort-classifier'
import { resolveConsent, type ConsentState } from './consent'
import { isInTabCloseTelemetrySample } from './tab-close-sampling'
import { commonPropsSchema, validate } from './validator'
// Compile-time feature flag, independent of the build-identity gate — both must be satisfied to transmit.
@@ -41,7 +39,6 @@ const IS_OFFICIAL_BUILD: boolean =
let posthog: PostHog | null = null
let sessionId: string | null = null
let commonProps: CommonProps | null = null
let tabCloseSampled = false
let shuttingDown = false
let storeRef: Store | null = null
@@ -52,8 +49,6 @@ let testTransportEnabled = false
// First-launch `app_opened` gate: no events transmit until the banner resolves; keep mark+emit atomic.
let appOpenedTrackedThisSession = false
// Main owns this budget so additional windows cannot multiply billed tab-action events.
const trackedTabCloseDirections = new Set<'left' | 'right'>()
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
// Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked.
@@ -75,7 +70,6 @@ export function initTelemetry(store: Store): void {
shuttingDown = false
// Reset per session: the "no app_opened until banner resolution" invariant is per-launch, not per-install.
appOpenedTrackedThisSession = false
trackedTabCloseDirections.clear()
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) {
return
@@ -105,7 +99,6 @@ export function initTelemetry(store: Store): void {
commonProps = null
return
}
tabCloseSampled = isInTabCloseTelemetrySample(commonProps.install_id)
posthog = new PostHog(WRITE_KEY as string, {
host: 'https://us.i.posthog.com',
@@ -193,21 +186,6 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
return false
}
const tabCloseDirection =
name === 'tabs_directional_close_requested' &&
props !== null &&
typeof props === 'object' &&
'direction' in props &&
(props.direction === 'left' || props.direction === 'right')
? props.direction
: null
if (tabCloseDirection && trackedTabCloseDirections.has(tabCloseDirection)) {
return false
}
if (name === 'tabs_directional_close_requested' && !tabCloseSampled) {
return false
}
// (2) Burst cap before consent: the O(1) cap drops floods before the costly settings read, so a compromised opted-out renderer can't burn CPU.
if (!consumeBurstToken(name)) {
return false
@@ -224,16 +202,6 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
if (!result.ok) {
return false
}
const samplingProps =
name === 'app_opened'
? {
tab_close_sampled: tabCloseSampled,
tab_close_sample_rate: TAB_CLOSE_TELEMETRY_SAMPLE_RATE
}
: name === 'tabs_directional_close_requested'
? { sample_rate: TAB_CLOSE_TELEMETRY_SAMPLE_RATE }
: {}
// (5) Capture. `$process_person_profile: false` stops posthog-node creating a person per install_id (no init-time equivalent).
posthog.capture({
distinctId: commonProps.install_id,
@@ -241,13 +209,9 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): boole
properties: {
...commonProps,
...result.props,
...samplingProps,
$process_person_profile: false
}
})
if (tabCloseDirection) {
trackedTabCloseDirections.add(tabCloseDirection)
}
return true
}
@@ -361,7 +325,6 @@ export function _setPostHogClientForTests(client: PostHog | null): void {
export function _setCommonPropsForTests(props: CommonProps | null): void {
commonProps = props
tabCloseSampled = props !== null && isInTabCloseTelemetrySample(props.install_id)
}
export function _setStoreForTests(store: Store | null): void {
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isInTabCloseTelemetrySample } from './tab-close-sampling'
describe('tab close telemetry sampling', () => {
it('keeps known anonymous installs consistently inside or outside the sample', () => {
for (let index = 0; index < 10; index++) {
expect(isInTabCloseTelemetrySample('00000000-0000-4000-8000-000000000013')).toBe(true)
expect(isInTabCloseTelemetrySample('00000000-0000-4000-8000-000000000000')).toBe(false)
}
})
it('selects about ten percent of a fixed set of installs', () => {
let sampled = 0
for (let index = 0; index < 10_000; index++) {
const installId = `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
if (isInTabCloseTelemetrySample(installId)) {
sampled++
}
}
expect(sampled).toBeGreaterThan(900)
expect(sampled).toBeLessThan(1100)
})
})
-8
View File
@@ -1,8 +0,0 @@
import { createHash } from 'node:crypto'
import { TAB_CLOSE_TELEMETRY_SAMPLE_RATE } from '../../shared/telemetry-tab-event-schemas'
export function isInTabCloseTelemetrySample(installId: string): boolean {
// Keep the namespace stable so cohort membership survives app restarts and updates.
const hash = createHash('sha256').update('tabs-directional-close:v1:').update(installId).digest()
return hash.readUInt32BE(0) / 2 ** 32 < TAB_CLOSE_TELEMETRY_SAMPLE_RATE
}
@@ -1,15 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import {
X,
ExternalLink,
Copy,
CopyX,
Pin,
PinOff,
PanelLeftClose,
PanelRightClose
} from 'lucide-react'
import { X, ExternalLink, Copy, CopyX, Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -76,13 +67,10 @@ export default function BrowserTab({
isActive,
isPinned,
hasTabsToRight,
hasTabsToLeft,
tabCount,
onActivate,
onClose,
onCloseOthers,
onCloseToRight,
onCloseToLeft,
onCloseSplitPane,
onDuplicate,
onTogglePin,
@@ -297,20 +285,6 @@ export default function BrowserTab({
<CopyX className="size-3.5" />
{translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
<PanelRightClose className="size-3.5" />
{translate(
'auto.components.tab.bar.BrowserTab.9dd880bd56',
'Close All Tabs To The Right'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate(
'components.tab.bar.BrowserTab.closeTabsToLeft',
'Close All Tabs To The Left'
)}
</DropdownMenuItem>
<CloseSplitPaneMenuItem onCloseSplitPane={onCloseSplitPane} />
<DropdownMenuItem
onSelect={() => void window.api.shell.openUrl(openInBrowserUrl)}
@@ -360,15 +360,15 @@ describe('EditorFileTabContextMenu close-all shortcut', () => {
expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3)
})
it('renders Close Others and both directional close items', async () => {
it('keeps Close Others without directional close items', async () => {
const tree = expandNode(await renderMenu())
const labels = findElementsByType(tree, 'DropdownMenuItem').map((item) =>
extractText(item.props.children)
)
expect(labels).toContain('Close Others')
expect(labels.some((label) => label.includes('Close All Tabs To The Right'))).toBe(true)
expect(labels.some((label) => label.includes('Close All Tabs To The Left'))).toBe(true)
expect(labels.some((label) => label.includes('Close All Tabs To The Right'))).toBe(false)
expect(labels.some((label) => label.includes('Close All Tabs To The Left'))).toBe(false)
})
it('hides the shortcut chip when close-all is unassigned', async () => {
@@ -1,16 +1,4 @@
import {
Copy,
CopyX,
ExternalLink,
Eye,
ListX,
PanelLeftClose,
PanelRightClose,
Pencil,
Pin,
PinOff,
X
} from 'lucide-react'
import { Copy, CopyX, ExternalLink, Eye, ListX, Pencil, Pin, PinOff, X } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -82,8 +70,6 @@ export function EditorFileTabContextMenu({
groupId,
isPinned,
isRenaming,
hasTabsToRight,
hasTabsToLeft,
tabCount,
canRename,
canShowMarkdownPreview,
@@ -97,8 +83,6 @@ export function EditorFileTabContextMenu({
onClose,
onCloseOthers,
onCloseAll,
onCloseToRight,
onCloseToLeft,
onCloseSplitPane,
onOpenMarkdownPreview
}: EditorFileTabContextMenuProps): React.JSX.Element {
@@ -182,20 +166,6 @@ export function EditorFileTabContextMenu({
<DropdownMenuShortcut>{closeAllShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
<PanelRightClose className="size-3.5" />
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.e5ff31ccaf',
'Close All Tabs To The Right'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate(
'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft',
'Close All Tabs To The Left'
)}
</DropdownMenuItem>
<CloseSplitPaneMenuItem onCloseSplitPane={onCloseSplitPane} />
{isVirtualEditorFile(file) ? null : <DropdownMenuSeparator />}
{canShowMarkdownPreview ? (
@@ -352,34 +352,14 @@ describe('SortableTabContextMenu', () => {
expect(container.textContent).not.toContain('Split terminal')
})
it('routes the directional close actions to their handlers with the tab id', () => {
it('keeps Close Others available without directional close entries', () => {
const onCloseOthers = vi.fn()
const onCloseToRight = vi.fn()
const onCloseToLeft = vi.fn()
const { container } = renderMenu({
onCloseOthers,
onCloseToRight,
onCloseToLeft
})
const { container } = renderMenu({ onCloseOthers })
act(() => getButton(container, 'Close Others').click())
expect(onCloseOthers).toHaveBeenCalledWith('term-1')
act(() => getButton(container, 'Close All Tabs To The Right').click())
expect(onCloseToRight).toHaveBeenCalledWith('term-1')
act(() => getButton(container, 'Close All Tabs To The Left').click())
expect(onCloseToLeft).toHaveBeenCalledWith('term-1')
})
it('disables directional closes when no tabs exist on that side', () => {
const { container } = renderMenu({
hasTabsToLeft: false,
hasTabsToRight: false
})
expect(getButton(container, 'Close All Tabs To The Left').disabled).toBe(true)
expect(getButton(container, 'Close All Tabs To The Right').disabled).toBe(true)
expect(container.textContent).not.toContain('Close All Tabs To The Left')
expect(container.textContent).not.toContain('Close All Tabs To The Right')
})
it('hides move-tab split actions for a single-tab group', () => {
@@ -1,13 +1,4 @@
import {
PanelLeftClose,
PanelRightClose,
Pin,
PinOff,
Pencil,
SquareTerminal,
X,
ListX
} from 'lucide-react'
import { Pin, PinOff, Pencil, SquareTerminal, X, ListX } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -135,8 +126,6 @@ export function SortableTabContextMenu({
open,
point,
tabCount,
hasTabsToRight,
hasTabsToLeft,
isPinned,
onOpenChange,
onSessionSwitchComplete,
@@ -144,8 +133,6 @@ export function SortableTabContextMenu({
onActivate,
onClose,
onCloseOthers,
onCloseToRight,
onCloseToLeft,
onCloseSplitPane,
onRenameOpen,
onSetTabColor,
@@ -228,20 +215,6 @@ export function SortableTabContextMenu({
<ListX className="size-3.5" />
{translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseToRight(tab.id)} disabled={!hasTabsToRight}>
<PanelRightClose className="size-3.5" />
{translate(
'auto.components.tab.bar.SortableTabContextMenu.c1ee099c7e',
'Close All Tabs To The Right'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate(
'components.tab.bar.SortableTabContextMenu.closeTabsToLeft',
'Close All Tabs To The Left'
)}
</DropdownMenuItem>
<CloseSplitPaneMenuItem onCloseSplitPane={onCloseSplitPane} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRenameOpen}>
@@ -1,158 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { Tab, TabContentType, TabGroup } from '../../../../shared/tab-types'
import { useTabGroupCloseScopeCommands } from './useTabGroupCloseScopeCommands'
const mocks = vi.hoisted(() => {
const state: {
groupsByWorktree: Record<string, TabGroup[]>
unifiedTabsByWorktree: Record<string, Tab[]>
} = { groupsByWorktree: {}, unifiedTabsByWorktree: {} }
return { track: vi.fn(), closeEmptyGroup: vi.fn(), state }
})
vi.mock('@/lib/telemetry', () => ({ track: mocks.track }))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
(selector: (state: { closeEmptyGroup: () => void }) => unknown) => selector(mocks),
{ getState: () => mocks.state }
)
}))
vi.mock('../confirmation-dialog-context', () => ({
useConfirmationDialog: () => async () => true
}))
vi.mock('./workspace-emptied-reaction', () => ({
captureWorkspaceEmptiedReaction: vi.fn(() => vi.fn())
}))
function tab(id: string, contentType: TabContentType = 'terminal', isPinned = false): Tab {
return {
id,
entityId: id,
contentType,
groupId: 'group-1',
worktreeId: 'worktree-1',
label: 'private tab title',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
isPinned
}
}
function commands({
worktreeId = 'worktree-1',
contentType = 'terminal',
closeMany = vi.fn()
}: {
worktreeId?: string
contentType?: TabContentType
closeMany?: (ids: string[]) => void
} = {}) {
const group: TabGroup = {
id: 'group-1',
worktreeId,
activeTabId: 'selected',
tabOrder: ['left', 'pinned-left', 'selected', 'right', 'pinned-right', 'missing', 'last']
}
// Storage order differs from the visible order; pinned and missing tabs must not count.
const groupTabs = [
tab('last'),
tab('selected', contentType),
tab('pinned-left', 'terminal', true),
tab('pinned-right', 'terminal', true),
tab('right'),
tab('left')
]
mocks.state = {
groupsByWorktree: { [worktreeId]: [group] },
unifiedTabsByWorktree: { [worktreeId]: groupTabs }
}
const hook = renderHook(() =>
useTabGroupCloseScopeCommands({
groupId: group.id,
worktreeId,
closeItem: vi.fn(),
closeMany
})
)
return { ...hook.result.current, closeMany }
}
beforeEach(() => vi.clearAllMocks())
describe('directional tab close telemetry', () => {
it.each(['left', 'right'] as const)(
'counts one %s request before asking to close tabs',
async (direction) => {
const expectedIds = direction === 'left' ? ['left'] : ['right', 'last']
const closeMany = vi.fn((ids: string[]) => {
expect(mocks.track).toHaveBeenCalledExactlyOnceWith('tabs_directional_close_requested', {
direction,
tab_type: 'terminal',
surface: 'workspace',
eligible_tab_count: expectedIds.length
})
expect(ids).toEqual(expectedIds)
// The close flow can leave these tabs open after a cancelled save prompt.
})
const scope = commands({ closeMany })
await act(async () => {
if (direction === 'left') {
scope.closeToLeft('selected')
} else {
scope.closeToRight('selected')
}
})
expect(closeMany).toHaveBeenCalledExactlyOnceWith(expectedIds)
}
)
it.each([
['terminal', 'terminal'],
['editor', 'editor'],
['diff', 'editor'],
['conflict-review', 'editor'],
['check-details', 'editor'],
['agent-session', 'agent-session'],
['browser', 'browser'],
['simulator', 'simulator']
] as const)('reports %s tabs as %s in the floating panel', (contentType, tabType) => {
commands({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, contentType }).closeToLeft('selected')
expect(mocks.track).toHaveBeenCalledExactlyOnceWith('tabs_directional_close_requested', {
direction: 'left',
tab_type: tabType,
surface: 'floating',
eligible_tab_count: 1
})
})
it('records an empty selection as a request with no eligible tabs', () => {
const scope = commands()
scope.closeToLeft('left')
expect(mocks.track).toHaveBeenCalledExactlyOnceWith('tabs_directional_close_requested', {
direction: 'left',
tab_type: 'terminal',
surface: 'workspace',
eligible_tab_count: 0
})
expect(scope.closeMany).not.toHaveBeenCalled()
})
it('does not count stale tab ids or unrelated close commands', () => {
const scope = commands()
scope.closeToLeft('stale')
scope.closeToRight('stale')
scope.closeOthers('selected')
scope.closeAllEditorTabsInGroup()
expect(mocks.track).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,4 @@
import { useCallback } from 'react'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { toVisibleTabType } from '../../../../shared/tab-types'
import { track } from '@/lib/telemetry'
import { useAppStore } from '../../store'
import { captureWorkspaceEmptiedReaction } from './workspace-emptied-reaction'
import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type'
@@ -93,27 +90,9 @@ export function useTabGroupCloseScopeCommands({
const closeInDirection = useCallback(
(itemId: string, direction: 'left' | 'right') => {
const state = useAppStore.getState()
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.id === groupId
)
if (!group?.tabOrder.includes(itemId)) {
return
}
const item = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.groupId === groupId && candidate.id === itemId
)
if (item) {
track('tabs_directional_close_requested', {
direction,
tab_type: toVisibleTabType(item.contentType),
surface: worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'floating' : 'workspace',
eligible_tab_count: selectTabIds(direction, itemId).length
})
}
confirmBulkClose(direction, () => selectTabIds(direction, itemId), closeMany)
},
[closeMany, confirmBulkClose, groupId, selectTabIds, worktreeId]
[closeMany, confirmBulkClose, selectTabIds]
)
const closeToRight = useCallback(
+3 -9
View File
@@ -3778,7 +3778,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "Open In Browser",
"9dd880bd56": "Close All Tabs To The Right",
"1611a1324b": "Close",
"5d6e89891f": "Duplicate Tab",
"966feb9ad5": "Split Right",
@@ -3795,7 +3794,6 @@
"52ce4f4605": "Copy Relative Path",
"5b85754786": "Copy Path",
"bfd5797ef4": "Open Markdown Preview",
"e5ff31ccaf": "Close All Tabs To The Right",
"ba1369dd24": "Close All Editor Tabs",
"1ba8492c5b": "Close",
"68cc610e7f": "Rename",
@@ -3839,7 +3837,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "Tab Color",
"2f697b3c31": "Change Title",
"c1ee099c7e": "Close All Tabs To The Right",
"8d16f9cd30": "Close Others",
"89359a36f7": "Close",
"21132389e9": "Split Right",
@@ -19452,16 +19449,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "Continue with Terminal Agent",
"switchToChatView": "Continue in Chat UI",
"closeTabsToLeft": "Close All Tabs To The Left"
"switchToChatView": "Continue in Chat UI"
},
"BrowserTab": {
"closeOthers": "Close Others",
"closeTabsToLeft": "Close All Tabs To The Left"
"closeOthers": "Close Others"
},
"EditorFileTabContextMenu": {
"closeOthers": "Close Others",
"closeTabsToLeft": "Close All Tabs To The Left"
"closeOthers": "Close Others"
},
"CopyTabIdMenuItem": {
"copied": "Tab ID copied",
+3 -9
View File
@@ -3510,7 +3510,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "Abrir en el navegador",
"9dd880bd56": "Cerrar todas las pestañas a la derecha",
"1611a1324b": "Cerrar",
"5d6e89891f": "Duplicar pestaña",
"966feb9ad5": "Dividir a la derecha",
@@ -3527,7 +3526,6 @@
"52ce4f4605": "Copiar ruta relativa",
"5b85754786": "Copiar ruta",
"bfd5797ef4": "Abrir vista previa de Markdown",
"e5ff31ccaf": "Cerrar todas las pestañas a la derecha",
"ba1369dd24": "Cerrar todas las pestañas del editor",
"1ba8492c5b": "Cerrar",
"68cc610e7f": "Renombrar",
@@ -3560,7 +3558,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "Color de pestaña",
"2f697b3c31": "Cambiar título",
"c1ee099c7e": "Cerrar todas las pestañas a la derecha",
"8d16f9cd30": "Cerrar otras",
"89359a36f7": "Cerrar",
"21132389e9": "Dividir a la derecha",
@@ -19079,16 +19076,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "Continuar con el agente de terminal",
"switchToChatView": "Continuar en la interfaz de chat",
"closeTabsToLeft": "Cerrar todas las pestañas a la izquierda"
"switchToChatView": "Continuar en la interfaz de chat"
},
"BrowserTab": {
"closeOthers": "Cerrar otras",
"closeTabsToLeft": "Cerrar todas las pestañas a la izquierda"
"closeOthers": "Cerrar otras"
},
"EditorFileTabContextMenu": {
"closeOthers": "Cerrar otras",
"closeTabsToLeft": "Cerrar todas las pestañas a la izquierda"
"closeOthers": "Cerrar otras"
},
"CopyTabIdMenuItem": {
"copied": "ID de pestaña copiado",
+3 -9
View File
@@ -3623,7 +3623,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "Ouvrir dans le navigateur",
"9dd880bd56": "Fermer tous les onglets à droite",
"1611a1324b": "Fermer",
"5d6e89891f": "Dupliquer l'onglet",
"966feb9ad5": "Scinder vers la droite",
@@ -3640,7 +3639,6 @@
"52ce4f4605": "Copier le chemin relatif",
"5b85754786": "Copier le chemin",
"bfd5797ef4": "Ouvrir l'aperçu Markdown",
"e5ff31ccaf": "Fermer tous les onglets à droite",
"ba1369dd24": "Fermer tous les onglets de l'éditeur",
"1ba8492c5b": "Fermer",
"68cc610e7f": "Renommer",
@@ -3673,7 +3671,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "Couleur de l'onglet",
"2f697b3c31": "Modifier le titre",
"c1ee099c7e": "Fermer tous les onglets à droite",
"8d16f9cd30": "Fermer les autres",
"89359a36f7": "Fermer",
"21132389e9": "Scinder vers la droite",
@@ -19188,16 +19185,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "Continuer avec l’agent de terminal",
"switchToChatView": "Continuer dans l’interface de chat",
"closeTabsToLeft": "Fermer tous les onglets à gauche"
"switchToChatView": "Continuer dans l’interface de chat"
},
"BrowserTab": {
"closeOthers": "Fermer les autres",
"closeTabsToLeft": "Fermer tous les onglets à gauche"
"closeOthers": "Fermer les autres"
},
"EditorFileTabContextMenu": {
"closeOthers": "Fermer les autres",
"closeTabsToLeft": "Fermer tous les onglets à gauche"
"closeOthers": "Fermer les autres"
},
"CopyTabIdMenuItem": {
"copied": "ID de l'onglet copié",
+3 -9
View File
@@ -3515,7 +3515,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "ブラウザで開く",
"9dd880bd56": "右側のすべてのタブを閉じる",
"1611a1324b": "閉じる",
"5d6e89891f": "タブの複製",
"966feb9ad5": "右に分割",
@@ -3532,7 +3531,6 @@
"52ce4f4605": "相対パスをコピー",
"5b85754786": "パスのコピー",
"bfd5797ef4": "Markdown プレビューを開く",
"e5ff31ccaf": "右側のすべてのタブを閉じる",
"ba1369dd24": "すべてのエディターのタブを閉じる",
"1ba8492c5b": "閉じる",
"68cc610e7f": "名前を変更する",
@@ -3565,7 +3563,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "タブの色",
"2f697b3c31": "タイトルの変更",
"c1ee099c7e": "右側のすべてのタブを閉じる",
"8d16f9cd30": "その他を閉じる",
"89359a36f7": "閉じる",
"21132389e9": "右に分割",
@@ -19119,16 +19116,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "ターミナル Agent で続ける",
"switchToChatView": "チャット UI で続ける",
"closeTabsToLeft": "左側のすべてのタブを閉じる"
"switchToChatView": "チャット UI で続ける"
},
"BrowserTab": {
"closeOthers": "その他を閉じる",
"closeTabsToLeft": "左側のすべてのタブを閉じる"
"closeOthers": "その他を閉じる"
},
"EditorFileTabContextMenu": {
"closeOthers": "その他を閉じる",
"closeTabsToLeft": "左側のすべてのタブを閉じる"
"closeOthers": "その他を閉じる"
},
"CopyTabIdMenuItem": {
"copied": "タブ ID をコピーしました",
+3 -9
View File
@@ -3515,7 +3515,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "브라우저에서 열기",
"9dd880bd56": "오른쪽의 모든 탭 닫기",
"1611a1324b": "닫기",
"5d6e89891f": "탭 복제",
"966feb9ad5": "오른쪽 분할",
@@ -3532,7 +3531,6 @@
"52ce4f4605": "상대 경로 복사",
"5b85754786": "경로 복사",
"bfd5797ef4": "Markdown 미리보기 열기",
"e5ff31ccaf": "오른쪽의 모든 탭 닫기",
"ba1369dd24": "모든 편집기 탭 닫기",
"1ba8492c5b": "닫기",
"68cc610e7f": "이름 바꾸기",
@@ -3565,7 +3563,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "탭 색상",
"2f697b3c31": "제목 변경",
"c1ee099c7e": "오른쪽의 모든 탭 닫기",
"8d16f9cd30": "다른 탭 닫기",
"89359a36f7": "닫기",
"21132389e9": "오른쪽 분할",
@@ -19119,16 +19116,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "터미널 에이전트로 계속하기",
"switchToChatView": "채팅 UI에서 계속하기",
"closeTabsToLeft": "왼쪽의 모든 탭 닫기"
"switchToChatView": "채팅 UI에서 계속하기"
},
"BrowserTab": {
"closeOthers": "다른 탭 닫기",
"closeTabsToLeft": "왼쪽의 모든 탭 닫기"
"closeOthers": "다른 탭 닫기"
},
"EditorFileTabContextMenu": {
"closeOthers": "다른 탭 닫기",
"closeTabsToLeft": "왼쪽의 모든 탭 닫기"
"closeOthers": "다른 탭 닫기"
},
"CopyTabIdMenuItem": {
"copied": "탭 ID 복사됨",
+3 -9
View File
@@ -3515,7 +3515,6 @@
"bar": {
"BrowserTab": {
"6e0bc8f3a8": "在浏览器中打开",
"9dd880bd56": "关闭右侧的所有选项卡",
"1611a1324b": "关闭",
"5d6e89891f": "复制选项卡",
"966feb9ad5": "向右拆分",
@@ -3532,7 +3531,6 @@
"52ce4f4605": "复制相对路径",
"5b85754786": "复制路径",
"bfd5797ef4": "打开 Markdown 预览",
"e5ff31ccaf": "关闭右侧的所有选项卡",
"ba1369dd24": "关闭所有编辑器选项卡",
"1ba8492c5b": "关闭",
"68cc610e7f": "重命名",
@@ -3565,7 +3563,6 @@
"SortableTabContextMenu": {
"35e8892fd0": "标签颜色",
"2f697b3c31": "更改标题",
"c1ee099c7e": "关闭右侧的所有选项卡",
"8d16f9cd30": "关闭其他",
"89359a36f7": "关闭",
"21132389e9": "向右拆分",
@@ -19084,16 +19081,13 @@
"bar": {
"SortableTabContextMenu": {
"switchToTerminalView": "使用终端智能体继续",
"switchToChatView": "在聊天界面中继续",
"closeTabsToLeft": "关闭左侧的所有选项卡"
"switchToChatView": "在聊天界面中继续"
},
"BrowserTab": {
"closeOthers": "关闭其他",
"closeTabsToLeft": "关闭左侧的所有选项卡"
"closeOthers": "关闭其他"
},
"EditorFileTabContextMenu": {
"closeOthers": "关闭其他",
"closeTabsToLeft": "关闭左侧的所有选项卡"
"closeOthers": "关闭其他"
},
"CopyTabIdMenuItem": {
"copied": "已复制标签页 ID",
+2 -2
View File
@@ -21,7 +21,6 @@ import {
workspaceSourceSchema
} from './telemetry-property-schemas'
import { workspaceCreatedTimingProperties } from './telemetry-workspace-create-schemas'
import { TAB_CLOSE_TELEMETRY_SAMPLE_RATE } from './telemetry-tab-event-schemas'
// ── Per-event schemas ───────────────────────────────────────────────────
@@ -31,8 +30,9 @@ export const nthRepoAddedSchema = z.number().int().nonnegative().optional()
export const appOpenedSchema = z
.object({
nth_repo_added: nthRepoAddedSchema,
// Accept retired sampling fields from older callers; current builds no longer emit them.
tab_close_sampled: z.boolean().optional(),
tab_close_sample_rate: z.literal(TAB_CLOSE_TELEMETRY_SAMPLE_RATE).optional()
tab_close_sample_rate: z.literal(0.1).optional()
})
.strict()
-2
View File
@@ -93,7 +93,6 @@ import {
workspaceCreateFailedSchema
} from './telemetry-repository-event-schemas'
import { sshRemoteRuntimeResolvedSchema } from './telemetry-ssh-runtime-event-schemas'
import { tabsDirectionalCloseRequestedSchema } from './telemetry-tab-event-schemas'
import {
sshHostServerConversionSchema,
sshHostServerDecidedSchema,
@@ -197,7 +196,6 @@ export const eventSchemas = {
setup_guide_closed: setupGuideClosedSchema,
setup_guide_step_completed: setupGuideStepCompletedSchema,
terminal_pane_split: terminalPaneSplitSchema,
tabs_directional_close_requested: tabsDirectionalCloseRequestedSchema,
editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema,
editor_external_change_conflict_action: editorExternalChangeConflictActionSchema,
@@ -1,39 +0,0 @@
import { describe, expect, it } from 'vitest'
import { eventSchemas } from './telemetry-events'
const valid = {
direction: 'left',
tab_type: 'terminal',
surface: 'workspace',
eligible_tab_count: 2
}
describe('tabs_directional_close_requested schema', () => {
it('accepts requests, including empty selections', () => {
for (const direction of ['left', 'right']) {
for (const surface of ['workspace', 'floating']) {
for (const eligible_tab_count of [0, 2]) {
const props = { ...valid, direction, surface, eligible_tab_count }
expect(eventSchemas.tabs_directional_close_requested.parse(props)).toEqual(props)
}
}
}
})
it.each([
{ direction: 'others' },
{ tab_type: 'private title' },
{ surface: 'unknown' },
{ eligible_tab_count: -1 },
{ eligible_tab_count: 1.5 },
{ eligible_tab_count: '2' },
{ sample_rate: 1 },
{ tab_id: '/private/file.ts' },
{ title: 'private title' },
{ worktree_id: 'private workspace' }
])('rejects invalid values and private fields: %j', (extra) => {
expect(
eventSchemas.tabs_directional_close_requested.safeParse({ ...valid, ...extra }).success
).toBe(false)
})
})
-15
View File
@@ -1,15 +0,0 @@
import { z } from 'zod'
export const TAB_CLOSE_TELEMETRY_SAMPLE_RATE = 0.1
// First request per direction per app session, including empty selections and cancelled save prompts.
// Properties describe that first request; event counts measure session adoption, not click frequency.
export const tabsDirectionalCloseRequestedSchema = z
.object({
direction: z.enum(['left', 'right']),
tab_type: z.enum(['terminal', 'editor', 'agent-session', 'browser', 'simulator']),
surface: z.enum(['workspace', 'floating']),
eligible_tab_count: z.number().int().min(0),
sample_rate: z.literal(TAB_CLOSE_TELEMETRY_SAMPLE_RATE).optional()
})
.strict()