diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index b39b6b54..54b85372 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -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') + }) +}) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 7531895b..c05095d1 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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 | 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(null) + const [pullState, setPullState] = useState('idle') + const pullStateRef = useRef('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 (
@@ -1227,19 +1332,6 @@ export function SessionList(props: {
) : null} - {props.sessions.length === 0 && ( - - )} - - {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? ( -
- {t('sessions.search.noResults')} -
- ) : null} - {showMachineFilterBar ? ( { @@ -1261,8 +1353,40 @@ export function SessionList(props: { ) : null}
-
+
+ {isRefreshing || pullState !== 'idle' || props.isLoading ? ( +
+ {isRefreshing || props.isLoading ? : null} + + {isRefreshing + ? t('sessions.refresh.refreshing') + : props.isLoading + ? t('misc.loading') + : pullState === 'ready' + ? t('sessions.refresh.release') + : t('sessions.refresh.pull')} + +
+ ) : null} +
+ {props.sessions.length === 0 && !props.isLoading ? ( + + ) : null} + + {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? ( +
+ {t('sessions.search.noResults')} +
+ ) : null} + {groups.map((group) => { const isCollapsed = isGroupCollapsed(group) const visibleGroupSessions = getVisibleGroupSessions(group) @@ -1366,6 +1490,7 @@ export function SessionList(props: { })}
+
) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 67342506..83801eec 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 499fdfdc..b83f551c 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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 会话,再选择模型/思考强度,点创建时导入。', diff --git a/web/src/router.tsx b/web/src/router.tsx index a45d6600..5e513f73 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -104,26 +104,6 @@ function PlusIcon(props: { className?: string }) { ) } -function RefreshIcon(props: { className?: string }) { - return ( - - - - - ) -} - function FolderOpenIcon(props: { className?: string }) { return ( { - 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={(
-