diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index ad7d8037..a803aff0 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -69,7 +69,7 @@ export function resetPersistedPanesCacheForTests() { import { migrateV2ToV3 } from './persistedState.js' -let cachedPersistedLayout: { tabs: any; panes: any; tombstones: any } | null | undefined +let cachedPersistedLayout: { tabs: any; panes: any; tombstones: any; persistedAt?: number } | null | undefined /** * Load the combined layout from v3 key, or migrate from v2 keys. @@ -87,6 +87,7 @@ export function loadPersistedLayout(): typeof cachedPersistedLayout { tabs: { tabs: layoutParsed.tabs }, panes: layoutParsed.panes, tombstones: layoutParsed.tombstones, + persistedAt: typeof layoutParsed.persistedAt === 'number' ? layoutParsed.persistedAt : undefined, } return cachedPersistedLayout } @@ -370,6 +371,7 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { let tabsDirty = false let panesDirty = false let flushTimer: ReturnType | null = null + const sessionPersistedAt = Date.now() const canUseStorage = () => typeof localStorage !== 'undefined' @@ -413,6 +415,7 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { } const layoutPayload = { + persistedAt: sessionPersistedAt, version: LAYOUT_SCHEMA_VERSION, tabs: { activeTabId: state.tabs.activeTabId, diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 60bada0a..c57cf058 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -127,6 +127,7 @@ export type ParsedPersistedLayout = { tabs: z.infer panes: ParsedPersistedPanes tombstones: Array<{ id: string; deletedAt: number }> + persistedAt?: number } export function parsePersistedLayoutRaw(raw: string): ParsedPersistedLayout | null { @@ -158,6 +159,7 @@ export function parsePersistedLayoutRaw(raw: string): ParsedPersistedLayout | nu paneTitleSetByUser: (panes.paneTitleSetByUser || {}) as Record>, }, tombstones: res.data.tombstones || [], + persistedAt: typeof (res.data as any).persistedAt === 'number' ? (res.data as any).persistedAt : undefined, } } diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index cf38c5ea..a846ae2d 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -263,6 +263,10 @@ export const sessionsSlice = createSlice({ if (!state.activeSurface || state.activeSurface === action.payload.surface) { syncTopLevelFromWindow(state, action.payload.surface) } + // A successful HTTP fetch establishes a valid baseline for WS patches. + if (!state.wsSnapshotReceived) { + state.wsSnapshotReceived = true + } }, markWsSnapshotReceived: (state) => { state.wsSnapshotReceived = true diff --git a/src/store/sessionsThunks.ts b/src/store/sessionsThunks.ts index 101be949..d006868d 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -580,8 +580,8 @@ export function fetchSessionWindow(args: FetchSessionWindowArgs) { export function refreshActiveSessionWindow() { return async (dispatch: AppDispatch, getState: () => RootState) => { - const surface = getState().sessions.activeSurface as SessionSurface | undefined - if (!surface) return + const active = getState().sessions.activeSurface as SessionSurface | undefined + const surface: SessionSurface = active ?? 'sidebar' const windowState = getState().sessions.windows[surface] if (!hasCommittedWindowData(windowState)) { const requestedSearchContext = getRequestedWindowSearchContext(windowState) @@ -608,9 +608,11 @@ export function refreshActiveSessionWindow() { export function queueActiveSessionWindowRefresh() { return async (dispatch: AppDispatch, getState: () => RootState) => { const activeSurface = getState().sessions.activeSurface - if (!isSessionSurface(activeSurface)) return + // Default to 'sidebar' if activeSurface hasn't been initialized yet — + // sessions.changed can arrive before bootstrap sets the active surface. + const surface: SessionSurface = isSessionSurface(activeSurface) ? activeSurface : 'sidebar' - const existing = invalidationRefreshState.get(activeSurface) + const existing = invalidationRefreshState.get(surface) if (existing?.inFlight) { existing.queued = true return existing.inFlight @@ -621,13 +623,13 @@ export function queueActiveSessionWindowRefresh() { inFlight: null as Promise | null, queued: true, } - invalidationRefreshState.set(activeSurface, state) + invalidationRefreshState.set(surface, state) const run = (async () => { try { while (generation === sessionWindowThunkGeneration) { - const activeRequest = inFlightRequests.get(activeSurface) ?? null - const windowState = getState().sessions.windows[activeSurface] + const activeRequest = inFlightRequests.get(surface) ?? null + const windowState = getState().sessions.windows[surface] const hasCommittedWindow = hasCommittedWindowData(windowState) if (!hasCommittedWindow) { @@ -643,7 +645,7 @@ export function queueActiveSessionWindowRefresh() { state.queued = false const requestedSearchContext = getRequestedWindowSearchContext(windowState) await dispatch(fetchSessionWindow({ - surface: activeSurface, + surface, priority: 'background', query: requestedSearchContext.query, searchTier: requestedSearchContext.searchTier, @@ -663,7 +665,7 @@ export function queueActiveSessionWindowRefresh() { await refreshVisibleSessionWindowSilently({ dispatch, getState, - surface: activeSurface, + surface, generation, identity: getVisibleResultIdentity(windowState), preserveLoadingState: activeRequest !== null, @@ -683,15 +685,15 @@ export function queueActiveSessionWindowRefresh() { await refreshVisibleSessionWindowSilently({ dispatch, getState, - surface: activeSurface, + surface, generation, identity: getVisibleResultIdentity(windowState), preserveLoadingState: false, }) } } finally { - if (invalidationRefreshState.get(activeSurface) === state) { - invalidationRefreshState.delete(activeSurface) + if (invalidationRefreshState.get(surface) === state) { + invalidationRefreshState.delete(surface) } } })() diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index c4ea3f2b..681b3a1b 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -71,7 +71,13 @@ function loadInitialTabsState(): TabsState { const tabsState = layout.tabs?.tabs as Partial | undefined if (!Array.isArray(tabsState?.tabs)) return defaultState - log.debug('Loaded initial state from localStorage:', tabsState.tabs.map((t: Tab) => t.id)) + const persistedAt = typeof layout.persistedAt === 'number' ? layout.persistedAt : undefined + const ageMs = persistedAt ? Date.now() - persistedAt : undefined + const ageHours = ageMs ? Math.round(ageMs / 3600000) : undefined + if (ageHours !== undefined && ageHours > 24) { + log.warn(`Restoring tab state from ${ageHours}h ago — may be stale (persistedAt: ${new Date(persistedAt!).toISOString()})`) + } + log.debug('Loaded initial state from localStorage:', tabsState.tabs.map((t: Tab) => t.id), persistedAt ? `(${ageHours}h old)` : '(no timestamp)') const mappedTabs = tabsState.tabs.map(migrateTabFields) const desired = tabsState.activeTabId