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.
This commit is contained in:
Hermes Agent
2026-09-24 23:12:16 -05:00
committed by brooklyn!
parent 138e33d51f
commit f9f1413f4e
4 changed files with 90 additions and 4 deletions

View File

@@ -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)

View File

@@ -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) }
}

View File

@@ -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 }

View File

@@ -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()