fix web session intent retention

This commit is contained in:
Neil
2026-07-11 04:17:11 -07:00
parent a4583eb765
commit 48c0f1c421
8 changed files with 347 additions and 21 deletions
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES,
clearWebSessionCloseIntentsForWorktree,
getWebSessionCloseIntentCountsForTests,
isWebSessionCloseIntentPending,
reconcileWebSessionCloseIntents,
recordWebSessionCloseIntent,
@@ -31,6 +34,34 @@ describe('web session close intent', () => {
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000 + 11_000)).toBe(false)
})
it('prunes expired worktree intents when recording a newer intent', () => {
recordWebSessionCloseIntent('old-worktree', 'host-tab-old', 1000)
recordWebSessionCloseIntent(WT, 'host-tab-1', 11_001)
expect(getWebSessionCloseIntentCountsForTests()).toEqual({ worktrees: 1, tabs: 1 })
expect(isWebSessionCloseIntentPending('old-worktree', 'host-tab-old', 11_001)).toBe(false)
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 11_001)).toBe(true)
})
it('bounds worktree intents while retaining recently reused worktrees', () => {
recordWebSessionCloseIntent('keep', 'host-tab-keep', 1000)
for (let i = 0; i < MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES - 1; i += 1) {
recordWebSessionCloseIntent(`worktree-${i}`, 'host-tab', 1000)
}
expect(isWebSessionCloseIntentPending('keep', 'host-tab-keep', 1000)).toBe(true)
recordWebSessionCloseIntent('worktree-new', 'host-tab-new', 1000)
expect(getWebSessionCloseIntentCountsForTests()).toEqual({
worktrees: MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES,
tabs: MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES
})
expect(isWebSessionCloseIntentPending('worktree-0', 'host-tab', 1000)).toBe(false)
expect(isWebSessionCloseIntentPending('keep', 'host-tab-keep', 1000)).toBe(true)
})
it('scopes intents per worktree', () => {
recordWebSessionCloseIntent(WT, 'host-tab-1', 1000)
expect(isWebSessionCloseIntentPending('other::/wt', 'host-tab-1', 1000)).toBe(false)
@@ -40,4 +71,14 @@ describe('web session close intent', () => {
recordWebSessionCloseIntent(WT, ' ', 1000)
expect(isWebSessionCloseIntentPending(WT, '', 1000)).toBe(false)
})
it('clears one worktree without touching another', () => {
recordWebSessionCloseIntent(WT, 'host-tab-1', 1000)
recordWebSessionCloseIntent('repo::/other', 'host-tab-2', 1000)
clearWebSessionCloseIntentsForWorktree(WT)
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(false)
expect(isWebSessionCloseIntentPending('repo::/other', 'host-tab-2', 1000)).toBe(true)
})
})
@@ -13,12 +13,45 @@
// permanently hiding a tab that legitimately still exists host-side.
const CLOSE_INTENT_TTL_MS = 10_000
export const MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES = 256
type CloseIntent = { recordedAt: number }
// worktreeId -> (hostTabId -> intent)
const pendingCloseByWorktree = new Map<string, Map<string, CloseIntent>>()
function deleteEmptyCloseIntentWorktree(worktreeId: string, byTab: Map<string, CloseIntent>): void {
if (byTab.size === 0) {
pendingCloseByWorktree.delete(worktreeId)
}
}
function refreshCloseIntentWorktree(worktreeId: string, byTab: Map<string, CloseIntent>): void {
pendingCloseByWorktree.delete(worktreeId)
pendingCloseByWorktree.set(worktreeId, byTab)
}
function pruneExpiredWebSessionCloseIntents(now: number): void {
for (const [worktreeId, byTab] of pendingCloseByWorktree) {
for (const [hostTabId, intent] of byTab) {
if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) {
byTab.delete(hostTabId)
}
}
deleteEmptyCloseIntentWorktree(worktreeId, byTab)
}
}
function trimWebSessionCloseIntentWorktrees(): void {
while (pendingCloseByWorktree.size > MAX_WEB_SESSION_CLOSE_INTENT_WORKTREES) {
const oldestWorktreeId = pendingCloseByWorktree.keys().next().value
if (oldestWorktreeId === undefined) {
break
}
pendingCloseByWorktree.delete(oldestWorktreeId)
}
}
export function recordWebSessionCloseIntent(
worktreeId: string,
hostTabId: string,
@@ -28,12 +61,16 @@ export function recordWebSessionCloseIntent(
if (!worktreeId || !trimmed) {
return
}
// Why: close intents already expire; prune all worktrees on new writes so
// removed worktrees that never reconcile again do not retain stale tab ids.
pruneExpiredWebSessionCloseIntents(now)
let byTab = pendingCloseByWorktree.get(worktreeId)
if (!byTab) {
byTab = new Map()
pendingCloseByWorktree.set(worktreeId, byTab)
}
refreshCloseIntentWorktree(worktreeId, byTab)
byTab.set(trimmed, { recordedAt: now })
trimWebSessionCloseIntentWorktrees()
}
/**
@@ -47,17 +84,19 @@ export function isWebSessionCloseIntentPending(
now: number
): boolean {
const byTab = pendingCloseByWorktree.get(worktreeId)
const intent = byTab?.get(hostTabId)
if (!byTab) {
return false
}
const intent = byTab.get(hostTabId)
if (!intent) {
return false
}
if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) {
byTab!.delete(hostTabId)
if (byTab!.size === 0) {
pendingCloseByWorktree.delete(worktreeId)
}
byTab.delete(hostTabId)
deleteEmptyCloseIntentWorktree(worktreeId, byTab)
return false
}
refreshCloseIntentWorktree(worktreeId, byTab)
return true
}
@@ -82,11 +121,25 @@ export function reconcileWebSessionCloseIntents(
for (const hostTabId of confirmed) {
byTab.delete(hostTabId)
}
if (byTab.size === 0) {
pendingCloseByWorktree.delete(worktreeId)
if (byTab.size > 0) {
refreshCloseIntentWorktree(worktreeId, byTab)
return
}
pendingCloseByWorktree.delete(worktreeId)
}
export function clearWebSessionCloseIntentsForWorktree(worktreeId: string): void {
pendingCloseByWorktree.delete(worktreeId)
}
export function resetWebSessionCloseIntentForTests(): void {
pendingCloseByWorktree.clear()
}
export function getWebSessionCloseIntentCountsForTests(): { worktrees: number; tabs: number } {
let tabs = 0
for (const byTab of pendingCloseByWorktree.values()) {
tabs += byTab.size
}
return { worktrees: pendingCloseByWorktree.size, tabs }
}
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
MAX_WEB_SESSION_FOCUS_INTENT_WORKTREES,
clearWebSessionFocusIntent,
getWebSessionFocusIntentCountForTests,
peekWebSessionFocusIntent,
recordWebSessionFocusIntent,
resetWebSessionFocusIntentForTests
} from './web-session-focus-intent'
const WT = 'repo::/wt'
afterEach(() => resetWebSessionFocusIntentForTests())
describe('web session focus intent', () => {
it('expires a never-confirmed focus intent', () => {
recordWebSessionFocusIntent(WT, 'host-tab-1', 1000)
expect(peekWebSessionFocusIntent(WT, 1000)).toBe('host-tab-1')
expect(peekWebSessionFocusIntent(WT, 61_001)).toBeNull()
expect(getWebSessionFocusIntentCountForTests()).toBe(0)
})
it('bounds worktree intents while retaining recently reused worktrees', () => {
recordWebSessionFocusIntent('keep', 'host-tab-keep', 1000)
for (let i = 0; i < MAX_WEB_SESSION_FOCUS_INTENT_WORKTREES - 1; i += 1) {
recordWebSessionFocusIntent(`worktree-${i}`, `host-tab-${i}`, 1000)
}
expect(peekWebSessionFocusIntent('keep', 1000)).toBe('host-tab-keep')
recordWebSessionFocusIntent('worktree-new', 'host-tab-new', 1000)
expect(getWebSessionFocusIntentCountForTests()).toBe(MAX_WEB_SESSION_FOCUS_INTENT_WORKTREES)
expect(peekWebSessionFocusIntent('worktree-0', 1000)).toBeNull()
expect(peekWebSessionFocusIntent('keep', 1000)).toBe('host-tab-keep')
})
it('clears one worktree without touching another', () => {
recordWebSessionFocusIntent(WT, 'host-tab-1', 1000)
recordWebSessionFocusIntent('repo::/other', 'host-tab-2', 1000)
clearWebSessionFocusIntent(WT)
expect(peekWebSessionFocusIntent(WT, 1000)).toBeNull()
expect(peekWebSessionFocusIntent('repo::/other', 1000)).toBe('host-tab-2')
})
})
@@ -9,18 +9,60 @@
// The intent persists until a snapshot matches it (surviving racing/duplicate
// snapshots, unlike a transient per-snapshot flag).
const pendingFocusByWorktree = new Map<string, string>()
// Why: a create/activate echo should arrive quickly; if a removed worktree never
// publishes again, the focus intent needs a bounded lifetime.
const FOCUS_INTENT_TTL_MS = 60_000
export const MAX_WEB_SESSION_FOCUS_INTENT_WORKTREES = 256
export function recordWebSessionFocusIntent(worktreeId: string, hostTabId: string): void {
type FocusIntent = { hostTabId: string; recordedAt: number }
const pendingFocusByWorktree = new Map<string, FocusIntent>()
function pruneExpiredWebSessionFocusIntents(now: number): void {
for (const [worktreeId, intent] of pendingFocusByWorktree) {
if (now - intent.recordedAt > FOCUS_INTENT_TTL_MS) {
pendingFocusByWorktree.delete(worktreeId)
}
}
}
function trimWebSessionFocusIntents(): void {
while (pendingFocusByWorktree.size > MAX_WEB_SESSION_FOCUS_INTENT_WORKTREES) {
const oldestWorktreeId = pendingFocusByWorktree.keys().next().value
if (oldestWorktreeId === undefined) {
break
}
pendingFocusByWorktree.delete(oldestWorktreeId)
}
}
export function recordWebSessionFocusIntent(
worktreeId: string,
hostTabId: string,
now = Date.now()
): void {
const trimmed = hostTabId.trim()
if (!worktreeId || !trimmed) {
return
}
pendingFocusByWorktree.set(worktreeId, trimmed)
pruneExpiredWebSessionFocusIntents(now)
pendingFocusByWorktree.delete(worktreeId)
pendingFocusByWorktree.set(worktreeId, { hostTabId: trimmed, recordedAt: now })
trimWebSessionFocusIntents()
}
export function peekWebSessionFocusIntent(worktreeId: string): string | null {
return pendingFocusByWorktree.get(worktreeId) ?? null
export function peekWebSessionFocusIntent(worktreeId: string, now = Date.now()): string | null {
const intent = pendingFocusByWorktree.get(worktreeId)
if (!intent) {
return null
}
if (now - intent.recordedAt > FOCUS_INTENT_TTL_MS) {
pendingFocusByWorktree.delete(worktreeId)
return null
}
pendingFocusByWorktree.delete(worktreeId)
pendingFocusByWorktree.set(worktreeId, intent)
return intent.hostTabId
}
export function clearWebSessionFocusIntent(worktreeId: string): void {
@@ -30,3 +72,7 @@ export function clearWebSessionFocusIntent(worktreeId: string): void {
export function resetWebSessionFocusIntentForTests(): void {
pendingFocusByWorktree.clear()
}
export function getWebSessionFocusIntentCountForTests(): number {
return pendingFocusByWorktree.size
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
MAX_WEB_SESSION_REORDER_INTENT_WORKTREES,
clearWebSessionReorderIntentsForWorktree,
getWebSessionReorderIntentCountsForTests,
recordWebSessionReorderIntent,
resetWebSessionReorderIntentForTests,
resolveWebSessionReorderedOrder
} from './web-session-reorder-intent'
const WT = 'repo::/wt'
afterEach(() => resetWebSessionReorderIntentForTests())
describe('web session reorder intent', () => {
it('prunes expired worktree intents when recording a newer intent', () => {
recordWebSessionReorderIntent('old-worktree', 'group-1', ['tab-b', 'tab-a'], 1000)
recordWebSessionReorderIntent(WT, 'group-1', ['tab-c', 'tab-d'], 11_001)
expect(getWebSessionReorderIntentCountsForTests()).toEqual({ worktrees: 1, groups: 1 })
expect(
resolveWebSessionReorderedOrder('old-worktree', 'group-1', ['tab-a', 'tab-b'], 11_001)
).toEqual(['tab-a', 'tab-b'])
expect(resolveWebSessionReorderedOrder(WT, 'group-1', ['tab-d', 'tab-c'], 11_001)).toEqual([
'tab-c',
'tab-d'
])
})
it('bounds worktree intents while retaining recently reused worktrees', () => {
recordWebSessionReorderIntent('keep', 'group-1', ['tab-b', 'tab-a'], 1000)
for (let i = 0; i < MAX_WEB_SESSION_REORDER_INTENT_WORKTREES - 1; i += 1) {
recordWebSessionReorderIntent(`worktree-${i}`, 'group-1', ['tab-b', 'tab-a'], 1000)
}
expect(resolveWebSessionReorderedOrder('keep', 'group-1', ['tab-a', 'tab-b'], 1000)).toEqual([
'tab-b',
'tab-a'
])
recordWebSessionReorderIntent('worktree-new', 'group-1', ['tab-b', 'tab-a'], 1000)
expect(getWebSessionReorderIntentCountsForTests()).toEqual({
worktrees: MAX_WEB_SESSION_REORDER_INTENT_WORKTREES,
groups: MAX_WEB_SESSION_REORDER_INTENT_WORKTREES
})
expect(
resolveWebSessionReorderedOrder('worktree-0', 'group-1', ['tab-a', 'tab-b'], 1000)
).toEqual(['tab-a', 'tab-b'])
expect(resolveWebSessionReorderedOrder('keep', 'group-1', ['tab-a', 'tab-b'], 1000)).toEqual([
'tab-b',
'tab-a'
])
})
it('clears one worktree without touching another', () => {
recordWebSessionReorderIntent(WT, 'group-1', ['tab-b', 'tab-a'], 1000)
recordWebSessionReorderIntent('repo::/other', 'group-1', ['tab-d', 'tab-c'], 1000)
clearWebSessionReorderIntentsForWorktree(WT)
expect(resolveWebSessionReorderedOrder(WT, 'group-1', ['tab-a', 'tab-b'], 1000)).toEqual([
'tab-a',
'tab-b'
])
expect(
resolveWebSessionReorderedOrder('repo::/other', 'group-1', ['tab-c', 'tab-d'], 1000)
).toEqual(['tab-d', 'tab-c'])
})
})
@@ -13,12 +13,51 @@
// rejected RPC) from pinning a stale order forever.
const REORDER_INTENT_TTL_MS = 10_000
export const MAX_WEB_SESSION_REORDER_INTENT_WORKTREES = 256
type ReorderIntent = { order: string[]; recordedAt: number }
// worktreeId -> (groupId -> intent)
const pendingReorderByWorktree = new Map<string, Map<string, ReorderIntent>>()
function deleteEmptyReorderIntentWorktree(
worktreeId: string,
byGroup: Map<string, ReorderIntent>
): void {
if (byGroup.size === 0) {
pendingReorderByWorktree.delete(worktreeId)
}
}
function refreshReorderIntentWorktree(
worktreeId: string,
byGroup: Map<string, ReorderIntent>
): void {
pendingReorderByWorktree.delete(worktreeId)
pendingReorderByWorktree.set(worktreeId, byGroup)
}
function pruneExpiredWebSessionReorderIntents(now: number): void {
for (const [worktreeId, byGroup] of pendingReorderByWorktree) {
for (const [groupId, intent] of byGroup) {
if (now - intent.recordedAt > REORDER_INTENT_TTL_MS) {
byGroup.delete(groupId)
}
}
deleteEmptyReorderIntentWorktree(worktreeId, byGroup)
}
}
function trimWebSessionReorderIntentWorktrees(): void {
while (pendingReorderByWorktree.size > MAX_WEB_SESSION_REORDER_INTENT_WORKTREES) {
const oldestWorktreeId = pendingReorderByWorktree.keys().next().value
if (oldestWorktreeId === undefined) {
break
}
pendingReorderByWorktree.delete(oldestWorktreeId)
}
}
function sameMembership(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
@@ -40,12 +79,16 @@ export function recordWebSessionReorderIntent(
if (!worktreeId || !groupId || order.length === 0) {
return
}
// Why: reorder intents already expire; prune all worktrees on new writes so
// removed worktrees that never reconcile again do not retain stale orders.
pruneExpiredWebSessionReorderIntents(now)
let byGroup = pendingReorderByWorktree.get(worktreeId)
if (!byGroup) {
byGroup = new Map()
pendingReorderByWorktree.set(worktreeId, byGroup)
}
refreshReorderIntentWorktree(worktreeId, byGroup)
byGroup.set(groupId, { order: [...order], recordedAt: now })
trimWebSessionReorderIntentWorktrees()
}
/**
@@ -62,15 +105,16 @@ export function resolveWebSessionReorderedOrder(
now: number
): string[] {
const byGroup = pendingReorderByWorktree.get(worktreeId)
const intent = byGroup?.get(groupId)
if (!byGroup) {
return hostOrder
}
const intent = byGroup.get(groupId)
if (!intent) {
return hostOrder
}
const clear = (): void => {
byGroup!.delete(groupId)
if (byGroup!.size === 0) {
pendingReorderByWorktree.delete(worktreeId)
}
byGroup.delete(groupId)
deleteEmptyReorderIntentWorktree(worktreeId, byGroup)
}
if (now - intent.recordedAt > REORDER_INTENT_TTL_MS) {
clear()
@@ -87,6 +131,7 @@ export function resolveWebSessionReorderedOrder(
clear()
return hostOrder
}
refreshReorderIntentWorktree(worktreeId, byGroup)
return [...intent.order]
}
@@ -97,3 +142,11 @@ export function clearWebSessionReorderIntentsForWorktree(worktreeId: string): vo
export function resetWebSessionReorderIntentForTests(): void {
pendingReorderByWorktree.clear()
}
export function getWebSessionReorderIntentCountsForTests(): { worktrees: number; groups: number } {
let groups = 0
for (const byGroup of pendingReorderByWorktree.values()) {
groups += byGroup.size
}
return { worktrees: pendingReorderByWorktree.size, groups }
}
@@ -7,16 +7,19 @@ import { makePaneKey } from '../../../shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import {
peekWebSessionFocusIntent,
recordWebSessionFocusIntent,
resetWebSessionFocusIntentForTests
} from './web-session-focus-intent'
import {
isWebSessionCloseIntentPending,
recordWebSessionCloseIntent,
resetWebSessionCloseIntentForTests
} from './web-session-close-intent'
import {
recordWebSessionReorderIntent,
resetWebSessionReorderIntentForTests
resetWebSessionReorderIntentForTests,
resolveWebSessionReorderedOrder
} from './web-session-reorder-intent'
import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types'
import type { OpenFile } from '../store/slices/editor'
@@ -582,6 +585,9 @@ describe('applyWebSessionTabsSnapshot', () => {
freshness: 1,
hostMappings: 1
})
recordWebSessionFocusIntent(WT, 'host-browser-page', NOW)
recordWebSessionCloseIntent(WT, 'host-browser-unified', NOW)
recordWebSessionReorderIntent(WT, 'host-group-1', ['tab-b', 'tab-a'], NOW)
applyFreshWebSessionTabsSnapshot(
afterHostSnapshot,
@@ -603,6 +609,11 @@ describe('applyWebSessionTabsSnapshot', () => {
freshness: 0,
hostMappings: 0
})
expect(peekWebSessionFocusIntent(WT, NOW + 1)).toBeNull()
expect(isWebSessionCloseIntentPending(WT, 'host-browser-unified', NOW + 1)).toBe(false)
expect(
resolveWebSessionReorderedOrder(WT, 'host-group-1', ['tab-a', 'tab-b'], NOW + 1)
).toEqual(['tab-a', 'tab-b'])
})
it('clears web session tracking maps for one runtime environment on teardown', () => {
@@ -53,6 +53,7 @@ import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
import {
clearWebSessionCloseIntentsForWorktree,
isWebSessionCloseIntentPending,
reconcileWebSessionCloseIntents
} from './web-session-close-intent'
@@ -314,6 +315,8 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI
latestSessionTabsSnapshotByWorktree.delete(key)
lastHostTerminalTabCountByWorktree.delete(key)
clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId)
clearWebSessionFocusIntent(worktreeId)
clearWebSessionCloseIntentsForWorktree(worktreeId)
clearWebSessionReorderIntentsForWorktree(worktreeId)
const keyPrefix = `${environmentId}:${worktreeId}:`
for (const key of hostSessionTabIdByLocalKey.keys()) {
@@ -1655,7 +1658,7 @@ export function applyWebSessionTabsSnapshot(
// An unsolicited server-active (e.g. an agent "thinking" echo) must not steal
// focus — that's the #5435 contract. Intent matches by the host active tab id
// (terminal session id, or browserPageId for browser tabs); consume it once.
const focusIntentHostTabId = peekWebSessionFocusIntent(worktreeId)
const focusIntentHostTabId = peekWebSessionFocusIntent(worktreeId, now)
const honorSnapshotActiveFocus =
focusIntentHostTabId !== null &&
snapshot.activeTabId !== null &&