fix(desktop): reveal clipped profile rail edges and pin actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof observeMask>
|
||||
|
||||
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(<ProfileRail />)
|
||||
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(<ProfileRail />)
|
||||
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)',
|
||||
|
||||
@@ -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 | string>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollMask, setScrollMask] = useState<string | undefined>()
|
||||
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<string | null>(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() {
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[stepThroughCells]}
|
||||
onDragCancel={() => setDragging(false)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
@@ -563,64 +595,69 @@ export function ProfileRail() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* The active gateway's squares. In fleet mode they sit in the
|
||||
<>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
onScroll={measureScroll}
|
||||
ref={scrollRef}
|
||||
style={{ maskImage: dragging ? undefined : scrollMask }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1" ref={scrollContentRef}>
|
||||
{/* 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' ? (
|
||||
<Fragment key="active">
|
||||
<FleetDivider
|
||||
connection={activeConnection}
|
||||
first={index === 0}
|
||||
label={activeConnection ? p.fleet.gateway(activeConnection.label) : null}
|
||||
reachable
|
||||
/>
|
||||
<span
|
||||
aria-label={activeConnection ? p.fleet.gateway(activeConnection.label) : undefined}
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
data-active="true"
|
||||
data-connection-id={activeConnection?.id}
|
||||
data-slot="profile-rail-gateway"
|
||||
role="group"
|
||||
>
|
||||
{defaultProfile && (
|
||||
<ProfilePill
|
||||
active={onDefault}
|
||||
connectionId={activeConnectionId ?? undefined}
|
||||
glyph="home"
|
||||
label={profileLabel(defaultProfile)}
|
||||
onSelect={() => selectProfile(defaultProfile.name)}
|
||||
profile={defaultProfile.name}
|
||||
{fleet
|
||||
? fleetSequence.map((entry, index) =>
|
||||
entry.kind === 'active' ? (
|
||||
<Fragment key="active">
|
||||
<FleetDivider
|
||||
connection={activeConnection}
|
||||
first={index === 0}
|
||||
label={activeConnection ? p.fleet.gateway(activeConnection.label) : null}
|
||||
reachable
|
||||
/>
|
||||
)}
|
||||
{activeStrip}
|
||||
</span>
|
||||
</Fragment>
|
||||
) : (
|
||||
<FleetRestGroup
|
||||
colors={colors}
|
||||
first={index === 0}
|
||||
group={entry.group}
|
||||
key={entry.group.connectionId}
|
||||
onDelete={setPendingRestDelete}
|
||||
onEditSoul={setPendingRestSoul}
|
||||
onRecolor={(agent, color) => setProfileColor(agent.profile, color)}
|
||||
onRename={setPendingRestRename}
|
||||
onSelect={switchToRest}
|
||||
pendingRoute={pendingRoute}
|
||||
/>
|
||||
)
|
||||
)
|
||||
: activeStrip}
|
||||
|
||||
<span
|
||||
aria-label={activeConnection ? p.fleet.gateway(activeConnection.label) : undefined}
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
data-active="true"
|
||||
data-connection-id={activeConnection?.id}
|
||||
data-slot="profile-rail-gateway"
|
||||
role="group"
|
||||
>
|
||||
{defaultProfile && (
|
||||
<ProfilePill
|
||||
active={onDefault}
|
||||
connectionId={activeConnectionId ?? undefined}
|
||||
glyph="home"
|
||||
label={profileLabel(defaultProfile)}
|
||||
onSelect={() => selectProfile(defaultProfile.name)}
|
||||
profile={defaultProfile.name}
|
||||
/>
|
||||
)}
|
||||
{activeStrip}
|
||||
</span>
|
||||
</Fragment>
|
||||
) : (
|
||||
<FleetRestGroup
|
||||
colors={colors}
|
||||
first={index === 0}
|
||||
group={entry.group}
|
||||
key={entry.group.connectionId}
|
||||
onDelete={setPendingRestDelete}
|
||||
onEditSoul={setPendingRestSoul}
|
||||
onRecolor={(agent, color) => setProfileColor(agent.profile, color)}
|
||||
onRename={setPendingRestRename}
|
||||
onSelect={switchToRest}
|
||||
pendingRoute={pendingRoute}
|
||||
/>
|
||||
)
|
||||
)
|
||||
: activeStrip}
|
||||
</div>
|
||||
</div>
|
||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
<ImportProfileButton label={p.importProfile} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Always reachable, even with only the default profile: the manage
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user