fix(desktop): reveal clipped profile rail edges and pin actions

This commit is contained in:
funky-xamarin
2026-09-26 11:08:31 +08:00
committed by brooklyn!
parent a88dce6e2d
commit 24bbdab4c7
4 changed files with 166 additions and 56 deletions

View File

@@ -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

View File

@@ -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)',

View File

@@ -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

View File

@@ -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. */