Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0e117be

Browse files
author
Dan Shapiro
committed
feat: bound tab recency sync pressure
1 parent 7214b88 commit 0e117be

23 files changed

Lines changed: 1780 additions & 117 deletions

src/components/TerminalView.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { focusNextTerminalSearchMatch, focusPreviousTerminalSearchMatch, loadTer
2828
import { isFatalConnectionErrorCode } from '@/store/connectionSlice'
2929
import { buildTerminalDurableSessionRefUpdate, flushPersistedLayoutNow } from '@/store/persistControl'
3030
import { getWsClient } from '@/lib/ws-client'
31+
import { bucketTabRecencyAt } from '@/lib/tab-recency'
3132
import { getTerminalTheme } from '@/lib/terminal-themes'
3233
import { getCreateSessionStateFromRef } from '@/components/terminal-view-utils'
3334
import { copyText, readText } from '@/lib/clipboard'
@@ -105,6 +106,7 @@ import {
105106
} from '@/lib/terminal-behavior'
106107
import { buildRestoreError } from '@shared/session-contract'
107108
import type { CodingCliProviderName } from '@/store/types'
109+
import { recordPaneTabActivity } from '@/store/tabRecencySlice'
108110

109111
const log = createLogger('TerminalView')
110112

@@ -323,6 +325,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
323325
const hasAttentionRef = useRef(hasAttention)
324326
const hasPaneAttention = useAppSelector((s) => !!s.turnCompletion?.attentionByPane?.[paneId])
325327
const hasPaneAttentionRef = useRef(hasPaneAttention)
328+
const paneTabRecencyBucket = useAppSelector((s) => s.tabRecency?.paneLastInputAt?.[paneId])
326329

327330
// All hooks MUST be called before any conditional returns
328331
const ws = useMemo(() => getWsClient(), [])
@@ -359,6 +362,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
359362
const hiddenRef = useRef(hidden)
360363
const hydrationRegisteredRef = useRef(false)
361364
const lastSessionActivityAtRef = useRef(0)
365+
const lastPaneTabRecencyBucketRef = useRef<number | undefined>(paneTabRecencyBucket)
362366
const rateLimitRetryRef = useRef<{ count: number; timer: ReturnType<typeof setTimeout> | null }>({ count: 0, timer: null })
363367
const restoreRequestIdRef = useRef<string | null>(null)
364368
const restoreFlagRef = useRef(false)
@@ -504,6 +508,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
504508
}
505509
}, [terminalContent, paneId, applySeqState])
506510

511+
useEffect(() => {
512+
lastPaneTabRecencyBucketRef.current = paneTabRecencyBucket
513+
}, [paneId, paneTabRecencyBucket])
514+
507515
// Register terminal buffer accessor with test harness (for E2E tests).
508516
// Uses xterm.js Terminal.buffer.active API which works with all renderers
509517
// (WebGL, canvas, DOM) — unlike DOM scraping via .xterm-rows which only
@@ -1357,7 +1365,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
13571365
const currentContent = contentRef.current
13581366
if (currentTab) {
13591367
const now = Date.now()
1360-
dispatch(updateTab({ id: currentTab.id, updates: { lastInputAt: now } }))
1368+
const bucket = bucketTabRecencyAt(now)
1369+
const previousBucket = lastPaneTabRecencyBucketRef.current
1370+
if (bucket !== undefined && (previousBucket === undefined || bucket > previousBucket)) {
1371+
lastPaneTabRecencyBucketRef.current = bucket
1372+
dispatch(recordPaneTabActivity({ paneId, at: now }))
1373+
}
13611374
const resumeSessionId = currentContent?.resumeSessionId
13621375
if (resumeSessionId && currentContent?.mode && currentContent.mode !== 'shell') {
13631376
if (now - lastSessionActivityAtRef.current >= SESSION_ACTIVITY_THROTTLE_MS) {

src/components/context-menu/ContextMenuProvider.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { ClientExtensionEntry } from '@shared/extension-types'
3030
import { buildResumeContent } from '@/lib/session-type-utils'
3131
import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils'
3232
import { mergeSessionMetadataByKey } from '@/lib/session-metadata'
33+
import { deriveTabRecencyAt } from '@/lib/tab-recency'
3334
import { ConfirmModal } from '@/components/ui/confirm-modal'
3435
import type { AppView } from '@/components/Sidebar'
3536
import type { CodingCliProviderName, CodingCliSession, ProjectGroup } from '@/store/types'
@@ -51,6 +52,8 @@ import { nanoid } from 'nanoid'
5152

5253
const CONTEXT_MENU_KEYS = ['ContextMenu']
5354
const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = []
55+
const EMPTY_PANE_LAST_INPUT_AT: Record<string, number | undefined> = {}
56+
const EMPTY_FEATURE_FLAGS: Record<string, boolean> = {}
5457

5558

5659
type MenuState = {
@@ -107,9 +110,10 @@ export function ContextMenuProvider({
107110
const historySessions = useAppSelector((s) => s.sessions.windows?.history?.projects ?? s.sessions.projects)
108111
const expandedProjects = useAppSelector((s) => s.sessions.expandedProjects)
109112
const platform = useAppSelector((s) => s.connection?.platform ?? null)
110-
const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? {})
113+
const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? EMPTY_FEATURE_FLAGS)
111114
const appSettings = useAppSelector((s) => s.settings.settings)
112115
const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES)
116+
const paneLastInputAt = useAppSelector((s) => s.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT)
113117

114118
const [menuState, setMenuState] = useState<MenuState | null>(null)
115119
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
@@ -523,7 +527,14 @@ export function ContextMenuProvider({
523527
return refs.some((ref) => ref.provider === keyProvider && ref.sessionId === sessionId)
524528
})
525529
const hasTab = relatedTabs.length > 0
526-
const tabLastInputAt = relatedTabs.reduce((max, tab) => Math.max(max, tab.lastInputAt ?? 0), 0) || undefined
530+
const tabLastInputAt = relatedTabs.reduce((max, tab) => {
531+
const layout = panes[tab.id]
532+
return Math.max(max, deriveTabRecencyAt({
533+
tab,
534+
layout,
535+
paneLastInputAt,
536+
}))
537+
}, 0)
527538
const runningTerminalId =
528539
menuState?.target.kind === 'sidebar-session' && menuState?.target.sessionId === sessionId
529540
? menuState?.target.runningTerminalId
@@ -544,14 +555,14 @@ export function ContextMenuProvider({
544555
archived: session.archived,
545556
sourceFile: session.sourceFile,
546557
hasTab,
547-
tabLastInputAt,
548-
tabLastInputAtIso: tabLastInputAt ? new Date(tabLastInputAt).toISOString() : null,
558+
tabLastInputAt: hasTab ? tabLastInputAt : undefined,
559+
tabLastInputAtIso: hasTab ? new Date(tabLastInputAt).toISOString() : null,
549560
isRunning: !!runningTerminalId,
550561
runningTerminalId: runningTerminalId || null,
551562
projectColor: project.color,
552563
}
553564
await copyText(JSON.stringify(metadata, null, 2))
554-
}, [getSessionInfo, tabsState.tabs, panes, menuState?.target])
565+
}, [getSessionInfo, tabsState.tabs, panes, paneLastInputAt, menuState?.target])
555566

556567
const copyResumeCommand = useCallback(async (provider: ResumeCommandProvider, sessionId: string) => {
557568
const command = buildResumeCommand(provider, sessionId, extensionEntries)

src/lib/tab-recency.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { PaneNode } from '@/store/paneTypes'
2+
import type { Tab } from '@/store/types'
3+
4+
export const TAB_RECENCY_RESOLUTION_MS = 60 * 1000
5+
6+
type TimestampCandidate = number | null | undefined
7+
8+
export function bucketTabRecencyAt(at: TimestampCandidate): number | undefined {
9+
if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) return undefined
10+
return Math.floor(at / TAB_RECENCY_RESOLUTION_MS) * TAB_RECENCY_RESOLUTION_MS
11+
}
12+
13+
export function collectTerminalPaneIds(node: PaneNode | undefined): string[] {
14+
if (!node) return []
15+
if (node.type === 'leaf') {
16+
return node.content.kind === 'terminal' ? [node.id] : []
17+
}
18+
return [
19+
...collectTerminalPaneIds(node.children[0]),
20+
...collectTerminalPaneIds(node.children[1]),
21+
]
22+
}
23+
24+
export function deriveTabRecencyAt(input: {
25+
tab: Pick<Tab, 'createdAt' | 'lastInputAt'>
26+
layout: PaneNode | undefined
27+
paneLastInputAt: Record<string, number | undefined>
28+
}): number {
29+
const candidates: number[] = []
30+
for (const raw of [input.tab.createdAt, input.tab.lastInputAt]) {
31+
const bucket = bucketTabRecencyAt(raw)
32+
if (bucket !== undefined) candidates.push(bucket)
33+
}
34+
for (const paneId of collectTerminalPaneIds(input.layout)) {
35+
const bucket = bucketTabRecencyAt(input.paneLastInputAt[paneId])
36+
if (bucket !== undefined) candidates.push(bucket)
37+
}
38+
return candidates.length > 0 ? Math.max(...candidates) : 0
39+
}

src/store/crossTabSync.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { getPendingBrowserPreferencesWriteState } from './browserPreferencesPers
88
import { parsePersistedLayoutRaw, LAYOUT_STORAGE_KEY } from './persistedState'
99
import { getPersistBroadcastSourceId, onPersistBroadcast, PERSIST_BROADCAST_CHANNEL_NAME } from './persistBroadcast'
1010
import { shouldPreserveLocalCanonicalResumeSessionId } from './persistControl'
11-
import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys'
11+
import { BROWSER_PREFERENCES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys'
12+
import { collectLiveTerminalPaneIds } from './tabRecencyPruneMiddleware'
13+
import {
14+
loadPersistedTabRecency,
15+
mergeHydratedTabRecency,
16+
prunePaneTabActivityToLiveTerminalPanes,
17+
} from './tabRecencySlice'
1218
import { parseBrowserPreferencesRaw, resolveBrowserPreferenceSettings } from '@/lib/browser-preferences'
1319

1420
type StoreLike = {
@@ -25,6 +31,12 @@ const zPersistBroadcastMsg = z.object({
2531
sourceId: z.string(),
2632
})
2733

34+
const CROSS_TAB_SYNC_STORAGE_KEYS = [
35+
LAYOUT_STORAGE_KEY,
36+
BROWSER_PREFERENCES_STORAGE_KEY,
37+
TAB_RECENCY_STORAGE_KEY,
38+
] as const
39+
2840
function collectPaneIdsSafe(node: unknown): string[] {
2941
const ids: string[] = []
3042

@@ -260,6 +272,17 @@ function handleIncomingRaw(
260272
dispatchHydrateLayoutFromPersisted(store, raw, localLayoutPersistedAt)
261273
} else if (key === BROWSER_PREFERENCES_STORAGE_KEY) {
262274
dispatchHydrateBrowserPreferencesFromPersisted(store, raw, previousRaw)
275+
} else if (key === TAB_RECENCY_STORAGE_KEY) {
276+
store.dispatch({
277+
...mergeHydratedTabRecency(loadPersistedTabRecency(raw)),
278+
meta: { skipPersist: true, source: 'cross-tab' },
279+
})
280+
store.dispatch({
281+
...prunePaneTabActivityToLiveTerminalPanes({
282+
paneIds: collectLiveTerminalPaneIds(store.getState()),
283+
}),
284+
meta: { source: 'cross-tab' },
285+
})
263286
}
264287
}
265288

@@ -270,7 +293,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
270293
// Dedupe by exact raw value so we don't hydrate twice.
271294
const lastProcessedRawByKey = new Map<string, string>()
272295
let currentLocalLayoutPersistedAt: number | undefined
273-
for (const key of [LAYOUT_STORAGE_KEY, BROWSER_PREFERENCES_STORAGE_KEY]) {
296+
for (const key of CROSS_TAB_SYNC_STORAGE_KEYS) {
274297
const existingRaw = localStorage.getItem(key)
275298
if (typeof existingRaw === 'string') {
276299
lastProcessedRawByKey.set(key, existingRaw)
@@ -307,10 +330,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
307330
// then diverge locally (persisted raw changes), a later remote event with the original raw
308331
// could be incorrectly ignored.
309332
const unsubscribeLocal = onPersistBroadcast((msg) => {
310-
if (
311-
msg.key !== LAYOUT_STORAGE_KEY
312-
&& msg.key !== BROWSER_PREFERENCES_STORAGE_KEY
313-
) {
333+
if (!CROSS_TAB_SYNC_STORAGE_KEYS.includes(msg.key as any)) {
314334
return
315335
}
316336
lastProcessedRawByKey.set(msg.key, msg.raw)
@@ -322,10 +342,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
322342
const onStorage = (e: StorageEvent) => {
323343
if (e.storageArea && e.storageArea !== localStorage) return
324344
const key = e.key
325-
if (
326-
key !== LAYOUT_STORAGE_KEY
327-
&& key !== BROWSER_PREFERENCES_STORAGE_KEY
328-
) {
345+
if (typeof key !== 'string' || !CROSS_TAB_SYNC_STORAGE_KEYS.includes(key as any)) {
329346
return
330347
}
331348
if (typeof e.newValue !== 'string') return

0 commit comments

Comments
 (0)