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({