diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 775fc9e530..07cc6fdec0 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -243,6 +243,11 @@ separate chip component. ## Reel +The profile rail keeps its create/import actions outside the scrolling squares. +Clipped horizontal edges reuse `edgeMask(edges, 'x')` from `fade-scroll.tsx`; +the default axis remains vertical for `FadeScroll`. Fitting content is unmasked, +and profile drag gestures temporarily remove the mask so the dragged square stays legible. + `src/components/ui/reel.tsx`: one horizontal, snap-scrolling row (catalog category shelves, screenshot strips). Children keep their width and snap to the start; set it once from the parent (`className="*:w-68"`). Use it instead of diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx index 5508b6b401..e90543c1d6 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx @@ -238,6 +238,7 @@ afterEach(() => { cleanup() $profileOrder.set([]) vi.clearAllMocks() + vi.restoreAllMocks() _resetFleetRosterForTests() hasMultipleConnections.set(false) connectionsRegistry.set(null) @@ -247,6 +248,73 @@ afterEach(() => { delete (window as { hermesDesktop?: unknown }).hermesDesktop }) +describe('ProfileRail overflow', () => { + // jsdom drops valid gradient values containing calc(); observe the real DOM + // style assignment instead. Actual layout, resize and painting run in Chromium. + const observeMask = () => vi.spyOn(Object.getPrototypeOf(document.createElement('div').style), 'maskImage', 'set') + let mask: ReturnType + + beforeEach(() => { + mask = observeMask() + }) + + it('marks only clipped edges and leaves create/import outside the scrolling profiles', () => { + profiles.set( + Array.from({ length: 8 }, (_, index) => ({ is_default: index === 0, name: index ? `agent${index}` : 'default' })) + ) + const { container } = render() + const square = screen.getByRole('button', { name: 'agent1' }) + const scroller = square.closest('.overflow-x-auto') as HTMLDivElement + expect(scroller).not.toBeNull() + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: 100 }, + scrollWidth: { configurable: true, value: 200 } + }) + fireEvent.scroll(scroller) + expect(mask.mock.calls.at(-1)?.[0]).toContain('to right, black,') + expect(mask.mock.calls.at(-1)?.[0]).toContain('transparent)') + expect(scroller.contains(screen.getByRole('button', { name: 'New profile' }))).toBe(false) + expect(scroller.contains(screen.getByRole('button', { name: 'Import profile…' }))).toBe(false) + + scroller.scrollLeft = 50 + fireEvent.scroll(scroller) + expect(mask.mock.calls.at(-1)?.[0]).toContain('to right, transparent,') + expect(mask.mock.calls.at(-1)?.[0]).toContain('transparent)') + scroller.scrollLeft = 100 + fireEvent.scroll(scroller) + expect(mask.mock.calls.at(-1)?.[0]).toContain('to right, transparent,') + expect(mask.mock.calls.at(-1)?.[0]).toMatch(/, black\)$/) + + Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: 300 }) + scroller.scrollLeft = 0 + fireEvent.scroll(scroller) + expect(mask.mock.calls.at(-1)?.[0]).toBe('') + expect(container.querySelector('[data-slot="profile-dropdown"]')).toBeNull() + }) + + it('restores wheel navigation and edge feedback after leaving the condensed menu', () => { + profiles.set( + Array.from({ length: 14 }, (_, index) => ({ is_default: index === 0, name: index ? `agent${index}` : 'default' })) + ) + const { container } = render() + expect(container.querySelector('[data-slot="profile-dropdown"]')).not.toBeNull() + act(() => profiles.set(profiles.get().slice(0, 8))) + const scroller = screen.getByRole('button', { name: 'agent1' }).closest('.overflow-x-auto') as HTMLDivElement + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: 100 }, + scrollWidth: { configurable: true, value: 200 } + }) + const wheel = new WheelEvent('wheel', { cancelable: true, deltaY: 30 }) + scroller.dispatchEvent(wheel) + expect(wheel.defaultPrevented).toBe(true) + expect(scroller.scrollLeft).toBe(30) + fireEvent.scroll(scroller) + expect(mask.mock.calls.at(-1)?.[0]).toContain('transparent') + fireEvent.click(screen.getByRole('button', { name: 'agent1' })) + expect(selectProfile).toHaveBeenCalledWith('agent1') + }) +}) + describe('ProfileRail fleet mode', () => { it.each([false, true])( 'keeps right-click launch routes exact without selecting a profile (condensed=%s)', diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 9d3a2af36b..a639a76950 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -20,7 +20,7 @@ import { import { CSS } from '@dnd-kit/utilities' import { LOCAL_CONNECTION_ID } from '@hermes/shared' import { useStore } from '@nanostores/react' -import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router' import type { ProfileScope } from '@/api/client' @@ -41,11 +41,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { edgeMask, scrollEdges } from '@/components/ui/fade-scroll' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { ProfileGlyph } from '@/components/ui/profile-glyph' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import type { DesktopRegistryConnection } from '@/global' import { getProfileSoul, updateProfileSoul } from '@/hermes' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { type Translations, useI18n } from '@/i18n' import { sortConnectionsForDisplay } from '@/lib/connection-display' import { triggerHaptic } from '@/lib/haptics' @@ -237,6 +239,9 @@ export function ProfileRail() { // square, not in the statusbar — the previous source stays painted). const [pendingRoute, setPendingRoute] = useState(null) const scrollRef = useRef(null) + const scrollContentRef = useRef(null) + const [scrollMask, setScrollMask] = useState() + const [dragging, setDragging] = useState(false) const { dialog: localDeviceDialog, request: requestLocalDevice } = useLocalDeviceSwitch() useFleetRoster(multipleConnections) @@ -295,6 +300,30 @@ export function ProfileRail() { // squares wherever they live. const condensed = profiles.length + countRestAgents(restGroups) > PROFILE_DROPDOWN_THRESHOLD + const measureScroll = useCallback(() => { + const el = scrollRef.current + + if (condensed || !el) { + setScrollMask(undefined) + + return + } + + setScrollMask( + edgeMask( + scrollEdges({ + clientHeight: el.clientWidth, + scrollHeight: el.scrollWidth, + scrollTop: el.scrollLeft + }), + 'x' + ) + ) + }, [condensed]) + + // Observe both widths: adding/removing a profile need not resize the viewport. + useResizeObserver(measureScroll, scrollRef, scrollContentRef) + const switchToRest = (agent: FleetAgent) => { const commitRestSwitch = (target: FleetAgent) => { const key = fleetRouteKey(target.connectionId, target.profile) @@ -382,6 +411,7 @@ export function ProfileRail() { const lastOverRef = useRef(null) const handleDragStart = ({ active }: DragStartEvent) => { + setDragging(true) lastOverRef.current = String(active.id) } @@ -395,6 +425,7 @@ export function ProfileRail() { } const handleDragEnd = ({ active, over }: DragEndEvent) => { + setDragging(false) lastOverRef.current = null if (!over || active.id === over.id) { @@ -452,6 +483,7 @@ export function ProfileRail() { setDragging(false)} onDragEnd={handleDragEnd} onDragOver={handleDragOver} onDragStart={handleDragStart} @@ -563,64 +595,69 @@ export function ProfileRail() { /> ) : ( -
- {/* The active gateway's squares. In fleet mode they sit in the + <> +
+
+ {/* The active gateway's squares. In fleet mode they sit in the gateway's registry slot with a home square at their head, so the strip keeps one shape whichever gateway is active. */} - {fleet - ? fleetSequence.map((entry, index) => - entry.kind === 'active' ? ( - - - - {defaultProfile && ( - selectProfile(defaultProfile.name)} - profile={defaultProfile.name} + {fleet + ? fleetSequence.map((entry, index) => + entry.kind === 'active' ? ( + + - )} - {activeStrip} - - - ) : ( - setProfileColor(agent.profile, color)} - onRename={setPendingRestRename} - onSelect={switchToRest} - pendingRoute={pendingRoute} - /> - ) - ) - : activeStrip} - + + {defaultProfile && ( + selectProfile(defaultProfile.name)} + profile={defaultProfile.name} + /> + )} + {activeStrip} + + + ) : ( + setProfileColor(agent.profile, color)} + onRename={setPendingRestRename} + onSelect={switchToRest} + pendingRoute={pendingRoute} + /> + ) + ) + : activeStrip} +
+
setCreateOpen(true)} /> -
+ )} {/* Always reachable, even with only the default profile: the manage diff --git a/apps/desktop/src/components/ui/fade-scroll.tsx b/apps/desktop/src/components/ui/fade-scroll.tsx index 38bafe0c84..50a0acc355 100644 --- a/apps/desktop/src/components/ui/fade-scroll.tsx +++ b/apps/desktop/src/components/ui/fade-scroll.tsx @@ -15,7 +15,7 @@ export interface FadeEdges { * The mask for a pair of clipped edges, or `undefined` when nothing is clipped * — a list that fits must not be dimmed at all. */ -export function edgeMask({ above, below }: FadeEdges): string | undefined { +export function edgeMask({ above, below }: FadeEdges, axis: 'x' | 'y' = 'y'): string | undefined { if (!above && !below) { return undefined } @@ -23,7 +23,7 @@ export function edgeMask({ above, below }: FadeEdges): string | undefined { const top = above ? `transparent, black ${FADE}` : 'black' const bottom = below ? `black calc(100% - ${FADE}), transparent` : 'black' - return `linear-gradient(to bottom, ${top}, ${bottom})` + return `linear-gradient(to ${axis === 'x' ? 'right' : 'bottom'}, ${top}, ${bottom})` } /** Which edges of a scroller currently have content clipped behind them. */