From 62128cd8ffdcc199a477a4eecda72bec958aec9f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:39:43 +0530 Subject: [PATCH] fix(desktop): a profile whose scan failed no longer shows "No sessions yet" Grouped by profile, a profile whose session scan failed while another profile's rows loaded got an empty section saying "No sessions yet", as if its store had been read and found empty. Root cause: the empty sections were gated only on the whole-list load error, which is set only when the scan returns no rows at all, so a per-profile failure passed through. The sidebar refresh now records which profiles its recents scan reported as failed, and those profiles get no empty section; any rows carried forward for them still show, and the next refresh replaces the record. Scan errors carry no gateway id and only the aggregate base's (the registry primary's) reach the renderer, so the record applies to that pool only; a foregrounded secondary, or a window primary booted on another registered route, keeps the empty section of its own same-named profile. A gateway switch clears the record with the other per-profile sidebar maps, so the outgoing gateway's failures cannot hide the incoming gateway's empty sections. Introduced by 47f0fc99d2 (#146116, @teknium1, 2026-10-28), which added empty profile sections; not a regression, as no section existed for them before. --- .../app/chat/sidebar/gateway-group-model.ts | 28 +++- .../sidebar/gateway-groups-refresh.test.tsx | 132 ++++++++++++++++++ .../sidebar/gateway-groups-webapp.test.tsx | 113 ++++++++++++++- .../hooks/use-session-list-actions.test.tsx | 25 ++++ .../session/hooks/use-session-list-actions.ts | 2 + apps/desktop/src/store/gateway-switch.test.ts | 6 + apps/desktop/src/store/gateway-switch.ts | 2 + .../desktop/src/store/reactions-local.test.ts | 1 + apps/desktop/src/store/session.ts | 30 +++- 9 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/gateway-groups-refresh.test.tsx diff --git a/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts b/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts index 23db2282f1..8066d5889f 100644 --- a/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts +++ b/apps/desktop/src/app/chat/sidebar/gateway-group-model.ts @@ -11,7 +11,7 @@ import { $connectionsRegistry } from '@/store/connection-registry-state' import { $primaryGatewayRoute } from '@/store/gateway-primary-route' import { $sidebarShowArchived } from '@/store/layout' import { $profileColors, $profiles, normalizeProfileKey } from '@/store/profile' -import { $sessionsLoadError } from '@/store/session' +import { $sessionProfilesFailed, $sessionsLoadError, isSessionProfileUnread } from '@/store/session' import type { SidebarSessionGroup } from './projects/workspace-groups' @@ -140,7 +140,8 @@ export function withEmptyProfileGroups( } /** Owner groups for the recents list. Every listed profile keeps its section even with no chats, - * unless the list is a narrowed view (filters, archive) or failed to load. */ + * unless the list is a narrowed view (filters, archive) or failed to load, or that profile's own + * scan failed (empty there means unread, not "No sessions yet"). */ export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolean, narrowed = true) { const registry = useStore($connectionsRegistry) const colors = useStore($profileColors) @@ -156,6 +157,7 @@ export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolea const localIsPrimary = windowIsBase ? primary.mode !== 'remote' : registry.primary === LOCAL_CONNECTION_ID const showArchived = useStore($sidebarShowArchived) const loadError = useStore($sessionsLoadError) + const failed = useStore($sessionProfilesFailed) const includeEmptyProfiles = !narrowed && !showArchived && !loadError return useMemo(() => { @@ -169,14 +171,30 @@ export function useGatewaySessionGroups(sessions: SessionInfo[], enabled: boolea return groups } - const owner = profileListConnectionId(apiConnectionId, isBrowserHostedDesktop(), localIsPrimary) + const browserHosted = isBrowserHostedDesktop() + const owner = profileListConnectionId(apiConnectionId, browserHosted, localIsPrimary) + // Scan errors carry no connection id and only the aggregate base's reach the renderer (registry + // sources drop theirs), so they speak for that pool alone, never a secondary's profiles. + const ownsErrors = browserHosted || owner === null || (windowIsBase && owner === primary.connectionId) return withEmptyProfileGroups( groups, - profiles.map(profile => profile.name), + profiles.map(profile => profile.name).filter(name => !(ownsErrors && isSessionProfileUnread(failed, name))), owner, registry, colors ) - }, [sessions, enabled, includeEmptyProfiles, registry, colors, profiles, apiConnectionId, localIsPrimary]) + }, [ + sessions, + enabled, + includeEmptyProfiles, + registry, + colors, + profiles, + failed, + apiConnectionId, + localIsPrimary, + windowIsBase, + primary.connectionId + ]) } diff --git a/apps/desktop/src/app/chat/sidebar/gateway-groups-refresh.test.tsx b/apps/desktop/src/app/chat/sidebar/gateway-groups-refresh.test.tsx new file mode 100644 index 0000000000..787100c00b --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/gateway-groups-refresh.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { act, cleanup, render, renderHook, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { setApiRequestConnection } from '@/api/client' +import { useSessionListActions } from '@/app/session/hooks/use-session-list-actions' +import { SidebarProvider } from '@/components/ui/sidebar' +import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' +import { $connectionsRegistry } from '@/store/connection-registry-state' +import { setSidebarGrouping } from '@/store/layout' +import { $profiles, $showAllProfiles } from '@/store/profile' +import { $sessions } from '@/store/session' +import { makeSessionInfo } from '@/test/session-info' + +import { ChatSidebar } from './index' + +// End to end from the sidebar refresh into the grouped sidebar: only the API response is stubbed, +// the real refresh writes the real stores and the real ChatSidebar groups them. + +const listSidebarSessions = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getCronJobs: async () => [], + listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args) +})) + +const noop = () => {} + +const noopAsync = async () => {} + +const chat = (id: string, profile: string): SessionInfo => + makeSessionInfo({ id, profile, title: `${id} title`, last_active: Date.now() / 1000 }) + +const page = ( + sessions: SessionInfo[], + errors?: Array<{ error: string; profile: string }> +): SidebarSessionsResponse => ({ + recents: { sessions, ...(errors ? { errors } : {}) }, + cron: { sessions: [] }, + messaging: { sessions: [] } +}) + +function mountSidebar() { + render( + + + + + + ) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'all' })) + + return async (response: SidebarSessionsResponse) => { + listSidebarSessions.mockResolvedValueOnce(response) + await act(async () => { + await result.current.refreshSessions() + }) + } +} + +const groupIds = () => + [...window.document.querySelectorAll('[data-gateway-group]')].map(group => group.getAttribute('data-gateway-group')) + +const group = (profile: string) => + window.document.querySelector(`[data-gateway-group='${JSON.stringify([null, profile])}']`) as HTMLElement | null + +beforeEach(() => { + listSidebarSessions.mockReset() + setApiRequestConnection(null) + $connectionsRegistry.set(null) + $profiles.set([ + { name: 'default', is_default: true }, + { name: 'quiet', is_default: false }, + { name: 'work', is_default: false } + ] as typeof $profiles.value) + $showAllProfiles.set(true) + setSidebarGrouping('profile') +}) + +afterEach(() => { + cleanup() + $showAllProfiles.set(false) + $sessions.set([]) +}) + +it('hides the empty section of a profile whose scan failed until a clean refresh reads it', async () => { + const refresh = mountSidebar() + + await refresh(page([chat('one', 'default')], [{ error: 'database is locked', profile: 'work' }])) + + expect(groupIds().sort()).toEqual([JSON.stringify([null, 'default']), JSON.stringify([null, 'quiet'])]) + expect(within(group('quiet')!).getByText('No sessions yet')).toBeTruthy() + expect(within(group('default')!).getByText('one title')).toBeTruthy() + + await refresh(page([chat('one', 'default')])) + + expect(groupIds().sort()).toEqual( + [JSON.stringify([null, 'default']), JSON.stringify([null, 'quiet']), JSON.stringify([null, 'work'])].sort() + ) + expect(within(group('work')!).getByText('No sessions yet')).toBeTruthy() + expect(window.document.querySelectorAll('[data-gateway-group-empty]')).toHaveLength(2) +}) + +it('keeps the loaded rows of a profile whose next scan failed', async () => { + const refresh = mountSidebar() + + await refresh(page([chat('one', 'default'), chat('kept', 'work')])) + await refresh(page([chat('one', 'default')], [{ error: 'database is locked', profile: 'work' }])) + + expect(groupIds().sort()).toEqual( + [JSON.stringify([null, 'default']), JSON.stringify([null, 'quiet']), JSON.stringify([null, 'work'])].sort() + ) + expect(within(group('work')!).getByText('kept title')).toBeTruthy() + expect(within(group('work')!).queryByText('No sessions yet')).toBeNull() + expect(within(group('quiet')!).getByText('No sessions yet')).toBeTruthy() +}) diff --git a/apps/desktop/src/app/chat/sidebar/gateway-groups-webapp.test.tsx b/apps/desktop/src/app/chat/sidebar/gateway-groups-webapp.test.tsx index b9b19d0c4d..3b046a3e76 100644 --- a/apps/desktop/src/app/chat/sidebar/gateway-groups-webapp.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/gateway-groups-webapp.test.tsx @@ -10,7 +10,7 @@ import { $connectionsRegistry } from '@/store/connection-registry-state' import { setPrimaryGatewayConnection } from '@/store/gateway' import { $sidebarRowMeta, setSidebarGrouping } from '@/store/layout' import { $profiles, $showAllProfiles } from '@/store/profile' -import { $connection, $sessionProfilesUsage, $sessions } from '@/store/session' +import { $connection, $sessionProfilesUsage, $sessions, setSessionProfilesFailed } from '@/store/session' import { makeSessionInfo } from '@/test/session-info' import { ChatSidebar } from './index' @@ -47,6 +47,7 @@ afterEach(() => { $connection.set(null) setPrimaryGatewayConnection(null) setApiRequestConnection(null) + setSessionProfilesFailed([]) $showAllProfiles.set(false) $sidebarRowMeta.set([]) $sessionProfilesUsage.set({}) @@ -194,3 +195,113 @@ it.each([ expect(window.document.querySelectorAll('[data-gateway-group-empty]')).toHaveLength(1) } ) + +// A profile whose scan failed while another profile's rows loaded was not read: "No sessions yet" +// would be a claim about a store nobody could open. +it('gives a profile whose scan failed no empty "No sessions yet" section', () => { + mount() + act(() => { + $connectionsRegistry.set(null) + $profiles.set([ + { name: 'default', is_default: true }, + { name: 'quiet', is_default: false }, + { name: 'work', is_default: false } + ] as typeof $profiles.value) + $showAllProfiles.set(true) + setSidebarGrouping('profile') + setSessionProfilesFailed([{ profile: 'work' }]) + $sessions.set([ + makeSessionInfo({ id: 'one', profile: 'default', title: 'only chat', last_active: Date.now() / 1000 }) + ]) + }) + + const groups = [...window.document.querySelectorAll('[data-gateway-group]')].map(group => + group.getAttribute('data-gateway-group') + ) + + expect(groups.sort()).toEqual([JSON.stringify([null, 'default']), JSON.stringify([null, 'quiet'])]) +}) + +// Scan errors name no gateway and only the aggregate base's (the registry primary's) reach the +// renderer, so they never hide a foregrounded secondary's own profile that happens to share the +// name, nor one on a window primary booted on another registered route. +it.each([ + { window: 'local', mode: 'local' }, + { window: 'remote-b', mode: 'remote' } +] as const)( + "keeps a secondary's empty section for a profile that failed on the primary ($window window)", + ({ window: windowPrimary, mode }) => { + mount() + act(() => { + setPrimaryGatewayConnection({ connectionId: windowPrimary, mode }) + setApiRequestConnection('remote-b') + $connectionsRegistry.set({ + version: 2, + primary: 'local', + secureTokenStorage: true, + connections: [ + { id: 'local', label: 'This computer', kind: 'local', tokenSet: false, tokenPreview: null }, + { id: 'remote-b', label: 'Homelab', kind: 'remote', tokenSet: false, tokenPreview: null } + ] + }) + $profiles.set([ + { name: 'default', is_default: true }, + { name: 'work', is_default: false } + ] as typeof $profiles.value) + $showAllProfiles.set(true) + setSidebarGrouping('profile') + setSessionProfilesFailed([{ profile: 'work' }]) + $sessions.set([ + makeSessionInfo({ + id: 'one', + connection_id: 'remote-b', + profile: 'default', + title: 'only chat', + last_active: Date.now() / 1000 + }) + ]) + }) + + const groups = [...window.document.querySelectorAll('[data-gateway-group]')].map(group => + group.getAttribute('data-gateway-group') + ) + + expect(groups.sort()).toEqual([JSON.stringify(['remote-b', 'default']), JSON.stringify(['remote-b', 'work'])]) + } +) + +// The primary's own errors still apply while it is in front under its registry id. +it('skips the empty section of a profile that failed on a registry primary in front', () => { + mount() + act(() => { + setPrimaryGatewayConnection({ connectionId: 'remote-1', mode: 'remote' }) + $connectionsRegistry.set({ + version: 2, + primary: 'remote-1', + secureTokenStorage: true, + connections: [{ id: 'remote-1', label: 'Homelab', kind: 'remote', tokenSet: false, tokenPreview: null }] + }) + $profiles.set([ + { name: 'default', is_default: true }, + { name: 'work', is_default: false } + ] as typeof $profiles.value) + $showAllProfiles.set(true) + setSidebarGrouping('profile') + setSessionProfilesFailed([{ profile: 'work' }]) + $sessions.set([ + makeSessionInfo({ + id: 'one', + connection_id: 'remote-1', + profile: 'default', + title: 'only chat', + last_active: Date.now() / 1000 + }) + ]) + }) + + const groups = [...window.document.querySelectorAll('[data-gateway-group]')].map(group => + group.getAttribute('data-gateway-group') + ) + + expect(groups).toEqual([JSON.stringify(['remote-1', 'default'])]) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index b0580bfa43..4b99577520 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -15,6 +15,7 @@ import { $messagingPlatformTotals, $messagingSessions, $messagingTruncated, + $sessionProfilesFailed, $sessionProfilesTruncated, $sessionProfilesUsage, $sessions, @@ -24,6 +25,7 @@ import { setMessagingPlatformTotals, setMessagingSessions, setMessagingTruncated, + setSessionProfilesFailed, setSessionProfilesTruncated, setSessionProfilesUsage, setSessions, @@ -131,6 +133,7 @@ beforeEach(() => { setMessagingTruncated(false) setSessionProfilesTruncated({}) setSessionProfilesUsage({}) + setSessionProfilesFailed([]) setSessionsLoading(false) setSessionsLoadError(false) }) @@ -260,6 +263,28 @@ describe('refreshSessions cold-start load error', () => { expect($sessionsLoadError.get()).toBe(false) expect($sessions.get().map(s => s.id)).toEqual(['a']) }) + + it('names a profile whose scan failed beside rows that loaded, until a scan reads it', async () => { + listSidebarSessions.mockResolvedValueOnce({ + ...sidebar({ sessions: [row('a')] }), + errors: [{ error: 'database is locked', profile: 'work' }] + }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'all' })) + + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessionsLoadError.get()).toBe(false) + expect($sessionProfilesFailed.get()).toEqual(['work']) + + listSidebarSessions.mockResolvedValueOnce(sidebar({ sessions: [row('a')] })) + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessionProfilesFailed.get()).toEqual([]) + }) }) describe('refreshSessions identity + loading hygiene', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 76e49d2799..759f64c2b3 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -35,6 +35,7 @@ import { setMessagingPlatformTotals, setMessagingSessions, setMessagingTruncated, + setSessionProfilesFailed, setSessionProfilesTruncated, setSessionProfilesUsage, setSessions, @@ -410,6 +411,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setSessionsLoadError( Boolean(showLoading && (scopedRetry || retryableErrors?.length) && (recents.sessions?.length ?? 0) === 0) ) + setSessionProfilesFailed(recentsErrors) // Drop rows the user just deleted/archived: a refresh can race an // in-flight mutation and the backend page still carries the doomed row. diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts index ed2229f838..23b1968b0e 100644 --- a/apps/desktop/src/store/gateway-switch.test.ts +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -10,6 +10,7 @@ import { $currentCwd, $freshDraftReady, $messagingSessions, + $sessionProfilesFailed, $sessionProfilesTruncated, $sessions, $sessionsLoading, @@ -19,6 +20,7 @@ import { setCurrentCwdTransient, setFreshDraftReady, setMessagingSessions, + setSessionProfilesFailed, setSessionProfilesTruncated, setSessions, setSessionsLoading @@ -75,6 +77,7 @@ describe('wipeSessionListsForGatewaySwitch', () => { $gatewaySwitching.set(false) setSessions([{ id: 's1', title: 'old', profile: 'default' } as never]) setSessionProfilesTruncated({ default: true }) + setSessionProfilesFailed([{ profile: 'work' }]) setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never]) setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never]) $stalledSessionIds.set(['s1']) @@ -86,6 +89,7 @@ describe('wipeSessionListsForGatewaySwitch', () => { afterEach(() => { resetSessionsLimit() setSessions([]) + setSessionProfilesFailed([]) setCronSessions([]) setMessagingSessions([]) $stalledSessionIds.set([]) @@ -109,6 +113,8 @@ describe('wipeSessionListsForGatewaySwitch', () => { expect($sessions.get()).toEqual([]) expect($sessionProfilesTruncated.get()).toEqual({}) + // The outgoing gateway's failed scans must not hide the next one's empty profile sections. + expect($sessionProfilesFailed.get()).toEqual([]) expect($cronSessions.get()).toEqual([]) expect($messagingSessions.get()).toEqual([]) expect($stalledSessionIds.get()).toEqual([]) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index fa62a6cda7..0b98d8b5d2 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -24,6 +24,7 @@ import { setMessagingSessions, setMessagingTruncated, setSelectedStoredSessionId, + setSessionProfilesFailed, setSessionProfilesTruncated, setSessionProfilesUsage, setSessions, @@ -215,6 +216,7 @@ export function wipeSessionListsForGatewaySwitch(): void { resetDeadSessionPrune() setSessionProfilesTruncated({}) setSessionProfilesUsage({}) + setSessionProfilesFailed([]) setCronSessions([]) invalidateCronJobsRequests() // The star map is a scan of the outgoing backend's skills and memory; an open view reloads it diff --git a/apps/desktop/src/store/reactions-local.test.ts b/apps/desktop/src/store/reactions-local.test.ts index 82ab390675..e97fe1fe95 100644 --- a/apps/desktop/src/store/reactions-local.test.ts +++ b/apps/desktop/src/store/reactions-local.test.ts @@ -55,6 +55,7 @@ vi.mock('@/store/session', async () => { setMessagingSessions: vi.fn(), setMessagingTruncated: vi.fn(), setSelectedStoredSessionId: vi.fn(), + setSessionProfilesFailed: vi.fn(), setSessionProfilesTruncated: vi.fn(), setSessionProfilesUsage: vi.fn(), setSessions: vi.fn(), diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index ccaae7d375..eb0afd6f98 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -15,7 +15,15 @@ import { import { hostHas } from '@/lib/host-capabilities' import { isMessagingSource } from '@/lib/session-source' import type { TileSessionFocusStamp } from '@/lib/session-timer-since' -import { persistBoolean, persistString, readJson, storedBoolean, storedString, writeJson } from '@/lib/storage' +import { + arraysEqual, + persistBoolean, + persistString, + readJson, + storedBoolean, + storedString, + writeJson +} from '@/lib/storage' import type { SessionInfo, UsageStats } from '@/types/hermes' import { $removedSessionIds, isSessionRemovalPending, tombstoneRowIds } from './session-removal' @@ -881,6 +889,13 @@ export function keepFailedProfileMeta( return next } +/** Whether the last recents scan left `profile` unread: it failed, alone or as part of a failed + * unified read. Its rows may be carried forward, but an empty section for it would claim + * "No sessions yet" about a store nobody could read. */ +export function isSessionProfileUnread(failed: readonly string[], profile: string): boolean { + return failed.includes(ALL_PROFILES_SCAN) || failed.includes(profileKeyOf(profile)) +} + /** Raise a session in recents on user send (before stream / turn resolve). */ export function touchSessionActivity( sessionId: string | null | undefined, @@ -1125,6 +1140,9 @@ export interface ProfileUsage { export const $sessionProfilesUsage = atom>({}) +/** Profiles named in the last recents scan's errors (see isSessionProfileUnread). */ +export const $sessionProfilesFailed = atom([]) + /** Profiles whose state.db the backend reports as structurally corrupt (the list * endpoints' `storage` map, #72046). An empty or partial list for one of these * is a damaged store, not deleted history, and the sidebar says so. */ @@ -1507,6 +1525,16 @@ export const setSessionProfilesTruncated = (next: Updater>) => updateAtom($sessionProfilesUsage, next) + +/** Publish the profiles a recents scan reported as failed; unchanged keeps the array identity. */ +export function setSessionProfilesFailed(errors: Array<{ profile?: string }> | undefined | null): void { + const next = [...new Set((errors ?? []).map(error => profileKeyOf(error.profile)))].sort() + + if (!arraysEqual($sessionProfilesFailed.get(), next)) { + $sessionProfilesFailed.set(next) + } +} + export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) export const setSessionsLoadError = (next: Updater) => updateAtom($sessionsLoadError, next)