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:
weishu
2026-08-01 10:37:09 +08:00
parent 39da5c4b8e
commit b6897ee736
5 changed files with 157 additions and 59 deletions
+10
View File
@@ -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')
})
})
+140 -15
View File
@@ -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>
)
}