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)