From 837a3b23f99014d727de2d9d9336ca31088d225a Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 14:54:39 -0500 Subject: [PATCH 1/2] fix: prevent session sidebar from going stale when activeSurface is unset The session sidebar could permanently stop updating due to a race condition: when sessions.changed WS messages arrived before bootstrap set activeSurface, queueActiveSessionWindowRefresh() silently exited and never queued a refresh. All subsequent session updates were also silently dropped. Three fixes: - Default to 'sidebar' when activeSurface is undefined in refresh thunks - Mark wsSnapshotReceived=true on any successful HTTP fetch (not just during the 30-second bootstrap window), so WS patches aren't silently discarded - Add persistedAt timestamp to tab state in localStorage and log a warning when restoring state older than 24 hours Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/persistMiddleware.ts | 1 + src/store/sessionsSlice.ts | 4 ++++ src/store/sessionsThunks.ts | 22 ++++++++++++---------- src/store/tabsSlice.ts | 8 +++++++- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index c370adc8..46bf2be2 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -324,6 +324,7 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { if (tabsDirty) { const tabsPayload = { + persistedAt: Date.now(), tabs: { // Persist only stable tab state. Keep ephemeral UI fields out of storage. activeTabId: state.tabs.activeTabId, diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index e1117206..16855fb6 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -224,6 +224,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 f73c387b..10ffe1ea 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -357,8 +357,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] await dispatch(fetchSessionWindow({ surface, @@ -372,9 +372,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 @@ -385,12 +387,12 @@ 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 activeRequest = inFlightRequests.get(surface) ?? null if (activeRequest) { try { await activeRequest @@ -401,17 +403,17 @@ export function queueActiveSessionWindowRefresh() { } if (!state.queued) break state.queued = false - const windowState = getState().sessions.windows[activeSurface] + const windowState = getState().sessions.windows[surface] await dispatch(fetchSessionWindow({ - surface: activeSurface, + surface, priority: 'background', query: windowState?.query, searchTier: windowState?.searchTier, }) as any) } } 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 5ad624a9..8de55353 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -49,7 +49,13 @@ function loadInitialTabsState(): TabsState { const tabsState = parsed?.tabs as Partial | undefined if (!Array.isArray(tabsState?.tabs)) return defaultState - log.debug('Loaded initial state from localStorage:', tabsState.tabs.map((t) => t.id)) + const persistedAt = typeof parsed.persistedAt === 'number' ? parsed.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) => t.id), persistedAt ? `(${ageHours}h old)` : '(no timestamp)') // Apply same transformations as hydrateTabs to ensure consistency const mappedTabs = tabsState.tabs.map((t: Tab) => { From cf589b1f362d637ca30c697e253dc4b4584c80ba Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 15:35:34 -0500 Subject: [PATCH 2/2] fix: use stable persistedAt to avoid cross-tab sync churn Set persistedAt once per session instead of on every flush, so the serialized payload stays identical when tab content hasn't changed. This preserves the raw-string dedup in cross-tab sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/persistMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index b8551bc5..a803aff0 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -371,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' @@ -414,7 +415,7 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { } const layoutPayload = { - persistedAt: Date.now(), + persistedAt: sessionPersistedAt, version: LAYOUT_SCHEMA_VERSION, tabs: { activeTabId: state.tabs.activeTabId,