From bd9addb4497a58abc18683106a4e0ae56ce2d9e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:08:04 -0700 Subject: [PATCH] fix(tasks): restore GitHub page and scroll position on reopen (#13096) --- src/renderer/src/components/TaskPage.tsx | 291 +++++++++++++++++- .../task-page-github-resume-cache.test.ts | 75 +++++ .../task-page-github-resume-cache.ts | 84 +++++ src/renderer/src/store/slices/ui.ts | 4 + tests/e2e/tasks-page.spec.ts | 156 ++++++++++ 5 files changed, 594 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-resume-cache.test.ts create mode 100644 src/renderer/src/components/task-page-github-resume-cache.ts diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8358490a9..055f7cb84 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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(null) + const githubListScrollTopRef = useRef(0) + const pendingGithubScrollRestoreRef = useRef(null) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) const [countedTotalPages, setCountedTotalPages] = useState(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.
{ + 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 + } + }} >
{ + 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) diff --git a/src/renderer/src/components/task-page-github-resume-cache.test.ts b/src/renderer/src/components/task-page-github-resume-cache.test.ts new file mode 100644 index 000000000..cb0440630 --- /dev/null +++ b/src/renderer/src/components/task-page-github-resume-cache.test.ts @@ -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() + 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() + 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() + + 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({ 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() + 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]) + }) +}) diff --git a/src/renderer/src/components/task-page-github-resume-cache.ts b/src/renderer/src/components/task-page-github-resume-cache.ts new file mode 100644 index 000000000..c42ac9a0c --- /dev/null +++ b/src/renderer/src/components/task-page-github-resume-cache.ts @@ -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 = { + items: readonly T[] + cachedAt: number + lastAccessedAt: number +} + +export type TaskPageResumeCachedPage = { + 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(options: TaskPageResumePageCacheOptions = {}): { + read: (contextKey: string, page: number, now?: number) => TaskPageResumeCachedPage | 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>() + 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() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 908310a56..4f57a72ce 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -706,6 +706,8 @@ export type UISlice = { } taskResumeState: TaskResumeState | undefined setTaskResumeState: (updates: Partial) => 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 = (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 = (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) => { diff --git a/tests/e2e/tasks-page.spec.ts b/tests/e2e/tasks-page.spec.ts index 7991cbf27..c9836cfd0 100644 --- a/tests/e2e/tasks-page.spec.ts +++ b/tests/e2e/tasks-page.spec.ts @@ -54,6 +54,60 @@ async function getRenderedTaskSources( }, TASK_SOURCE_BY_LABEL) } +async function openMockedPaginatedGitHubTasks( + page: Parameters[0] +): Promise { + 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[0] ): Promise { @@ -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 }) => {