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>
)
}
+3 -3
View File
@@ -898,9 +898,9 @@ export default {
'share.preview.empty': '(empty share)',
'share.preview.files': '{n} files',
'share.noActiveSessions': 'No active sessions. Pick "New session" below.',
'button.refresh': 'Refresh',
'sessions.refresh.success.title': 'Refreshed',
'sessions.refresh.success.body': 'Session list updated.',
'sessions.refresh.pull': 'Pull to refresh',
'sessions.refresh.release': 'Release to refresh',
'sessions.refresh.refreshing': 'Refreshing…',
'sessions.refresh.failed.title': 'Refresh failed',
'codexSync.newSessionInline.title': 'Import Codex history',
'codexSync.newSessionInline.description': 'Optional: choose one local Codex session, then pick model/settings and Create to import.',
+3 -3
View File
@@ -902,9 +902,9 @@ export default {
'share.preview.empty': '(空分享)',
'share.preview.files': '{n} 个文件',
'share.noActiveSessions': '没有活跃会话。请在下方选择"新建会话"。',
'button.refresh': '刷新',
'sessions.refresh.success.title': '刷新',
'sessions.refresh.success.body': '会话列表已更新。',
'sessions.refresh.pull': '下拉即可刷新',
'sessions.refresh.release': '释放即可刷新',
'sessions.refresh.refreshing': '正在刷新…',
'sessions.refresh.failed.title': '刷新失败',
'codexSync.newSessionInline.title': '导入 Codex 历史',
'codexSync.newSessionInline.description': '可选:先单选一个本机 Codex 会话,再选择模型/思考强度,点创建时导入。',
+1 -38
View File
@@ -104,26 +104,6 @@ function PlusIcon(props: { className?: string }) {
)
}
function RefreshIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<path d="M21 3v5h-5" />
</svg>
)
}
function FolderOpenIcon(props: { className?: string }) {
return (
<svg
@@ -173,15 +153,9 @@ function SessionsPage() {
const { sessions, isLoading, error, refetch } = useSessions(api)
const { machines } = useMachines(api, true)
const handleRefresh = useCallback(() => {
void (async () => {
return (async () => {
try {
await refetch()
addToast({
title: t('sessions.refresh.success.title'),
body: t('sessions.refresh.success.body'),
sessionId: '',
url: ''
})
} catch (error) {
addToast({
title: t('sessions.refresh.failed.title'),
@@ -252,17 +226,6 @@ function SessionsPage() {
renderHeader={false}
headerActions={(
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
aria-label={t('button.refresh')}
aria-busy={isLoading}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors disabled:opacity-60 disabled:cursor-wait"
title={t('button.refresh')}
>
<RefreshIcon className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => navigate({ to: '/browse' })}