From d021e1b71141ae377b32ba514b6c50f2ac67d43b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:48:50 -0700 Subject: [PATCH] Improve gh issue table (#9068) * Refactor GitHub work-item mutations onto a shared optimistic coordinator - Extract PR/issue status, assignee, reviewer, and merge/auto-merge mutations out of TaskPage cell components into a registry-backed begin/confirm/rollback pipeline (task-page-github-work-item-mutation-*), so soft-hide, sticky filter-membership, and quiet revalidation behave consistently across all mutation types instead of each cell re-implementing optimistic update/rollback/toast logic. - Add quiet revalidation (no filter skeleton, no page blanking) and soft-hide handling so a row that exits the active filter (e.g. closing an issue under `is:open`) stays hidden without a jarring list reflow. - Restyle the GitHub task table: opaque sticky ID/Title cells, distinct header fill, accent hover, and tighter row/toolbar chrome to fix background bleed and muddy contrast in the scrolled table. * Fix quiet-revalidate cancellation and sticky-hide scoping in TaskPage - Replace per-render `cancelled` flag with a ref that only flips on true unmount, so a nonce-triggered re-render no longer strands the shared quietState's trailing/backoff bookkeeping mid-flight. - Fix backoff index to use max lag attempts instead of lagging-key count, matching processTaskPageQuietRevalidateSettle so several single-lag items can't jump the delay tier. - Scope sticky-hide retention in materializeTaskPageItemList to the originating query key, preventing non-membership confirms (e.g. auto-merge) from lingering as stale rows across refetches. * Fix is:draft filter to soft-hide non-draft PRs Previously state was forced to 'open' for is:draft queries, so a PR that stopped being a draft still passed the state check and stayed visible. Add an explicit draft check to soft-hide it. * Improve GitHub work-item mutations with scoped quiet revalidation Prevent race conditions and stale data by tracking quiet run ownership, validating scope changes with generations, and blocking overlapping mutations with pre-flight checks. Extract quiet state management into a dedicated module with improved authority clearing and network retry logic. --- src/renderer/src/components/TaskPage.tsx | 1368 ++++++++++++----- ...ture-interaction-writer-boundaries.test.ts | 20 +- .../components/github/PRFilterDropdowns.tsx | 5 +- ...page-github-work-item-authority-refresh.ts | 115 ++ ...github-work-item-filter-membership.test.ts | 167 ++ ...page-github-work-item-filter-membership.ts | 106 ++ ...e-github-work-item-mutation-composition.ts | 243 +++ ...ask-page-github-work-item-mutation-keys.ts | 40 + ...age-github-work-item-mutation-lifecycle.ts | 207 +++ ...sk-page-github-work-item-mutation-pages.ts | 154 ++ ...-github-work-item-mutation-patches.test.ts | 119 ++ ...-page-github-work-item-mutation-patches.ts | 230 +++ ...page-github-work-item-mutation-registry.ts | 328 ++++ ...hub-work-item-mutation-regressions.test.ts | 373 +++++ ...sk-page-github-work-item-mutation-types.ts | 32 + ...sk-page-github-work-item-mutations.test.ts | 836 ++++++++++ .../task-page-github-work-item-mutations.ts | 224 +++ .../task-page-github-work-item-quiet-adopt.ts | 172 +++ ...-page-github-work-item-quiet-revalidate.ts | 171 +++ .../task-page-github-work-item-quiet-state.ts | 88 ++ ...sk-page-github-work-item-registry-types.ts | 30 + ...age-github-work-item-mutation-host.test.ts | 34 + .../useTaskPageGitHubWorkItemMutation.ts | 193 +++ src/renderer/src/store/slices/github.test.ts | 105 +- src/renderer/src/store/slices/github.ts | 50 +- 25 files changed, 4983 insertions(+), 427 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-work-item-authority-refresh.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-filter-membership.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-composition.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-keys.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-lifecycle.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-pages.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-patches.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-registry.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutation-types.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutations.test.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-mutations.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-quiet-revalidate.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-quiet-state.ts create mode 100644 src/renderer/src/components/task-page-github-work-item-registry-types.ts create mode 100644 src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts create mode 100644 src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 31b571fd6..2c3917ab1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: repo selector, task-source controls, and task list stay co-located so their wiring reads in one place. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { @@ -148,6 +148,39 @@ import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-op import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { useTaskPageGitHubWorkItemMutation } from '@/hooks/useTaskPageGitHubWorkItemMutation' +import { useMountedRef } from '@/hooks/useMountedRef' +import { + advanceTaskPageQuietRevalidateScope, + clearTaskPageGitHubAuthorityAbsentFromLoadedItems, + clearTaskPageGitHubAuthorityThroughGeneration, + getTaskPageQuietRevalidateBackoffAttempt, + getTaskPageGitHubRevalidatableAuthorityItemKeys, + isTaskPageQuietRevalidateRunCurrent, + isTaskPageQuietRevalidateScopeCurrent, + LAG_BACKOFF_MS, + LAG_WALL_BUDGET_MS, + materializeTaskPageItemList, + MAX_LAG_TRAILS, + overlayPendingOnTaskPagePages, + patchTaskPageGitHubWorkItemPages, + processTaskPageQuietRevalidateSettle, + reapplyPendingTaskPageGitHubMutationsToCache, + reconcileTaskPagePagesAfterQuietRefresh, + rebuildSoftHiddenKeysFromPendingAndSticky +} from '@/components/task-page-github-work-item-mutations' +import { + getOrCreateQuietRevalidateState, + getTaskPageGitHubConfirmedAuthorityItemKeys, + setTaskPageGitHubMutationQueryKey, + taskPageGitHubItemKey +} from '@/components/task-page-github-work-item-mutation-registry' +import { + beginTaskPageQuietRevalidateRun, + finishTaskPageQuietRevalidateRun +} from '@/components/task-page-github-work-item-quiet-state' +import type { TaskPageGitHubMutationIntent } from '@/components/task-page-github-work-item-mutation-patches' +import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page-github-work-item-mutation-types' import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog' import PullRequestPage from '@/components/PullRequestPage' import GitLabItemDialog from '@/components/GitLabItemDialog' @@ -418,10 +451,11 @@ const GITHUB_TASK_GRID_CLASS = 'min-w-[790px] grid-cols-[72px_minmax(320px,1fr)_84px_100px_92px_122px]' const GITHUB_PR_TASK_GRID_CLASS = 'min-w-[1020px] grid-cols-[72px_minmax(360px,2fr)_132px_128px_132px_92px_158px]' -const GITHUB_TASK_ROW_SURFACE_CLASS = - '[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]' -const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = - 'group-hover/github-task-row:[background:color-mix(in_srgb,var(--muted)_70%,var(--background))]' +// Why: sticky cells need the row's opaque, animated surface to prevent bleed and hover flashes. +const GITHUB_TASK_ROW_SURFACE_CLASS = 'bg-background transition-colors' +const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = 'group-hover/github-task-row:bg-accent' +const GITHUB_TASK_HEADER_SURFACE_CLASS = + '[background:color-mix(in_srgb,var(--muted)_25%,var(--background))]' function getGitHubWorkItemWorkspaceSeed(item: GitHubWorkItem): string { return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) @@ -540,14 +574,15 @@ function getTaskPageRepoCacheInput(repo: Repo): { } } -// Why: sticky header bg must be opaque or scrolled rows bleed through; the ::before gap-cover keeps horizontally-scrolled columns off the px-3 padding strip. +// Why: opaque sticky headers and a padding-gap cover prevent vertical and horizontal bleed. const GITHUB_TASK_STICKY_ID_HEADER_CLASS = cn( - 'sticky left-3 z-30 before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', - GITHUB_TASK_ROW_SURFACE_CLASS + // Why: full-height flex keeps the sticky fill from shrinking around its label. + 'sticky left-3 z-30 flex items-center before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', + GITHUB_TASK_HEADER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_TITLE_HEADER_CLASS = cn( - 'sticky left-[92px] z-30 border-r border-border/50 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', - GITHUB_TASK_ROW_SURFACE_CLASS + 'sticky left-[92px] z-30 flex items-center border-r border-border/40 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', + GITHUB_TASK_HEADER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_ID_CELL_CLASS = cn( 'sticky left-3 z-20 flex items-center before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', @@ -555,7 +590,7 @@ const GITHUB_TASK_STICKY_ID_CELL_CLASS = cn( GITHUB_TASK_ROW_HOVER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_TITLE_CELL_CLASS = cn( - 'sticky left-[92px] z-20 min-w-0 border-r border-border/50 pr-2 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', + 'sticky left-[92px] z-20 flex min-w-0 flex-col justify-center border-r border-border/40 pr-2 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', GITHUB_TASK_ROW_SURFACE_CLASS, GITHUB_TASK_ROW_HOVER_SURFACE_CLASS ) @@ -1074,20 +1109,38 @@ function buildJiraCreateCustomFields( return Object.keys(customFields).length > 0 ? customFields : undefined } +type TaskPageGitHubWorkItemMutationRunner = { + run: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void> + successToast?: string + errorToast: string + }) => Promise<'confirmed' | 'rolled_back' | 'stale'> + isIntentPending: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + }) => boolean +} + function GHStatusCell({ item, repo, - sourceContext + sourceContext, + workItemMutation }: { item: GitHubWorkItem repo: Repo | null sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner }): React.JSX.Element { - const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [statusStateDraft, setStatusStateDraft] = useState(() => createTaskPageGitHubStatusStateDraft(item) ) const [open, setOpen] = useState(false) + const [statusUpdating, setStatusUpdating] = useState(false) const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) const [duplicateSearch, setDuplicateSearch] = useState('') const [duplicateError, setDuplicateError] = useState(null) @@ -1125,7 +1178,6 @@ function GHStatusCell({ : repoOwnerSettings, [repoOwnerSettings, sourceContext] ) - const reqRef = useRef(0) const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) const filteredDuplicateCandidates = useMemo( () => @@ -1155,6 +1207,11 @@ function GHStatusCell({ setStatusStateDraft(resolvedStatusStateDraft) } const localState = resolvedStatusStateDraft.localState + const stateMutationPending = workItemMutation.isIntentPending({ + item, + intent: { type: 'setState', state: localState === 'open' ? 'closed' : 'open' }, + sourceContext + }) const updateLocalState = useCallback( (nextState: GitHubWorkItem['state']) => { setStatusStateDraft((current) => @@ -1165,46 +1222,59 @@ function GHStatusCell({ ) const handleStateChange = useCallback( - (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { - if (newState === localState || item.type !== 'issue') { + async (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if ( + statusUpdating || + stateMutationPending || + newState === localState || + item.type !== 'issue' + ) { return } const parsedOwnerRepo = parsedIssueLink?.slug if (!repo && !parsedOwnerRepo) { return } - reqRef.current += 1 - const reqId = reqRef.current const updates: GitHubIssueUpdate = newState === 'closed' && closeAction ? buildTaskPageGitHubCloseUpdate(closeAction) : { state: newState } updateLocalState(newState) - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - const target = getActiveRuntimeTarget(sourceSettings) - // Why: issue rows can be sourced by owner/repo URL, not local repo context; slug-aware writes preserve close reasons and duplicates. - const updatePromise = parsedOwnerRepo - ? target.kind === 'environment' - ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( - target, - 'github.project.updateIssueBySlug', - { - owner: parsedOwnerRepo.owner, - repo: parsedOwnerRepo.repo, - host: githubProjectHost(parsedOwnerRepo.host), - number: item.number, - updates - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.updateIssueBySlug({ - owner: parsedOwnerRepo.owner, - repo: parsedOwnerRepo.repo, - host: githubProjectHost(parsedOwnerRepo.host), - number: item.number, - updates - }) - : (() => { + // Why: coordinator owns durable patch + soft-hide + quiet revalidate; keep + // the status draft so one-frame flash is still covered until proven safe. + setStatusUpdating(true) + try { + await workItemMutation.run({ + item, + intent: { type: 'setState', state: newState, closeAction }, + sourceContext, + errorToast: translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'), + mutate: async () => { + const target = getActiveRuntimeTarget(sourceSettings) + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + if (parsedOwnerRepo) { + return target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( + target, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }) + } if (!repo) { throw new Error('No GitHub repository context available for this issue.') } @@ -1224,59 +1294,25 @@ function GHStatusCell({ number: item.number, updates }) - })() - updatePromise - .then((result) => { - if (reqId !== reqRef.current) { - return } - const typed = result as { ok?: boolean; error?: string | { message?: string } } - if (typed && typed.ok === false) { - updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem( - item.id, - { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId, - { sourceContext } - ) - toast.error( - typeof typed.error === 'string' - ? typed.error - : (typed.error?.message ?? - translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) - ) - return - } - if (repo) { - useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - }) - .catch(() => { - if (reqId !== reqRef.current) { - return - } - updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem( - item.id, - { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId, - { - sourceContext - } - ) - toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) + } finally { + setStatusUpdating(false) + } + // Why: draft realigns from item.state via resolveTaskPageGitHubStatusStateDraft + // when patchWorkItem (begin/rollback) updates the cache-backed row. }, [ item, localState, parsedIssueLink, - patchWorkItem, repo, sourceContext, sourceSettings, - updateLocalState + stateMutationPending, + statusUpdating, + updateLocalState, + workItemMutation ] ) @@ -1325,6 +1361,7 @@ function GHStatusCell({