fix(desktop): restore folder-pick affordance and filter sidebar to session-bearing projects

PR #49037 (projects paradigm) broke the folder->session->sidebar flow (#53004):

- The right sidebar's Files pane gated the tree on $currentCwd, which is ''
  for global/detached sessions, so those sessions hit a dead-end 'No project
  open' pane with no way back into a folder. Restore an affordance in the
  empty state: 'Open folder' runs the existing open-folder-as-project flow
  (upsert + enter project + fresh session anchored at the picked folder),
  decoupled from $currentCwd.
- The sidebar overview listed every auto-promoted repo on disk, session or
  not. Auto projects with zero sessions now stay out of the sidebar until
  they own a session (they reappear the moment work lands there); explicit
  projects and the Home bucket always render.

UI-affecting: coordinator should hold auto-merge for visual review.

Fixes #53004
This commit is contained in:
Austin Pickett
2026-09-25 14:02:33 -04:00
parent 7262ab2d9a
commit fc4d0e616d
4 changed files with 60 additions and 6 deletions

View File

@@ -157,7 +157,7 @@ import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds }
import { filterSessionsByProfileScope } from './profile-scope'
import { ProfileRail } from './profile-switcher'
import { ProjectDialog } from './project-dialog'
import { resolveLiveProjectFilter } from './project-filter'
import { filterToSessionBearingProjects, resolveLiveProjectFilter } from './project-filter'
import {
excludeProjectSessions,
orderProjectsByIds,
@@ -1013,7 +1013,7 @@ export function ChatSidebar({
// overview sort. Membership is the backend tree's — never re-derived here.
const projectModel = useMemo<SidebarProjectTree[]>(() => {
const sorted = sortProjectsForOverview(
filterVisibleProjects(projectTree, dismissedAutoProjects)
filterToSessionBearingProjects(filterVisibleProjects(projectTree, dismissedAutoProjects))
// A filtered-out project drops its whole lane, header included — hiding
// only its rows would leave a row of empty folders behind.
.filter(project => !projectFilter.length || projectFilter.includes(project.id))

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { resolveLiveProjectFilter } from './project-filter'
import { filterToSessionBearingProjects, resolveLiveProjectFilter } from './project-filter'
describe('resolveLiveProjectFilter', () => {
const treeA = [{ id: 'p_aaa' }, { id: '/Users/me/repos/alpha' }] as const
@@ -34,3 +34,28 @@ describe('resolveLiveProjectFilter', () => {
expect(resolveLiveProjectFilter(['p_aaa'], undefined)).toEqual([])
})
})
describe('filterToSessionBearingProjects (#53004)', () => {
const base = { label: 'repo', path: '/tmp/repo', repos: [] } as const
const tree = [
{ ...base, id: 'auto_empty', isAuto: true, sessionCount: 0 },
{ ...base, id: 'auto_active', isAuto: true, sessionCount: 2 },
{ ...base, id: 'explicit_empty', sessionCount: 0 },
{ ...base, id: 'home', isNoProject: true, sessionCount: 0 }
]
it('drops auto projects with no sessions but keeps active autos', () => {
const ids = filterToSessionBearingProjects(tree).map(project => project.id)
expect(ids).not.toContain('auto_empty')
expect(ids).toContain('auto_active')
})
it('never drops explicit projects or the Home bucket', () => {
const ids = filterToSessionBearingProjects(tree).map(project => project.id)
expect(ids).toContain('explicit_empty')
expect(ids).toContain('home')
})
})

View File

@@ -20,3 +20,19 @@ export function resolveLiveProjectFilter(
return projectFilter.filter(id => liveIds.has(id))
}
/**
* The sidebar overview is a session history, not a disk browser (#53004): an
* auto-promoted repo with no sessions yet stays out of the sidebar until work
* lands there (it reappears the moment it owns a session). Explicit projects
* and the Home bucket always render — the user created or kept those. Session
* counts come from the backend `projects.tree`, so this stays live without
* client-side re-derivation.
*/
export function filterToSessionBearingProjects<
T extends { isAuto?: boolean; isNoProject?: boolean; sessionCount?: number }
>(projects: readonly T[]): T[] {
return projects.filter(
project => !project.isAuto || project.isNoProject || (project.sessionCount ?? 0) > 0
)
}

View File

@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { openPreview } from '@/store/preview'
import { openFolderAsProject } from '@/store/projects'
import { $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
@@ -149,10 +150,22 @@ function FilesystemTab({
const { t } = useI18n()
const r = t.rightSidebar
// No working directory (a bare/detached chat) → no tree, just a terse hint.
// Switching workspace is a project/worktree action, never a raw folder picker.
// No working directory (a bare/detached chat) → no tree, but keep a way back
// into a folder (#53004): the projects paradigm removed the old folder picker,
// which stranded global sessions on a dead-end "No project open" pane. The
// affordance is the project-shaped one — ⌘O's open-folder-as-project flow,
// which upserts/enters the project and anchors a fresh session at the picked
// folder — entirely decoupled from $currentCwd.
if (!hasWorkspace) {
return <PaneEmptyState label={r.noProjectOpen} />
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<SidebarPanelLabel className="pl-0 text-(--ui-text-quaternary)">{r.noProjectOpen}</SidebarPanelLabel>
<Button className="h-7 gap-1.5 text-xs" onClick={() => void openFolderAsProject()} size="sm" variant="outline">
<Codicon name="folder-opened" size="0.8125rem" />
{r.openFolder}
</Button>
</div>
)
}
return (