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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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' })}
|
||||
|
||||
Reference in New Issue
Block a user