diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 3268772faa..99d31d6cd2 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -165,6 +165,7 @@ import { ProjectMenu, projectTreeCwd, reconcileEnteredProjectSessions, + sessionBucketId, sessionMatchesProjectFilter, sessionRecency as sessionTime, type SidebarProjectTree, @@ -1163,6 +1164,27 @@ export function ChatSidebar({ [projectOverview, agentSessions, projects, removedSessionIds, sortOrderIds, showAllSessions] ) + // A row's "Show all" hydrates raw backend lanes, which — like the drill-in — + // must go through the same exclusion as the previews above (pins, filter + // misses, optimistic removals), or a pinned chat renders twice and a + // just-deleted one comes back. The per-project count of loaded sessions + // that exclusion hides also corrects the backend's `sessionCount` in the + // "Show all N" label (a pin is always loaded — it renders in Pinned). + const overviewHidden = useMemo(() => { + const isHidden = (session: SessionInfo) => isHiddenFromProjects(session) || removedSessionIds.has(session.id) + const counts: Record = {} + + for (const session of sessions) { + const projectId = isHidden(session) ? sessionBucketId(session, projects) : null + + if (projectId) { + counts[projectId] = (counts[projectId] ?? 0) + 1 + } + } + + return { isHidden, counts } + }, [sessions, projects, isHiddenFromProjects, removedSessionIds]) + const onEnterProject = useCallback( (id: string) => { const project = projectModel.find(node => node.id === id) @@ -1857,6 +1879,7 @@ export function ChatSidebar({ } projectContent={inProject ? enteredProjectContent : undefined} projectOverview={projectOverview} + projectOverviewHidden={overviewHidden} projectOverviewPreviews={overviewPreviews} projectRepoWorktrees={inProject ? scopedRepoWorktrees : undefined} projectsLoading={worktreeGroupingActive ? projectTreeLoading : false} diff --git a/apps/desktop/src/app/chat/sidebar/projects/index.ts b/apps/desktop/src/app/chat/sidebar/projects/index.ts index 501ba79cd9..9364b7096f 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/index.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/index.ts @@ -16,6 +16,7 @@ export { overlayLiveLanes, overlayLivePreviews, reconcileEnteredProjectSessions, + sessionBucketId, sessionMatchesProjectFilter, sessionRecency, type SidebarProjectTree, diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx index f8d38ef6b1..825e08cafb 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -14,7 +14,7 @@ afterEach(cleanup) const workspaceOpen = vi.hoisted(() => ({ value: false })) const projectsStore = vi.hoisted(() => ({ - fetchProjectSessions: vi.fn<(id: string) => Promise>(), + fetchProjectSessions: vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise>(), projectProfile: vi.fn<() => null | string>(() => 'default') })) @@ -121,10 +121,39 @@ describe('ProjectOverviewRow', () => { fireEvent.click(screen.getByRole('button', { name: 'Show all 5 sessions' })) await waitFor(() => expect(screen.getByTestId('rows').textContent).toBe('s1,s2,s3,s4,s5')) - expect(projectsStore.fetchProjectSessions).toHaveBeenCalledWith('p1') + expect(projectsStore.fetchProjectSessions).toHaveBeenCalledWith('p1', { supersedable: false }) expect(screen.queryByRole('button', { name: 'Show all 5 sessions' })).toBeNull() }) + // The hydrated lanes are the raw backend payload: pinned, filtered-out and + // just-deleted sessions must go through the same exclusion the previews did, + // and N must not promise rows the view hides. + it('"Show all" runs the hydrated lanes through the tree exclusion and counts only what it will render', async () => { + workspaceOpen.value = true + const five = Array.from({ length: 5 }, (_, index) => session(`s${index + 1}`, 500 - index)) + const busy = { ...project, sessionCount: 5 } as SidebarProjectTree + projectsStore.fetchProjectSessions.mockResolvedValue({ + ...busy, + repos: [{ groups: [{ sessions: five }] }] + } as unknown as SidebarProjectTree) + // s2 is pinned (renders in Pinned), s5 was just deleted. + const hidden = new Set(['s2', 's5']) + + render( + hidden.has(item.id)} + previewSessions={[five[0], five[2]]} + project={busy} + renderRows={items =>
{items.map(item => item.id).join(',')}
} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Show all 3 sessions' })) + + await waitFor(() => expect(screen.getByTestId('rows').textContent).toBe('s1,s3,s4')) + }) + it('offers the "new session" add button on Home, which starts one with no folder', () => { const home = { id: '__no_project__', diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index 2a4c1a8c1c..3e8c5f4e9b 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -26,7 +26,7 @@ import { import { expandedProjectSessions, latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model' import { ProjectContextMenu, ProjectMenu } from './project-menu' -import type { SidebarProjectTree } from './workspace-groups' +import { excludeProjectSessions, type SidebarProjectTree } from './workspace-groups' import { WorkspaceAddButton } from './workspace-header' // A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else @@ -81,6 +81,13 @@ interface ProjectOverviewRowProps { renderRows?: (sessions: SessionInfo[]) => React.ReactNode activeProjectId?: null | string previewSessions?: SessionInfo[] + /** What the project tree drops (pins, filter misses, just-deleted rows) — + * the same predicate `previewSessions` was built with, so a "Show all" + * hydration can't resurrect them. */ + isSessionHidden?: (session: SessionInfo) => boolean + /** How many of the backend's `sessionCount` that predicate hides, so + * "Show all N" promises only rows the view will actually render. */ + hiddenSessionCount?: number reorderable?: boolean dragging?: boolean dragHandleProps?: React.HTMLAttributes @@ -96,6 +103,8 @@ export function ProjectOverviewRow({ renderRows, activeProjectId, previewSessions, + isSessionHidden, + hiddenSessionCount = 0, reorderable = false, dragging = false, dragHandleProps, @@ -118,8 +127,12 @@ export function ProjectOverviewRow({ const limit = showAllSessions || expanded ? Infinity : PROJECT_PREVIEW_COUNT const fetched = (previewSessions ?? []).slice(0, limit) const recent = fetched.length ? fetched : latestProjectSessions(project, limit) - const preview = renderRows ? (expanded ? expandedProjectSessions(recent, expanded) : recent) : [] - const hiddenCount = project.sessionCount - preview.length + // The hydrated lanes come straight from the backend, so — like the drill-in + // (index.tsx) — they haven't been through the tree's exclusion filter yet. + const visible = expanded && isSessionHidden ? excludeProjectSessions(expanded, isSessionHidden) : expanded + const preview = renderRows ? (visible ? expandedProjectSessions(recent, visible) : recent) : [] + const total = project.sessionCount - hiddenSessionCount + const hiddenCount = total - preview.length const offerShowAll = !showAllSessions && !expanded && preview.length > 0 && hiddenCount > 0 const showAll = () => { @@ -132,7 +145,7 @@ export function ProjectOverviewRow({ } setExpanding(true) - fetchProjectSessions(project.id) + fetchProjectSessions(project.id, { supersedable: false }) .then(tree => void (tree && setExpanded(tree))) .catch(() => onEnter?.(project.id)) .finally(() => setExpanding(false)) @@ -261,7 +274,7 @@ export function ProjectOverviewRow({ - {s.projects.showAllCount(project.sessionCount)} + {s.projects.showAllCount(total)} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index b05d9ab306..17b9117899 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -134,6 +134,10 @@ interface SidebarSessionsSectionProps { projectOverview?: SidebarProjectTree[] // Per-project preview rows (from the backend tree), keyed by project id. projectOverviewPreviews?: Record + // The exclusion the previews were built with (pins, filter misses, removed + // ids) plus how many of each project's `sessionCount` it hides — applied + // again when a row hydrates its full lanes on "Show all". + projectOverviewHidden?: { isHidden: (session: SessionInfo) => boolean; counts: Record } // True while the backend project tree is loading (overview skeleton). projectsLoading?: boolean onEnterProject?: (id: string) => void @@ -207,6 +211,7 @@ export function SidebarSessionsSection({ groups, projectOverview, projectOverviewPreviews, + projectOverviewHidden, projectsLoading = false, onEnterProject, projectContent, @@ -513,6 +518,8 @@ export function SidebarSessionsSection({ const projectRow = (project: SidebarProjectTree, Component: typeof ProjectOverviewRow) => ( { // membership match exactly. let projectSessionsRefreshGeneration = 0 -export async function fetchProjectSessions(projectId: string): Promise { - const generation = ++projectSessionsRefreshGeneration +// A drill-in only wants the LATEST request (an older one resolving late would +// paint the wrong project), so those are `supersedable` and resolve null when +// overtaken. A per-row "Show all" expansion is not: two rows expanding at once, +// or a drill-in elsewhere, must not silently leave the first row collapsed. +export async function fetchProjectSessions( + projectId: string, + { supersedable = true }: { supersedable?: boolean } = {} +): Promise { + const generation = supersedable ? ++projectSessionsRefreshGeneration : null const profile = projectProfile() if (!profile) { @@ -541,14 +548,14 @@ export async function fetchProjectSessions(projectId: string): Promise