From f9f1413f4e28a93e1a1aa1395f82f9b343c0a591 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 23:12:16 -0500 Subject: [PATCH] fix(desktop): stop an entered project refetching on every tree refresh (#77591) Every sessions.changed and window focus refreshes projects.tree, and each refresh handed the sidebar a new array. The entered project keyed its projects.project_sessions refetch on that array, so every refresh paid for a full hydrated tree build on the backend (seconds over a remote gateway) and rebuilt the lanes. The tree now keeps unchanged nodes by reference, the drill-in refetches only when its own overview node changes, an unchanged answer keeps its reference, and a background refetch no longer reports loading. --- .../use-entered-project-sessions.test.ts | 43 +++++++++++++++++++ .../sidebar/use-entered-project-sessions.ts | 16 +++++-- apps/desktop/src/store/projects.test.ts | 29 +++++++++++++ apps/desktop/src/store/projects.ts | 6 ++- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts index 047336035c..974c6ee3be 100644 --- a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts +++ b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts @@ -3,11 +3,54 @@ import { afterEach, expect, it, vi } from 'vitest' import { fetchProjectSessions } from '@/store/projects' +import type { SidebarProjectTree } from './projects/workspace-groups' import { useEnteredProjectSessions } from './use-entered-project-sessions' vi.mock('@/store/projects', () => ({ fetchProjectSessions: vi.fn() })) afterEach(cleanup) +const node = (id: string, sessionCount = 0): SidebarProjectTree => ({ + id, + label: id, + path: `/${id}`, + repos: [], + sessionCount +}) + +// #77591 D: over a remote gateway every `projects.project_sessions` call is a +// full hydrated tree build, and the tree refreshes on every sessions.changed +// and window focus. Only a change to the entered project may refetch it. +it('refetches only when the entered project changes, and keeps an unchanged result', async () => { + const fetched = vi.mocked(fetchProjectSessions) + fetched.mockReset() + fetched.mockImplementation(async id => ({ ...node(id, 1), repos: [] })) + + // `$projectTree` keeps unchanged nodes by reference across refreshes, so a + // refresh that only touched another project (or nothing at all) is a new + // array holding the same entered node. + const enteredNode = node('a', 1) + + const { result, rerender } = renderHook(({ tree }) => useEnteredProjectSessions('a', true, tree, 'default'), { + initialProps: { tree: [enteredNode, node('b')] } + }) + + await waitFor(() => expect(result.current.project?.id).toBe('a')) + const first = result.current.project + + rerender({ tree: [enteredNode, node('b', 5)] }) + rerender({ tree: [enteredNode, node('b', 5)] }) + await act(async () => {}) + expect(fetched).toHaveBeenCalledTimes(1) + + // The entered project itself changed: refetch in the background, with no + // skeleton, and keep the same object when the answer is unchanged. + rerender({ tree: [node('a', 2), node('b', 5)] }) + expect(result.current.loading).toBe(false) + await waitFor(() => expect(fetched).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(result.current.project).toBe(first) +}) + it('ignores departed drill-ins and clears failure on retry', async () => { let failOld!: (error: Error) => void vi.mocked(fetchProjectSessions) diff --git a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts index a86fb130b3..cac2228e11 100644 --- a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts +++ b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts @@ -1,3 +1,4 @@ +import { replaceEqualDeep } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { fetchProjectSessions } from '@/store/projects' @@ -17,6 +18,12 @@ export function useEnteredProjectSessions( const [loading, setLoading] = useState(false) const [retryToken, setRetryToken] = useState(0) + // Refetch when the entered project's own overview node changes, not on every + // tree refresh: each `projects.project_sessions` call hydrates the whole tree + // on the backend, which takes seconds over a remote gateway (#77591). The + // tree keeps unchanged nodes by reference, so this is stable across no-ops. + const enteredNode = projectId ? treeRevision.find(node => node.id === projectId) : undefined + useEffect(() => { setProject(null) }, [projectId, scope]) @@ -36,7 +43,8 @@ export function useEnteredProjectSessions( void fetchProjectSessions(projectId) .then(next => { if (!cancelled) { - setProject(next) + // An unchanged answer keeps its reference, so the lanes don't rebuild. + setProject(current => replaceEqualDeep(current, next)) } }) .catch(() => { @@ -53,7 +61,9 @@ export function useEnteredProjectSessions( return () => { cancelled = true } - }, [projectId, ready, treeRevision, scope, retryToken]) + }, [projectId, ready, enteredNode, scope, retryToken]) - return { project, failed, loading, retry: () => setRetryToken(token => token + 1) } + // A background refetch keeps painting the rows it has; only a drill-in with + // nothing loaded yet reports loading (the sidebar shows skeletons for it). + return { project, failed, loading: loading && !project, retry: () => setRetryToken(token => token + 1) } } diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 45c83437ac..fa3ab16c15 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -166,6 +166,35 @@ describe('projects RPC profile forwarding', () => { }) }) + it('keeps unchanged project nodes by reference across tree refreshes (#77591)', async () => { + const payload = () => ({ + active_id: null, + projects: [ + { id: 'p_a', label: 'a', path: '/a', repos: [], sessionCount: 1, sessionIds: ['s1'] }, + { id: 'p_b', label: 'b', path: '/b', repos: [], sessionCount: 0, sessionIds: [] } + ], + scoped_session_ids: ['s1'] + }) + + const next = payload() + next.projects[1] = { ...next.projects[1], sessionCount: 1, sessionIds: ['s2'] } + const request = vi.fn().mockResolvedValueOnce(payload()).mockResolvedValueOnce(payload()).mockResolvedValueOnce(next) + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + + await refreshProjectTree() + const first = $projectTree.get() + + await refreshProjectTree() + expect($projectTree.get()).toBe(first) + + await refreshProjectTree() + expect($projectTree.get()).not.toBe(first) + expect($projectTree.get()[0]).toBe(first[0]) + expect($projectTree.get()[1].sessionIds).toEqual(['s2']) + }) + it('skips project reads in the all-profiles view rather than forwarding its sentinel', async () => { const request = vi.fn() const gateway = { connectionState: 'open', request } diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 3234abeb4b..f7b2eb8541 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1,3 +1,4 @@ +import { replaceEqualDeep } from '@tanstack/react-query' import { atom, computed } from 'nanostores' import type { NewSessionPlacement } from '@/app/chat/new-session-drag' @@ -428,7 +429,10 @@ let projectTreeRefreshGeneration = 0 function applyProjectTreePayload(res: ProjectTreePayload): void { const scoped = new Set(res.scoped_session_ids ?? []) - $projectTree.set(res.projects ?? []) + // The tree refreshes on every sessions.changed and window focus, and most of + // those answers are unchanged. Keep unchanged nodes by reference so the + // entered project doesn't refetch and rebuild on a no-op (#77591). + $projectTree.set(replaceEqualDeep($projectTree.get(), res.projects ?? [])) $activeProjectId.set(res.active_id ?? null) const tombstones = $removedSessionIds.get()