mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
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:
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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 をコピーしました",
|
||||
|
||||
@@ -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 복사됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user