Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/plans/2026-03-30-fix-sidebar-session-staleness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Fix Sidebar Session Staleness

## Goal

The sidebar session list goes stale and never recovers. Fresh data arrives from the server every few seconds (confirmed: 200 OK, 50 items), gets committed to Redux (`resultVersion` incrementing), but the sidebar renders old data. Fix the root causes so the sidebar always reflects current server state.

## Root Causes (confirmed via live profiling on localhost:3347)

### 1. Window/top-level state divergence (primary cause)

`sidebarSelectors.ts:38` reads `sessions.windows.sidebar.projects`, falling back to `sessions.projects`. WebSocket patches update `sessions.projects` (top-level) and sync only to the **active** surface via `syncActiveWindowFromTopLevel()`. When sidebar isn't active, its window state goes stale — and the selector always picks the stale window data over the fresh top-level data because it exists.

Key locations:
- `src/store/selectors/sidebarSelectors.ts:38` — selector reads stale window state
- `src/store/sessionsSlice.ts:152` — `syncActiveWindowFromTopLevel()` only syncs active surface
- `src/store/sessionsSlice.ts:243-245, 263-265` — commit guards skip sync when not active
- `src/store/sessionsThunks.ts:609-614` — `queueActiveSessionWindowRefresh()` only refreshes active surface

### 2. Silent error swallowing in refresh path

`refreshVisibleSessionWindowSilently()` (sessionsThunks.ts:420-427) catches all errors and just sets `loading: false`. No retry, no error state surfaced, no logging. If a refresh fails, sidebar stays stale forever.

### 3. Zero observability

`sessionsThunks.ts` has zero `console.log`, `console.warn`, or `console.error` calls. The entire refresh coordination system (generation checks, identity matching, commit/discard decisions) is completely silent. Makes debugging impossible.

## Approach

Fix the selector to always use fresh data, add recovery mechanisms for failed refreshes, and add logging so these issues are visible in the future. Verify with a running server using Chrome browser automation.

## Verification Criteria

1. **Sidebar stays fresh**: With a running dev server, create new Claude sessions and verify they appear in the sidebar within seconds — not just in Redux state, but in the rendered DOM.
2. **Surface switching doesn't cause staleness**: Switch between sidebar and history surfaces, verify sidebar data stays current after switching back.
3. **Error recovery**: Simulate a failed fetch (e.g., kill server briefly), verify sidebar recovers on next successful fetch rather than staying stale forever.
4. **Logging exists**: Key decision points in the refresh flow (commit, discard, error, retry) emit debug-level log messages.
5. **All existing tests pass**: `npm run check` green.
6. **No regressions in history view**: History surface still works correctly since it shares the same window system.
31 changes: 4 additions & 27 deletions src/store/selectors/sidebarSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ const selectProjects = (state: RootState) => state.sessions.windows?.sidebar?.pr
const selectTabs = (state: RootState) => state.tabs.tabs
const selectPanes = (state: RootState) => state.panes
const selectSortMode = (state: RootState) => state.settings.settings.sidebar?.sortMode || 'activity'
const selectSessionActivityForSort = (state: RootState) => {
const sortMode = state.settings.settings.sidebar?.sortMode || 'activity'
if (sortMode !== 'activity') return EMPTY_ACTIVITY
return state.sessionActivity?.sessions || EMPTY_ACTIVITY
}
const selectSessionActivityForSort = (_state: RootState) => EMPTY_ACTIVITY
const selectWorktreeGrouping = (state: RootState): WorktreeGrouping => state.settings.settings.sidebar?.worktreeGrouping || 'repo'
const selectShowSubagents = (state: RootState) => state.settings.settings.sidebar?.showSubagents ?? false
const selectIgnoreCodexSubagents = (state: RootState) => state.settings.settings.sidebar?.ignoreCodexSubagents ?? true
Expand Down Expand Up @@ -340,14 +336,7 @@ export function sortSessionItems(
const archived = sorted.filter((i) => i.archived)

const compareByRecency = (a: SidebarSessionItem, b: SidebarSessionItem) => b.timestamp - a.timestamp
const compareByActivity = (a: SidebarSessionItem, b: SidebarSessionItem) => {
const aHasRatcheted = typeof a.ratchetedActivity === 'number'
const bHasRatcheted = typeof b.ratchetedActivity === 'number'
if (aHasRatcheted !== bHasRatcheted) return aHasRatcheted ? -1 : 1
const aTime = a.ratchetedActivity ?? a.timestamp
const bTime = b.ratchetedActivity ?? b.timestamp
return bTime - aTime
}
const compareByActivity = compareByRecency

const sortByMode = (list: SidebarSessionItem[]) => {
const copy = [...list]
Expand Down Expand Up @@ -378,20 +367,8 @@ export function sortSessionItems(
const withTabs = copy.filter((i) => i.hasTab)
const withoutTabs = copy.filter((i) => !i.hasTab)

withTabs.sort((a, b) => {
const aTime = a.ratchetedActivity ?? a.timestamp
const bTime = b.ratchetedActivity ?? b.timestamp
return bTime - aTime
})

withoutTabs.sort((a, b) => {
const aHasRatcheted = typeof a.ratchetedActivity === 'number'
const bHasRatcheted = typeof b.ratchetedActivity === 'number'
if (aHasRatcheted !== bHasRatcheted) return aHasRatcheted ? -1 : 1
const aTime = a.ratchetedActivity ?? a.timestamp
const bTime = b.ratchetedActivity ?? b.timestamp
return bTime - aTime
})
withTabs.sort(compareByActivity)
withoutTabs.sort(compareByActivity)

return [...withTabs, ...withoutTabs]
}
Expand Down
31 changes: 25 additions & 6 deletions src/store/sessionsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,38 @@ function commitWindowPayload(
window.partialReason = payload.partialReason
}

function syncActiveWindowFromTopLevel(state: SessionsState) {
if (!state.activeSurface) return
const window = ensureWindow(state, state.activeSurface)
function syncWindowProjectsFromTopLevel(window: SessionWindowState, state: SessionsState) {
window.projects = state.projects
window.lastLoadedAt = state.lastLoadedAt
window.totalSessions = state.totalSessions
window.oldestLoadedTimestamp = state.oldestLoadedTimestamp
window.oldestLoadedSessionId = state.oldestLoadedSessionId
window.hasMore = state.hasMore
}

function syncActiveWindowFromTopLevel(state: SessionsState) {
if (!state.activeSurface) return
const window = ensureWindow(state, state.activeSurface)
syncWindowProjectsFromTopLevel(window, state)
window.loading = state.loadingMore
window.loadingKind = state.loadingKind
}

function syncAllWindowsFromTopLevel(state: SessionsState) {
if (!state.windows) return
for (const [surface, window] of Object.entries(state.windows)) {
if (!window) continue
// Skip windows with active search queries — their results are query-specific
if (window.appliedQuery) continue
syncWindowProjectsFromTopLevel(window, state)
// Only sync loading state to the active surface
if (surface === state.activeSurface) {
window.loading = state.loadingMore
window.loadingKind = state.loadingKind
}
}
}

export const sessionsSlice = createSlice({
name: 'sessions',
initialState,
Expand Down Expand Up @@ -279,7 +298,7 @@ export const sessionsSlice = createSlice({
state.lastLoadedAt = Date.now()
const valid = new Set(state.projects.map((p) => p.projectPath))
state.expandedProjects = new Set(Array.from(state.expandedProjects).filter((k) => valid.has(k)))
syncActiveWindowFromTopLevel(state)
syncAllWindowsFromTopLevel(state)
},
clearProjects: (state) => {
state.projects = []
Expand Down Expand Up @@ -309,7 +328,7 @@ export const sessionsSlice = createSlice({
state.lastLoadedAt = Date.now()
const valid = new Set(state.projects.map((p) => p.projectPath))
state.expandedProjects = new Set(Array.from(state.expandedProjects).filter((k) => valid.has(k)))
syncActiveWindowFromTopLevel(state)
syncAllWindowsFromTopLevel(state)
},
applySessionsPatch: (
state,
Expand All @@ -329,7 +348,7 @@ export const sessionsSlice = createSlice({

const valid = new Set(state.projects.map((p) => p.projectPath))
state.expandedProjects = new Set(Array.from(state.expandedProjects).filter((k) => valid.has(k)))
syncActiveWindowFromTopLevel(state)
syncAllWindowsFromTopLevel(state)
},
clearPaginationMeta: (state) => {
state.totalSessions = undefined
Expand Down
23 changes: 18 additions & 5 deletions src/store/sessionsThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
type SearchOptions,
type SearchResult,
} from '@/lib/api'
import { createLogger } from '@/lib/client-logger'
import type { AppDispatch, RootState } from './store'
import type { ProjectGroup } from './types'

const log = createLogger('SessionsThunks')
import {
commitSessionWindowReplacement,
commitSessionWindowVisibleRefresh,
Expand Down Expand Up @@ -340,7 +343,10 @@ async function refreshVisibleSessionWindowSilently(args: {
query?: string
searchTier?: SearchOptions['tier']
}) => {
if (!canCommit()) return false
if (!canCommit()) {
log.debug('Discarded refresh result for', surface, '— identity mismatch or generation changed')
return false
}
dispatch(commitSessionWindowVisibleRefresh({
...payload,
preserveLoading: preserveLoadingState,
Expand Down Expand Up @@ -417,12 +423,19 @@ async function refreshVisibleSessionWindowSilently(args: {
query: identity.query,
searchTier: identity.searchTier,
})
} catch {
if (!preserveLoadingState && canCommit()) {
dispatch(setSessionWindowLoading({
} catch (error) {
log.warn('Background refresh failed for', surface, error instanceof Error ? error.message : error)
if (canCommit()) {
dispatch(setSessionWindowError({
surface,
loading: false,
error: error instanceof Error ? error.message : 'Background refresh failed',
}))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep loading kind intact during preserved refresh errors

When refreshVisibleSessionWindowSilently runs with preserveLoadingState: true (the search/requested-vs-visible drift path), this new setSessionWindowError dispatch clears loadingKind in the reducer even though the foreground request is still loading. That breaks the contract of preserveLoadingState and can hide the active search/loading indicator while the in-flight visible fetch is still running, which is especially reproducible when the background refresh fails transiently during a query transition.

Useful? React with 👍 / 👎.

if (!preserveLoadingState) {
dispatch(setSessionWindowLoading({
surface,
loading: false,
}))
}
}
}
}
Expand Down
Loading
Loading