feat(mobile): follow the project sort order in the sessions drawer

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).
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent d4a0bf3ee1
commit 9e7692db5d
4 changed files with 173 additions and 53 deletions
+78 -16
View File
@@ -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<string>();
// 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<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
const projectsMeta = React.useMemo<ProjectMeta[]>(
() =>
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<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ 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 ? (
<Button
@@ -1543,6 +1580,31 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</button>
) : null}
</div>
{/* 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 ? (
<div
role="group"
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
className="oc-hide-scrollbar -mx-4 mt-2 flex gap-1.5 overflow-x-auto px-4 pb-0.5"
>
{PROJECT_SORT_OPTIONS.map(([order, labelKey]) => (
<Button
key={order}
type="button"
variant="chip"
size="sm"
className="shrink-0"
aria-pressed={projectSortOrder === order}
onClick={() => handleProjectSortChange(order)}
style={{ touchAction: 'manipulation' }}
>
{t(labelKey)}
</Button>
))}
</div>
) : null}
</div>
{projectsMeta.length === 0 && chatSessions.length === 0 ? (
<MobileSessionsEmpty
@@ -38,6 +38,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories';
import { sortProjectsByOrder } from './sidebar/list/projectSort';
import { z } from 'zod';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import {
@@ -492,43 +493,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}
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(() => (
@@ -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']);
});
});
@@ -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 = <T extends SortableProject>(
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;
};