mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): replace session list refresh button with pull-to-refresh
Remove the refresh icon button from the session list toolbar and make the list itself the refresh affordance via a touch pull gesture. Gesture (SessionList): - Touch listeners on the scrollable list container; pull engages only at scrollTop 0, with 16px feedback / 64px trigger thresholds, and fires on release past the trigger. Mirrors the established pull-to-load-older pattern in HappyThread. - Touch-only by design: desktop has no overscroll bounce, so a wheel pull feels broken; desktop keeps relying on SSE live updates and query focus refetch. - onRefresh widened to () => Promise<unknown> | void so the indicator tracks the in-flight refetch and ignores re-entrant pulls. Feedback: - Status pill over the list (role=status, aria-live) shows pull/release/refreshing states with a spinner while refreshing; it also covers the initial useSessions load (isLoading), which lost its only busy indication when the toolbar button was removed. - The success toast is dropped (the pill is the feedback); the failure toast is kept. handleRefresh now returns its promise. Empty states (review P2s): - SessionsEmptyState and the no-results message move from the shrink-0 header container into the scroll container, so the gesture works on the visible empty state (retry path after a failed initial fetch) and short viewports scroll instead of crushing the gesture area. - SessionsEmptyState is gated on !isLoading so a slow initial request no longer flashes the final empty state with active actions. i18n: add sessions.refresh.pull/release/refreshing (en + zh-CN), remove now-unused button.refresh and sessions.refresh.success.*. Desktop wheel pull was implemented and then reverted after review.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
getSessionTimeRange,
|
||||
getNextSessionVisibleCount,
|
||||
getPreviousSessionVisibleCount,
|
||||
getPullToRefreshState,
|
||||
getSessionDedupKey,
|
||||
getWorktreeSessionLabel,
|
||||
getVisibleSessionPreview,
|
||||
@@ -470,3 +471,12 @@ describe('expandSelectedSessionCollapseOverrides', () => {
|
||||
expect(result.has('sessions::machine-1::/work/hapi')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPullToRefreshState', () => {
|
||||
it('requires a deliberate pull past the trigger distance', () => {
|
||||
expect(getPullToRefreshState(15)).toBe('idle')
|
||||
expect(getPullToRefreshState(16)).toBe('pulling')
|
||||
expect(getPullToRefreshState(63)).toBe('pulling')
|
||||
expect(getPullToRefreshState(64)).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MachineFilterBar } from '@/components/MachineFilterBar'
|
||||
import { useSessionListMachineFilter } from '@/hooks/useSessionListMachineFilter'
|
||||
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
|
||||
import { SessionRowSummary } from '@/components/SessionRowSummary'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
|
||||
export { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
|
||||
|
||||
@@ -942,13 +943,28 @@ function SessionItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
type PullToRefreshState = 'idle' | 'pulling' | 'ready'
|
||||
|
||||
const PULL_REFRESH_FEEDBACK_PX = 16
|
||||
const PULL_REFRESH_TRIGGER_PX = 64
|
||||
|
||||
export function getPullToRefreshState(distancePx: number): PullToRefreshState {
|
||||
if (distancePx >= PULL_REFRESH_TRIGGER_PX) {
|
||||
return 'ready'
|
||||
}
|
||||
if (distancePx >= PULL_REFRESH_FEEDBACK_PX) {
|
||||
return 'pulling'
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function SessionList(props: {
|
||||
sessions: SessionSummary[]
|
||||
onSelect: (sessionId: string) => void
|
||||
onNewSession: () => void
|
||||
onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void
|
||||
onBrowse?: () => void
|
||||
onRefresh: () => void
|
||||
onRefresh: () => Promise<unknown> | void
|
||||
isLoading: boolean
|
||||
renderHeader?: boolean
|
||||
headerActions?: React.ReactNode
|
||||
@@ -1188,6 +1204,95 @@ export function SessionList(props: {
|
||||
|
||||
const showHeaderRow = showSearch || renderHeader || Boolean(props.headerActions)
|
||||
|
||||
// Pull-to-refresh on the scrollable list. Touch-only gesture mirroring the
|
||||
// pull-to-load-older pattern in HappyThread; desktop has no overscroll
|
||||
// bounce to make a wheel pull feel right, so it stays on live updates.
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>('idle')
|
||||
const pullStateRef = useRef<PullToRefreshState>('idle')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const isRefreshingRef = useRef(false)
|
||||
const onRefreshRef = useRef(props.onRefresh)
|
||||
useEffect(() => {
|
||||
onRefreshRef.current = props.onRefresh
|
||||
}, [props.onRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
let pullStartY: number | null = null
|
||||
|
||||
const updatePullState = (state: PullToRefreshState) => {
|
||||
if (pullStateRef.current === state) {
|
||||
return
|
||||
}
|
||||
pullStateRef.current = state
|
||||
setPullState(state)
|
||||
}
|
||||
|
||||
const triggerRefresh = () => {
|
||||
if (isRefreshingRef.current) {
|
||||
return
|
||||
}
|
||||
isRefreshingRef.current = true
|
||||
setIsRefreshing(true)
|
||||
void Promise.resolve(onRefreshRef.current()).finally(() => {
|
||||
isRefreshingRef.current = false
|
||||
setIsRefreshing(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
updatePullState('idle')
|
||||
pullStartY = container.scrollTop <= 0 && !isRefreshingRef.current
|
||||
? event.touches[0]?.clientY ?? null
|
||||
: null
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (pullStartY === null) {
|
||||
return
|
||||
}
|
||||
if (container.scrollTop > 0) {
|
||||
pullStartY = null
|
||||
updatePullState('idle')
|
||||
return
|
||||
}
|
||||
const currentY = event.touches[0]?.clientY
|
||||
if (currentY !== undefined) {
|
||||
updatePullState(getPullToRefreshState(currentY - pullStartY))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const shouldRefresh = pullStartY !== null
|
||||
&& pullStateRef.current === 'ready'
|
||||
&& container.scrollTop <= 0
|
||||
pullStartY = null
|
||||
updatePullState('idle')
|
||||
if (shouldRefresh) {
|
||||
triggerRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
pullStartY = null
|
||||
updatePullState('idle')
|
||||
}
|
||||
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="session-list-scrollbar-offset mx-auto w-full max-w-content shrink-0">
|
||||
@@ -1227,19 +1332,6 @@ export function SessionList(props: {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length === 0 && (
|
||||
<SessionsEmptyState
|
||||
onNewSession={props.onNewSession}
|
||||
onBrowse={props.onBrowse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
||||
{t('sessions.search.noResults')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMachineFilterBar ? (
|
||||
<MachineFilterBar
|
||||
machines={machineFilters.map((mg) => {
|
||||
@@ -1261,8 +1353,40 @@ export function SessionList(props: {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="app-scroll-y session-list-scrollbar-left min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{isRefreshing || pullState !== 'idle' || props.isLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-bg)]/90 px-2.5 py-1 text-xs text-[var(--app-hint)] shadow-sm backdrop-blur"
|
||||
>
|
||||
{isRefreshing || props.isLoading ? <Spinner size="sm" label={null} className="text-current" /> : null}
|
||||
<span>
|
||||
{isRefreshing
|
||||
? t('sessions.refresh.refreshing')
|
||||
: props.isLoading
|
||||
? t('misc.loading')
|
||||
: pullState === 'ready'
|
||||
? t('sessions.refresh.release')
|
||||
: t('sessions.refresh.pull')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={scrollContainerRef} className="app-scroll-y session-list-scrollbar-left min-h-0 flex-1">
|
||||
<div className="mx-auto flex w-full max-w-content flex-col gap-1 pl-1.5 pr-2 pb-2">
|
||||
{props.sessions.length === 0 && !props.isLoading ? (
|
||||
<SessionsEmptyState
|
||||
onNewSession={props.onNewSession}
|
||||
onBrowse={props.onBrowse}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
||||
{t('sessions.search.noResults')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = isGroupCollapsed(group)
|
||||
const visibleGroupSessions = getVisibleGroupSessions(group)
|
||||
@@ -1366,6 +1490,7 @@ export function SessionList(props: {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user