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:
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
reconcileEnteredProjectSessions,
|
||||
sessionBucketId,
|
||||
sessionMatchesProjectFilter,
|
||||
sessionRecency,
|
||||
type SidebarProjectTree,
|
||||
|
||||
@@ -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__',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user