mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
fix web session intent retention
This commit is contained in:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user