fix: run "Show all" hydration through the project tree's exclusion

The per-project "Show all N sessions" row folded the raw
`projects.project_sessions` payload straight into the row. The overview
previews and the drill-in both go through the sidebar's exclusion
pipeline (isHiddenFromProjects = pins + active filters, plus the
optimistic removedSessionIds); the expansion bypassed it, so a pinned
chat rendered twice (Pinned section + project), filter-excluded and
just-deleted sessions came back, and N (the backend's sessionCount)
promised rows the view hides.

index.tsx now hands the same predicate (plus removed ids) and the
per-project count of loaded sessions it hides down through
SidebarSessionsSection to the row; the row applies excludeProjectSessions
to the hydrated tree before folding it under the preview and derives N
from the corrected total. The invariant test fails on the previous head
(label reads "Show all 5", rows come back s1,s3,s2,s4,s5).

fetchProjectSessions gained `{ supersedable }`: the drill-in keeps the
latest-request-wins generation check, but a row expansion opts out — a
second "Show all" on another project (or a drill-in elsewhere) used to
make the first request resolve null and leave that row silently collapsed.
This commit is contained in:
teknium1
2026-09-16 12:37:08 -07:00
committed by Teknium
parent d6e62fd934
commit 4d1eb9191d
6 changed files with 91 additions and 11 deletions

View File

@@ -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<string, number> = {}
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}

View File

@@ -16,6 +16,7 @@ export {
overlayLiveLanes,
overlayLivePreviews,
reconcileEnteredProjectSessions,
sessionBucketId,
sessionMatchesProjectFilter,
sessionRecency,
type SidebarProjectTree,

View File

@@ -14,7 +14,7 @@ afterEach(cleanup)
const workspaceOpen = vi.hoisted(() => ({ value: false }))
const projectsStore = vi.hoisted(() => ({
fetchProjectSessions: vi.fn<(id: string) => Promise<null | SidebarProjectTree>>(),
fetchProjectSessions: vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise<null | SidebarProjectTree>>(),
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(
<ProjectOverviewRow
hiddenSessionCount={hidden.size}
isSessionHidden={item => hidden.has(item.id)}
previewSessions={[five[0], five[2]]}
project={busy}
renderRows={items => <div data-testid="rows">{items.map(item => item.id).join(',')}</div>}
/>
)
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__',

View File

@@ -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<HTMLElement>
@@ -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({
</SidebarRowLeadGlyph>
</SidebarRowLead>
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/more:underline">
{s.projects.showAllCount(project.sessionCount)}
{s.projects.showAllCount(total)}
</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>

View File

@@ -134,6 +134,10 @@ interface SidebarSessionsSectionProps {
projectOverview?: SidebarProjectTree[]
// Per-project preview rows (from the backend tree), keyed by project id.
projectOverviewPreviews?: Record<string, SessionInfo[]>
// 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<string, number> }
// 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) => (
<Component
activeProjectId={activeProjectId}
hiddenSessionCount={projectOverviewHidden?.counts[project.id]}
isSessionHidden={projectOverviewHidden?.isHidden}
key={project.id}
onEnter={onEnterProject}
onNewSession={onNewSessionInWorkspace}

View File

@@ -522,8 +522,15 @@ async function refreshProjectTreeAcrossProfiles(): Promise<void> {
// membership match exactly.
let projectSessionsRefreshGeneration = 0
export async function fetchProjectSessions(projectId: string): Promise<SidebarProjectTree | null> {
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<SidebarProjectTree | null> {
const generation = supersedable ? ++projectSessionsRefreshGeneration : null
const profile = projectProfile()
if (!profile) {
@@ -541,14 +548,14 @@ export async function fetchProjectSessions(projectId: string): Promise<SidebarPr
projectParams({ project_id: projectId }, context.profile)
)
if (generation !== projectSessionsRefreshGeneration || !stillOnProjectsContext(context)) {
if ((generation !== null && generation !== projectSessionsRefreshGeneration) || !stillOnProjectsContext(context)) {
return null
}
return res.project ?? null
} catch (error) {
if (
generation !== projectSessionsRefreshGeneration ||
(generation !== null && generation !== projectSessionsRefreshGeneration) ||
profile !== projectProfile() ||
(context && !stillOnProjectsContext(context))
) {