fix(tasks): restore GitHub page and scroll position on reopen (#13096)

This commit is contained in:
Jinwoo Hong 2026-08-09 11:08:04 -07:00 committed by GitHub
parent 34f2a62cda
commit bd9addb449
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 594 additions and 16 deletions

View File

@ -244,6 +244,11 @@ import {
type TaskPageRepoSourceState
} from '@/components/task-page-cache-selectors'
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
import {
buildTaskPageGitHubResumeContextKey,
taskPageGitHubResumeCache,
TASK_PAGE_GITHUB_RESUME_FRESH_MS
} from '@/components/task-page-github-resume-cache'
import {
applyEmptyPageClamp,
applyWindowPageLimit,
@ -3886,6 +3891,11 @@ export default function TaskPage(): React.JSX.Element {
CROSS_REPO_DISPLAY_LIMIT
)
const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length)
const githubResumeContextKey = buildTaskPageGitHubResumeContextKey({
selectedReposKey,
query: appliedTaskSearch.trim(),
pageSize: githubPageSize
})
// Why: null entries are pages not fetched yet; numbered provider pages let a high-page click load directly without reading intermediate pages.
const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => {
const trimmed = initialTaskQuery.trim()
@ -3913,6 +3923,11 @@ export default function TaskPage(): React.JSX.Element {
const currentPageRef = useRef(currentPage)
pagesRef.current = pages
currentPageRef.current = currentPage
const githubResumeConsumedRef = useRef(false)
const githubResumeContextRef = useRef('')
const githubListScrollRef = useRef<HTMLDivElement>(null)
const githubListScrollTopRef = useRef(0)
const pendingGithubScrollRestoreRef = useRef<number | null>(null)
const [paginationLoading, setPaginationLoading] = useState(false)
const [loadingTargetPage, setLoadingTargetPage] = useState<number | null>(null)
const [countedTotalPages, setCountedTotalPages] = useState<number | null>(null)
@ -3927,6 +3942,51 @@ export default function TaskPage(): React.JSX.Element {
const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage)
const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos)
useEffect(() => {
const page = pages[currentPage]
if (!taskResumeApplied || taskSource !== 'github' || githubMode !== 'items' || !page) {
return
}
taskPageGitHubResumeCache.write(githubResumeContextKey, currentPage, page)
}, [currentPage, githubMode, githubResumeContextKey, pages, taskResumeApplied, taskSource])
const taskListPositionRef = useRef<{
contextKey: string
page: number
scrollTop: number
} | null>(null)
useLayoutEffect(() => {
if (
taskSource !== 'github' ||
githubMode !== 'items' ||
pageData.openGitHubWorkItem ||
pendingGithubScrollRestoreRef.current !== null
) {
return
}
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPage,
scrollTop: githubListScrollTopRef.current
}
}, [currentPage, githubMode, githubResumeContextKey, pageData.openGitHubWorkItem, taskSource])
useEffect(
() => () => {
const position = taskListPositionRef.current
const state = useAppStore.getState()
if (position && !state.taskPageData.openGitHubWorkItem) {
state.setTaskListPosition({
contextKey: position.contextKey,
page: position.page,
scrollTop: position.scrollTop
})
}
},
[]
)
// Why: keyed on selectedReposKey, not the selectedRepos array — a background
// repos:changed refresh mid-flight would otherwise bump the generation and
// silently discard the user's page navigation (#11485). Mirrors every dep of
@ -3976,6 +4036,68 @@ export default function TaskPage(): React.JSX.Element {
const dialogWorkItem = dialogWorkItemKey
? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem)
: null
useLayoutEffect(() => {
const scrollTop = pendingGithubScrollRestoreRef.current
const scrollElement = githubListScrollRef.current
if (scrollTop === null || !scrollElement || !pages[currentPage]) {
return
}
let frame: number | null = null
let timeout: number | null = null
let observer: ResizeObserver | null = null
const clearScheduledRestore = (): void => {
if (frame !== null) {
window.cancelAnimationFrame(frame)
frame = null
}
if (timeout !== null) {
window.clearTimeout(timeout)
timeout = null
}
observer?.disconnect()
}
const restore = (): void => {
const committedScrollElement = githubListScrollRef.current
if (!committedScrollElement || pendingGithubScrollRestoreRef.current !== scrollTop) {
return
}
committedScrollElement.scrollTop = scrollTop
githubListScrollTopRef.current = scrollTop
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPage,
scrollTop
}
if (Math.abs(committedScrollElement.scrollTop - scrollTop) < 1) {
pendingGithubScrollRestoreRef.current = null
clearScheduledRestore()
}
}
observer = new ResizeObserver(restore)
for (const child of scrollElement.children) {
observer.observe(child)
}
restore()
if (pendingGithubScrollRestoreRef.current === scrollTop) {
frame = window.requestAnimationFrame(restore)
timeout = window.setTimeout(() => {
if (pendingGithubScrollRestoreRef.current === scrollTop) {
const committedScrollTop = githubListScrollRef.current?.scrollTop ?? 0
githubListScrollTopRef.current = committedScrollTop
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPage,
scrollTop: committedScrollTop
}
pendingGithubScrollRestoreRef.current = null
}
clearScheduledRestore()
}, 5_000)
}
return clearScheduledRestore
}, [currentPage, dialogWorkItem, githubResumeContextKey, pages])
const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
const dialogSourceContext = useMemo(() => {
if (!dialogWorkItem) {
@ -4039,6 +4161,15 @@ export default function TaskPage(): React.JSX.Element {
const openGitHubDetailPage = useCallback(
(item: GitHubWorkItem, initialTab: ItemDialogTab = 'conversation') => {
const scrollTop = githubListScrollRef.current?.scrollTop ?? githubListScrollTopRef.current
githubListScrollTopRef.current = scrollTop
pendingGithubScrollRestoreRef.current = scrollTop
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPageRef.current,
scrollTop
}
useAppStore.getState().setTaskListPosition(taskListPositionRef.current)
openTaskPage(
{
taskSource: 'github',
@ -4050,7 +4181,7 @@ export default function TaskPage(): React.JSX.Element {
{ recordTasksInteraction: false }
)
},
[openTaskPage, repoMap]
[githubResumeContextKey, openTaskPage, repoMap]
)
const openGitLabDetailPage = useCallback(
@ -6616,6 +6747,30 @@ export default function TaskPage(): React.JSX.Element {
// Why: strip repo:owner/name qualifiers before fan-out — cross-repo they'd pin every fetch to one repo. See stripRepoQualifiers.
const q = stripRepoQualifiers(appliedTaskSearch.trim())
let cancelled = false
const contextChanged = githubResumeContextRef.current !== githubResumeContextKey
githubResumeContextRef.current = githubResumeContextKey
const savedPosition = !githubResumeConsumedRef.current
? useAppStore.getState().taskListPosition
: undefined
githubResumeConsumedRef.current = true
const savedPositionMatches = savedPosition?.contextKey === githubResumeContextKey
const targetPage = savedPositionMatches
? savedPosition.page
: contextChanged
? 0
: currentPageRef.current
const liveTargetItems = pagesRef.current[targetPage]
const cachedTargetPage = liveTargetItems
? { items: liveTargetItems, cachedAt: Date.now() }
: taskPageGitHubResumeCache.read(githubResumeContextKey, targetPage)
const cachedTargetIsFresh =
cachedTargetPage !== null &&
Date.now() - cachedTargetPage.cachedAt < TASK_PAGE_GITHUB_RESUME_FRESH_MS
if (savedPositionMatches) {
pendingGithubScrollRestoreRef.current = savedPosition.scrollTop
} else if (contextChanged) {
pendingGithubScrollRestoreRef.current = 0
}
// Why: paint cached rows synchronously before the fan-out so a selection change doesn't leave the prior rows on screen for a frame.
const preMerged: GitHubWorkItem[] = []
@ -6636,26 +6791,33 @@ export default function TaskPage(): React.JSX.Element {
preMerged.push(...cached)
}
}
// Why: always replace so an empty cache clears the previous query's rows.
// Why: page-one metadata and the restored numbered page have independent lifecycles.
const page0Raw =
preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : []
// Why: pre-paint must still overlay in-flight mutations (K4/K18).
setPages((previous) => [
materializeTaskPageItemList({
networkItems: page0Raw,
previousItems: previous.flatMap((page) => page ?? []),
queryKey: githubWorkItemMutationQueryKey
})
])
currentPageRef.current = 0
setCurrentPage(0)
const landingPages: (GitHubWorkItem[] | null)[] = Array.from(
{ length: targetPage + 1 },
() => null
)
landingPages[0] = materializeTaskPageItemList({
networkItems: page0Raw,
previousItems: pagesRef.current.flatMap((page) => page ?? []),
queryKey: githubWorkItemMutationQueryKey
})
if (targetPage > 0 && cachedTargetPage) {
landingPages[targetPage] = overlayPendingOnTaskPagePages([cachedTargetPage.items])[0] ?? []
}
pagesRef.current = landingPages
currentPageRef.current = targetPage
setPages(landingPages)
setCurrentPage(targetPage)
setCountedTotalPages(null)
countedTotalPagesRef.current = null
setProvenPageLimit(null)
setTasksError(null)
setFailedCount(0) // reset so a prior failure banner doesn't linger
setGithubUnavailable(false)
setTasksLoading(anyUncached)
setTasksLoading(targetPage > 0 ? cachedTargetPage === null : anyUncached)
// Preserve the existing nonce-gated force behavior.
const forceRefresh = taskRefreshNonce !== lastFetchedNonceRef.current
@ -6679,7 +6841,10 @@ export default function TaskPage(): React.JSX.Element {
}))
const landingRefreshKey = `${repoArgs.map((r) => `${r.repoId}:${r.path}`).join('|')}::${q}`
const shouldProbeOnLanding =
!forcedFetch && anyRepoCached && !landingGitHubRefreshKeysRef.current.has(landingRefreshKey)
!forcedFetch &&
!cachedTargetIsFresh &&
anyRepoCached &&
!landingGitHubRefreshKeysRef.current.has(landingRefreshKey)
if (shouldProbeOnLanding) {
landingGitHubRefreshKeysRef.current = new Set([
...landingGitHubRefreshKeysRef.current,
@ -6689,6 +6854,63 @@ export default function TaskPage(): React.JSX.Element {
// Why: manual refresh keeps cached rows (tasksLoading stays false), so track forced fetch separately for the toolbar spinner.
setTasksRefreshing(forcedFetch)
if (targetPage > 0 && (!cachedTargetIsFresh || forcedFetch)) {
const requestGeneration = paginationGenerationRef.current
if (!cachedTargetPage) {
setPaginationLoading(true)
setLoadingTargetPage(targetPage)
}
void fetchWorkItemsNextPage(
repoArgs,
githubPerRepoPageLimit,
githubPageSize,
q,
taskPageToGitHubApiPage(targetPage)
)
.then(({ items, failedCount, errorTypes }) => {
if (cancelled || paginationGenerationRef.current !== requestGeneration) {
return
}
if (items.length === 0) {
const { reason } = resolveEmptyPageOutcome({
target: targetPage,
failedCount,
errorTypes,
countedTotalPages: null
})
if (reason === 'load-failed' && cachedTargetPage) {
return
}
pendingGithubScrollRestoreRef.current = 0
currentPageRef.current = 0
setCurrentPage(0)
const next = [pagesRef.current[0] ?? []]
pagesRef.current = next
setPages(next)
return
}
const restoredItems = overlayPendingOnTaskPagePages([items])[0] ?? []
taskPageGitHubResumeCache.write(githubResumeContextKey, targetPage, restoredItems)
const next = [...pagesRef.current]
while (next.length <= targetPage) {
next.push(null)
}
next[targetPage] = restoredItems
pagesRef.current = next
setPages(next)
})
.catch((error) => {
console.error('Failed to restore GitHub task page:', error)
})
.finally(() => {
if (!cancelled && paginationGenerationRef.current === requestGeneration) {
setPaginationLoading(false)
setLoadingTargetPage(null)
setTasksLoading(false)
}
})
}
// Why: snapshot retrying keys at dispatch so an earlier settling effect doesn't wipe a newer retry's pending source.
const dispatchedRetrySourceKeys = retryingSourceKeys
void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, {
@ -6739,7 +6961,16 @@ export default function TaskPage(): React.JSX.Element {
patchWorkItem: useAppStore.getState().patchWorkItem,
sourceContextByRepoId
})
if (shouldProbeOnLanding) {
if (targetPage > 0) {
const next = [...pagesRef.current]
next[0] = materializeTaskPageItemList({
networkItems: items,
previousItems: next.flatMap((page) => page ?? []),
queryKey: githubWorkItemMutationQueryKey
})
pagesRef.current = next
setPages(next)
} else if (shouldProbeOnLanding) {
const replaceFirstPage = shouldReplaceTaskPageItemsAfterRefresh(page0Raw, items)
const resetPagination = shouldResetTaskPagePaginationAfterLandingRefresh(
page0Raw,
@ -6767,7 +6998,9 @@ export default function TaskPage(): React.JSX.Element {
}
setFailedCount(failed)
setGithubUnavailable(unavailable)
setTasksLoading(false)
if (targetPage === 0 || cachedTargetPage) {
setTasksLoading(false)
}
setTasksRefreshing(false)
setTasksFiltering(false)
}
@ -6791,7 +7024,9 @@ export default function TaskPage(): React.JSX.Element {
setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.')
setFailedCount(0) // the per-repo banner would be misleading next to tasksError
setGithubUnavailable(false)
setTasksLoading(false)
if (targetPage === 0 || cachedTargetPage) {
setTasksLoading(false)
}
setTasksRefreshing(false)
setTasksFiltering(false)
})
@ -9974,8 +10209,27 @@ export default function TaskPage(): React.JSX.Element {
// chrome (no gap, no top border/radius) so toolbar + table read as one.
<div className="flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div
ref={githubListScrollRef}
data-task-list-scroll="github"
className="min-h-0 flex-initial overflow-auto scrollbar-sleek scrollbar-sleek-lg"
style={{ scrollbarGutter: 'stable' }}
onScroll={(event) => {
const state = useAppStore.getState()
if (
state.activeView !== 'tasks' ||
state.taskPageData.openGitHubWorkItem ||
pendingGithubScrollRestoreRef.current !== null
) {
return
}
const scrollTop = event.currentTarget.scrollTop
githubListScrollTopRef.current = scrollTop
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPageRef.current,
scrollTop
}
}}
>
<div
// Why: z-40 must beat the rows' sticky left cells (z-20); this stacking context's z sets the whole header's level.
@ -10561,6 +10815,11 @@ export default function TaskPage(): React.JSX.Element {
totalPages={totalPages}
loadingTarget={loadingTargetPage}
onPageChange={(page) => {
pendingGithubScrollRestoreRef.current = null
githubListScrollTopRef.current = 0
if (githubListScrollRef.current) {
githubListScrollRef.current.scrollTop = 0
}
if (pages[page] !== null && pages[page] !== undefined) {
currentPageRef.current = page
setCurrentPage(page)

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
buildTaskPageGitHubResumeContextKey,
createTaskPageResumePageCache,
TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT
} from './task-page-github-resume-cache'
describe('task page GitHub resume cache', () => {
it('isolates entries by full list context', () => {
const cache = createTaskPageResumePageCache<number>()
const first = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'local:repo-a',
query: 'is:pr',
pageSize: 30
})
const second = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'ssh:host-a:repo-a',
query: 'is:pr',
pageSize: 30
})
cache.write(first, 2, [1])
cache.write(second, 2, [2])
expect(cache.read(first, 2)?.items).toEqual([1])
expect(cache.read(second, 2)?.items).toEqual([2])
})
it('evicts the least recently used page at the global cap', () => {
const cache = createTaskPageResumePageCache<number>()
for (let page = 0; page < TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT; page += 1) {
cache.write('scope', page, [page], page)
}
expect(cache.read('scope', 0, 10)?.items).toEqual([0])
cache.write('scope', TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT, [5], 11)
expect(cache.size()).toBe(TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT)
expect(cache.read('scope', 1, 12)).toBeNull()
expect(cache.read('scope', 0, 12)?.items).toEqual([0])
})
it('retains only five payloads after all 28 pages are visited', () => {
const cache = createTaskPageResumePageCache<number>()
for (let page = 0; page < 28; page += 1) {
cache.write('scope', page, [page], page)
}
expect(cache.size()).toBe(5)
expect(cache.read('scope', 22, 28)).toBeNull()
expect(cache.read('scope', 23, 28)?.items).toEqual([23])
expect(cache.read('scope', 27, 28)?.items).toEqual([27])
})
it('expires entries after the inactivity window', () => {
const cache = createTaskPageResumePageCache<number>({ ttlMs: 100 })
cache.write('scope', 4, [4], 0)
expect(cache.read('scope', 4, 99)?.items).toEqual([4])
expect(cache.read('scope', 4, 198)?.items).toEqual([4])
expect(cache.read('scope', 4, 298)).toBeNull()
})
it('copies page arrays at the cache boundary', () => {
const cache = createTaskPageResumePageCache<number>()
const source = [1, 2]
cache.write('scope', 0, source)
source.push(3)
const firstRead = cache.read('scope', 0)
firstRead?.items.push(4)
expect(cache.read('scope', 0)?.items).toEqual([1, 2])
})
})

View File

@ -0,0 +1,84 @@
import type { GitHubWorkItem } from '../../../shared/types'
export const TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT = 5
export const TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS = 10 * 60_000
export const TASK_PAGE_GITHUB_RESUME_FRESH_MS = 30_000
type CachedPage<T> = {
items: readonly T[]
cachedAt: number
lastAccessedAt: number
}
export type TaskPageResumeCachedPage<T> = {
items: T[]
cachedAt: number
}
type TaskPageResumePageCacheOptions = {
maxEntries?: number
ttlMs?: number
}
export function buildTaskPageGitHubResumeContextKey(args: {
selectedReposKey: string
query: string
pageSize: number
}): string {
return JSON.stringify(['github', 'items', args.selectedReposKey, args.query, args.pageSize])
}
export function createTaskPageResumePageCache<T>(options: TaskPageResumePageCacheOptions = {}): {
read: (contextKey: string, page: number, now?: number) => TaskPageResumeCachedPage<T> | null
write: (contextKey: string, page: number, items: readonly T[], now?: number) => void
clear: () => void
size: () => number
} {
const maxEntries = options.maxEntries ?? TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT
const ttlMs = options.ttlMs ?? TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS
const entries = new Map<string, CachedPage<T>>()
const keyFor = (contextKey: string, page: number): string => `${contextKey}\u0000${page}`
const pruneExpired = (now: number): void => {
for (const [key, entry] of entries) {
if (now - entry.lastAccessedAt >= ttlMs) {
entries.delete(key)
}
}
}
return {
read(contextKey, page, now = Date.now()) {
pruneExpired(now)
const key = keyFor(contextKey, page)
const entry = entries.get(key)
if (!entry) {
return null
}
entries.delete(key)
entries.set(key, { ...entry, lastAccessedAt: now })
return { items: [...entry.items], cachedAt: entry.cachedAt }
},
write(contextKey, page, items, now = Date.now()) {
pruneExpired(now)
const key = keyFor(contextKey, page)
entries.delete(key)
entries.set(key, { items: [...items], cachedAt: now, lastAccessedAt: now })
while (entries.size > maxEntries) {
const oldestKey = entries.keys().next().value
if (oldestKey === undefined) {
break
}
entries.delete(oldestKey)
}
},
clear() {
entries.clear()
},
size() {
return entries.size
}
}
}
export const taskPageGitHubResumeCache = createTaskPageResumePageCache<GitHubWorkItem>()

View File

@ -706,6 +706,8 @@ export type UISlice = {
}
taskResumeState: TaskResumeState | undefined
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
taskListPosition: { contextKey: string; page: number; scrollTop: number } | null
setTaskListPosition: (position: UISlice['taskListPosition']) => void
githubTaskDrawerWorkItem: GitHubWorkItem | null
setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void
newWorkspaceDraft: {
@ -1265,6 +1267,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
taskListPosition: null,
githubTaskDrawerWorkItem: null,
newWorkspaceDraft: null,
openTaskPage: (data = {}, options = {}) => {
@ -1419,6 +1422,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
window.api.ui.set({ taskResumeState: next }).catch(console.error)
return { taskResumeState: next }
}),
setTaskListPosition: (taskListPosition) => set({ taskListPosition }),
setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }),
closeTaskPage: () =>
set((state) => {

View File

@ -54,6 +54,60 @@ async function getRenderedTaskSources(
}, TASK_SOURCE_BY_LABEL)
}
async function openMockedPaginatedGitHubTasks(
page: Parameters<typeof getStoreState>[0]
): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const repos = store.getState().repos.map((repo, index) =>
index === 0
? {
...repo,
gitRemoteIdentity: {
canonicalKey: 'github.com/example/repo',
remoteName: 'origin',
remoteUrl: 'https://github.com/example/repo.git'
}
}
: repo
)
const makePage = (pageNumber: number) =>
Array.from({ length: 30 }, (_, index) => ({
id: `issue-${pageNumber}-${index + 1}`,
type: 'issue' as const,
number: pageNumber * 100 + index + 1,
title: `Issue page ${pageNumber} item ${index + 1}`,
state: 'open' as const,
url: `https://github.com/example/repo/issues/${pageNumber * 100 + index + 1}`,
labels: [],
updatedAt: new Date(1_700_000_000_000 - index * 1_000).toISOString(),
author: 'octocat',
repoId: repos[0]?.id ?? 'repo-1'
}))
store.setState({
repos,
getCachedWorkItems: () => makePage(1),
prefetchWorkItems: () => {},
fetchWorkItemsAcrossRepos: async () => ({
items: makePage(1),
failedCount: 0,
githubUnavailable: false
}),
fetchWorkItemsNextPage: async (_repos, _perRepoLimit, _displayLimit, _query, pageNumber) => ({
items: makePage(pageNumber),
failedCount: 0,
errorTypes: []
}),
countWorkItemsAcrossRepos: async () => ({ totalCount: 840, totalPages: 28 })
})
store.getState().openTaskPage({ taskSource: 'github' })
})
}
async function openInstrumentedGitHubTasksPage(
page: Parameters<typeof getStoreState>[0]
): Promise<void> {
@ -234,6 +288,108 @@ test.describe('Tasks page', () => {
}
})
test('reopening restores the GitHub page and scroll position', async ({ orcaPage }) => {
await openMockedPaginatedGitHubTasks(orcaPage)
await orcaPage.getByRole('button', { name: 'Page 28', exact: true }).click()
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
const list = orcaPage.locator('[data-task-list-scroll="github"]')
await list.evaluate((element) => {
element.scrollTop = 360
element.dispatchEvent(new Event('scroll'))
})
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(300)
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
await expect(list).toHaveCount(0)
const clampedRowsStyle = await orcaPage.addStyleTag({
content:
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
})
await openTasksPage(orcaPage)
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
'aria-current',
'page'
)
const restoredList = orcaPage.locator('[data-task-list-scroll="github"]')
await expect.poll(() => restoredList.evaluate((element) => element.scrollTop)).toBe(0)
await clampedRowsStyle.evaluate((element) => element.remove())
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
await expect
.poll(() => restoredList.evaluate((element) => element.scrollTop))
.toBeGreaterThan(300)
await orcaPage.getByText('Issue page 28 item 12', { exact: true }).click()
await expect(restoredList).toHaveCount(0)
await expect
.poll(async () => {
const position = await getStoreState<{ scrollTop: number }>(orcaPage, 'taskListPosition')
return position.scrollTop
})
.toBeGreaterThan(300)
await orcaPage.getByRole('button', { name: 'GitHub list', exact: true }).click()
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
await expect
.poll(() => restoredList.evaluate((element) => element.scrollTop))
.toBeGreaterThan(300)
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
const pendingRestoreStyle = await orcaPage.addStyleTag({
content:
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
})
await openTasksPage(orcaPage)
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
'aria-current',
'page'
)
await orcaPage.getByRole('button', { name: 'Page 1', exact: true }).click()
await pendingRestoreStyle.evaluate((element) => element.remove())
await expect(orcaPage.getByRole('button', { name: 'Page 1', exact: true })).toHaveAttribute(
'aria-current',
'page'
)
await expect
.poll(() =>
orcaPage
.locator('[data-task-list-scroll="github"]')
.evaluate((element) => element.scrollTop)
)
.toBe(0)
await orcaPage.getByRole('button', { name: 'Page 28', exact: true }).click()
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
await restoredList.evaluate((element) => {
element.scrollTop = 360
element.dispatchEvent(new Event('scroll'))
})
await expect
.poll(() => restoredList.evaluate((element) => element.scrollTop))
.toBeGreaterThan(300)
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
const permanentlyClampedRowsStyle = await orcaPage.addStyleTag({
content:
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
})
await openTasksPage(orcaPage)
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
'aria-current',
'page'
)
await orcaPage.waitForTimeout(5_500)
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
await expect
.poll(async () => {
const position = await getStoreState<{ scrollTop: number }>(orcaPage, 'taskListPosition')
return position.scrollTop
})
.toBe(0)
await permanentlyClampedRowsStyle.evaluate((element) => element.remove())
})
test('GitHub search waits for idle, keeps rows visible, and Enter does not double-fetch', async ({
orcaPage
}) => {