From 9e7692db5dd2e0740340e0d7c63f49330aa5fd93 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 08:29:02 +0300 Subject: [PATCH] feat(mobile): follow the project sort order in the sessions drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's project order (manual, A-Z, Z-A, newest, recently used) is a shared setting, but the mobile drawer ignored it and always rendered the manual order, so a phone could not put the project you actually work in at the top of the list. Read the same setting in the drawer and offer it as a chip row under the search field, which scrolls away with it — mobile navigation has no overflow menu to hide it in, and a control touched once a month should not hold permanent room above the list. Picking an order persists it the way the desktop sidebar does, so the choice follows the user across surfaces. Drag-to-reorder is offered only under the manual order, since dragging rewrites exactly the order the other modes ignore. The ordering itself moves into one helper the desktop sidebar now uses too, so both surfaces answer the setting identically. Testing: package type-check and lint; new unit tests for the helper plus the sidebar list suite under the isolated runner; drove the mobile surface in a browser against an isolated server (chips fit a 390px viewport, switching to "recent" reorders the list, the reorder button hides, and the choice reaches the server settings). --- packages/ui/src/apps/MobileSessionsSheet.tsx | 94 +++++++++++++++---- .../src/components/session/SessionSidebar.tsx | 42 +-------- .../session/sidebar/list/projectSort.test.ts | 41 ++++++++ .../session/sidebar/list/projectSort.ts | 49 ++++++++++ 4 files changed, 173 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/list/projectSort.test.ts create mode 100644 packages/ui/src/components/session/sidebar/list/projectSort.ts 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; +};