From 6cffc37b5ac4467aa41fbdddbba29e0f04876378 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 2 Jul 2026 14:15:07 -0500 Subject: [PATCH] feat(desktop): collapse profile rail to a select past 13 profiles (#57306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colored-square rail stops scaling once a user racks up many profiles: tiny drag targets and an endless horizontal scroll strip. Past a threshold (13) the rail swaps the squares for a compact select dropdown — same active tint + initial glyph, minus the drag-reorder / long-press-recolor / per-row context menu that only make sense at small counts. Two render paths behind one flag; the left default↔all toggle, the "+" create button, and Manage stay put in both. Rename/delete/color remain reachable via Manage. --- .../src/app/chat/sidebar/profile-switcher.tsx | 172 +++++++++++++----- 1 file changed, 127 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 100ad8001e4..4c919b14456 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -27,6 +27,7 @@ import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -57,6 +58,11 @@ import { PROFILES_ROUTE } from '../../routes' const RAIL_GAP = 4 // px — matches gap-1 between squares. +// Past this many profiles the strip of colored squares stops scaling (tiny +// drag targets, endless horizontal scroll), so the rail collapses to a compact +// select. Drag-reorder and long-press-recolor live only on the squares path. +const PROFILE_DROPDOWN_THRESHOLD = 13 + // easeOutBack — a little overshoot so squares spring into their new slot rather // than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square // glides between snapped cells on the snappier DRAG_TRANSITION. @@ -102,6 +108,10 @@ export function ProfileRail() { const [pendingDelete, setPendingDelete] = useState(null) const scrollRef = useRef(null) + // Too many profiles for the square strip → collapse to the select. Declared + // ahead of the wheel effect, which re-binds when the strip mounts/unmounts. + const condensed = profiles.length > PROFILE_DROPDOWN_THRESHOLD + // A plain mouse wheel only emits deltaY; map it to horizontal scroll so the // rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes // through. Native + non-passive so we can preventDefault and not bleed the @@ -125,7 +135,8 @@ export function ProfileRail() { el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) - }, []) + // `condensed` swaps the strip out for the dropdown (ref goes null/back). + }, [condensed]) const isAll = scope === ALL_PROFILES const activeKey = normalizeProfileKey(gatewayProfile) @@ -228,51 +239,57 @@ export function ProfileRail() { /> )} -
- {multiProfile && ( - - profile.name)} strategy={horizontalListSortingStrategy}> - {/* relative → the strip is the dragged square's offsetParent, so the - clamp modifier bounds drags to the occupied cells (not the +). */} -
- {named.map(profile => ( - setPendingDelete(profile)} - onRecolor={color => setProfileColor(profile.name, color)} - onRename={() => setPendingRename(profile)} - onSelect={() => selectProfile(profile.name)} - /> - ))} -
-
-
- )} + {condensed ? ( + // Condensed path: one compact dropdown instead of N squares. No drag + // reorder, no long-press recolor, no per-square context menu — Manage + // covers rename/delete at this scale. +
+ + setCreateOpen(true)} /> +
+ ) : ( +
+ {multiProfile && ( + + profile.name)} strategy={horizontalListSortingStrategy}> + {/* relative → the strip is the dragged square's offsetParent, so the + clamp modifier bounds drags to the occupied cells (not the +). */} +
+ {named.map(profile => ( + setPendingDelete(profile)} + onRecolor={color => setProfileColor(profile.name, color)} + onRename={() => setPendingRename(profile)} + onSelect={() => selectProfile(profile.name)} + /> + ))} +
+
+
+ )} - - - -
+ setCreateOpen(true)} /> +
+ )} {/* Always reachable, even with only the default profile: the manage overlay is the only place to edit a profile's SOUL.md, and a @@ -309,6 +326,71 @@ export function ProfileRail() { ) } +// The "+" create button, shared by both rail render paths. +function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + + + ) +} + +// The condensed rail: every named profile in one compact select. The trigger +// shows the active profile (tinted initial + name); on default/all scope it +// falls back to the placeholder since the left toggle pill carries that state. +function ProfileDropdown({ + activeKey, + colors, + onSelect, + profiles +}: { + activeKey: null | string + colors: Record + onSelect: (name: string) => void + profiles: ProfileInfo[] +}) { + const { t } = useI18n() + const p = t.profiles + + const value = activeKey ? (profiles.find(profile => normalizeProfileKey(profile.name) === activeKey)?.name ?? '') : '' + + return ( + + ) +} + interface ProfilePillProps { active: boolean // home / All / Manage are glyph action buttons (navigation, not identity).