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