diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index f2d80e9b..7d35b453 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -43,9 +43,11 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getProjectLabel, normalizePath } from './mobilePaths'; import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories'; import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection'; +import { sortProjectsByOrder } from '@/components/session/sidebar/list/projectSort'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; +import { updateDesktopSettings } from '@/lib/persistence'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { @@ -57,6 +59,7 @@ import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSess import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore'; import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionDisplayStore, type ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; import { @@ -95,6 +98,16 @@ type MobileSessionsSheetProps = { const EMPTY_PINNED_SESSION_IDS = new Set(); +// Same orders, same labels as the desktop sidebar's sort menu — the setting +// itself is shared, so the two surfaces must offer the same choices. +const PROJECT_SORT_OPTIONS = [ + ['manual', 'sessions.sidebar.header.projectSort.manual'], + ['a-z', 'sessions.sidebar.header.projectSort.aToZ'], + ['z-a', 'sessions.sidebar.header.projectSort.zToA'], + ['date-added', 'sessions.sidebar.header.projectSort.dateAdded'], + ['recent', 'sessions.sidebar.header.projectSort.recent'], +] as const; + // Pseudo-project key for the collapsible "recent" group's persisted expansion. type ProjectMeta = { @@ -107,6 +120,9 @@ type ProjectMeta = { iconBackground?: string | null; isGitRepo: boolean; worktrees: WorktreeMetadata[]; + /** Read by the 'date-added' / 'recent' project orders. */ + addedAt?: number; + lastOpenedAt?: number; }; type WorktreeBucket = { @@ -914,6 +930,9 @@ export const MobileSessionsSheet: React.FC = ({ open, const setActiveProject = useProjectsStore((state) => state.setActiveProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const reorderProjects = useProjectsStore((state) => state.reorderProjects); + const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); + const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); + const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder); const removeProject = useProjectsStore((state) => state.removeProject); const projectExpandedMap = useMobileSessionTreeStore((state) => state.projectExpanded); const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded); @@ -1025,21 +1044,27 @@ export const MobileSessionsSheet: React.FC = ({ open, const projectsMeta = React.useMemo( () => - projects.map((project) => ({ - id: project.id, - label: project.label?.trim() || getProjectLabel(project.path), - path: normalizePath(project.path), - icon: project.icon, - color: project.color, - iconImage: project.iconImage, - iconBackground: project.iconBackground, - isGitRepo: gitProjectPaths.has(normalizePath(project.path)), - worktrees: orderWorktrees( - worktreeOrderByProject[project.id], - worktreesByProject.get(normalizePath(project.path)) ?? [], - ), - })), - [gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject], + sortProjectsByOrder( + projects.map((project) => ({ + id: project.id, + label: project.label?.trim() || getProjectLabel(project.path), + path: normalizePath(project.path), + icon: project.icon, + color: project.color, + iconImage: project.iconImage, + iconBackground: project.iconBackground, + isGitRepo: gitProjectPaths.has(normalizePath(project.path)), + worktrees: orderWorktrees( + worktreeOrderByProject[project.id], + worktreesByProject.get(normalizePath(project.path)) ?? [], + ), + addedAt: project.addedAt, + lastOpenedAt: project.lastOpenedAt, + })), + projectSortOrder, + manualProjectOrder, + ), + [gitProjectPaths, manualProjectOrder, projectSortOrder, projects, worktreeOrderByProject, worktreesByProject], ); /** @@ -1375,6 +1400,16 @@ export const MobileSessionsSheet: React.FC = ({ open, useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + // The order is a shared setting, so persist it the same way the desktop + // sidebar does — picking it here follows the user to their other surfaces. + const handleProjectSortChange = (order: ProjectSortOrder) => { + setProjectSortOrder(order); + void updateDesktopSettings({ sidebarProjectSortOrder: order }); + // Dragging projects rewrites the manual order; it means nothing while the + // list is sorted by something else. + if (order !== 'manual') setEditingOrder(false); + }; + const handleReorderDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; @@ -1459,7 +1494,9 @@ export const MobileSessionsSheet: React.FC = ({ open, const hasNoMatches = normalizedQuery && searchSessionMatches.length === 0 && searchProjectMatches.length === 0; - const canEditOrder = !normalizedQuery && projectsMeta.length > 1; + // Drag order IS the manual order: offering it under another sort would let + // the user rearrange a list that is about to be re-sorted anyway. + const canEditOrder = !normalizedQuery && projectsMeta.length > 1 && projectSortOrder === 'manual'; const editToggle = canEditOrder ? ( ) : null} + {/* Sorting sits with the search field rather than in the header: + it scrolls away with it, so a setting touched once a month + costs no permanent room above the list. */} + {!normalizedQuery && projectsMeta.length > 1 ? ( +
+ {PROJECT_SORT_OPTIONS.map(([order, labelKey]) => ( + + ))} +
+ ) : null} {projectsMeta.length === 0 && chatSessions.length === 0 ? ( = ({ } previousSidebarRenderSourcesRef.current = sidebarRenderSources; - const sortedProjects = React.useMemo(() => { - const list = [...normalizedProjects]; - - switch (projectSortOrder) { - case 'a-z': - list.sort((a, b) => { - const aLabel = (a.label || a.path).toLowerCase(); - const bLabel = (b.label || b.path).toLowerCase(); - return aLabel.localeCompare(bLabel); - }); - break; - case 'z-a': - list.sort((a, b) => { - const aLabel = (a.label || a.path).toLowerCase(); - const bLabel = (b.label || b.path).toLowerCase(); - return bLabel.localeCompare(aLabel); - }); - break; - case 'date-added': - list.sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0)); - break; - case 'recent': - list.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0)); - break; - case 'manual': { - const orderMap = new Map(manualProjectOrder.map((id, i) => [id, i])); - list.sort((a, b) => { - const ai = orderMap.get(a.id) ?? Infinity; - const bi = orderMap.get(b.id) ?? Infinity; - return ai - bi; - }); - break; - } - } - - return list; - }, [normalizedProjects, projectSortOrder, manualProjectOrder]); + const sortedProjects = React.useMemo( + () => sortProjectsByOrder(normalizedProjects, projectSortOrder, manualProjectOrder), + [normalizedProjects, projectSortOrder, manualProjectOrder], + ); const projectView = useSessionProjectViewState({ isVSCode, projects: sortedProjects }); const searchEmptyState = React.useMemo(() => ( diff --git a/packages/ui/src/components/session/sidebar/list/projectSort.test.ts b/packages/ui/src/components/session/sidebar/list/projectSort.test.ts new file mode 100644 index 00000000..a0f4c3a7 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/list/projectSort.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { sortProjectsByOrder } from './projectSort'; + +const projects = [ + { id: 'beta', label: 'Beta', path: '/repos/beta', addedAt: 300, lastOpenedAt: 100 }, + { id: 'alpha', label: 'alpha', path: '/repos/alpha', addedAt: 100, lastOpenedAt: 300 }, + { id: 'gamma', label: null, path: '/repos/gamma', addedAt: 200, lastOpenedAt: 200 }, +]; + +const ids = (list: ReadonlyArray<{ id: string }>): string[] => list.map((project) => project.id); + +describe('sortProjectsByOrder', () => { + // A label-less project compares by its whole path, so it sorts under '/'. + // Both surfaces fill the label in before rendering; this only pins the + // fallback down. + test('orders by label case-insensitively, falling back to the path', () => { + expect(ids(sortProjectsByOrder(projects, 'a-z', []))).toEqual(['gamma', 'alpha', 'beta']); + expect(ids(sortProjectsByOrder(projects, 'z-a', []))).toEqual(['beta', 'alpha', 'gamma']); + }); + + test('puts the newest first for date-added and the most recently opened first for recent', () => { + expect(ids(sortProjectsByOrder(projects, 'date-added', []))).toEqual(['beta', 'gamma', 'alpha']); + expect(ids(sortProjectsByOrder(projects, 'recent', []))).toEqual(['alpha', 'gamma', 'beta']); + }); + + test('follows the manual order and keeps unlisted projects at the end', () => { + expect(ids(sortProjectsByOrder(projects, 'manual', ['gamma', 'alpha']))).toEqual(['gamma', 'alpha', 'beta']); + }); + + test('leaves the input untouched', () => { + const input = [...projects]; + sortProjectsByOrder(input, 'a-z', []); + expect(ids(input)).toEqual(['beta', 'alpha', 'gamma']); + }); + + test('treats a missing timestamp as the oldest', () => { + const withoutStamps = [{ id: 'none', path: '/repos/none' }, ...projects]; + expect(ids(sortProjectsByOrder(withoutStamps, 'recent', []))).toEqual(['alpha', 'gamma', 'beta', 'none']); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/list/projectSort.ts b/packages/ui/src/components/session/sidebar/list/projectSort.ts new file mode 100644 index 00000000..2aad8d0a --- /dev/null +++ b/packages/ui/src/components/session/sidebar/list/projectSort.ts @@ -0,0 +1,49 @@ +import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; + +/** The fields any project list needs to be sortable. Both the desktop sidebar + and the mobile sessions drawer build their own richer project shapes on top + of the store entries, so this stays structural. */ +export type SortableProject = { + id: string; + label?: string | null; + path: string; + addedAt?: number | null; + lastOpenedAt?: number | null; +}; + +const compareLabels = (left: SortableProject, right: SortableProject): number => + (left.label || left.path).toLowerCase().localeCompare((right.label || right.path).toLowerCase()); + +/** One ordering for every surface that lists projects, so the sidebar and the + mobile drawer answer the same setting the same way. `manualOrder` is the + user's drag order (`useProjectsStore.manualProjectOrder`); projects missing + from it keep their incoming position at the end. */ +export const sortProjectsByOrder = ( + projects: readonly T[], + order: ProjectSortOrder, + manualOrder: readonly string[], +): T[] => { + const sorted = [...projects]; + + switch (order) { + case 'a-z': + sorted.sort(compareLabels); + break; + case 'z-a': + sorted.sort((left, right) => compareLabels(right, left)); + break; + case 'date-added': + sorted.sort((left, right) => (right.addedAt ?? 0) - (left.addedAt ?? 0)); + break; + case 'recent': + sorted.sort((left, right) => (right.lastOpenedAt ?? 0) - (left.lastOpenedAt ?? 0)); + break; + case 'manual': { + const rankById = new Map(manualOrder.map((id, index) => [id, index])); + sorted.sort((left, right) => (rankById.get(left.id) ?? Infinity) - (rankById.get(right.id) ?? Infinity)); + break; + } + } + + return sorted; +};