diff --git a/apps/desktop/src/app/desktop-controller-utils.test.ts b/apps/desktop/src/app/desktop-controller-utils.test.ts new file mode 100644 index 00000000000..ac39395db09 --- /dev/null +++ b/apps/desktop/src/app/desktop-controller-utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { sameCronSignature } from './desktop-controller-utils' + +const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo + +describe('sameCronSignature', () => { + it('is false when the lengths differ', () => { + expect(sameCronSignature([session('a', 't')], [])).toBe(false) + }) + + it('is true when ids and titles match in order', () => { + const a = [session('a', 'one'), session('b', 'two')] + const b = [session('a', 'one'), session('b', 'two')] + expect(sameCronSignature(a, b)).toBe(true) + }) + + it('is false when a title changed', () => { + const a = [session('a', 'one')] + const b = [session('a', 'renamed')] + expect(sameCronSignature(a, b)).toBe(false) + }) + + it('is false when order differs', () => { + const a = [session('a', 't'), session('b', 't')] + const b = [session('b', 't'), session('a', 't')] + expect(sameCronSignature(a, b)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/desktop-controller-utils.ts b/apps/desktop/src/app/desktop-controller-utils.ts new file mode 100644 index 00000000000..cb7d618e6af --- /dev/null +++ b/apps/desktop/src/app/desktop-controller-utils.ts @@ -0,0 +1,11 @@ +import type { SessionInfo } from '@/hermes' + +// Cheap signature compare so a poll only swaps the atom (and re-renders the +// sidebar) when the visible rows actually changed. +export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) { + return false + } + + return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index fece8887401..ed3ea225aeb 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -15,23 +15,15 @@ import { cn } from '@/lib/utils' import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' +import { getSessionMessages, triggerCronJob } from '../hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { storedSessionIdForNotification } from '../lib/session-ids' -import { - isMessagingSource, - LOCAL_SESSION_SOURCE_IDS, - MESSAGING_SESSION_SOURCE_IDS, - normalizeSessionSource -} from '../lib/session-source' import { latestSessionTodos } from '../lib/todos' -import { setCronFocusJobId, setCronJobs } from '../store/cron' +import { setCronFocusJobId } from '../store/cron' import { $fileBrowserOpen, $panesFlipped, $pinnedSessionIds, - $sessionsLimit, - bumpSessionsLimit, FILE_BROWSER_DEFAULT_WIDTH, FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, @@ -41,7 +33,6 @@ import { setSidebarOverlayMounted, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, - SIDEBAR_SESSIONS_PAGE_SIZE, unpinSession } from '../store/layout' import { respondToApprovalAction } from '../store/native-notifications' @@ -58,8 +49,6 @@ import { $activeGatewayProfile, $freshSessionRequest, $profileScope, - ALL_PROFILES, - normalizeProfileKey, refreshActiveProfile } from '../store/profile' import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' @@ -71,34 +60,20 @@ import { $freshDraftReady, $gatewayState, $messages, - $messagingSessions, $resumeExhaustedSessionId, $resumeFailedSessionId, $selectedStoredSessionId, $sessions, - $workingSessionIds, - CRON_SECTION_LIMIT, - getRecentlySettledSessionIds, getRememberedSessionId, - mergeSessionPage, - MESSAGING_SECTION_LIMIT, sessionPinId, setAwaitingResponse, setBusy, - setCronSessions, setCurrentBranch, setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, - setMessagingPlatformTotals, - setMessagingSessions, - setMessagingTruncated, - setRememberedSessionId, - setSessionProfileTotals, - setSessions, - setSessionsLoading, - setSessionsTotal + setRememberedSessionId } from '../store/session' import { onSessionsChanged } from '../store/session-sync' import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' @@ -143,6 +118,7 @@ import { usePreviewRouting } from './session/hooks/use-preview-routing' import { usePromptActions } from './session/hooks/use-prompt-actions' import { useRouteResume } from './session/hooks/use-route-resume' import { useSessionActions } from './session/hooks/use-session-actions' +import { useSessionListActions } from './session/hooks/use-session-list-actions' import { useSessionStateCache } from './session/hooks/use-session-state-cache' import { AppShell } from './shell/app-shell' import { useOverlayRouting } from './shell/hooks/use-overlay-routing' @@ -169,51 +145,6 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill // this cadence while the app is open + visible so new runs surface promptly // instead of waiting for the next user-triggered refreshSessions(). const CRON_POLL_INTERVAL_MS = 30_000 -// The recents list is local-only: cron rows have their own section, and each -// messaging platform (telegram, discord, …) is fetched separately into its own -// self-managed sidebar section (refreshMessagingSessions). Excluding both here -// keeps "Load more" paging through interactive local chats instead of -// interleaving gateway threads that bury them. -const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] -// The messaging slice is the inverse: drop cron + every local source so only -// external-platform conversations remain, then split per platform in the UI. -const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] - -// Cheap signature compare so the poll only swaps the atom (and re-renders the -// sidebar) when the visible cron rows actually changed. -function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { - if (a.length !== b.length) { - return false - } - - return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) -} - -// Rows a session refresh must preserve even if the aggregator omits them: -// in-flight first turns (message_count 0), pinned rows aged off the page, the -// actively-viewed chat (its "working" flag clears a beat before the aggregator -// sees the persisted row), and sessions whose turn just settled (same race, but -// for a chat the user has already navigated away from). Pass `scope` to only -// keep the active row when it belongs to the profile being paged. -function sessionsToKeep(scope?: string): Set { - const keep = new Set([ - ...$workingSessionIds.get(), - ...$pinnedSessionIds.get(), - ...getRecentlySettledSessionIds() - ]) - - const active = $selectedStoredSessionId.get() - - if (active) { - const session = scope ? $sessions.get().find(s => s.id === active) : null - - if (!scope || !session || normalizeProfileKey(session.profile) === scope) { - keep.add(active) - } - } - - return keep -} export function DesktopController() { const queryClient = useQueryClient() @@ -222,7 +153,6 @@ export function DesktopController() { const busyRef = useRef(false) const creatingSessionRef = useRef(false) - const refreshSessionsRequestRef = useRef(0) const gatewayState = useStore($gatewayState) const activeSessionId = useStore($activeSessionId) @@ -426,126 +356,13 @@ export function DesktopController() { } }, []) - // Cron-job sessions as their own list (latest N). Independent of the recents - // page so the two never compete for slots. Cheap + bounded. Kept (even though - // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run - // still resolves into the Pinned section via sessionByAnyId. - const refreshCronSessions = useCallback(async () => { - try { - const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - source: 'cron' - }) - - setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) - } catch { - // Non-fatal: the cron section just stays empty/stale. - } - }, []) - - // Messaging-platform sessions as their own slice, fetched separately from - // local recents so each platform renders a self-managed section and never - // competes with local chats for the recents page budget. One combined fetch - // seeds every platform; the sidebar splits the rows per source. - const refreshMessagingSessions = useCallback(async () => { - try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - excludeSources: MESSAGING_EXCLUDED_SOURCES - }) - - // Drop any non-messaging source the broad exclude didn't catch (custom - // sources) — those stay in local recents, not a platform section. - const rows = result.sessions.filter(s => isMessagingSource(s.source)) - - setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) - // Hit the cap → at least one platform may have more on disk than loaded, - // so platform sections offer their own per-platform "load more". - setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) - } catch { - // Non-fatal: the messaging sections just stay empty/stale. - } - }, []) - - // Page a single platform's section independently (mirrors the per-profile - // pager): fetch that source's next window and merge it back in place, leaving - // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) - - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) - - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) - - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) - - // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created - // synchronously (agent tool call or the cron UI), so refreshing here right - // after an agent turn surfaces a new job immediately; the interval poll keeps - // next-run/state fresh as the scheduler advances them. - const refreshCronJobs = useCallback(async () => { - try { - const jobs = await getCronJobs() - - setCronJobs(jobs) - } catch { - // Non-fatal: the cron section just keeps its last-known jobs. - } - }, []) - - const refreshSessions = useCallback(async () => { - const requestId = refreshSessionsRequestRef.current + 1 - refreshSessionsRequestRef.current = requestId - setSessionsLoading(true) - - try { - const limit = $sessionsLimit.get() - - // Require at least one message so abandoned/empty "Untitled" drafts (one - // was created per TUI/desktop launch before the lazy-create fix) don't - // clutter the sidebar. - // Unified cross-profile list (served read-only off each profile's - // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". Cron sessions are excluded here - // and fetched separately (refreshCronSessions) so the scheduler's - // always-newest rows can't consume the recents page budget. - // Scope the fetch to the active profile (not always 'all') so a profile - // with few recent sessions isn't windowed out of the cross-profile - // recency page — the empty-history-on-profile-switch bug. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - - const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - if (refreshSessionsRequestRef.current === requestId) { - setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) - setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) - setSessionProfileTotals(result.profile_totals ?? {}) - } - } finally { - if (refreshSessionsRequestRef.current === requestId) { - setSessionsLoading(false) - } - } - - void refreshCronSessions() - void refreshCronJobs() - void refreshMessagingSessions() - }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) - - const loadMoreSessions = useCallback(async () => { - bumpSessionsLimit() - await refreshSessions() - }, [refreshSessions]) + const { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshSessions + } = useSessionListActions({ profileScope }) // Another window mutated the shared session list (e.g. a chat started in the // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so @@ -558,28 +375,6 @@ export function DesktopController() { return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) }, [refreshSessions]) - // ALL-profiles view pages one profile at a time: fetch that profile's next - // page and merge it in place, leaving every other profile's rows untouched. - const loadMoreSessionsForProfile = useCallback(async (profile: string) => { - const key = normalizeProfileKey(profile) - const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key - const loaded = $sessions.get().filter(inKey).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - const keep = sessionsToKeep(key) - - setSessions(prev => [ - ...prev.filter(s => !inKey(s)), - ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) - ]) - - const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length - setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) - }, []) - const toggleSelectedPin = useCallback(() => { const sessionId = $selectedStoredSessionId.get() @@ -1117,9 +912,7 @@ export function DesktopController() { // layer) so pane resize handles still paint above it. Terminals own their state // (incl. a snapshotted cwd) independent of the session, so switching sessions // never rebuilds or closes them; toggling the pane never rebuilds the shells. - const mainOverlays = ( - - ) + const mainOverlays = const overlays = ( <> 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 new file mode 100644 index 00000000000..6c5d89e7112 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -0,0 +1,230 @@ +import { useCallback, useRef } from 'react' + +import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes' +import { + isMessagingSource, + LOCAL_SESSION_SOURCE_IDS, + MESSAGING_SESSION_SOURCE_IDS, + normalizeSessionSource +} from '@/lib/session-source' +import { setCronJobs } from '@/store/cron' +import { $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' +import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { + $messagingSessions, + $selectedStoredSessionId, + $sessions, + $workingSessionIds, + CRON_SECTION_LIMIT, + getRecentlySettledSessionIds, + mergeSessionPage, + MESSAGING_SECTION_LIMIT, + setCronSessions, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '@/store/session' + +import { sameCronSignature } from '../../desktop-controller-utils' + +// The recents list is local-only: cron rows have their own section, and each +// messaging platform (telegram, discord, …) is fetched separately into its own +// self-managed sidebar section (refreshMessagingSessions). Excluding both here +// keeps "Load more" paging through interactive local chats instead of +// interleaving gateway threads that bury them. +const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] +// The messaging slice is the inverse: drop cron + every local source so only +// external-platform conversations remain, then split per platform in the UI. +const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] + +// Rows a session refresh must preserve even if the aggregator omits them: +// in-flight first turns (message_count 0), pinned rows aged off the page, the +// actively-viewed chat (its "working" flag clears a beat before the aggregator +// sees the persisted row), and sessions whose turn just settled (same race, but +// for a chat the user has already navigated away from). Pass `scope` to only +// keep the active row when it belongs to the profile being paged. +function sessionsToKeep(scope?: string): Set { + const keep = new Set([ + ...$workingSessionIds.get(), + ...$pinnedSessionIds.get(), + ...getRecentlySettledSessionIds() + ]) + + const active = $selectedStoredSessionId.get() + + if (active) { + const session = scope ? $sessions.get().find(s => s.id === active) : null + + if (!scope || !session || normalizeProfileKey(session.profile) === scope) { + keep.add(active) + } + } + + return keep +} + +interface UseSessionListActionsArgs { + profileScope: string +} + +/** Owns the sidebar's session-list fetching + paging: recents, cron runs/jobs, + * and the per-platform messaging slices. Returns the callbacks the controller + * wires into the sidebar and refresh effects. */ +export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { + const refreshSessionsRequestRef = useRef(0) + + // Cron-job sessions as their own list (latest N). Independent of the recents + // page so the two never compete for slots. Cheap + bounded. Kept (even though + // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run + // still resolves into the Pinned section via sessionByAnyId. + const refreshCronSessions = useCallback(async () => { + try { + const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + source: 'cron' + }) + + setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) + } catch { + // Non-fatal: the cron section just stays empty/stale. + } + }, []) + + // Messaging-platform sessions as their own slice, fetched separately from + // local recents so each platform renders a self-managed section and never + // competes with local chats for the recents page budget. One combined fetch + // seeds every platform; the sidebar splits the rows per source. + const refreshMessagingSessions = useCallback(async () => { + try { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + excludeSources: MESSAGING_EXCLUDED_SOURCES + }) + + // Drop any non-messaging source the broad exclude didn't catch (custom + // sources) — those stay in local recents, not a platform section. + const rows = result.sessions.filter(s => isMessagingSource(s.source)) + + setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) + // Hit the cap → at least one platform may have more on disk than loaded, + // so platform sections offer their own per-platform "load more". + setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) + } catch { + // Non-fatal: the messaging sections just stay empty/stale. + } + }, []) + + // Page a single platform's section independently (mirrors the per-profile + // pager): fetch that source's next window and merge it back in place, leaving + // every other platform's rows untouched. Resolves the platform's exact total. + const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform + const loaded = $messagingSessions.get().filter(inPlatform).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { + source: platform + }) + + const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) + + const total = result.total ?? incoming.length + setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) + }, []) + + // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created + // synchronously (agent tool call or the cron UI), so refreshing here right + // after an agent turn surfaces a new job immediately; the interval poll keeps + // next-run/state fresh as the scheduler advances them. + const refreshCronJobs = useCallback(async () => { + try { + const jobs = await getCronJobs() + + setCronJobs(jobs) + } catch { + // Non-fatal: the cron section just keeps its last-known jobs. + } + }, []) + + const refreshSessions = useCallback(async () => { + const requestId = refreshSessionsRequestRef.current + 1 + refreshSessionsRequestRef.current = requestId + setSessionsLoading(true) + + try { + const limit = $sessionsLimit.get() + + // Require at least one message so abandoned/empty "Untitled" drafts (one + // was created per TUI/desktop launch before the lazy-create fix) don't + // clutter the sidebar. + // Unified cross-profile list (served read-only off each profile's + // state.db; no per-profile backend is spawned). Single-profile users get + // the same rows tagged profile="default". Cron sessions are excluded here + // and fetched separately (refreshCronSessions) so the scheduler's + // always-newest rows can't consume the recents page budget. + // Scope the fetch to the active profile (not always 'all') so a profile + // with few recent sessions isn't windowed out of the cross-profile + // recency page — the empty-history-on-profile-switch bug. + const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + if (refreshSessionsRequestRef.current === requestId) { + setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) + setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) + setSessionProfileTotals(result.profile_totals ?? {}) + } + } finally { + if (refreshSessionsRequestRef.current === requestId) { + setSessionsLoading(false) + } + } + + void refreshCronSessions() + void refreshCronJobs() + void refreshMessagingSessions() + }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) + + const loadMoreSessions = useCallback(async () => { + bumpSessionsLimit() + await refreshSessions() + }, [refreshSessions]) + + // ALL-profiles view pages one profile at a time: fetch that profile's next + // page and merge it in place, leaving every other profile's rows untouched. + const loadMoreSessionsForProfile = useCallback(async (profile: string) => { + const key = normalizeProfileKey(profile) + const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key + const loaded = $sessions.get().filter(inKey).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + const keep = sessionsToKeep(key) + + setSessions(prev => [ + ...prev.filter(s => !inKey(s)), + ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) + ]) + + const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length + setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) + }, []) + + return { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshSessions + } +}