diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ceec2ebc9e..4d8369e4cd 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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(() => { 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)) diff --git a/apps/desktop/src/app/chat/sidebar/project-filter.test.ts b/apps/desktop/src/app/chat/sidebar/project-filter.test.ts index a22d0390e3..a363de2ed9 100644 --- a/apps/desktop/src/app/chat/sidebar/project-filter.test.ts +++ b/apps/desktop/src/app/chat/sidebar/project-filter.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/project-filter.ts b/apps/desktop/src/app/chat/sidebar/project-filter.ts index 33ab9ddd15..4c1fc894fa 100644 --- a/apps/desktop/src/app/chat/sidebar/project-filter.ts +++ b/apps/desktop/src/app/chat/sidebar/project-filter.ts @@ -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 + ) +} diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 77a7a70aed..e1e824c7a6 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -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 + return ( +
+ {r.noProjectOpen} + +
+ ) } return (