diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 85e6ff310..cc475856b 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -28,6 +28,7 @@ import { focusNextTerminalSearchMatch, focusPreviousTerminalSearchMatch, loadTer import { isFatalConnectionErrorCode } from '@/store/connectionSlice' import { buildTerminalDurableSessionRefUpdate, flushPersistedLayoutNow } from '@/store/persistControl' import { getWsClient } from '@/lib/ws-client' +import { bucketTabRecencyAt } from '@/lib/tab-recency' import { getTerminalTheme } from '@/lib/terminal-themes' import { getCreateSessionStateFromRef } from '@/components/terminal-view-utils' import { copyText, readText } from '@/lib/clipboard' @@ -105,6 +106,7 @@ import { } from '@/lib/terminal-behavior' import { buildRestoreError } from '@shared/session-contract' import type { CodingCliProviderName } from '@/store/types' +import { recordPaneTabActivity } from '@/store/tabRecencySlice' const log = createLogger('TerminalView') @@ -323,6 +325,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const hasAttentionRef = useRef(hasAttention) const hasPaneAttention = useAppSelector((s) => !!s.turnCompletion?.attentionByPane?.[paneId]) const hasPaneAttentionRef = useRef(hasPaneAttention) + const paneTabRecencyBucket = useAppSelector((s) => s.tabRecency?.paneLastInputAt?.[paneId]) // All hooks MUST be called before any conditional returns const ws = useMemo(() => getWsClient(), []) @@ -359,6 +362,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const hiddenRef = useRef(hidden) const hydrationRegisteredRef = useRef(false) const lastSessionActivityAtRef = useRef(0) + const lastPaneTabRecencyBucketRef = useRef(paneTabRecencyBucket) const rateLimitRetryRef = useRef<{ count: number; timer: ReturnType | null }>({ count: 0, timer: null }) const restoreRequestIdRef = useRef(null) const restoreFlagRef = useRef(false) @@ -504,6 +508,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } }, [terminalContent, paneId, applySeqState]) + useEffect(() => { + lastPaneTabRecencyBucketRef.current = paneTabRecencyBucket + }, [paneId, paneTabRecencyBucket]) + // Register terminal buffer accessor with test harness (for E2E tests). // Uses xterm.js Terminal.buffer.active API which works with all renderers // (WebGL, canvas, DOM) — unlike DOM scraping via .xterm-rows which only @@ -1357,7 +1365,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const currentContent = contentRef.current if (currentTab) { const now = Date.now() - dispatch(updateTab({ id: currentTab.id, updates: { lastInputAt: now } })) + const bucket = bucketTabRecencyAt(now) + const previousBucket = lastPaneTabRecencyBucketRef.current + if (bucket !== undefined && (previousBucket === undefined || bucket > previousBucket)) { + lastPaneTabRecencyBucketRef.current = bucket + dispatch(recordPaneTabActivity({ paneId, at: now })) + } const resumeSessionId = currentContent?.resumeSessionId if (resumeSessionId && currentContent?.mode && currentContent.mode !== 'shell') { if (now - lastSessionActivityAtRef.current >= SESSION_ACTIVITY_THROTTLE_MS) { diff --git a/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index 128bc47dc..a5a11180f 100644 --- a/src/components/context-menu/ContextMenuProvider.tsx +++ b/src/components/context-menu/ContextMenuProvider.tsx @@ -30,6 +30,7 @@ import type { ClientExtensionEntry } from '@shared/extension-types' import { buildResumeContent } from '@/lib/session-type-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' import { mergeSessionMetadataByKey } from '@/lib/session-metadata' +import { deriveTabRecencyAt } from '@/lib/tab-recency' import { ConfirmModal } from '@/components/ui/confirm-modal' import type { AppView } from '@/components/Sidebar' import type { CodingCliProviderName, CodingCliSession, ProjectGroup } from '@/store/types' @@ -51,6 +52,8 @@ import { nanoid } from 'nanoid' const CONTEXT_MENU_KEYS = ['ContextMenu'] const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] +const EMPTY_PANE_LAST_INPUT_AT: Record = {} +const EMPTY_FEATURE_FLAGS: Record = {} type MenuState = { @@ -107,9 +110,10 @@ export function ContextMenuProvider({ const historySessions = useAppSelector((s) => s.sessions.windows?.history?.projects ?? s.sessions.projects) const expandedProjects = useAppSelector((s) => s.sessions.expandedProjects) const platform = useAppSelector((s) => s.connection?.platform ?? null) - const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? {}) + const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? EMPTY_FEATURE_FLAGS) const appSettings = useAppSelector((s) => s.settings.settings) const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES) + const paneLastInputAt = useAppSelector((s) => s.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT) const [menuState, setMenuState] = useState(null) const [confirmState, setConfirmState] = useState(null) @@ -523,7 +527,14 @@ export function ContextMenuProvider({ return refs.some((ref) => ref.provider === keyProvider && ref.sessionId === sessionId) }) const hasTab = relatedTabs.length > 0 - const tabLastInputAt = relatedTabs.reduce((max, tab) => Math.max(max, tab.lastInputAt ?? 0), 0) || undefined + const tabLastInputAt = relatedTabs.reduce((max, tab) => { + const layout = panes[tab.id] + return Math.max(max, deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt, + })) + }, 0) const runningTerminalId = menuState?.target.kind === 'sidebar-session' && menuState?.target.sessionId === sessionId ? menuState?.target.runningTerminalId @@ -544,14 +555,14 @@ export function ContextMenuProvider({ archived: session.archived, sourceFile: session.sourceFile, hasTab, - tabLastInputAt, - tabLastInputAtIso: tabLastInputAt ? new Date(tabLastInputAt).toISOString() : null, + tabLastInputAt: hasTab ? tabLastInputAt : undefined, + tabLastInputAtIso: hasTab ? new Date(tabLastInputAt).toISOString() : null, isRunning: !!runningTerminalId, runningTerminalId: runningTerminalId || null, projectColor: project.color, } await copyText(JSON.stringify(metadata, null, 2)) - }, [getSessionInfo, tabsState.tabs, panes, menuState?.target]) + }, [getSessionInfo, tabsState.tabs, panes, paneLastInputAt, menuState?.target]) const copyResumeCommand = useCallback(async (provider: ResumeCommandProvider, sessionId: string) => { const command = buildResumeCommand(provider, sessionId, extensionEntries) diff --git a/src/lib/tab-recency.ts b/src/lib/tab-recency.ts new file mode 100644 index 000000000..e4a5c6dd2 --- /dev/null +++ b/src/lib/tab-recency.ts @@ -0,0 +1,39 @@ +import type { PaneNode } from '@/store/paneTypes' +import type { Tab } from '@/store/types' + +export const TAB_RECENCY_RESOLUTION_MS = 60 * 1000 + +type TimestampCandidate = number | null | undefined + +export function bucketTabRecencyAt(at: TimestampCandidate): number | undefined { + if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) return undefined + return Math.floor(at / TAB_RECENCY_RESOLUTION_MS) * TAB_RECENCY_RESOLUTION_MS +} + +export function collectTerminalPaneIds(node: PaneNode | undefined): string[] { + if (!node) return [] + if (node.type === 'leaf') { + return node.content.kind === 'terminal' ? [node.id] : [] + } + return [ + ...collectTerminalPaneIds(node.children[0]), + ...collectTerminalPaneIds(node.children[1]), + ] +} + +export function deriveTabRecencyAt(input: { + tab: Pick + layout: PaneNode | undefined + paneLastInputAt: Record +}): number { + const candidates: number[] = [] + for (const raw of [input.tab.createdAt, input.tab.lastInputAt]) { + const bucket = bucketTabRecencyAt(raw) + if (bucket !== undefined) candidates.push(bucket) + } + for (const paneId of collectTerminalPaneIds(input.layout)) { + const bucket = bucketTabRecencyAt(input.paneLastInputAt[paneId]) + if (bucket !== undefined) candidates.push(bucket) + } + return candidates.length > 0 ? Math.max(...candidates) : 0 +} diff --git a/src/store/crossTabSync.ts b/src/store/crossTabSync.ts index 28233a995..f4c3c9fd7 100644 --- a/src/store/crossTabSync.ts +++ b/src/store/crossTabSync.ts @@ -8,7 +8,13 @@ import { getPendingBrowserPreferencesWriteState } from './browserPreferencesPers import { parsePersistedLayoutRaw, LAYOUT_STORAGE_KEY } from './persistedState' import { getPersistBroadcastSourceId, onPersistBroadcast, PERSIST_BROADCAST_CHANNEL_NAME } from './persistBroadcast' import { shouldPreserveLocalCanonicalResumeSessionId } from './persistControl' -import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys' +import { BROWSER_PREFERENCES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys' +import { collectLiveTerminalPaneIds } from './tabRecencyPruneMiddleware' +import { + loadPersistedTabRecency, + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, +} from './tabRecencySlice' import { parseBrowserPreferencesRaw, resolveBrowserPreferenceSettings } from '@/lib/browser-preferences' type StoreLike = { @@ -25,6 +31,12 @@ const zPersistBroadcastMsg = z.object({ sourceId: z.string(), }) +const CROSS_TAB_SYNC_STORAGE_KEYS = [ + LAYOUT_STORAGE_KEY, + BROWSER_PREFERENCES_STORAGE_KEY, + TAB_RECENCY_STORAGE_KEY, +] as const + function collectPaneIdsSafe(node: unknown): string[] { const ids: string[] = [] @@ -260,6 +272,17 @@ function handleIncomingRaw( dispatchHydrateLayoutFromPersisted(store, raw, localLayoutPersistedAt) } else if (key === BROWSER_PREFERENCES_STORAGE_KEY) { dispatchHydrateBrowserPreferencesFromPersisted(store, raw, previousRaw) + } else if (key === TAB_RECENCY_STORAGE_KEY) { + store.dispatch({ + ...mergeHydratedTabRecency(loadPersistedTabRecency(raw)), + meta: { skipPersist: true, source: 'cross-tab' }, + }) + store.dispatch({ + ...prunePaneTabActivityToLiveTerminalPanes({ + paneIds: collectLiveTerminalPaneIds(store.getState()), + }), + meta: { source: 'cross-tab' }, + }) } } @@ -270,7 +293,7 @@ export function installCrossTabSync(store: StoreLike): () => void { // Dedupe by exact raw value so we don't hydrate twice. const lastProcessedRawByKey = new Map() let currentLocalLayoutPersistedAt: number | undefined - for (const key of [LAYOUT_STORAGE_KEY, BROWSER_PREFERENCES_STORAGE_KEY]) { + for (const key of CROSS_TAB_SYNC_STORAGE_KEYS) { const existingRaw = localStorage.getItem(key) if (typeof existingRaw === 'string') { lastProcessedRawByKey.set(key, existingRaw) @@ -307,10 +330,7 @@ export function installCrossTabSync(store: StoreLike): () => void { // then diverge locally (persisted raw changes), a later remote event with the original raw // could be incorrectly ignored. const unsubscribeLocal = onPersistBroadcast((msg) => { - if ( - msg.key !== LAYOUT_STORAGE_KEY - && msg.key !== BROWSER_PREFERENCES_STORAGE_KEY - ) { + if (!CROSS_TAB_SYNC_STORAGE_KEYS.includes(msg.key as any)) { return } lastProcessedRawByKey.set(msg.key, msg.raw) @@ -322,10 +342,7 @@ export function installCrossTabSync(store: StoreLike): () => void { const onStorage = (e: StorageEvent) => { if (e.storageArea && e.storageArea !== localStorage) return const key = e.key - if ( - key !== LAYOUT_STORAGE_KEY - && key !== BROWSER_PREFERENCES_STORAGE_KEY - ) { + if (typeof key !== 'string' || !CROSS_TAB_SYNC_STORAGE_KEYS.includes(key as any)) { return } if (typeof e.newValue !== 'string') return diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index 2b3c10e56..eee5fcf62 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -6,11 +6,18 @@ import { nanoid } from 'nanoid' import { broadcastPersistedRaw } from './persistBroadcast' import { isWellFormedPaneTree } from './paneTreeValidation.js' import { PANES_SCHEMA_VERSION, LAYOUT_SCHEMA_VERSION, parsePersistedLayoutRaw } from './persistedState.js' -import { LAYOUT_STORAGE_KEY, PANES_STORAGE_KEY } from './storage-keys' +import { LAYOUT_STORAGE_KEY, PANES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys' import { createLogger } from '@/lib/client-logger' import { flushPersistedLayoutNow } from './persistControl' import { sanitizeSessionRef } from '@shared/session-contract' import { normalizeAgentChatEffortOverride, normalizeAgentChatModelSelection } from './paneTypes' +import { + loadPersistedTabRecency, + mergeTabRecencyStatesByMax, + prunePaneTabActivityToLiveTerminalPanes, + serializePersistableTabRecency, + type TabRecencyState, +} from './tabRecencySlice' const log = createLogger('PanesPersist') @@ -403,13 +410,16 @@ function migratePanesData(parsed: any): any | null { } type PersistState = { - tabs: TabsState - panes: PanesState + tabs?: TabsState + panes?: PanesState + tabRecency?: TabRecencyState } export const persistMiddleware: Middleware<{}, PersistState> = (store) => { let tabsDirty = false let panesDirty = false + let tabRecencyDirty = false + let tabRecencyPruneDirty = false let flushTimer: ReturnType | null = null const canUseStorage = () => typeof localStorage !== 'undefined' @@ -417,63 +427,88 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { const flush = () => { flushTimer = null if (!canUseStorage()) return - if (!tabsDirty && !panesDirty) return + if (!tabsDirty && !panesDirty && !tabRecencyDirty) return const state = store.getState() try { - // Prune tombstones older than 1 hour - const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000 - const tombstoneCutoff = Date.now() - TOMBSTONE_MAX_AGE_MS - const tombstones = (state.tabs.tombstones || []).filter((t: { deletedAt: number }) => t.deletedAt > tombstoneCutoff) - - const sanitizedLayouts: Record = {} - if (state.panes?.layouts) { - for (const [tabId, node] of Object.entries(state.panes.layouts)) { - sanitizedLayouts[tabId] = stripEditorContentFromNode(node) + if (tabsDirty || panesDirty) { + // Prune tombstones older than 1 hour + const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000 + const tombstoneCutoff = Date.now() - TOMBSTONE_MAX_AGE_MS + const tombstones = (state.tabs?.tombstones || []).filter((t: { deletedAt: number }) => t.deletedAt > tombstoneCutoff) + + const sanitizedLayouts: Record = {} + if (state.panes?.layouts) { + for (const [tabId, node] of Object.entries(state.panes.layouts)) { + sanitizedLayouts[tabId] = stripEditorContentFromNode(node) + } } - } - let persistablePanesSection: Record = { - layouts: sanitizedLayouts, - version: PANES_SCHEMA_VERSION, - } - if (state.panes) { - const { - renameRequestTabId: _rrt, - renameRequestPaneId: _rrp, - zoomedPane: _zp, - refreshRequestsByPane: _rrbp, - restoreFallbackAttemptsByPane: _rfabp, - ...persistablePanes - } = state.panes - persistablePanesSection = { - ...persistablePanes, + let persistablePanesSection: Record = { layouts: sanitizedLayouts, version: PANES_SCHEMA_VERSION, } - } + if (state.panes) { + const { + renameRequestTabId: _rrt, + renameRequestPaneId: _rrp, + zoomedPane: _zp, + refreshRequestsByPane: _rrbp, + restoreFallbackAttemptsByPane: _rfabp, + ...persistablePanes + } = state.panes + persistablePanesSection = { + ...persistablePanes, + layouts: sanitizedLayouts, + version: PANES_SCHEMA_VERSION, + } + } - const layoutPayload = { - persistedAt: Date.now(), - version: LAYOUT_SCHEMA_VERSION, - tabs: { - activeTabId: state.tabs.activeTabId, - tabs: state.tabs.tabs.map(stripTabVolatileFields), - }, - panes: persistablePanesSection, - tombstones, + const layoutPayload = { + persistedAt: Date.now(), + version: LAYOUT_SCHEMA_VERSION, + tabs: { + activeTabId: state.tabs?.activeTabId ?? null, + tabs: (state.tabs?.tabs ?? []).map(stripTabVolatileFields), + }, + panes: persistablePanesSection, + tombstones, + } + + const raw = JSON.stringify(layoutPayload) + localStorage.setItem(LAYOUT_STORAGE_KEY, raw) + broadcastPersistedRaw(LAYOUT_STORAGE_KEY, raw) } - const raw = JSON.stringify(layoutPayload) - localStorage.setItem(LAYOUT_STORAGE_KEY, raw) - broadcastPersistedRaw(LAYOUT_STORAGE_KEY, raw) + if (tabRecencyDirty) { + const liveTabIds = new Set((state.tabs?.tabs ?? []).map((tab) => tab.id)) + const nextTabRecency = serializePersistableTabRecency( + state.tabRecency ?? { paneLastInputAt: {} }, + tabRecencyPruneDirty ? state.panes?.layouts ?? {} : undefined, + tabRecencyPruneDirty ? liveTabIds : undefined, + ) + const persistedTabRecency = tabRecencyPruneDirty + ? nextTabRecency + : mergeTabRecencyStatesByMax( + loadPersistedTabRecency(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)), + { paneLastInputAt: nextTabRecency.paneLastInputAt }, + ) + const rawTabRecency = JSON.stringify({ + version: 1, + paneLastInputAt: persistedTabRecency.paneLastInputAt, + }) + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, rawTabRecency) + broadcastPersistedRaw(TAB_RECENCY_STORAGE_KEY, rawTabRecency) + } } catch (err) { log.error('Failed to save to localStorage:', err) } tabsDirty = false panesDirty = false + tabRecencyDirty = false + tabRecencyPruneDirty = false } const scheduleFlush = () => { @@ -492,7 +527,9 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { registerFlushCallback(flushNow) return (next) => (action) => { + const previousState = store.getState() const result = next(action) + const state = store.getState() const a = action as any if (a?.type === flushPersistedLayoutNow.type) { @@ -504,14 +541,25 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { } if (typeof a?.type === 'string') { - if (a.type.startsWith('tabs/')) { + const tabsChanged = state.tabs !== previousState.tabs + const panesChanged = state.panes !== previousState.panes + const tabRecencyChanged = state.tabRecency !== previousState.tabRecency + + if (a.type.startsWith('tabs/') && tabsChanged) { tabsDirty = true scheduleFlush() } - if (a.type.startsWith('panes/')) { + if (a.type.startsWith('panes/') && panesChanged) { panesDirty = true scheduleFlush() } + if (a.type.startsWith('tabRecency/') && tabRecencyChanged) { + tabRecencyDirty = true + if (a.type === prunePaneTabActivityToLiveTerminalPanes.type) { + tabRecencyPruneDirty = true + } + scheduleFlush() + } } return result diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index 362184b94..20cb5a536 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -8,6 +8,7 @@ import { getSessionMetadata } from '@/lib/session-metadata' import { getProviderLabel, isNonShellMode } from '@/lib/coding-cli-utils' import type { SessionListMetadata } from '../types' import { getLeafDirectoryName, matchTitleTierMetadata } from '../../../shared/session-title-search.js' +import { deriveTabRecencyAt } from '@/lib/tab-recency' export interface SidebarSessionItem { id: string @@ -36,10 +37,12 @@ export interface SidebarSessionItem { const EMPTY_ACTIVITY: Record = {} const EMPTY_STRINGS: string[] = [] +const EMPTY_PANE_LAST_INPUT_AT: Record = {} const selectProjects = (state: RootState) => state.sessions.windows?.sidebar?.projects ?? state.sessions.projects const selectTabs = (state: RootState) => state.tabs.tabs const selectPanes = (state: RootState) => state.panes +const selectPaneLastInputAt = (state: RootState) => state.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT const selectSortMode = (state: RootState) => state.settings.settings.sidebar?.sortMode || 'activity' const selectSessionActivityForSort = (state: RootState) => { const sortMode = state.settings.settings.sidebar?.sortMode || 'activity' @@ -105,6 +108,7 @@ export function buildSessionItems( terminals: BackgroundTerminal[], sessionActivity: Record, worktreeGrouping: WorktreeGrouping = 'repo', + paneLastInputAt: Record = EMPTY_PANE_LAST_INPUT_AT, ): SidebarSessionItem[] { const items: SidebarSessionItem[] = [] const itemsByKey = new Map() @@ -258,7 +262,11 @@ export function buildSessionItems( } const paneTitle = paneTitles?.[tab.id]?.[node.id] - const fallbackTimestamp = tab.lastInputAt ?? tab.createdAt ?? 0 + const fallbackTimestamp = deriveTabRecencyAt({ + tab, + layout: panes.layouts?.[tab.id], + paneLastInputAt, + }) if (node.content.kind === 'agent-chat') { const sessionRef = node.content.sessionRef @@ -311,7 +319,11 @@ export function buildSessionItems( sessionType: metadata?.sessionType || provider, title: tab.title, cwd: undefined, - timestamp: tab.lastInputAt ?? tab.createdAt ?? 0, + timestamp: deriveTabRecencyAt({ + tab, + layout: undefined, + paneLastInputAt, + }), metadata, }) } @@ -522,6 +534,7 @@ export const makeSelectSortedSessionItems = () => selectProjects, selectTabs, selectPanes, + selectPaneLastInputAt, selectSessionActivityForSort, selectSortMode, selectWorktreeGrouping, @@ -540,6 +553,7 @@ export const makeSelectSortedSessionItems = () => projects, tabs, panes, + paneLastInputAt, sessionActivity, sortMode, worktreeGrouping, @@ -554,7 +568,7 @@ export const makeSelectSortedSessionItems = () => terminals, filter ) => { - const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity, worktreeGrouping) + const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity, worktreeGrouping, paneLastInputAt) const visible = filterSessionItemsByVisibility(items, { showSubagents, ignoreCodexSubagents, diff --git a/src/store/selectors/tabsRegistrySelectors.ts b/src/store/selectors/tabsRegistrySelectors.ts index 59e53f2b2..438db15eb 100644 --- a/src/store/selectors/tabsRegistrySelectors.ts +++ b/src/store/selectors/tabsRegistrySelectors.ts @@ -3,6 +3,9 @@ import type { RootState } from '@/store/store' import type { RegistryTabRecord } from '@/store/tabRegistryTypes' import { buildOpenTabRegistryRecord } from '@/lib/tab-registry-snapshot' import { UNKNOWN_SERVER_INSTANCE_ID } from '@/store/tabRegistryConstants' +import { deriveTabRecencyAt } from '@/lib/tab-recency' + +const EMPTY_PANE_LAST_INPUT_AT: Record = {} function sortUpdatedDesc(a: RegistryTabRecord, b: RegistryTabRecord): number { return b.updatedAt - a.updatedAt @@ -28,6 +31,7 @@ function dedupeByTabKey(records: RegistryTabRecord[]): RegistryTabRecord[] { const selectTabs = (state: RootState) => state.tabs.tabs const selectLayouts = (state: RootState) => state.panes.layouts const selectPaneTitles = (state: RootState) => state.panes.paneTitles +const selectPaneLastInputAt = (state: RootState) => state.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT const selectDeviceId = (state: RootState) => state.tabRegistry.deviceId const selectDeviceLabel = (state: RootState) => state.tabRegistry.deviceLabel const selectServerInstanceId = (state: RootState) => state.connection.serverInstanceId || UNKNOWN_SERVER_INSTANCE_ID @@ -36,13 +40,17 @@ const selectClosed = (state: RootState) => state.tabRegistry.closed const selectLocalClosed = (state: RootState) => state.tabRegistry.localClosed export const selectLiveLocalTabRecords = createSelector( - [selectTabs, selectLayouts, selectPaneTitles, selectDeviceId, selectDeviceLabel, selectServerInstanceId], - (tabs, layouts, paneTitles, deviceId, deviceLabel, serverInstanceId): RegistryTabRecord[] => { + [selectTabs, selectLayouts, selectPaneTitles, selectPaneLastInputAt, selectDeviceId, selectDeviceLabel, selectServerInstanceId], + (tabs, layouts, paneTitles, paneLastInputAt, deviceId, deviceLabel, serverInstanceId): RegistryTabRecord[] => { const records: RegistryTabRecord[] = [] for (const tab of tabs) { const layout = layouts[tab.id] if (!layout) continue - const updatedAt = tab.lastInputAt || tab.createdAt || 0 + const updatedAt = deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt, + }) records.push(buildOpenTabRegistryRecord({ tab, layout, diff --git a/src/store/storage-keys.ts b/src/store/storage-keys.ts index e84011125..b19d3b50a 100644 --- a/src/store/storage-keys.ts +++ b/src/store/storage-keys.ts @@ -5,6 +5,7 @@ export const STORAGE_KEYS = { sessionActivity: 'freshell.sessionActivity.v2', terminalCursor: 'freshell.terminal-cursors.v1', browserPreferences: 'freshell.browser-preferences.v1', + tabRecency: 'freshell.tab-recency.v1', deviceId: 'freshell.device-id.v2', deviceLabel: 'freshell.device-label.v2', deviceLabelCustom: 'freshell.device-label-custom.v2', @@ -20,6 +21,7 @@ export const PANES_STORAGE_KEY = STORAGE_KEYS.panes export const SESSION_ACTIVITY_STORAGE_KEY = STORAGE_KEYS.sessionActivity export const TERMINAL_CURSOR_STORAGE_KEY = STORAGE_KEYS.terminalCursor export const BROWSER_PREFERENCES_STORAGE_KEY = STORAGE_KEYS.browserPreferences +export const TAB_RECENCY_STORAGE_KEY = STORAGE_KEYS.tabRecency export const DEVICE_ID_STORAGE_KEY = STORAGE_KEYS.deviceId export const DEVICE_LABEL_STORAGE_KEY = STORAGE_KEYS.deviceLabel export const DEVICE_LABEL_CUSTOM_STORAGE_KEY = STORAGE_KEYS.deviceLabelCustom diff --git a/src/store/store.ts b/src/store/store.ts index 5b3319f0c..f72f98320 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,6 +9,7 @@ import panesReducer from './panesSlice' import sessionActivityReducer from './sessionActivitySlice' import terminalActivityReducer from './terminalActivitySlice' import terminalDirectoryReducer from './terminalDirectorySlice' +import tabRecencyReducer from './tabRecencySlice' import turnCompletionReducer from './turnCompletionSlice' import terminalMetaReducer from './terminalMetaSlice' @@ -27,6 +28,10 @@ import { createLogger } from '@/lib/client-logger' import { layoutMirrorMiddleware } from './layoutMirrorMiddleware' import { serverSettingsSaveStateMiddleware } from './settingsThunks' import { tabFallbackIdentityMiddleware } from './tabFallbackIdentityMiddleware' +import { + pruneTabRecencyToCurrentLayout, + tabRecencyPruneMiddleware, +} from './tabRecencyPruneMiddleware' enableMapSet() @@ -43,6 +48,7 @@ export const store = configureStore({ sessionActivity: sessionActivityReducer, terminalActivity: terminalActivityReducer, terminalDirectory: terminalDirectoryReducer, + tabRecency: tabRecencyReducer, turnCompletion: turnCompletionReducer, terminalMeta: terminalMetaReducer, @@ -62,6 +68,7 @@ export const store = configureStore({ }).concat( perfMiddleware, tabFallbackIdentityMiddleware, + tabRecencyPruneMiddleware, persistMiddleware, serverSettingsSaveStateMiddleware, browserPreferencesPersistenceMiddleware, @@ -70,6 +77,8 @@ export const store = configureStore({ ), }) +pruneTabRecencyToCurrentLayout(store) + // Note: Tabs and Panes are now loaded from localStorage directly in their slice // initial states (see tabsSlice.ts and panesSlice.ts). This ensures the state // is available BEFORE the store is created, preventing any race conditions. diff --git a/src/store/tabRecencyPruneMiddleware.ts b/src/store/tabRecencyPruneMiddleware.ts new file mode 100644 index 000000000..2e245a7af --- /dev/null +++ b/src/store/tabRecencyPruneMiddleware.ts @@ -0,0 +1,84 @@ +import type { Middleware } from '@reduxjs/toolkit' +import { collectTerminalPaneIds } from '@/lib/tab-recency' +import { prunePaneTabActivityToLiveTerminalPanes } from './tabRecencySlice' + +type RecencyPruneState = { + tabs?: { tabs?: Array<{ id: string }> } + panes?: { layouts?: Record } +} + +function collectLiveTabIds(state: RecencyPruneState): string[] { + return (state.tabs?.tabs ?? []).map((tab) => tab.id) +} + +export function collectLiveTerminalPaneIds(state: RecencyPruneState): string[] { + const liveTabIds = new Set(collectLiveTabIds(state)) + return Object.entries(state.panes?.layouts ?? {}) + .filter(([tabId]) => liveTabIds.has(tabId)) + .flatMap(([, layout]) => collectTerminalPaneIds(layout as any)) +} + +function sameStringSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + const bSet = new Set(b) + return a.every((value) => bSet.has(value)) +} + +export const tabRecencyPruneMiddleware: Middleware<{}, RecencyPruneState> = (store) => { + let pruneQueued = false + + const pruneToCurrentState = () => { + pruneQueued = false + pruneTabRecencyToCurrentLayout(store) + } + + const queuePruneToCurrentState = () => { + if (pruneQueued) return + pruneQueued = true + const enqueue = typeof queueMicrotask === 'function' + ? queueMicrotask + : (fn: () => void) => setTimeout(fn, 0) + enqueue(pruneToCurrentState) + } + + return (next) => (action) => { + const a = action as any + if (typeof a?.type !== 'string') return next(action) + if (!a.type.startsWith('tabs/') && !a.type.startsWith('panes/')) { + return next(action) + } + + const previousState = store.getState() + const previousLiveTabIds = collectLiveTabIds(previousState) + const result = next(action) + const state = store.getState() + + const liveTabIdsChanged = !sameStringSet(previousLiveTabIds, collectLiveTabIds(state)) + const paneLayoutsChanged = state.panes?.layouts !== previousState.panes?.layouts + if (!liveTabIdsChanged && !paneLayoutsChanged) return result + + const previousLivePaneIds = collectLiveTerminalPaneIds(previousState) + const livePaneIds = collectLiveTerminalPaneIds(state) + if (!sameStringSet(previousLivePaneIds, livePaneIds)) { + store.dispatch(prunePaneTabActivityToLiveTerminalPanes({ + paneIds: livePaneIds, + })) + return result + } + + if (liveTabIdsChanged) { + queuePruneToCurrentState() + } + + return result + } +} + +export function pruneTabRecencyToCurrentLayout(store: { + getState: () => RecencyPruneState + dispatch: (action: any) => any +}): void { + store.dispatch(prunePaneTabActivityToLiveTerminalPanes({ + paneIds: collectLiveTerminalPaneIds(store.getState()), + })) +} diff --git a/src/store/tabRecencySlice.ts b/src/store/tabRecencySlice.ts new file mode 100644 index 000000000..b9256b17c --- /dev/null +++ b/src/store/tabRecencySlice.ts @@ -0,0 +1,133 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { bucketTabRecencyAt, collectTerminalPaneIds } from '@/lib/tab-recency' +import type { PaneNode } from './paneTypes' +import { TAB_RECENCY_STORAGE_KEY } from './storage-keys' + +export interface TabRecencyState { + paneLastInputAt: Record +} + +export type PersistedTabRecencyPayload = { + version: 1 + paneLastInputAt: Record +} + +function emptyState(): TabRecencyState { + return { + paneLastInputAt: {}, + } +} + +export function loadPersistedTabRecency(raw: string | null | undefined): TabRecencyState { + if (!raw) return emptyState() + try { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== 1 || !parsed.paneLastInputAt || typeof parsed.paneLastInputAt !== 'object') { + return emptyState() + } + const paneLastInputAt: Record = {} + for (const [paneId, value] of Object.entries(parsed.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (trimmed && bucket !== undefined) paneLastInputAt[trimmed] = bucket + } + return { paneLastInputAt } + } catch { + return emptyState() + } +} + +export function loadInitialTabRecencyState(): TabRecencyState { + try { + return loadPersistedTabRecency(typeof localStorage !== 'undefined' + ? localStorage.getItem(TAB_RECENCY_STORAGE_KEY) + : null) + } catch { + return emptyState() + } +} + +export function mergeTabRecencyStatesByMax( + base: TabRecencyState, + incoming: TabRecencyState, +): TabRecencyState { + const paneLastInputAt = { ...base.paneLastInputAt } + for (const [paneId, value] of Object.entries(incoming.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (!trimmed || bucket === undefined) continue + const current = paneLastInputAt[trimmed] + if (current === undefined || bucket > current) { + paneLastInputAt[trimmed] = bucket + } + } + return { paneLastInputAt } +} + +export function serializePersistableTabRecency( + state: TabRecencyState, + layouts?: Record, + liveTabIds?: ReadonlySet, +): PersistedTabRecencyPayload { + const layoutEntries = layouts + ? Object.entries(layouts).filter(([tabId]) => !liveTabIds || liveTabIds.has(tabId)) + : [] + const liveTerminalPaneIds = layouts + ? new Set(layoutEntries.flatMap(([, layout]) => collectTerminalPaneIds(layout))) + : undefined + const paneLastInputAt: Record = {} + + for (const [paneId, value] of Object.entries(state.paneLastInputAt)) { + if (liveTerminalPaneIds && !liveTerminalPaneIds.has(paneId)) continue + const bucket = bucketTabRecencyAt(value) + if (paneId.trim() && bucket !== undefined) paneLastInputAt[paneId] = bucket + } + + return { + version: 1, + paneLastInputAt, + } +} + +const tabRecencySlice = createSlice({ + name: 'tabRecency', + initialState: loadInitialTabRecencyState(), + reducers: { + mergeHydratedTabRecency: (state, action: PayloadAction) => { + for (const [paneId, value] of Object.entries(action.payload.paneLastInputAt)) { + const trimmed = paneId.trim() + const bucket = bucketTabRecencyAt(value) + if (!trimmed || bucket === undefined) continue + const current = state.paneLastInputAt[trimmed] + if (current === undefined || bucket > current) { + state.paneLastInputAt[trimmed] = bucket + } + } + }, + recordPaneTabActivity: (state, action: PayloadAction<{ paneId: string; at: number }>) => { + const paneId = action.payload.paneId.trim() + if (!paneId) return + const bucket = bucketTabRecencyAt(action.payload.at) + if (bucket === undefined) return + const current = state.paneLastInputAt[paneId] + if (current === undefined || bucket > current) { + state.paneLastInputAt[paneId] = bucket + } + }, + prunePaneTabActivityToLiveTerminalPanes: (state, action: PayloadAction<{ paneIds: string[] }>) => { + const livePaneIds = new Set(action.payload.paneIds.map((paneId) => paneId.trim()).filter(Boolean)) + for (const paneId of Object.keys(state.paneLastInputAt)) { + if (!livePaneIds.has(paneId)) { + delete state.paneLastInputAt[paneId] + } + } + }, + }, +}) + +export const { + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, + recordPaneTabActivity, +} = tabRecencySlice.actions +export default tabRecencySlice.reducer diff --git a/src/store/tabRegistrySync.ts b/src/store/tabRegistrySync.ts index 2c544eb62..b24e40c04 100644 --- a/src/store/tabRegistrySync.ts +++ b/src/store/tabRegistrySync.ts @@ -9,6 +9,7 @@ import { } from './tabRegistrySlice' import { buildOpenTabRegistryRecord } from '@/lib/tab-registry-snapshot' import type { PaneNode } from './paneTypes' +import { deriveTabRecencyAt } from '@/lib/tab-recency' export const SYNC_INTERVAL_MS = 5000 @@ -49,13 +50,18 @@ function nextRevision(record: RegistryTabRecord, revisions: RevisionState): numb return revision } -function buildRecords(state: RootState, now: number, revisions: RevisionState, serverInstanceId: string): RegistryTabRecord[] { +function buildRecords(state: RootState, revisions: RevisionState, serverInstanceId: string): RegistryTabRecord[] { const records: RegistryTabRecord[] = [] const { deviceId, deviceLabel } = state.tabRegistry for (const tab of state.tabs.tabs) { const layout = state.panes.layouts[tab.id] if (!layout) continue + const updatedAt = deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: state.tabRecency?.paneLastInputAt ?? {}, + }) const recordBase = buildOpenTabRegistryRecord({ tab, layout, @@ -64,7 +70,7 @@ function buildRecords(state: RootState, now: number, revisions: RevisionState, s deviceId, deviceLabel, revision: 0, - updatedAt: tab.lastInputAt || tab.createdAt || now, + updatedAt, }) records.push({ ...recordBase, @@ -97,6 +103,11 @@ function lifecycleSignature(state: RootState): string { status: tab.status, mode: tab.mode, titleSetByUser: !!tab.titleSetByUser, + recencyAt: deriveTabRecencyAt({ + tab, + layout: state.panes.layouts[tab.id], + paneLastInputAt: state.tabRecency?.paneLastInputAt ?? {}, + }), })), panes: Object.entries(state.panes.layouts).map(([tabId, node]) => ({ tabId, @@ -140,7 +151,7 @@ export function startTabRegistrySync(store: AppStore, ws: TabRegistryWsClient): // Do not publish snapshot records until the server identity is known. // Without this, tabs can be attributed to a synthetic/unstable server key. if (!serverInstanceId) return - const records = buildRecords(state, Date.now(), revisions, serverInstanceId) + const records = buildRecords(state, revisions, serverInstanceId) const fingerprint = JSON.stringify(records) if (!force && fingerprint === lastPushFingerprint) return lastPushFingerprint = fingerprint diff --git a/test/e2e-browser/specs/tab-recency-sync.spec.ts b/test/e2e-browser/specs/tab-recency-sync.spec.ts new file mode 100644 index 000000000..a099fe2fc --- /dev/null +++ b/test/e2e-browser/specs/tab-recency-sync.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../helpers/fixtures.js' + +test.describe('Tab recency sync', () => { + test('rapid terminal input sends a bounded number of tab activity pushes', async ({ + freshellPage, + harness, + terminal, + }) => { + await terminal.waitForTerminal() + await terminal.waitForPrompt() + + await freshellPage.waitForTimeout(1000) + await harness.clearSentWsMessages() + await freshellPage.waitForTimeout(250) + expect((await harness.getSentWsMessages()).filter((message: any) => message?.type === 'tabs.sync.push')).toHaveLength(0) + + const startBucket = await freshellPage.evaluate(() => Math.floor(Date.now() / 60_000) * 60_000) + await terminal.typeInTerminal('aaaaaaaaaaaaaaaaaaaaaaaa') + await freshellPage.waitForTimeout(500) + const endBucket = await freshellPage.evaluate(() => Math.floor(Date.now() / 60_000) * 60_000) + + const messages = await harness.getSentWsMessages() + const pushes = messages.filter((message: any) => message?.type === 'tabs.sync.push') + const allowedActivityPushes = endBucket > startBucket ? 2 : 1 + + expect(pushes.length).toBeLessThanOrEqual(allowedActivityPushes) + const updatedAtBuckets = pushes + .map((push: any) => push.records?.[0]?.updatedAt) + .filter((updatedAt: any): updatedAt is number => typeof updatedAt === 'number') + expect(updatedAtBuckets).toHaveLength(pushes.length) + expect(new Set(updatedAtBuckets).size).toBeLessThanOrEqual(allowedActivityPushes) + for (const updatedAt of updatedAtBuckets) { + expect(updatedAt % 60_000).toBe(0) + } + }) +}) diff --git a/test/unit/client/components/ContextMenuProvider.test.tsx b/test/unit/client/components/ContextMenuProvider.test.tsx index 0eb11204e..7172ccce3 100644 --- a/test/unit/client/components/ContextMenuProvider.test.tsx +++ b/test/unit/client/components/ContextMenuProvider.test.tsx @@ -10,6 +10,7 @@ import sessionsReducer from '@/store/sessionsSlice' import connectionReducer from '@/store/connectionSlice' import settingsReducer from '@/store/settingsSlice' import extensionsReducer from '@/store/extensionsSlice' +import tabRecencyReducer from '@/store/tabRecencySlice' import { ContextMenuProvider } from '@/components/context-menu/ContextMenuProvider' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -1001,6 +1002,246 @@ describe('ContextMenuProvider', () => { expect(clipboardMocks.copyText).toHaveBeenCalledWith(`claude --resume ${VALID_SESSION_ID}`) }) + it('copies session metadata with minute-bucketed open-tab recency', async () => { + const user = userEvent.setup() + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + sessions: sessionsReducer, + connection: connectionReducer, + settings: settingsReducer, + extensions: extensionsReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }), + preloadedState: { + tabs: { + tabs: [ + { + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Claude Tab', + status: 'running', + mode: 'claude', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + ], + activeTabId: 'tab-1', + renameRequestTabId: null, + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'claude', + status: 'running', + createRequestId: 'req-1', + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: { 'tab-1': { 'pane-1': 'Claude Tab' } }, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_080_000, + }, + }, + sessions: { + projects: [ + { + projectPath: '/test/project', + sessions: [ + { + sessionId: VALID_SESSION_ID, + provider: 'claude', + title: 'Test Session', + cwd: '/test/project', + createdAt: 1000, + lastActivityAt: 2000, + messageCount: 5, + }, + ], + }, + ], + expandedProjects: new Set(), + }, + extensions: { + entries: defaultCliExtensions, + }, + connection: { + status: 'ready', + platform: null, + }, + }, + }) + + render( + + {}} + onToggleSidebar={() => {}} + sidebarCollapsed={false} + > +
+ Sidebar Session +
+
+
+ ) + + await user.pointer({ target: screen.getByText('Sidebar Session'), keys: '[MouseRight]' }) + await user.click(screen.getByRole('menuitem', { name: 'Copy full metadata' })) + + const copied = JSON.parse(clipboardMocks.copyText.mock.calls.at(-1)?.[0] ?? '{}') + expect(copied.tabLastInputAt).toBe(1_740_000_060_000) + expect(copied.tabLastInputAtIso).toBe(new Date(1_740_000_060_000).toISOString()) + }) + + it('copies session metadata when open-tab recency is the zero bucket', async () => { + const user = userEvent.setup() + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + sessions: sessionsReducer, + connection: connectionReducer, + settings: settingsReducer, + extensions: extensionsReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }), + preloadedState: { + tabs: { + tabs: [ + { + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Claude Tab', + status: 'running', + mode: 'claude', + createdAt: 0, + updatedAt: 999_999, + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + ], + activeTabId: 'tab-1', + renameRequestTabId: null, + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'terminal', + mode: 'claude', + status: 'running', + createRequestId: 'req-1', + sessionRef: { + provider: 'claude', + sessionId: VALID_SESSION_ID, + }, + }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: { 'tab-1': { 'pane-1': 'Claude Tab' } }, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 0, + }, + }, + sessions: { + projects: [ + { + projectPath: '/test/project', + sessions: [ + { + sessionId: VALID_SESSION_ID, + provider: 'claude', + title: 'Test Session', + cwd: '/test/project', + createdAt: 1000, + lastActivityAt: 2000, + messageCount: 5, + }, + ], + }, + ], + expandedProjects: new Set(), + }, + extensions: { + entries: defaultCliExtensions, + }, + connection: { + status: 'ready', + platform: null, + }, + }, + }) + + render( + + {}} + onToggleSidebar={() => {}} + sidebarCollapsed={false} + > +
+ Sidebar Session +
+
+
+ ) + + await user.pointer({ target: screen.getByText('Sidebar Session'), keys: '[MouseRight]' }) + await user.click(screen.getByRole('menuitem', { name: 'Copy full metadata' })) + + const copied = JSON.parse(clipboardMocks.copyText.mock.calls.at(-1)?.[0] ?? '{}') + expect(copied.tabLastInputAt).toBe(0) + expect(copied.tabLastInputAtIso).toBe(new Date(0).toISOString()) + }) + it('copies resume command from terminal pane context menu for codex pane', async () => { const user = userEvent.setup() const store = configureStore({ diff --git a/test/unit/client/components/TerminalView.lastInputAt.test.tsx b/test/unit/client/components/TerminalView.lastInputAt.test.tsx index 8af9ff6f2..909a4879a 100644 --- a/test/unit/client/components/TerminalView.lastInputAt.test.tsx +++ b/test/unit/client/components/TerminalView.lastInputAt.test.tsx @@ -7,6 +7,7 @@ import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import sessionActivityReducer from '@/store/sessionActivitySlice' +import tabRecencyReducer from '@/store/tabRecencySlice' import TerminalView from '@/components/TerminalView' import type { TerminalPaneContent } from '@/store/paneTypes' @@ -71,7 +72,11 @@ describe('TerminalView - lastInputAt updates', () => { const VALID_CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' - function createStore(opts?: { resumeSessionId?: string; provider?: 'claude' | 'codex' }) { + function createStore(opts?: { + resumeSessionId?: string + provider?: 'claude' | 'codex' + paneLastInputAt?: Record + }) { const provider = opts?.provider || (opts?.resumeSessionId ? 'claude' : undefined) return configureStore({ reducer: { @@ -80,6 +85,7 @@ describe('TerminalView - lastInputAt updates', () => { settings: settingsReducer, connection: connectionReducer, sessionActivity: sessionActivityReducer, + tabRecency: tabRecencyReducer, }, preloadedState: { tabs: { @@ -109,12 +115,19 @@ describe('TerminalView - lastInputAt updates', () => { sessionActivity: { sessions: {}, }, + tabRecency: { + paneLastInputAt: opts?.paneLastInputAt ?? {}, + }, }, }) } - it('dispatches updateTab with lastInputAt when user types', async () => { + it('records one minute-bucketed tab recency action per pane per minute without mutating tabs', async () => { + vi.setSystemTime(new Date(1_740_000_010_000)) const store = createStore() + const originalDispatch = store.dispatch + const dispatchSpy = vi.fn((action) => originalDispatch(action)) + store.dispatch = dispatchSpy as typeof store.dispatch const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: 'req-1', @@ -135,13 +148,63 @@ describe('TerminalView - lastInputAt updates', () => { ) expect(onDataCallback).not.toBeNull() - const beforeInput = Date.now() + dispatchSpy.mockClear() onDataCallback!('hello') - const afterInput = Date.now() - const tab = store.getState().tabs.tabs[0] - expect(tab.lastInputAt).toBeGreaterThanOrEqual(beforeInput) - expect(tab.lastInputAt).toBeLessThanOrEqual(afterInput) + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + expect(store.getState().tabs.tabs[0].lastInputAt).toBeUndefined() + + onDataCallback!('same-minute') + onDataCallback!('same-minute-again') + + const actionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(actionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(1) + expect(actionTypes.filter((type) => type === 'tabs/updateTab')).toHaveLength(0) + + vi.setSystemTime(new Date(1_740_000_060_000)) + onDataCallback!('next-minute') + + const nextActionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(nextActionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(2) + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_060_000) + }) + + it('does not dispatch a same-minute no-op recency action after reload', async () => { + vi.setSystemTime(new Date(1_740_000_050_000)) + const store = createStore({ + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + const originalDispatch = store.dispatch + const dispatchSpy = vi.fn((action) => originalDispatch(action)) + store.dispatch = dispatchSpy as typeof store.dispatch + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-1', + terminalId: 'term-1', + mode: 'shell', + shell: 'system', + status: 'running', + } + + render( + + + + ) + + expect(onDataCallback).not.toBeNull() + dispatchSpy.mockClear() + onDataCallback!('same-minute-after-reload') + + const actionTypes = dispatchSpy.mock.calls.map((call) => call[0]?.type) + expect(actionTypes.filter((type) => type === 'tabRecency/recordPaneTabActivity')).toHaveLength(0) + expect(actionTypes.filter((type) => type === 'tabs/updateTab')).toHaveLength(0) }) it('updates sessionActivity for Claude sessions with resumeSessionId', async () => { diff --git a/test/unit/client/lib/tab-recency.test.ts b/test/unit/client/lib/tab-recency.test.ts new file mode 100644 index 000000000..43fc311d0 --- /dev/null +++ b/test/unit/client/lib/tab-recency.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + TAB_RECENCY_RESOLUTION_MS, + bucketTabRecencyAt, + collectTerminalPaneIds, + deriveTabRecencyAt, +} from '@/lib/tab-recency' + +describe('tab recency helpers', () => { + it('rounds timestamps down to 60-second buckets', () => { + expect(TAB_RECENCY_RESOLUTION_MS).toBe(60_000) + expect(bucketTabRecencyAt(1_740_000_059_999)).toBe(1_740_000_000_000) + expect(bucketTabRecencyAt(1_740_000_060_000)).toBe(1_740_000_060_000) + }) + + it('ignores missing and invalid timestamps', () => { + expect(bucketTabRecencyAt(undefined)).toBeUndefined() + expect(bucketTabRecencyAt(null)).toBeUndefined() + expect(bucketTabRecencyAt(Number.NaN)).toBeUndefined() + expect(bucketTabRecencyAt(-1)).toBeUndefined() + }) + + it('collects only current terminal pane ids', () => { + const layout = { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-terminal', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-picker', + content: { kind: 'picker' }, + }, + ], + } as any + + expect(collectTerminalPaneIds(layout)).toEqual(['pane-terminal']) + }) + + it('derives tab recency from latest terminal-pane activity and tab fallback fields', () => { + const tab = { + id: 'tab-1', + createdAt: 1_740_000_000_000, + lastInputAt: 1_740_000_020_000, + } + const layout = { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-2', + content: { kind: 'terminal' }, + }, + ], + } as any + + expect(deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: { + 'pane-1': 1_740_000_030_000, + 'pane-2': 1_740_000_080_000, + }, + })).toBe(1_740_000_060_000) + }) + + it('does not treat tab.updatedAt or non-terminal pane ids as activity recency', () => { + const tab = { + id: 'tab-1', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_180_000, + lastInputAt: 1_740_000_080_000, + } as any + const layout = { + type: 'leaf', + id: 'pane-1', + content: { kind: 'picker' }, + } as any + + expect(deriveTabRecencyAt({ + tab, + layout, + paneLastInputAt: { + 'pane-1': 1_740_000_240_000, + }, + })).toBe(1_740_000_060_000) + }) +}) diff --git a/test/unit/client/store/crossTabSync.test.ts b/test/unit/client/store/crossTabSync.test.ts index 98d4b7fcb..258304ee1 100644 --- a/test/unit/client/store/crossTabSync.test.ts +++ b/test/unit/client/store/crossTabSync.test.ts @@ -3,16 +3,22 @@ import { configureStore } from '@reduxjs/toolkit' import tabsReducer, { hydrateTabs } from '../../../../src/store/tabsSlice' import panesReducer, { hydratePanes } from '../../../../src/store/panesSlice' +import tabRecencyReducer from '../../../../src/store/tabRecencySlice' import settingsReducer, { setLocalSettings, updateSettingsLocal } from '../../../../src/store/settingsSlice' import tabRegistryReducer, { setTabRegistrySearchRangeDays } from '../../../../src/store/tabRegistrySlice' import { installCrossTabSync } from '../../../../src/store/crossTabSync' +import { + persistMiddleware, + PERSIST_DEBOUNCE_MS, + resetPersistFlushListenersForTests, +} from '../../../../src/store/persistMiddleware' import { BROWSER_PREFERENCES_PERSIST_DEBOUNCE_MS, browserPreferencesPersistenceMiddleware, resetBrowserPreferencesFlushListenersForTests, } from '../../../../src/store/browserPreferencesPersistence' import { broadcastPersistedRaw, resetPersistBroadcastForTests } from '../../../../src/store/persistBroadcast' -import { BROWSER_PREFERENCES_STORAGE_KEY, LAYOUT_STORAGE_KEY } from '../../../../src/store/storage-keys' +import { BROWSER_PREFERENCES_STORAGE_KEY, LAYOUT_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from '../../../../src/store/storage-keys' import { resolveLocalSettings } from '@shared/settings' import { sessionMetadataKey } from '@/lib/session-metadata' @@ -22,7 +28,9 @@ describe('crossTabSync', () => { afterEach(() => { vi.useRealTimers() localStorage.clear() + vi.restoreAllMocks() resetBrowserPreferencesFlushListenersForTests() + resetPersistFlushListenersForTests() resetPersistBroadcastForTests() for (const cleanup of cleanups.splice(0)) cleanup() }) @@ -324,6 +332,165 @@ describe('crossTabSync', () => { expect(store.getState().tabRegistry.searchRangeDays).toBe(30) }) + it('merges tab recency sidecar events without rewriting layout or echoing the sidecar', () => { + vi.useFakeTimers() + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer, tabRecency: tabRecencyReducer }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + + store.dispatch({ + ...hydrateTabs({ + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Tab 1', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + } as any), + meta: { skipPersist: true }, + }) + store.dispatch({ + ...hydratePanes({ + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + } as any, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + paneTitleSetByUser: {}, + } as any), + meta: { skipPersist: true }, + }) + + cleanups.push(installCrossTabSync(store as any)) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + window.dispatchEvent(new StorageEvent('storage', { + key: TAB_RECENCY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_059_999, + }, + }), + })) + + expect(store.getState().tabRecency.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + }) + + it('merges tab recency sidecars by max and persists pruned local terminal panes', () => { + vi.useFakeTimers() + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer, tabRecency: tabRecencyReducer }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + preloadedState: { + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Tab 1', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-1': { + type: 'split', + id: 'root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-local', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-local', status: 'running' }, + }, + { + type: 'split', + id: 'right', + direction: 'vertical', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-shared', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-shared', status: 'running' }, + }, + { + type: 'leaf', + id: 'pane-remote', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-remote', status: 'running' }, + }, + ], + }, + ], + } as any, + }, + activePane: { 'tab-1': 'pane-local' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + }, + } as any, + }) + + cleanups.push(installCrossTabSync(store as any)) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + window.dispatchEvent(new StorageEvent('storage', { + key: TAB_RECENCY_STORAGE_KEY, + newValue: JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-shared': 1_740_000_000_000, + 'pane-remote': 1_740_000_060_000, + 'pane-stale': 1_740_000_180_000, + }, + }), + })) + + expect(store.getState().tabRecency.paneLastInputAt).toEqual({ + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + it('merges remote browser-preference writes without clobbering dirty local settings', () => { vi.useFakeTimers() diff --git a/test/unit/client/store/selectors/sidebarSelectors.test.ts b/test/unit/client/store/selectors/sidebarSelectors.test.ts index 67ad7fc04..6407a2388 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.test.ts @@ -66,6 +66,7 @@ function createSelectorState(options: { appliedQuery?: string appliedSearchTier?: 'title' | 'userMessages' | 'fullText' sessionActivity?: Record + tabRecency?: { paneLastInputAt: Record } } = {}) { const projects = options.projects ?? [] return { @@ -105,6 +106,9 @@ function createSelectorState(options: { sessionActivity: { sessions: options.sessionActivity ?? {}, }, + tabRecency: options.tabRecency ?? { + paneLastInputAt: {}, + }, } as any } @@ -559,6 +563,42 @@ describe('sidebarSelectors', () => { }), ]) }) + + it('uses minute-bucketed pane activity for open-tab fallback timestamps', () => { + const fallback = createFallbackTab('tab-restored', 'codex-restored', 'Restored Session', '/tmp/restored-project') + const selectSortedItems = makeSelectSortedSessionItems() + + const items = selectSortedItems(createSelectorState({ + tabs: [{ + ...fallback.tab, + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + }], + panes: { + layouts: { + [fallback.tab.id]: fallback.layout, + }, + activePane: { + [fallback.tab.id]: fallback.paneId, + }, + paneTitles: { + [fallback.tab.id]: { [fallback.paneId]: fallback.tab.title }, + }, + }, + tabRecency: { + paneLastInputAt: { + [fallback.paneId]: 1_740_000_080_000, + }, + }, + }), [], '') + + expect(items).toEqual([ + expect.objectContaining({ + sessionId: 'codex-restored', + timestamp: 1_740_000_060_000, + }), + ]) + }) }) describe('worktree grouping', () => { diff --git a/test/unit/client/store/tabRecencyPruneMiddleware.test.ts b/test/unit/client/store/tabRecencyPruneMiddleware.test.ts new file mode 100644 index 000000000..2d8225950 --- /dev/null +++ b/test/unit/client/store/tabRecencyPruneMiddleware.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +import { tabRecencyPruneMiddleware } from '@/store/tabRecencyPruneMiddleware' + +describe('tabRecencyPruneMiddleware', () => { + it('does not inspect pane topology for unrelated actions', () => { + const action = { type: 'settings/updateSettingsLocal', payload: { theme: 'dark' } } + const store = { + getState: vi.fn(() => { + throw new Error('unrelated actions should not inspect recency topology') + }), + dispatch: vi.fn(), + } + const next = vi.fn((received) => received) + + const result = tabRecencyPruneMiddleware(store as any)(next)(action) + + expect(result).toBe(action) + expect(next).toHaveBeenCalledWith(action) + expect(store.getState).not.toHaveBeenCalled() + expect(store.dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/client/store/tabRecencySlice.test.ts b/test/unit/client/store/tabRecencySlice.test.ts new file mode 100644 index 000000000..316fee708 --- /dev/null +++ b/test/unit/client/store/tabRecencySlice.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import reducer, { + loadPersistedTabRecency, + mergeHydratedTabRecency, + prunePaneTabActivityToLiveTerminalPanes, + recordPaneTabActivity, + serializePersistableTabRecency, +} from '@/store/tabRecencySlice' + +describe('tabRecencySlice', () => { + it('stores pane activity at 60-second resolution', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + + expect(state.paneLastInputAt['pane-1']).toBe(1_740_000_000_000) + }) + + it('does not move a pane backward', () => { + const first = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_120_000, + })) + const second = reducer(first, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_060_000, + })) + + expect(second.paneLastInputAt['pane-1']).toBe(1_740_000_120_000) + }) + + it('records the zero minute bucket for deterministic tests and epoch data', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 0, + })) + + expect(state.paneLastInputAt['pane-1']).toBe(0) + }) + + it('ignores invalid pane ids and timestamps', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: '', + at: Number.NaN, + })) + + expect(state.paneLastInputAt).toEqual({}) + }) + + it('loads only valid persisted minute buckets', () => { + expect(loadPersistedTabRecency(JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + 'pane-2': 1_740_000_059_999, + bad: -1, + }, + }))).toEqual({ + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + 'pane-2': 1_740_000_000_000, + }, + }) + }) + + it('serializes only minute-bucketed recency values for live terminal panes', () => { + const state = reducer(undefined, recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + const withStalePane = { + paneLastInputAt: { + ...state.paneLastInputAt, + stale: 1_740_000_000_000, + 'pane-picker': 1_740_000_120_000, + }, + } + + expect(serializePersistableTabRecency(withStalePane, { + 'tab-1': { + type: 'split', + id: 'root', + direction: 'horizontal', + children: [ + { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal' }, + }, + { + type: 'leaf', + id: 'pane-picker', + content: { kind: 'picker' }, + }, + ], + } as any, + 'closed-tab': { + type: 'leaf', + id: 'stale', + content: { kind: 'terminal' }, + } as any, + }, new Set(['tab-1']))).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + }) + + it('merges cross-window recency by per-pane max without dropping local panes', () => { + const state = reducer({ + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + }, mergeHydratedTabRecency({ + paneLastInputAt: { + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_000_000, + }, + })) + + expect(state).toEqual({ + paneLastInputAt: { + 'pane-local': 1_740_000_120_000, + 'pane-remote': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + + it('prunes live state to current terminal pane ids', () => { + const state = reducer({ + paneLastInputAt: { + 'pane-terminal': 1_740_000_000_000, + 'pane-replaced': 1_740_000_060_000, + }, + }, prunePaneTabActivityToLiveTerminalPanes({ + paneIds: ['pane-terminal'], + })) + + expect(state).toEqual({ + paneLastInputAt: { + 'pane-terminal': 1_740_000_000_000, + }, + }) + }) +}) diff --git a/test/unit/client/store/tabRegistrySlice.test.ts b/test/unit/client/store/tabRegistrySlice.test.ts index c19b45fa9..be56504a4 100644 --- a/test/unit/client/store/tabRegistrySlice.test.ts +++ b/test/unit/client/store/tabRegistrySlice.test.ts @@ -18,6 +18,7 @@ import { DEVICE_LABEL_STORAGE_KEY, } from '../../../../src/store/storage-keys' import type { RegistryTabRecord } from '../../../../src/store/tabRegistryTypes' +import { selectTabsRegistryGroups } from '../../../../src/store/selectors/tabsRegistrySelectors' function makeRecord(overrides: Partial): RegistryTabRecord { return { @@ -161,4 +162,47 @@ describe('tabRegistrySlice', () => { expect(freshReducer(undefined, { type: 'unknown' }).searchRangeDays).toBe(365) }) + + it('derives local open tab recency from minute-bucketed pane activity', () => { + const result = selectTabsRegistryGroups({ + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Active Tab', + status: 'running', + mode: 'shell', + createdAt: 1_740_000_000_000, + updatedAt: 1_740_000_999_999, + }], + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + }, + }, + paneTitles: { 'tab-1': { 'pane-1': 'Shell' } }, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_080_000, + }, + }, + tabRegistry: { + deviceId: 'device-1', + deviceLabel: 'Device', + remoteOpen: [], + closed: [], + localClosed: {}, + }, + connection: { + serverInstanceId: 'srv-test', + }, + } as any) + + expect(result.localOpen[0].updatedAt).toBe(1_740_000_060_000) + }) }) diff --git a/test/unit/client/store/tabRegistrySync.test.ts b/test/unit/client/store/tabRegistrySync.test.ts index bdc7eed23..1e1c1c0b6 100644 --- a/test/unit/client/store/tabRegistrySync.test.ts +++ b/test/unit/client/store/tabRegistrySync.test.ts @@ -18,6 +18,7 @@ function createState(): RootState { }], activeTabId: 'tab-1', renameRequestTabId: null, + tombstones: [], }, panes: { layouts: { @@ -39,6 +40,10 @@ function createState(): RootState { renameRequestTabId: null, renameRequestPaneId: null, zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: {}, }, tabRegistry: { deviceId: 'local-device', @@ -67,6 +72,19 @@ describe('tabRegistrySync', () => { let dispatch: ReturnType let ws: any + function createStore() { + return { + getState: () => state, + dispatch, + subscribe: (listener: Listener) => { + listeners.push(listener) + return () => { + listeners = listeners.filter((item) => item !== listener) + } + }, + } + } + beforeEach(() => { vi.useFakeTimers() listeners = [] @@ -98,16 +116,7 @@ describe('tabRegistrySync', () => { }) it('pushes tabs.sync only when lifecycle changes', () => { - const store = { - getState: () => state, - dispatch, - subscribe: (listener: Listener) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((item) => item !== listener) - } - }, - } + const store = createStore() const stop = startTabRegistrySync(store as any, ws) expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) @@ -140,16 +149,7 @@ describe('tabRegistrySync', () => { }, } - const store = { - getState: () => state, - dispatch, - subscribe: (listener: Listener) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((item) => item !== listener) - } - }, - } + const store = createStore() const stop = startTabRegistrySync(store as any, ws) expect(ws.sendTabsSyncQuery).toHaveBeenCalledTimes(1) @@ -166,16 +166,7 @@ describe('tabRegistrySync', () => { }, } - const store = { - getState: () => state, - dispatch, - subscribe: (listener: Listener) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((item) => item !== listener) - } - }, - } + const store = createStore() const stop = startTabRegistrySync(store as any, ws) ws.sendTabsSyncQuery.mockClear() @@ -188,16 +179,7 @@ describe('tabRegistrySync', () => { }) it('applies tabs.sync.snapshot responses into store dispatch', () => { - const store = { - getState: () => state, - dispatch, - subscribe: (listener: Listener) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((item) => item !== listener) - } - }, - } + const store = createStore() const stop = startTabRegistrySync(store as any, ws) const queryCall = ws.sendTabsSyncQuery.mock.calls[0][0] @@ -216,4 +198,120 @@ describe('tabRegistrySync', () => { expect(dispatch.mock.calls.some((call) => call[0]?.type === 'tabRegistry/setTabRegistrySnapshot')).toBe(true) stop() }) + + it('sends at most one activity snapshot for repeated terminal input in the same minute bucket', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_010_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].updatedAt).toBe(1_740_000_000_000) + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_050_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + stop() + }) + + it('sends a new activity snapshot when terminal input enters the next minute bucket', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_010_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + state = { + ...state, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_060_000, + }, + }, + } as RootState + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(2) + expect(ws.sendTabsSyncPush.mock.calls[1][0].records[0].updatedAt).toBe(1_740_000_060_000) + stop() + }) + + it('still pushes real tab changes immediately even when recency bucket does not change', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ ...tab, title: 'renamed tab' })), + }, + } + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].tabName).toBe('renamed tab') + stop() + }) + + it('does not push when only tab.updatedAt changes', () => { + const stop = startTabRegistrySync(createStore() as any, ws) + ws.sendTabsSyncPush.mockClear() + + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ ...tab, updatedAt: 1_740_000_999_999 })), + }, + } + listeners.forEach((listener) => listener()) + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(0) + stop() + }) + + it('preserves a zero recency bucket without falling back to Date.now', () => { + vi.setSystemTime(new Date(1_740_000_010_123)) + state = { + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab) => ({ + ...tab, + createdAt: 0, + updatedAt: 0, + lastInputAt: undefined, + })), + }, + } as RootState + + const stop = startTabRegistrySync(createStore() as any, ws) + + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].updatedAt).toBe(0) + stop() + }) }) diff --git a/test/unit/client/store/tabsPersistence.test.ts b/test/unit/client/store/tabsPersistence.test.ts index d7df7fc0d..8ace2f860 100644 --- a/test/unit/client/store/tabsPersistence.test.ts +++ b/test/unit/client/store/tabsPersistence.test.ts @@ -15,11 +15,20 @@ const localStorageMock = (() => { Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) import tabsReducer, { updateTab } from '@/store/tabsSlice' +import panesReducer, { replacePane } from '@/store/panesSlice' +import tabRecencyReducer, { + loadPersistedTabRecency, + recordPaneTabActivity, +} from '@/store/tabRecencySlice' +import { tabRecencyPruneMiddleware } from '@/store/tabRecencyPruneMiddleware' import { + PERSIST_DEBOUNCE_MS, persistMiddleware, resetPersistFlushListenersForTests, resetPersistedLayoutCacheForTests, } from '@/store/persistMiddleware' +import { onPersistBroadcast, resetPersistBroadcastForTests } from '@/store/persistBroadcast' +import { LAYOUT_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from '@/store/storage-keys' function makeStore() { return configureStore({ @@ -43,15 +52,65 @@ function makeStore() { }) } +function makeRecencyStore(preloadedState?: any) { + return configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRecency: tabRecencyReducer, + }, + middleware: (getDefault) => getDefault().concat( + tabRecencyPruneMiddleware as any, + persistMiddleware as any, + ), + preloadedState: preloadedState ?? { + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'req-tab-1', + title: 'Test', + status: 'running', + mode: 'shell', + createdAt: 123, + }], + activeTabId: 'tab-1', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-1', status: 'running' }, + }, + }, + activePane: { 'tab-1': 'pane-1' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: {}, + }, + }, + }) +} + describe('tabs persistence - skipPersist + strip volatile fields', () => { beforeEach(() => { localStorageMock.clear() vi.useFakeTimers() resetPersistFlushListenersForTests() + resetPersistBroadcastForTests() }) afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() }) it('does not schedule a new tabs write when meta.skipPersist is set', () => { @@ -84,6 +143,260 @@ describe('tabs persistence - skipPersist + strip volatile fields', () => { expect(parsed.tabs.tabs[0].lastInputAt).toBeUndefined() }) + it('persists recency-only activity to the sidecar without rewriting layout', () => { + const store = makeRecencyStore() + const broadcasts: Array<{ key: string; raw: string }> = [] + const unsubscribe = onPersistBroadcast((msg) => { + broadcasts.push({ key: msg.key, raw: msg.raw }) + }) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + try { + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_059_999, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }) + expect(localStorage.getItem(LAYOUT_STORAGE_KEY)).toBeNull() + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(broadcasts.map((msg) => msg.key)).toEqual([TAB_RECENCY_STORAGE_KEY]) + } finally { + unsubscribe() + } + }) + + it('does not rewrite the recency sidecar when topology changes prune nothing', () => { + const store = makeRecencyStore() + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + store.dispatch(replacePane({ tabId: 'tab-1', paneId: 'pane-1' })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(setItemSpy).toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + expect(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)).toBeNull() + }) + + it('prunes pane recency immediately when a terminal pane becomes non-terminal', () => { + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-live': 1_740_000_000_000, + 'pane-stale': 1_740_000_060_000, + }, + })) + + const store = makeRecencyStore({ + tabs: { + tabs: [{ + id: 'tab-live', + createRequestId: 'tab-live', + title: 'Live', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-live', + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: { + 'tab-live': { + type: 'split', + id: 'root', + direction: 'horizontal', + sizes: [50, 50], + children: [ + { + type: 'leaf', + id: 'pane-live', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-live', status: 'running' }, + }, + { + type: 'leaf', + id: 'pane-stale', + content: { kind: 'terminal', mode: 'shell', createRequestId: 'req-stale', status: 'running' }, + }, + ], + }, + }, + activePane: { 'tab-live': 'pane-live' }, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: loadPersistedTabRecency(localStorage.getItem(TAB_RECENCY_STORAGE_KEY)), + }) + + store.dispatch(replacePane({ tabId: 'tab-live', paneId: 'pane-stale' })) + + expect(store.getState().tabRecency.paneLastInputAt).toEqual({ + 'pane-live': 1_740_000_000_000, + }) + + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-live': 1_740_000_000_000, + }, + }) + }) + + it('prunes persisted recency during real store startup before pane ids can be reused', async () => { + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ + version: 4, + tabs: { + tabs: [{ + id: 'tab-1', + createRequestId: 'tab-1', + title: 'Picker Tab', + status: 'running', + mode: 'shell', + createdAt: 1, + }], + activeTabId: 'tab-1', + }, + panes: { + version: 7, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-reused', + content: { kind: 'picker' }, + }, + }, + activePane: { 'tab-1': 'pane-reused' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-reused': 1_740_000_060_000, + }, + })) + + vi.resetModules() + const [{ store }, { updatePaneContent }] = await Promise.all([ + import('@/store/store'), + import('@/store/panesSlice'), + ]) + + expect(store.getState().tabRecency.paneLastInputAt).not.toHaveProperty('pane-reused') + + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: {}, + }) + + store.dispatch(updatePaneContent({ + tabId: 'tab-1', + paneId: 'pane-reused', + content: { kind: 'terminal', mode: 'shell' }, + })) + + expect(store.getState().tabRecency.paneLastInputAt).not.toHaveProperty('pane-reused') + }) + + it('does not persist same-bucket no-op tab recency actions', () => { + const store = makeRecencyStore({ + tabs: { + tabs: [], + activeTabId: null, + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-1': 1_740_000_000_000, + }, + }, + }) + const setItemSpy = vi.spyOn(localStorage, 'setItem') + + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-1', + at: 1_740_000_050_000, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(setItemSpy).not.toHaveBeenCalledWith(TAB_RECENCY_STORAGE_KEY, expect.any(String)) + expect(setItemSpy).not.toHaveBeenCalledWith(LAYOUT_STORAGE_KEY, expect.any(String)) + }) + + it('merges recency-only persistence with the existing sidecar by per-pane max', () => { + localStorage.setItem(TAB_RECENCY_STORAGE_KEY, JSON.stringify({ + version: 1, + paneLastInputAt: { + 'pane-existing': 1_740_000_120_000, + 'pane-shared': 1_740_000_120_000, + }, + })) + const store = makeRecencyStore({ + tabs: { + tabs: [], + activeTabId: null, + renameRequestTabId: null, + tombstones: [], + }, + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + }, + tabRecency: { + paneLastInputAt: { + 'pane-shared': 1_740_000_000_000, + }, + }, + }) + + store.dispatch(recordPaneTabActivity({ + paneId: 'pane-new', + at: 1_740_000_060_000, + })) + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + + expect(JSON.parse(localStorage.getItem(TAB_RECENCY_STORAGE_KEY) || '{}')).toEqual({ + version: 1, + paneLastInputAt: { + 'pane-existing': 1_740_000_120_000, + 'pane-new': 1_740_000_060_000, + 'pane-shared': 1_740_000_120_000, + }, + }) + }) + it('drops stale shared session identity on initial load when the persisted layout is split', async () => { localStorageMock.clear() resetPersistedLayoutCacheForTests()