mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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.
This commit is contained in:
1 parent
927fe54ff1
commit
62128cd8ff
9 files changed
+332
-7
No files matched your search
@@ -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
|
||||
])
|
||||
}
|
||||
@@ -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<Record<string, unknown>>()),
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<SidebarProvider>
|
||||
<ChatSidebar
|
||||
currentView="chat"
|
||||
onArchiveSession={noop}
|
||||
onBranchSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onLoadMoreSessions={noop}
|
||||
onManageCronJob={noop}
|
||||
onNavigate={noop}
|
||||
onNewSessionInWorkspace={noop}
|
||||
onNewSessionSplit={noop}
|
||||
onResumeSession={noop}
|
||||
onRetrySessions={noopAsync}
|
||||
onTriggerCronJob={noopAsync}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -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'])])
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<T>(
|
||||
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<Record<string, ProfileUsage>>({})
|
||||
|
||||
/** Profiles named in the last recents scan's errors (see isSessionProfileUnread). */
|
||||
export const $sessionProfilesFailed = atom<string[]>([])
|
||||
|
||||
/** 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<Record<string, boolean
|
||||
updateAtom($sessionProfilesTruncated, next)
|
||||
export const setSessionProfilesUsage = (next: Updater<Record<string, ProfileUsage>>) =>
|
||||
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<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setSessionsLoadError = (next: Updater<boolean>) => updateAtom($sessionsLoadError, next)
|
||||
|
||||
|
||||
Reference in new issue
Block a user