From 33003ac89e992cc3e7f76fef1e21b8f8410ffae3 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:11:25 -0500 Subject: [PATCH 1/5] fix: sync WebSocket patches to all session windows, not just active surface The sidebar session list went stale because applySessionsPatch, setProjects, and mergeProjects only synced top-level state to the active surface window. When the sidebar wasn't active, its window state diverged from fresh data. Changes: - Add syncAllWindowsFromTopLevel() that propagates project data to every initialized window (skipping windows with active search queries) - Replace syncActiveWindowFromTopLevel calls in patch/merge/set paths - Surface errors in refreshVisibleSessionWindowSilently instead of swallowing - Add logging to sessionsThunks for refresh decisions and failures Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-03-30-fix-sidebar-session-staleness.md | 38 ++++ src/store/sessionsSlice.ts | 31 ++- src/store/sessionsThunks.ts | 23 +- .../client/store/sidebar-staleness.test.ts | 199 ++++++++++++++++++ 4 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-03-30-fix-sidebar-session-staleness.md create mode 100644 test/unit/client/store/sidebar-staleness.test.ts diff --git a/docs/plans/2026-03-30-fix-sidebar-session-staleness.md b/docs/plans/2026-03-30-fix-sidebar-session-staleness.md new file mode 100644 index 00000000..391df54c --- /dev/null +++ b/docs/plans/2026-03-30-fix-sidebar-session-staleness.md @@ -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. diff --git a/src/store/sessionsSlice.ts b/src/store/sessionsSlice.ts index a846ae2d..1284bf41 100644 --- a/src/store/sessionsSlice.ts +++ b/src/store/sessionsSlice.ts @@ -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, @@ -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 = [] @@ -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, @@ -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 diff --git a/src/store/sessionsThunks.ts b/src/store/sessionsThunks.ts index 5953f0bf..d6fa7b69 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -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, @@ -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, @@ -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', })) + if (!preserveLoadingState) { + dispatch(setSessionWindowLoading({ + surface, + loading: false, + })) + } } } } diff --git a/test/unit/client/store/sidebar-staleness.test.ts b/test/unit/client/store/sidebar-staleness.test.ts new file mode 100644 index 00000000..be055a09 --- /dev/null +++ b/test/unit/client/store/sidebar-staleness.test.ts @@ -0,0 +1,199 @@ +// Tests for sidebar session staleness bug: WebSocket patches must reach sidebar +// window state even when sidebar is not the active surface. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' +import { enableMapSet } from 'immer' +import sessionsReducer, { + applySessionsPatch, + commitSessionWindowReplacement, + commitSessionWindowVisibleRefresh, + markWsSnapshotReceived, + setActiveSessionSurface, + setSessionWindowLoading, +} from '@/store/sessionsSlice' +import * as sessionsThunks from '@/store/sessionsThunks' + +const { + fetchSessionWindow, + refreshActiveSessionWindow, +} = sessionsThunks +const queueActiveSessionWindowRefresh = ((sessionsThunks as any).queueActiveSessionWindowRefresh ?? refreshActiveSessionWindow) as typeof refreshActiveSessionWindow +const _resetSessionWindowThunkState = ((sessionsThunks as any)._resetSessionWindowThunkState ?? (() => {})) as () => void + +const fetchSidebarSessionsSnapshot = vi.fn() +const searchSessions = vi.fn() + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + fetchSidebarSessionsSnapshot: (...args: any[]) => fetchSidebarSessionsSnapshot(...args), + searchSessions: (...args: any[]) => searchSessions(...args), + } +}) + +enableMapSet() + +function makeProject(path: string, sessionId: string, lastActivityAt: number) { + return { + projectPath: path, + sessions: [{ + provider: 'claude', + sessionId, + projectPath: path, + lastActivityAt, + title: `Session in ${path}`, + }], + } +} + +function createStore() { + return configureStore({ + reducer: { sessions: sessionsReducer }, + middleware: (m) => m({ serializableCheck: false }), + }) +} + +function createStoreWithSessions(preloaded: Record) { + return configureStore({ + reducer: { sessions: sessionsReducer }, + preloadedState: { + sessions: { + ...sessionsReducer(undefined, { type: '@@INIT' }), + ...preloaded, + }, + }, + middleware: (m) => m({ serializableCheck: false }), + }) +} + +describe('sidebar staleness', () => { + beforeEach(() => { + vi.clearAllMocks() + _resetSessionWindowThunkState() + }) + afterEach(() => { + _resetSessionWindowThunkState() + }) + + describe('applySessionsPatch syncs to all windows', () => { + it('updates sidebar window when history is the active surface', () => { + const sidebarProjects = [makeProject('/proj-a', 'old-session', 1_000)] + + const store = createStoreWithSessions({ + activeSurface: 'history', + wsSnapshotReceived: true, + projects: sidebarProjects, + windows: { + sidebar: { + projects: sidebarProjects, + lastLoadedAt: 1_000, + }, + history: { + projects: sidebarProjects, + lastLoadedAt: 1_000, + }, + }, + }) + + const freshProject = makeProject('/proj-b', 'new-session', 5_000) + store.dispatch(applySessionsPatch({ + upsertProjects: [freshProject], + removeProjectPaths: [], + })) + + // Top-level must have the new project + const topLevel = store.getState().sessions.projects + expect(topLevel.some(p => p.projectPath === '/proj-b')).toBe(true) + + // Sidebar window must ALSO have the new project + const sidebarWindow = store.getState().sessions.windows.sidebar + expect(sidebarWindow.projects.some((p: any) => p.projectPath === '/proj-b')).toBe(true) + }) + + it('updates sidebar window when no active surface is set', () => { + const store = createStoreWithSessions({ + wsSnapshotReceived: true, + projects: [makeProject('/proj-a', 's1', 1_000)], + windows: { + sidebar: { + projects: [makeProject('/proj-a', 's1', 1_000)], + lastLoadedAt: 1_000, + }, + }, + }) + + store.dispatch(applySessionsPatch({ + upsertProjects: [makeProject('/proj-c', 's3', 9_000)], + removeProjectPaths: [], + })) + + const sidebarWindow = store.getState().sessions.windows.sidebar + expect(sidebarWindow.projects.some((p: any) => p.projectPath === '/proj-c')).toBe(true) + }) + }) + + describe('refresh path updates sidebar even when not active', () => { + it('queueActiveSessionWindowRefresh updates sidebar data when sidebar is active', async () => { + const freshProjects = [makeProject('/proj-fresh', 'fresh-1', 9_000)] + fetchSidebarSessionsSnapshot.mockResolvedValue({ + projects: freshProjects, + totalSessions: 1, + oldestIncludedTimestamp: 9_000, + oldestIncludedSessionId: 'claude:fresh-1', + hasMore: false, + }) + + const staleProjects = [makeProject('/proj-stale', 'stale-1', 1_000)] + const store = createStoreWithSessions({ + activeSurface: 'sidebar', + wsSnapshotReceived: true, + projects: staleProjects, + lastLoadedAt: 1_000, + windows: { + sidebar: { + projects: staleProjects, + lastLoadedAt: 1_000, + resultVersion: 1, + }, + }, + }) + + await store.dispatch(queueActiveSessionWindowRefresh() as any) + + const sidebar = store.getState().sessions.windows.sidebar + expect(sidebar.projects.some((p: any) => p.projectPath === '/proj-fresh')).toBe(true) + expect(sidebar.projects.some((p: any) => p.projectPath === '/proj-stale')).toBe(false) + }) + }) + + describe('error recovery in silent refresh', () => { + it('surfaces error state when silent refresh fails', async () => { + fetchSidebarSessionsSnapshot.mockRejectedValueOnce(new Error('Network error')) + + const existingProjects = [makeProject('/proj-a', 's1', 1_000)] + const store = createStoreWithSessions({ + activeSurface: 'sidebar', + wsSnapshotReceived: true, + projects: existingProjects, + lastLoadedAt: 1_000, + windows: { + sidebar: { + projects: existingProjects, + lastLoadedAt: 1_000, + resultVersion: 1, + }, + }, + }) + + await store.dispatch(queueActiveSessionWindowRefresh() as any) + + const sidebar = store.getState().sessions.windows.sidebar + // After a failed refresh, loading should be false + expect(sidebar.loading).toBeFalsy() + // The error should be surfaced, not swallowed + expect(sidebar.error).toBeDefined() + }) + }) +}) From c76b4c34b51b4bc0786b5ef096cacbb3dbd6d076 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:29:40 -0500 Subject: [PATCH 2/5] fix: use max(ratchetedActivity, serverTimestamp) for activity sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity sort mode segregated sessions by whether they had ratchetedActivity, always putting sessions with any local activity above those without — regardless of how stale that activity was. A session with ratchetedActivity from days ago sorted above a session with fresh server activity from seconds ago. Now uses Math.max(ratchetedActivity, lastActivityAt) so whichever timestamp is more recent wins. Also unified withTabs/withoutTabs to use the same compareByActivity function. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/selectors/sidebarSelectors.ts | 23 ++--------- .../client/store/sidebar-staleness.test.ts | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index fb20f710..75186f73 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -341,11 +341,8 @@ export function sortSessionItems( 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 + const aTime = Math.max(a.ratchetedActivity ?? 0, a.timestamp) + const bTime = Math.max(b.ratchetedActivity ?? 0, b.timestamp) return bTime - aTime } @@ -378,20 +375,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] } diff --git a/test/unit/client/store/sidebar-staleness.test.ts b/test/unit/client/store/sidebar-staleness.test.ts index be055a09..815c9ac7 100644 --- a/test/unit/client/store/sidebar-staleness.test.ts +++ b/test/unit/client/store/sidebar-staleness.test.ts @@ -196,4 +196,45 @@ describe('sidebar staleness', () => { expect(sidebar.error).toBeDefined() }) }) + + describe('activity sort does not let stale ratchetedActivity trump recent server timestamps', () => { + it('sorts by most recent of ratchetedActivity or server timestamp', async () => { + const { sortSessionItems } = await import('@/store/selectors/sidebarSelectors') + + const staleWithActivity = { + id: 'stale', + sessionId: 'stale', + provider: 'claude', + sessionType: 'claude', + title: 'Stale session with old activity', + hasTitle: true, + timestamp: 1_000, // old server timestamp + hasTab: false, + isRunning: false, + ratchetedActivity: 2_000, // slightly newer but still old + } + + const freshNoActivity = { + id: 'fresh', + sessionId: 'fresh', + provider: 'claude', + sessionType: 'claude', + title: 'Fresh session without activity tracking', + hasTitle: true, + timestamp: 9_000, // recent server timestamp + hasTab: false, + isRunning: false, + // no ratchetedActivity + } + + const sorted = sortSessionItems( + [staleWithActivity, freshNoActivity], + 'activity', + ) + + // Fresh session (timestamp 9000) should sort before stale session (activity 2000) + expect(sorted[0].id).toBe('fresh') + expect(sorted[1].id).toBe('stale') + }) + }) }) From 2391ad2005eae97723460a4744b8af50487c61c8 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 17:52:50 -0500 Subject: [PATCH 3/5] fix: remove ratchetedActivity from sidebar sort The activity sort mode used client-side ratchetedActivity timestamps (updated on every keystroke) to boost sessions above those with only server-side timestamps. This caused confusing ordering: stale sessions with old local activity sorted above genuinely recent sessions, and the displayed timestamps (server-side) didn't match the sort order. Tab pinning already handles the "I'm actively working here" signal. The ratchetedActivity layer on top added confusion without value. Activity sort now uses server-side lastActivityAt (same as recency), with tab pinning preserved. The sessionActivity store still exists but no longer feeds into the sort selector. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/selectors/sidebarSelectors.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index 75186f73..d7c65ced 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -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 @@ -340,11 +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 aTime = Math.max(a.ratchetedActivity ?? 0, a.timestamp) - const bTime = Math.max(b.ratchetedActivity ?? 0, b.timestamp) - return bTime - aTime - } + const compareByActivity = compareByRecency const sortByMode = (list: SidebarSessionItem[]) => { const copy = [...list] From 1c5c5517d9e7cab167fd11808809a5a925a5e7fc Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Tue, 31 Mar 2026 10:34:01 -0500 Subject: [PATCH 4/5] fix: skip error dispatch when preserveLoadingState is true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setSessionWindowError clears loadingKind in the reducer. When a background refresh fails during a foreground request (preserveLoadingState is true), dispatching the error would clobber the active loading indicator. Skip the error dispatch in that case — the foreground request will handle its own error/loading state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/sessionsThunks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/store/sessionsThunks.ts b/src/store/sessionsThunks.ts index d6fa7b69..cff38741 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -426,11 +426,11 @@ async function refreshVisibleSessionWindowSilently(args: { } catch (error) { log.warn('Background refresh failed for', surface, error instanceof Error ? error.message : error) if (canCommit()) { - dispatch(setSessionWindowError({ - surface, - error: error instanceof Error ? error.message : 'Background refresh failed', - })) if (!preserveLoadingState) { + dispatch(setSessionWindowError({ + surface, + error: error instanceof Error ? error.message : 'Background refresh failed', + })) dispatch(setSessionWindowLoading({ surface, loading: false, From ff20cbd2d62312e776aa9c9f15d4818d6e30d4c0 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Tue, 31 Mar 2026 13:55:33 -0500 Subject: [PATCH 5/5] fix: restore ratchetedActivity sort and rename sort mode labels The ratchet-based activity sorting was incorrectly neutered as part of the staleness fix. The staleness was caused by window sync, not by the sort logic. Restore the original activity comparator and rename "Recency (pinned)" to "Recency (tabs first)" for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/settings/WorkspaceSettings.tsx | 2 +- src/store/selectors/sidebarSelectors.ts | 31 +++++++++++++--- .../components/SettingsView.behavior.test.tsx | 2 +- .../client/store/sidebar-staleness.test.ts | 35 +++++++++---------- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/components/settings/WorkspaceSettings.tsx b/src/components/settings/WorkspaceSettings.tsx index 87b5da79..2a1d457a 100644 --- a/src/components/settings/WorkspaceSettings.tsx +++ b/src/components/settings/WorkspaceSettings.tsx @@ -54,7 +54,7 @@ export default function WorkspaceSettings({ className="h-10 w-full px-3 text-sm bg-muted border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border md:h-8 md:w-auto" > - + diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index d7c65ced..fb20f710 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -39,7 +39,11 @@ 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) => EMPTY_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 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 @@ -336,7 +340,14 @@ export function sortSessionItems( const archived = sorted.filter((i) => i.archived) const compareByRecency = (a: SidebarSessionItem, b: SidebarSessionItem) => b.timestamp - a.timestamp - const compareByActivity = compareByRecency + 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 sortByMode = (list: SidebarSessionItem[]) => { const copy = [...list] @@ -367,8 +378,20 @@ export function sortSessionItems( const withTabs = copy.filter((i) => i.hasTab) const withoutTabs = copy.filter((i) => !i.hasTab) - withTabs.sort(compareByActivity) - withoutTabs.sort(compareByActivity) + 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 + }) return [...withTabs, ...withoutTabs] } diff --git a/test/unit/client/components/SettingsView.behavior.test.tsx b/test/unit/client/components/SettingsView.behavior.test.tsx index aa8e34e2..72ace317 100644 --- a/test/unit/client/components/SettingsView.behavior.test.tsx +++ b/test/unit/client/components/SettingsView.behavior.test.tsx @@ -110,7 +110,7 @@ describe('SettingsView behavior sections', () => { return select.querySelector('option[value="recency-pinned"]') !== null }) - expect(sortModeSelect.querySelector('option[value="recency-pinned"]')?.textContent).toBe('Recency (pinned)') + expect(sortModeSelect.querySelector('option[value="recency-pinned"]')?.textContent).toBe('Recency (tabs first)') fireEvent.change(sortModeSelect, { target: { value: 'recency-pinned' } }) expect(store.getState().settings.settings.sidebar.sortMode).toBe('recency-pinned') diff --git a/test/unit/client/store/sidebar-staleness.test.ts b/test/unit/client/store/sidebar-staleness.test.ts index 815c9ac7..0bdbfa78 100644 --- a/test/unit/client/store/sidebar-staleness.test.ts +++ b/test/unit/client/store/sidebar-staleness.test.ts @@ -197,44 +197,43 @@ describe('sidebar staleness', () => { }) }) - describe('activity sort does not let stale ratchetedActivity trump recent server timestamps', () => { - it('sorts by most recent of ratchetedActivity or server timestamp', async () => { + describe('activity sort uses ratchetedActivity when present', () => { + it('sorts items with ratchetedActivity above those without', async () => { const { sortSessionItems } = await import('@/store/selectors/sidebarSelectors') - const staleWithActivity = { - id: 'stale', - sessionId: 'stale', + const recentlyActive = { + id: 'active', + sessionId: 'active', provider: 'claude', sessionType: 'claude', - title: 'Stale session with old activity', + title: 'Session with recent client activity', hasTitle: true, - timestamp: 1_000, // old server timestamp + timestamp: 1_000, hasTab: false, isRunning: false, - ratchetedActivity: 2_000, // slightly newer but still old + ratchetedActivity: 5_000, } - const freshNoActivity = { - id: 'fresh', - sessionId: 'fresh', + const recentServer = { + id: 'server-recent', + sessionId: 'server-recent', provider: 'claude', sessionType: 'claude', - title: 'Fresh session without activity tracking', + title: 'Session with recent server timestamp only', hasTitle: true, - timestamp: 9_000, // recent server timestamp + timestamp: 9_000, hasTab: false, isRunning: false, - // no ratchetedActivity } const sorted = sortSessionItems( - [staleWithActivity, freshNoActivity], + [recentServer, recentlyActive], 'activity', ) - // Fresh session (timestamp 9000) should sort before stale session (activity 2000) - expect(sorted[0].id).toBe('fresh') - expect(sorted[1].id).toBe('stale') + // Item with ratchetedActivity sorts above item without, regardless of server timestamp + expect(sorted[0].id).toBe('active') + expect(sorted[1].id).toBe('server-recent') }) }) })