From 1b01fa3acf1585c6d78c84374d089f20b4d5eeb7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 4 Jun 2026 20:12:37 -0500 Subject: [PATCH] feat(desktop): long-press a rail profile to pick its color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold (~450ms) a profile square — or right-click → Color… — to open a shadcn Popover of swatches and override its rail color, with Auto to fall back to the deterministic hue. The hold timer rides alongside the dnd pointer listener (a real drag cancels it, the trailing click is suppressed), so reorder/select/recolor stay distinct gestures. Overrides persist in localStorage ($profileColors), resolved via resolveProfileColor (override wins, else the name-hashed hue). Cosmetic and gated on the multi-profile rail, so single-profile users are unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella). --- .../src/app/chat/sidebar/profile-switcher.tsx | 212 +++++++++++++----- apps/desktop/src/components/ui/popover.tsx | 44 ++++ apps/desktop/src/lib/profile-color.ts | 22 ++ apps/desktop/src/lib/storage.ts | 30 +++ apps/desktop/src/store/profile.ts | 34 ++- 5 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/components/ui/popover.tsx diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 783bc17a2..7e624d59d 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -25,12 +25,14 @@ import { useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { triggerHaptic } from '@/lib/haptics' -import { profileColor, profileColorSoft } from '@/lib/profile-color' +import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { cn } from '@/lib/utils' import { $activeGatewayProfile, + $profileColors, $profileOrder, $profiles, $profileScope, @@ -38,6 +40,7 @@ import { normalizeProfileKey, refreshActiveProfile, selectProfile, + setProfileColor, setProfileOrder, setShowAllProfiles, sortByProfileOrder @@ -85,6 +88,7 @@ export function ProfileRail() { const scope = useStore($profileScope) const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) + const colors = useStore($profileColors) const navigate = useNavigate() const [createOpen, setCreateOpen] = useState(false) @@ -215,10 +219,11 @@ export function ProfileRail() { {named.map(profile => ( setPendingDelete(profile)} + onRecolor={color => setProfileColor(profile.name, color)} onRename={() => setPendingRename(profile)} onSelect={() => selectProfile(profile.name)} /> @@ -306,10 +311,15 @@ interface ProfileSquareProps { color: null | string label: string onSelect: () => void + onRecolor: (color: null | string) => void onRename: () => void onDelete: () => void } +// Hold this long without moving (a drag would have started first) to open the +// color picker — the "hard press" gesture, distinct from tap-to-select. +const LONG_PRESS_MS = 450 + // A profile *is* its colored square — no icon-button chrome. Soft profile-tint // fill + the initial in the full color; the active one pops to full opacity with // a color ring. These pack tightly so the rail reads as a strip of profiles, @@ -317,71 +327,165 @@ interface ProfileSquareProps { // right-click to rename/delete. The button carries both the tooltip and // context-menu triggers via nested asChild Slots, so a single element keeps the // dnd listeners, hover tip, and right-click menu. -function ProfileSquare({ active, color, label, onSelect, onRename, onDelete }: ProfileSquareProps) { +function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) { const hue = color ?? 'var(--ui-text-quaternary)' + const [pickerOpen, setPickerOpen] = useState(false) + const pressTimer = useRef(null) + const suppressClick = useRef(false) const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: label, transition: RAIL_TRANSITION }) + const clearPress = () => { + if (pressTimer.current != null) { + clearTimeout(pressTimer.current) + pressTimer.current = null + } + } + + // A real drag (movement past the dnd threshold) cancels the pending hold, so a + // reorder never doubles as a color pick. Also tidy up on unmount. + useEffect(() => { + if (isDragging) { + clearPress() + } + }, [isDragging]) + useEffect(() => clearPress, []) + const base = CSS.Transform.toString(transform) const ring = active ? `inset 0 0 0 1.5px ${hue}` : '' const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : '' - return ( - - - - - - - - - {label} - - + const pickColor = (next: null | string) => { + onRecolor(next) + setPickerOpen(false) + triggerHaptic('selection') + } - {/* The rail sits at the very bottom, so pad off the chrome (esp. the - statusbar) — Radix then flips the menu up instead of squishing it. */} - + + + + + + + + + + + {label} + + + + {/* The rail sits at the very bottom, so pad off the chrome (esp. the + statusbar) — Radix then flips the menu up instead of squishing it. */} + + setPickerOpen(true)}> + + Color… + + + + Rename + + + + Delete + + + + + - - - Rename - - - - Delete - - - +
+ {PROFILE_SWATCHES.map(swatch => ( +
+ + + ) } diff --git a/apps/desktop/src/components/ui/popover.tsx b/apps/desktop/src/components/ui/popover.tsx new file mode 100644 index 000000000..844493678 --- /dev/null +++ b/apps/desktop/src/components/ui/popover.tsx @@ -0,0 +1,44 @@ +import { Popover as PopoverPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Popover({ ...props }: React.ComponentProps) { + return +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return +} + +function PopoverContent({ + align = 'center', + className, + collisionPadding = 8, + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } diff --git a/apps/desktop/src/lib/profile-color.ts b/apps/desktop/src/lib/profile-color.ts index ab528e0c1..289b3c997 100644 --- a/apps/desktop/src/lib/profile-color.ts +++ b/apps/desktop/src/lib/profile-color.ts @@ -30,6 +30,28 @@ export function profileColor(name: null | string | undefined): null | string { return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` } +// A profile's effective color: a user-picked override wins, else the +// deterministic hue. Default/empty stays neutral (null) regardless. +export function resolveProfileColor( + name: null | string | undefined, + overrides: Record +): null | string { + const key = (name ?? '').trim() + + if (!key || key === 'default') { + return null + } + + return overrides[key] ?? profileColor(key) +} + +// Curated swatches for the rail color picker — evenly spaced hues at the same +// saturation/lightness as the deterministic palette, so picks stay cohesive. +export const PROFILE_SWATCHES: readonly string[] = Array.from( + { length: 12 }, + (_, index) => `hsl(${index * 30} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` +) + // Translucent fill derived from a profile color, for tag backgrounds. export function profileColorSoft(color: string, percent = 16): string { return `color-mix(in srgb, ${color} ${percent}%, transparent)` diff --git a/apps/desktop/src/lib/storage.ts b/apps/desktop/src/lib/storage.ts index 8174c9361..9f82ae4b8 100644 --- a/apps/desktop/src/lib/storage.ts +++ b/apps/desktop/src/lib/storage.ts @@ -64,6 +64,36 @@ export function persistStringArray(key: string, value: string[]) { } } +export function storedStringRecord(key: string): Record { + try { + const value = window.localStorage.getItem(key) + + if (!value) { + return {} + } + + const parsed = JSON.parse(value) + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) + } catch { + return {} + } +} + +export function persistStringRecord(key: string, value: Record) { + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Local preference; restricted storage should not break the app. + } +} + export function arraysEqual(left: string[], right: string[]) { return left.length === right.length && left.every((item, index) => item === right[index]) } diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 5a59de89b..f89d0dae1 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -3,7 +3,15 @@ import { atom, computed } from 'nanostores' import { getProfiles, setApiRequestProfile } from '@/hermes' import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { queryClient } from '@/lib/query-client' -import { arraysEqual, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage' +import { + arraysEqual, + persistBoolean, + persistStringArray, + persistStringRecord, + storedBoolean, + storedStringArray, + storedStringRecord +} from '@/lib/storage' import { $gateway } from '@/store/gateway' import { setConnection } from '@/store/session' import type { ProfileInfo } from '@/types/hermes' @@ -62,6 +70,30 @@ export function sortByProfileOrder(items: T[], order }) } +// ── Rail colors ──────────────────────────────────────────────────────────── +// Optional per-profile color override (long-press a rail square to pick). Absent +// names fall back to the deterministic hue from profileColor(); a local-only +// cosmetic preference, so single-profile users never touch it. +const PROFILE_COLORS_STORAGE_KEY = 'hermes.desktop.profileColors' + +export const $profileColors = atom>(storedStringRecord(PROFILE_COLORS_STORAGE_KEY)) + +$profileColors.subscribe(value => persistStringRecord(PROFILE_COLORS_STORAGE_KEY, value)) + +// Set (or, with null, clear) a profile's color override. +export function setProfileColor(name: string, color: null | string): void { + const key = normalizeProfileKey(name) + const next = { ...$profileColors.get() } + + if (color) { + next[key] = color + } else { + delete next[key] + } + + $profileColors.set(next) +} + interface ActiveProfileResponse { active: string current: string