Files
orca/mobile/src/hooks/use-active-worktree-scroll.ts
T
Jinwoo Hong 92bbb380a6 Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)
* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.
2026-10-21 12:24:27 -04:00

94 lines
3.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { SectionList, SectionListData } from 'react-native'
type WithId = { worktreeId: string; isActive?: boolean }
// Scrolls the highlighted (isActive) worktree into view when the highlight
// moves to a different workspace. A highlight a search or fold hides and then
// shows again is the same workspace, so it never yanks the list. Returns the
// ref to attach to the SectionList and the onScrollToIndexFailed handler it
// needs for rows that aren't measured yet (variable heights from the inline
// agent list).
export function useActiveWorktreeScroll<T extends WithId, S>(
sections: readonly (SectionListData<T, S> & { data: readonly T[] })[],
/** The folded sections: a highlight an unfold first reveals is not scrolled to either. */
collapsedGroups?: ReadonlySet<string>
): {
sectionListRef: React.RefObject<SectionList<T, S> | null>
onScrollToIndexFailed: (info: { averageItemLength: number }) => void
} {
const sectionListRef = useRef<SectionList<T, S>>(null)
const lastScrolledActiveIdRef = useRef<string | null>(null)
// The last workspace highlighted, scrolled to or not: a hidden row coming back is no change.
const seenActiveIdRef = useRef<string | null>(null)
const collapsedGroupsRef = useRef(collapsedGroups)
const activeWorktreeId = useMemo(() => {
for (const section of sections) {
const match = section.data.find((w) => w.isActive)
if (match) {
return match.worktreeId
}
}
return null
}, [sections])
// Live mirror of the current active id so the deferred retry can bail if the
// selection changed during its timeout (avoids a brief scroll to a stale row).
const activeWorktreeIdRef = useRef<string | null>(activeWorktreeId)
activeWorktreeIdRef.current = activeWorktreeId
const scrollToWorktree = useCallback(
(worktreeId: string): boolean => {
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId)
if (itemIndex !== -1) {
sectionListRef.current?.scrollToLocation({
sectionIndex,
itemIndex,
viewPosition: 0.5,
animated: true
})
return true
}
}
return false
},
[sections]
)
useEffect(() => {
const folded = collapsedGroupsRef.current !== collapsedGroups
collapsedGroupsRef.current = collapsedGroups
if (!activeWorktreeId || activeWorktreeId === seenActiveIdRef.current) {
return
}
seenActiveIdRef.current = activeWorktreeId
if (!folded && scrollToWorktree(activeWorktreeId)) {
lastScrolledActiveIdRef.current = activeWorktreeId
}
}, [collapsedGroups, activeWorktreeId, scrollToWorktree])
const onScrollToIndexFailed = useCallback(
(info: { averageItemLength: number }) => {
const target = lastScrolledActiveIdRef.current
if (!target) {
return
}
setTimeout(
() => {
// Bail if the active selection moved on while we waited — otherwise we'd
// scroll to a now-stale row before the effect corrects it.
if (activeWorktreeIdRef.current === target) {
scrollToWorktree(target)
}
},
info.averageItemLength > 0 ? 120 : 0
)
},
[scrollToWorktree]
)
return { sectionListRef, onScrollToIndexFailed }
}