mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
feat(desktop): collapse profile rail to a select past 13 profiles (#57306)
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.
This commit is contained in:
parent
a2d49de801
commit
6cffc37b5a
1 changed files with 127 additions and 45 deletions
|
|
@ -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 | ProfileInfo>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(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() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{multiProfile && (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[stepThroughCells]}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={named.map(profile => 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 +). */}
|
||||
<div className="relative flex items-center gap-1">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
{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.
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ProfileDropdown
|
||||
activeKey={isAll ? null : activeKey}
|
||||
colors={colors}
|
||||
onSelect={selectProfile}
|
||||
profiles={named}
|
||||
/>
|
||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{multiProfile && (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[stepThroughCells]}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={named.map(profile => 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 +). */}
|
||||
<div className="relative flex items-center gap-1">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Tip label={p.newProfile}>
|
||||
<button
|
||||
aria-label={p.newProfile}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string, string>
|
||||
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 (
|
||||
<Select onValueChange={name => name && onSelect(name)} value={value}>
|
||||
<SelectTrigger aria-label={p.title} className="min-w-0 flex-1" size="xs">
|
||||
<SelectValue placeholder={p.title} />
|
||||
</SelectTrigger>
|
||||
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
|
||||
{profiles.map(profile => {
|
||||
const color = resolveProfileColor(profile.name, colors)
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
||||
return (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilePillProps {
|
||||
active: boolean
|
||||
// home / All / Manage are glyph action buttons (navigation, not identity).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue