From e0121c59d3b33749bea292f52bcb62a8a811981b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 4 Jun 2026 16:59:39 -0500 Subject: [PATCH] feat(desktop): drag-sort profiles in the rail Make the named-profile squares reorderable via dnd-kit (horizontal sort, 4px activation so a tap still selects). Order persists in localStorage ($profileOrder); unordered/new profiles alphabetize at the tail. --- .../src/app/chat/sidebar/profile-switcher.tsx | 87 +++++++++++++++---- apps/desktop/src/store/profile.ts | 34 +++++++- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 6a4fb6fcf..bd4ecb617 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -1,3 +1,20 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from '@dnd-kit/core' +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -9,13 +26,16 @@ import { profileColor, profileColorSoft } from '@/lib/profile-color' import { cn } from '@/lib/utils' import { $activeGatewayProfile, + $profileOrder, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey, refreshActiveProfile, selectProfile, - setShowAllProfiles + setProfileOrder, + setShowAllProfiles, + sortByProfileOrder } from '@/store/profile' import { CreateProfileDialog } from '../../profiles/create-profile-dialog' @@ -29,6 +49,7 @@ export function ProfileRail() { const profiles = useStore($profiles) const scope = useStore($profileScope) const gatewayProfile = useStore($activeGatewayProfile) + const order = useStore($profileOrder) const navigate = useNavigate() const [createOpen, setCreateOpen] = useState(false) @@ -38,7 +59,27 @@ export function ProfileRail() { const defaultProfile = profiles.find(profile => profile.is_default) const onDefault = !isAll && activeKey === 'default' - const named = profiles.filter(profile => !profile.is_default).sort((a, b) => a.name.localeCompare(b.name)) + const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order) + + // distance constraint: a small drag reorders, a tap still selects the profile. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) { + return + } + + const ids = named.map(profile => profile.name) + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + + if (from >= 0 && to >= 0) { + setProfileOrder(arrayMove(ids, from, to)) + } + } // Re-pull the running profile + list on mount so a profile created elsewhere // shows up; cheap and best-effort. @@ -64,15 +105,19 @@ export function ProfileRail() { )}
- {named.map(profile => ( - selectProfile(profile.name)} - /> - ))} + + profile.name)} strategy={horizontalListSortingStrategy}> + {named.map(profile => ( + selectProfile(profile.name)} + /> + ))} + + diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 0c8d58184..e64717f10 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -3,7 +3,7 @@ import { atom, computed } from 'nanostores' import { getProfiles, setApiRequestProfile } from '@/hermes' import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { queryClient } from '@/lib/query-client' -import { persistBoolean, storedBoolean } from '@/lib/storage' +import { arraysEqual, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage' import { $gateway } from '@/store/gateway' import { setConnection } from '@/store/session' import type { ProfileInfo } from '@/types/hermes' @@ -30,6 +30,38 @@ export function setActiveProfile(name: string): void { $activeProfile.set(name || 'default') } +// ── Rail order ───────────────────────────────────────────────────────────── +// User-defined order for the named (non-default) profile squares in the rail. +// Names absent from the list fall back to alphabetical, appended at the tail — +// so a freshly created profile lands at the end until the user drags it. +const PROFILE_ORDER_STORAGE_KEY = 'hermes.desktop.profileOrder' + +export const $profileOrder = atom(storedStringArray(PROFILE_ORDER_STORAGE_KEY)) + +$profileOrder.subscribe(value => persistStringArray(PROFILE_ORDER_STORAGE_KEY, [...value])) + +export function setProfileOrder(names: string[]): void { + if (!arraysEqual($profileOrder.get(), names)) { + $profileOrder.set(names) + } +} + +// Sort items by the stored order; unordered names alphabetise at the tail. +export function sortByProfileOrder(items: T[], order: string[]): T[] { + const rank = new Map(order.map((name, index) => [name, index])) + + return [...items].sort((a, b) => { + const ra = rank.get(a.name) + const rb = rank.get(b.name) + + if (ra != null && rb != null) { + return ra - rb + } + + return ra != null ? -1 : rb != null ? 1 : a.name.localeCompare(b.name) + }) +} + interface ActiveProfileResponse { active: string current: string