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.
This commit is contained in:
Jinjing 2026-08-05 11:48:50 -07:00 committed by GitHub
parent 6c2d168f74
commit d021e1b711
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 4983 additions and 427 deletions

File diff suppressed because it is too large Load Diff

View File

@ -53,20 +53,22 @@ describe('feature interaction writer boundaries', () => {
it('records GitHub provider-depth for inline item mutation success paths', () => {
const source = componentSource('TaskPage.tsx')
const githubWriter = "recordFeatureInteraction('github-tasks')"
const mutationSections = [
sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='),
sourceBetween(source, 'function PRReviewCell', 'const requestReviewer ='),
sourceBetween(source, 'function PRMergeCell', 'const handleAutoMerge'),
// Why: table cells route success telemetry through the optimistic mutation
// hook so provider-depth recording stays on one confirm path.
const hookSource = readFileSync(
join(COMPONENT_ROOT, '../hooks/useTaskPageGitHubWorkItemMutation.ts'),
'utf8'
)
expect(
sourceBetween(hookSource, "if (confirmed === 'confirmed')", 'return confirmed')
).toContain(githubWriter)
expect(
sourceBetween(
source,
'const handleOpenOrUseGitHubWorkItem',
'const openComposerForGitLabItem'
)
]
for (const section of mutationSections) {
expect(section).toContain(githubWriter)
}
).toContain(githubWriter)
})
it('threads GitHub task source context through inline task mutations', () => {

View File

@ -177,8 +177,9 @@ export default function PRFilterDropdowns({
variant="outline"
size="sm"
className={cn(
'h-7 gap-1.5 rounded-md border-border/50 px-2 text-xs font-normal',
'bg-transparent hover:bg-muted/50',
// Why: solid fill + full foreground text so the control stays
// readable on both page canvas and the muted toolbar band.
'h-8 gap-1.5 rounded-md border-border/60 bg-background px-2.5 text-xs font-medium text-foreground shadow-xs hover:bg-muted/60',
activeCount > 0 && 'border-border'
)}
>

View File

@ -0,0 +1,115 @@
import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys'
import {
clearConfirmedAuthorityForItem,
deleteConfirmedListSnapshot,
deleteLastConfirmedClientValue,
deleteStickyHideEntry,
getConfirmedListSnapshot,
getLastConfirmedClientValue,
getStickyHideEntry,
getTaskPageGitHubConfirmedAuthorityItemKeys,
hasPendingTaskPageGitHubOpsForItem,
listPendingTaskPageGitHubOpsForItem,
notifyTaskPageGitHubMutationRegistry,
resolveItemSourceScope,
updateSoftHiddenItemKey
} from './task-page-github-work-item-mutation-registry'
import { getQuietRevalidateState } from './task-page-github-work-item-quiet-state'
import { MAX_LAG_TRAILS } from './task-page-github-work-item-quiet-adopt'
const AUTHORITY_FAMILIES = ['state', 'autoMerge', 'assignees', 'reviewRequests'] as const
export function clearTaskPageGitHubAuthorityAbsentFromLoadedItems(
loadedItemKeys: ReadonlySet<string>
): void {
let changed = false
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
if (loadedItemKeys.has(itemKey)) {
continue
}
const separator = itemKey.indexOf('\0')
const repoId = itemKey.slice(0, separator)
const itemId = itemKey.slice(separator + 1)
if (hasPendingTaskPageGitHubOpsForItem(repoId, itemId)) {
continue
}
clearConfirmedAuthorityForItem(repoId, itemId)
deleteStickyHideEntry(itemKey)
updateSoftHiddenItemKey(itemKey, false)
changed = true
}
if (changed) {
notifyTaskPageGitHubMutationRegistry()
}
}
export function getTaskPageGitHubRevalidatableAuthorityItemKeys(
queryKey: string
): ReadonlySet<string> {
const keys = new Set<string>()
const quiet = getQuietRevalidateState(queryKey)
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
const separator = itemKey.indexOf('\0')
if (separator < 0) {
continue
}
const repoId = itemKey.slice(0, separator)
const itemId = itemKey.slice(separator + 1)
const sourceScope = resolveItemSourceScope(repoId, itemId)
const activeFamilies = AUTHORITY_FAMILIES.filter((family) =>
family === 'assignees' || family === 'reviewRequests'
? getConfirmedListSnapshot(sourceScope, repoId, itemId, family) !== undefined
: getLastConfirmedClientValue(sourceScope, repoId, itemId, family) !== undefined
)
if (
activeFamilies.some(
(family) =>
(quiet?.lagSkipAttempts.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0) <
MAX_LAG_TRAILS
)
) {
keys.add(itemKey)
}
}
return keys
}
export function clearTaskPageGitHubAuthorityThroughGeneration(
queryKey: string,
generation: number
): void {
const quiet = getQuietRevalidateState(queryKey)
for (const itemKey of getTaskPageGitHubConfirmedAuthorityItemKeys()) {
const separator = itemKey.indexOf('\0')
const repoId = itemKey.slice(0, separator)
const itemId = itemKey.slice(separator + 1)
const sourceScope = resolveItemSourceScope(repoId, itemId)
for (const family of AUTHORITY_FAMILIES) {
const dirtyAt = quiet?.familyDirtyAt.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0
if (dirtyAt > generation) {
continue
}
if (family === 'assignees' || family === 'reviewRequests') {
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, family)
} else {
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, family)
}
}
const hasMembershipAuthority =
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'state') !== undefined ||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees') !== undefined ||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests') !== undefined
const hasPendingMembership = listPendingTaskPageGitHubOpsForItem(repoId, itemId).some(
(op) => op.listOp !== undefined || op.key.opKey === 'state' || op.key.opKey === 'merge'
)
if (!hasMembershipAuthority && !hasPendingMembership) {
if (getStickyHideEntry(itemKey)?.queryKey === queryKey) {
deleteStickyHideEntry(itemKey)
}
if (!hasPendingTaskPageGitHubOpsForItem(repoId, itemId)) {
updateSoftHiddenItemKey(itemKey, false)
}
}
}
notifyTaskPageGitHubMutationRegistry()
}

View File

@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import type { ParsedTaskQuery } from '../../../shared/task-query'
import {
recomputeTaskPageGitHubItemSoftHide,
shouldSoftHideTaskPageGitHubWorkItem
} from './task-page-github-work-item-filter-membership'
const baseQuery = (overrides: Partial<ParsedTaskQuery> = {}): ParsedTaskQuery => ({
scope: 'all',
state: null,
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: '',
...overrides
})
describe('shouldSoftHideTaskPageGitHubWorkItem', () => {
it('hides closed or merged under is:open', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'closed', assignees: [], reviewRequests: [] },
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'merged', assignees: [], reviewRequests: [] },
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('does not state-soft-hide when state is null', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'closed', assignees: [], reviewRequests: [] },
query: baseQuery({ state: null }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(false)
})
it('AND: hides on close or unassign under is:open assignee:@me', () => {
const query = baseQuery({ state: 'open', assignee: '@me' })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: {
state: 'closed',
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
reviewRequests: []
},
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('skips @me when skipMeQualifiers or viewerLogin null', () => {
const query = baseQuery({ assignee: '@me' })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: null,
skipMeQualifiers: false
})
).toBe(false)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: true
})
).toBe(false)
})
it('hides when concrete assignee login missing', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: {
state: 'open',
assignees: [{ login: 'bob', name: null, avatarUrl: '' }],
reviewRequests: []
},
query: baseQuery({ assignee: 'alice' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('hides non-draft items under is:draft', () => {
const query = baseQuery({ scope: 'pr', state: 'open', draft: true })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'draft', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(false)
})
it('hides when review-requested @me is missing', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query: baseQuery({ reviewRequested: '@me' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
})
describe('recomputeTaskPageGitHubItemSoftHide', () => {
it('includes sticky hide for matching queryKey', () => {
const itemKey = 'repo\0item'
const result = recomputeTaskPageGitHubItemSoftHide({
item: {
state: 'open',
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
reviewRequests: []
},
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false,
queryKey: 'q1',
sticky: new Map([
[
itemKey,
{ itemKey, sourceScope: null, queryKey: 'q1', reason: 'filter_membership' as const }
]
]),
itemKey
})
expect(result.hide).toBe(true)
expect(result.sticky).toBe(true)
})
})

View File

@ -0,0 +1,106 @@
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
import type { StickyHideEntry } from './task-page-github-work-item-mutation-registry'
function includesLogin(users: readonly GitHubAssignableUser[] | undefined, login: string): boolean {
const target = login.toLowerCase()
return (users ?? []).some((user) => user.login.toLowerCase() === target)
}
function resolveMeLogin(viewerLogin: string | null): string | null {
const trimmed = viewerLogin?.trim()
return trimmed ? trimmed.toLowerCase() : null
}
/**
* Whether a registry-merged work item should be soft-hidden for the active query.
* Evaluate every applicable membership signal; hide if any fails (AND of constraints).
*/
export function shouldSoftHideTaskPageGitHubWorkItem(args: {
item: Pick<GitHubWorkItem, 'state' | 'assignees' | 'reviewRequests'>
query: ParsedTaskQuery
viewerLogin: string | null
/**
* Per-item: true when this items sourceContext resolves to environment/SSH.
* Never a page-level flag (multi-repo can mix local + remote).
*/
skipMeQualifiers: boolean
}): boolean {
const { item, query, skipMeQualifiers } = args
const viewer = resolveMeLogin(args.viewerLogin)
// State membership
if (query.state === 'open') {
if (item.state === 'closed' || item.state === 'merged') {
return true
}
} else if (query.state === 'closed') {
// Why: closed-only lists treat merged as out-of-membership for soft-hide.
if (item.state === 'open' || item.state === 'merged' || item.state === 'draft') {
return true
}
} else if (query.state === 'merged') {
if (item.state !== 'merged') {
return true
}
}
// state === null | 'all' → no state-based soft-hide
// Why: is:draft forces state to 'open', so a draft that turns non-draft still
// passes the state check; the draft qualifier soft-hides it explicitly.
if (query.draft && item.state !== 'draft') {
return true
}
// Assignee membership
if (query.assignee) {
const assignee = query.assignee.trim()
if (assignee.toLowerCase() === '@me') {
if (!skipMeQualifiers && viewer && !includesLogin(item.assignees, viewer)) {
return true
}
} else if (!includesLogin(item.assignees, assignee.replace(/^@/, ''))) {
return true
}
}
// Review-requested membership
if (query.reviewRequested) {
const requested = query.reviewRequested.trim()
if (requested.toLowerCase() === '@me') {
if (!skipMeQualifiers && viewer && !includesLogin(item.reviewRequests, viewer)) {
return true
}
} else if (!includesLogin(item.reviewRequests, requested.replace(/^@/, ''))) {
return true
}
}
// author @me / labels / free text: do not soft-hide from those alone
return false
}
export function recomputeTaskPageGitHubItemSoftHide(args: {
item: Pick<GitHubWorkItem, 'state' | 'assignees' | 'reviewRequests'>
query: ParsedTaskQuery
viewerLogin: string | null
skipMeQualifiers: boolean
queryKey: string
sticky: ReadonlyMap<string, StickyHideEntry>
itemKey: string
}): { hide: boolean; sticky: boolean } {
const membershipHide = shouldSoftHideTaskPageGitHubWorkItem({
item: args.item,
query: args.query,
viewerLogin: args.viewerLogin,
skipMeQualifiers: args.skipMeQualifiers
})
const stickyEntry = args.sticky.get(args.itemKey)
const stickyActive = Boolean(stickyEntry && stickyEntry.queryKey === args.queryKey)
// Why: sticky keeps successful membership exits hidden after pending clears;
// pending membership hide still applies before confirm.
return {
hide: membershipHide || stickyActive,
sticky: stickyActive
}
}

View File

@ -0,0 +1,243 @@
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
import {
recomputeTaskPageGitHubItemSoftHide,
shouldSoftHideTaskPageGitHubWorkItem
} from './task-page-github-work-item-filter-membership'
import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches'
import {
deleteStickyHideEntry,
getAllStickyHideEntries,
getConfirmedListSnapshot,
getLastConfirmedClientValue,
getTaskPageGitHubSoftHiddenItemKeys,
hasConfirmedAuthorityForItem,
listPendingTaskPageGitHubOpsForItem,
resolveItemSourceScope,
notifyTaskPageGitHubMutationRegistry,
setSoftHiddenItemKeys,
setStickyHideEntry,
taskPageGitHubItemKey,
updateSoftHiddenItemKey,
type PendingListOp,
type PendingOp,
type TaskPageGitHubListFamily
} from './task-page-github-work-item-mutation-registry'
export function freezeTaskPageGitHubUsers(
users: readonly GitHubAssignableUser[]
): GitHubAssignableUser[] {
return users.map((user) => ({
login: user.login,
name: user.name,
avatarUrl: user.avatarUrl
}))
}
export function pendingListOpsForFamily(
ops: readonly PendingOp[],
family: TaskPageGitHubListFamily
): PendingListOp[] {
// Why: same login may appear in multiple pending ops after rapid toggles;
// keep only the latest op per login so composition matches last intent.
return [...ops]
.filter((pending) => pending.listOp?.family === family)
.sort((a, b) => a.startedAt - b.startedAt)
.reduce<PendingListOp[]>((acc, pending) => {
if (!pending.listOp) {
return acc
}
if (pending.listOp.logins.length > 1) {
acc.push(pending.listOp)
return acc
}
const login = pending.listOp.logins[0]
const lastIndex = acc.findIndex((op) => op.logins.length === 1 && op.logins[0] === login)
if (lastIndex >= 0) {
acc[lastIndex] = pending.listOp
} else {
acc.push(pending.listOp)
}
return acc
}, [])
}
export function stripFamilyPendingFromList(
item: GitHubWorkItem,
family: TaskPageGitHubListFamily,
ops: readonly PendingOp[]
): GitHubAssignableUser[] {
const current = freezeTaskPageGitHubUsers(
family === 'assignees' ? (item.assignees ?? []) : (item.reviewRequests ?? [])
)
let list = current
const familyOps = pendingListOpsForFamily(ops, family)
// Why: strip by reversing adds/removes so lazy snapshot ignores in-flight intent.
for (let i = familyOps.length - 1; i >= 0; i--) {
const op = familyOps[i]
for (let j = 0; j < op.logins.length; j++) {
const login = op.logins[j]
if (op.kind === 'add') {
list = list.filter((user) => user.login.toLowerCase() !== login)
} else if (!list.some((user) => user.login.toLowerCase() === login)) {
const restored = op.users?.[j] ?? { login, name: null, avatarUrl: '' }
list.push(restored)
}
}
}
return list
}
export function getRegistryMergedTaskPageGitHubWorkItem(
item: GitHubWorkItem,
sourceScope: string | null
): GitHubWorkItem {
const ops = listPendingTaskPageGitHubOpsForItem(item.repoId, item.id, sourceScope)
let merged: GitHubWorkItem = { ...item }
// Why: after confirm, pending is cleared but search may still lag — hold the
// last confirmed whole-field values until a matching adopt or newer pending.
const lastState = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'state')
if (typeof lastState === 'string') {
merged = { ...merged, state: lastState as GitHubWorkItem['state'] }
}
const lastAutoMerge = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'autoMerge')
if (typeof lastAutoMerge === 'boolean') {
merged = { ...merged, autoMergeEnabled: lastAutoMerge }
}
const wholeByOpKey = new Map<string, PendingOp>()
for (const op of ops) {
if (op.listOp) {
continue
}
wholeByOpKey.set(op.key.opKey, op)
}
for (const op of wholeByOpKey.values()) {
merged = { ...merged, ...op.next }
}
for (const family of ['assignees', 'reviewRequests'] as const) {
const familyOps = pendingListOpsForFamily(ops, family)
let snapshot = getConfirmedListSnapshot(sourceScope, item.repoId, item.id, family)
if (!snapshot && familyOps.length === 0) {
continue
}
if (!snapshot) {
snapshot = stripFamilyPendingFromList(item, family, ops)
}
const composed = applyTaskPageGitHubListOps(snapshot, familyOps)
merged =
family === 'assignees'
? { ...merged, assignees: composed }
: { ...merged, reviewRequests: composed }
}
return merged
}
export function recomputeSoftHideForItem(args: {
item: GitHubWorkItem
sourceScope: string | null
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
skipMeQualifiers: boolean
/** When true (confirm/rollback), set or clear sticky per K22. */
updateSticky: boolean
}): boolean {
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.sourceScope)
const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id)
const membershipHide = shouldSoftHideTaskPageGitHubWorkItem({
item: merged,
query: args.query,
viewerLogin: args.viewerLogin,
skipMeQualifiers: args.skipMeQualifiers
})
if (args.updateSticky) {
if (membershipHide) {
setStickyHideEntry({
itemKey,
sourceScope: args.sourceScope,
queryKey: args.queryKey,
reason: 'filter_membership'
})
} else {
deleteStickyHideEntry(itemKey)
}
}
const result = recomputeTaskPageGitHubItemSoftHide({
item: merged,
query: args.query,
viewerLogin: args.viewerLogin,
skipMeQualifiers: args.skipMeQualifiers,
queryKey: args.queryKey,
sticky: getAllStickyHideEntries(),
itemKey
})
updateSoftHiddenItemKey(itemKey, result.hide)
return result.hide
}
export function rebuildSoftHiddenKeysFromPendingAndSticky(args: {
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
items: readonly GitHubWorkItem[]
skipMeByItemKey?: ReadonlyMap<string, boolean>
}): void {
const next = new Set<string>()
for (const [itemKey, entry] of getAllStickyHideEntries()) {
if (entry.queryKey === args.queryKey) {
next.add(itemKey)
}
}
for (const item of args.items) {
const ops = listPendingTaskPageGitHubOpsForItem(item.repoId, item.id)
const itemKey = taskPageGitHubItemKey(item.repoId, item.id)
if (
ops.length === 0 &&
!next.has(itemKey) &&
!hasConfirmedAuthorityForItem(item.repoId, item.id)
) {
continue
}
const sourceScope = ops[0]?.key.sourceScope ?? resolveItemSourceScope(item.repoId, item.id)
const skipMe = args.skipMeByItemKey?.get(itemKey) ?? ops[0]?.skipMeQualifiers ?? false
const merged = getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
if (
shouldSoftHideTaskPageGitHubWorkItem({
item: merged,
query: args.query,
viewerLogin: args.viewerLogin,
skipMeQualifiers: skipMe
})
) {
next.add(itemKey)
}
}
const current = getTaskPageGitHubSoftHiddenItemKeys()
if (next.size === current.size && [...next].every((itemKey) => current.has(itemKey))) {
return
}
setSoftHiddenItemKeys(next)
notifyTaskPageGitHubMutationRegistry()
}
export function familiesFromPendingOp(op: PendingOp): string[] {
if (op.listOp) {
return [op.listOp.family]
}
if (op.key.opKey === 'merge') {
return ['state', 'merge', 'autoMerge']
}
if (op.key.opKey === 'autoMerge') {
return ['autoMerge']
}
if (op.key.opKey === 'state') {
return ['state']
}
return [op.key.opKey]
}

View File

@ -0,0 +1,40 @@
import type {
TaskPageGitHubListFamily,
TaskPageGitHubMutationKey
} from './task-page-github-work-item-registry-types'
export function taskPageGitHubItemKey(repoId: string, itemId: string): string {
return `${repoId}\0${itemId}`
}
export function serializeTaskPageGitHubMutationKey(key: TaskPageGitHubMutationKey): string {
return `${key.sourceScope ?? ''}\0${key.repoId}\0${key.itemId}\0${key.opKey}`
}
export function taskPageGitHubSnapshotKey(
sourceScope: string | null,
repoId: string,
itemId: string,
family: TaskPageGitHubListFamily
): string {
return `${sourceScope ?? ''}\0${repoId}\0${itemId}\0${family}`
}
export function taskPageGitHubLastConfirmedKey(
sourceScope: string | null,
repoId: string,
itemId: string,
family: string
): string {
return `${sourceScope ?? ''}\0${repoId}\0${itemId}\0${family}`
}
export function taskPageGitHubFamilyDirtyKey(itemKey: string, family: string): string {
return `${itemKey}\0${family}`
}
export function taskPageGitHubListOpKey(
family: TaskPageGitHubListFamily,
logins: readonly string[]
): string {
const normalized = logins.map((login) => login.toLowerCase()).sort()
if (normalized.length === 1) {
return `${family}:${normalized[0]}`
}
return `${family}:batch:${normalized.join(',')}`
}

View File

@ -0,0 +1,207 @@
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches'
import {
familiesFromPendingOp,
freezeTaskPageGitHubUsers,
getRegistryMergedTaskPageGitHubWorkItem,
recomputeSoftHideForItem
} from './task-page-github-work-item-mutation-composition'
import {
deletePendingTaskPageGitHubOp,
getConfirmedListSnapshot,
getPendingTaskPageGitHubOp,
getTaskPageGitHubMutationQueryKey,
listPendingTaskPageGitHubOpsForItem,
notifyTaskPageGitHubMutationRegistry,
setConfirmedListSnapshot,
setLastConfirmedClientValue,
markTaskPageGitHubFamiliesDirty,
isTaskPageGitHubMutationQueryKeyCurrent,
taskPageGitHubItemKey,
type TaskPageGitHubMutationKey
} from './task-page-github-work-item-mutation-registry'
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
function applyServerEntityIfPresent(
key: TaskPageGitHubMutationKey,
opts: {
serverEntity?: Partial<GitHubWorkItem>
patchWorkItem?: TaskPageGitHubPatchWorkItem
sourceContext?: TaskSourceContext | null
}
): void {
if (!opts.serverEntity || !opts.patchWorkItem) {
return
}
const entityPatch: Partial<GitHubWorkItem> = {}
if (opts.serverEntity.state !== undefined) {
entityPatch.state = opts.serverEntity.state
setLastConfirmedClientValue(
key.sourceScope,
key.repoId,
key.itemId,
'state',
opts.serverEntity.state
)
}
if (opts.serverEntity.autoMergeEnabled !== undefined) {
entityPatch.autoMergeEnabled = opts.serverEntity.autoMergeEnabled
setLastConfirmedClientValue(
key.sourceScope,
key.repoId,
key.itemId,
'autoMerge',
opts.serverEntity.autoMergeEnabled
)
}
if (opts.serverEntity.assignees) {
const users = freezeTaskPageGitHubUsers(opts.serverEntity.assignees)
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, 'assignees', users)
entityPatch.assignees = users
}
if (opts.serverEntity.reviewRequests) {
const users = freezeTaskPageGitHubUsers(opts.serverEntity.reviewRequests)
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, 'reviewRequests', users)
entityPatch.reviewRequests = users
}
if (Object.keys(entityPatch).length > 0) {
opts.patchWorkItem(key.itemId, entityPatch, key.repoId, {
sourceContext: opts.sourceContext
})
}
}
export function confirmTaskPageGitHubWorkItemMutation(
key: TaskPageGitHubMutationKey,
generation: number,
opts: {
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
item: GitHubWorkItem
serverEntity?: Partial<GitHubWorkItem>
patchWorkItem?: TaskPageGitHubPatchWorkItem
sourceContext?: TaskSourceContext | null
scheduleQuiet?: boolean
}
): 'confirmed' | 'stale' {
const pending = getPendingTaskPageGitHubOp(key)
if (!pending || pending.generation !== generation) {
return 'stale'
}
// K20: capture before delete.
const { skipMeQualifiers, listOp, next } = pending
if (listOp) {
const snapshot =
getConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, listOp.family) ??
freezeTaskPageGitHubUsers(
listOp.family === 'assignees'
? (opts.item.assignees ?? [])
: (opts.item.reviewRequests ?? [])
)
// K10: apply confirmed op into snapshot immediately. List authority lives in
// confirmedSnapshots; lastConfirmedClientValue is scalar-only (state/autoMerge).
const applied = applyTaskPageGitHubListOps(snapshot, [listOp])
setConfirmedListSnapshot(key.sourceScope, key.repoId, key.itemId, listOp.family, applied)
} else {
if (next.state !== undefined) {
setLastConfirmedClientValue(key.sourceScope, key.repoId, key.itemId, 'state', next.state)
}
if (next.autoMergeEnabled !== undefined) {
setLastConfirmedClientValue(
key.sourceScope,
key.repoId,
key.itemId,
'autoMerge',
next.autoMergeEnabled
)
}
}
deletePendingTaskPageGitHubOp(key)
applyServerEntityIfPresent(key, opts)
const remaining = listPendingTaskPageGitHubOpsForItem(key.repoId, key.itemId, key.sourceScope)
const merged = getRegistryMergedTaskPageGitHubWorkItem(opts.item, key.sourceScope)
if (opts.patchWorkItem && remaining.some((op) => op.listOp)) {
opts.patchWorkItem(
key.itemId,
{ assignees: merged.assignees, reviewRequests: merged.reviewRequests },
key.repoId,
{ sourceContext: opts.sourceContext }
)
}
if (isTaskPageGitHubMutationQueryKeyCurrent(opts.queryKey)) {
recomputeSoftHideForItem({
item: { ...opts.item, ...merged },
sourceScope: key.sourceScope,
query: opts.query,
queryKey: opts.queryKey,
viewerLogin: opts.viewerLogin,
skipMeQualifiers,
updateSticky: true
})
}
const itemKey = taskPageGitHubItemKey(key.repoId, key.itemId)
markTaskPageGitHubFamiliesDirty(
getTaskPageGitHubMutationQueryKey() ?? opts.queryKey,
itemKey,
familiesFromPendingOp(pending)
)
notifyTaskPageGitHubMutationRegistry()
return 'confirmed'
}
export function rollbackTaskPageGitHubWorkItemMutation(args: {
key: TaskPageGitHubMutationKey
generation: number
patchWorkItem: TaskPageGitHubPatchWorkItem
sourceContext?: TaskSourceContext | null
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
item: GitHubWorkItem
}): 'rolled_back' | 'stale' {
const pending = getPendingTaskPageGitHubOp(args.key)
if (!pending || pending.generation !== args.generation) {
return 'stale'
}
const { skipMeQualifiers, listOp } = pending
deletePendingTaskPageGitHubOp(args.key)
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.key.sourceScope)
if (listOp) {
args.patchWorkItem(
args.key.itemId,
listOp.family === 'assignees'
? { assignees: merged.assignees }
: { reviewRequests: merged.reviewRequests },
args.key.repoId,
{ sourceContext: args.sourceContext }
)
} else {
const recomposed = getRegistryMergedTaskPageGitHubWorkItem(
{ ...args.item, ...pending.previous },
args.key.sourceScope
)
args.patchWorkItem(
args.key.itemId,
{
state: recomposed.state,
autoMergeEnabled: recomposed.autoMergeEnabled
},
args.key.repoId,
{ sourceContext: args.sourceContext }
)
}
const after = getRegistryMergedTaskPageGitHubWorkItem(args.item, args.key.sourceScope)
if (isTaskPageGitHubMutationQueryKeyCurrent(args.queryKey)) {
recomputeSoftHideForItem({
item: { ...args.item, ...after },
sourceScope: args.key.sourceScope,
query: args.query,
queryKey: args.queryKey,
viewerLogin: args.viewerLogin,
skipMeQualifiers,
updateSticky: true
})
}
notifyTaskPageGitHubMutationRegistry()
return 'rolled_back'
}

View File

@ -0,0 +1,154 @@
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition'
import {
getStickyHideEntry,
hasConfirmedAuthorityForItem,
hasPendingTaskPageGitHubOpsForItem,
resolveItemSourceScope,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-registry'
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
export function patchTaskPageGitHubWorkItemPages(
pages: readonly (GitHubWorkItem[] | null)[],
itemKey: { id: string; repoId: string },
patch: Partial<GitHubWorkItem>,
shouldPatch?: (item: GitHubWorkItem) => boolean
): (GitHubWorkItem[] | null)[] {
let changed = false
const nextPages = pages.map((page) => {
if (!page) {
return null
}
let pageChanged = false
const nextPage = page.map((item) => {
if (
item.id !== itemKey.id ||
item.repoId !== itemKey.repoId ||
(shouldPatch && !shouldPatch(item))
) {
return item
}
changed = true
pageChanged = true
return { ...item, ...patch }
})
return pageChanged ? nextPage : page
})
return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[])
}
/** Match each item to pending/confirmed authority by repoId + itemId + remembered sourceScope. */
export function applyPendingTaskPageGitHubMutationsToItems(
items: readonly GitHubWorkItem[]
): GitHubWorkItem[] {
return items.map((item) => {
const sourceScope = resolveItemSourceScope(item.repoId, item.id)
return getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
})
}
export function reapplyPendingTaskPageGitHubMutationsToCache(args: {
items: readonly GitHubWorkItem[]
patchWorkItem: TaskPageGitHubPatchWorkItem
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
}): void {
for (const item of args.items) {
const sourceScope = resolveItemSourceScope(item.repoId, item.id)
const hasAuthority =
hasPendingTaskPageGitHubOpsForItem(item.repoId, item.id) ||
hasConfirmedAuthorityForItem(item.repoId, item.id)
if (!hasAuthority) {
continue
}
const merged = getRegistryMergedTaskPageGitHubWorkItem(item, sourceScope)
args.patchWorkItem(
item.id,
{
state: merged.state,
assignees: merged.assignees,
reviewRequests: merged.reviewRequests,
autoMergeEnabled: merged.autoMergeEnabled
},
item.repoId,
{ sourceContext: args.sourceContextByRepoId?.get(item.repoId) }
)
}
}
/** Full-replace flat list: overlay + retain pending/confirmed-omitted rows (K18). */
export function materializeTaskPageItemList(args: {
networkItems: readonly GitHubWorkItem[]
previousItems: readonly GitHubWorkItem[]
queryKey: string
}): GitHubWorkItem[] {
const overlaid = applyPendingTaskPageGitHubMutationsToItems(args.networkItems)
const byKey = new Map(overlaid.map((item) => [taskPageGitHubItemKey(item.repoId, item.id), item]))
for (const item of args.previousItems) {
const k = taskPageGitHubItemKey(item.repoId, item.id)
if (byKey.has(k)) {
continue
}
// Why: retain in-flight pending rows for rollback visibility; also retain
// confirmed rows soft-hidden by a sticky hide scoped to THIS query while
// search lag omits them. Requiring the query-scoped sticky avoids retaining
// non-membership confirms (e.g. auto-merge) as stale ghosts across refetch.
const hasPending = hasPendingTaskPageGitHubOpsForItem(item.repoId, item.id)
const sticky = getStickyHideEntry(k)
const hasConfirmedStickyHide =
hasConfirmedAuthorityForItem(item.repoId, item.id) && sticky?.queryKey === args.queryKey
if (!hasPending && !hasConfirmedStickyHide) {
continue
}
const scope = resolveItemSourceScope(item.repoId, item.id)
byKey.set(k, getRegistryMergedTaskPageGitHubWorkItem(item, scope))
}
return [...byKey.values()].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
}
export function reconcileTaskPagePagesAfterQuietRefresh(args: {
pages: readonly (GitHubWorkItem[] | null)[]
queryKey: string
authorityPage: number
authorityItems: readonly GitHubWorkItem[]
membershipChanged: boolean
visiblePage?: number
visibleItems?: readonly GitHubWorkItem[]
}): (GitHubWorkItem[] | null)[] {
const next = [...args.pages]
const lastPage = args.visiblePage ?? args.authorityPage
while (next.length <= lastPage) {
next.push(null)
}
next[args.authorityPage] = materializeTaskPageItemList({
networkItems: args.authorityItems,
previousItems: args.pages[args.authorityPage] ?? [],
queryKey: args.queryKey
})
if (args.visiblePage !== undefined && args.visibleItems !== undefined) {
if (args.membershipChanged) {
for (let page = args.authorityPage + 1; page < args.visiblePage; page++) {
next[page] = null
}
}
next[args.visiblePage] = materializeTaskPageItemList({
networkItems: args.visibleItems,
previousItems: args.pages[args.visiblePage] ?? [],
queryKey: args.queryKey
})
}
if (args.membershipChanged) {
next.length = lastPage + 1
}
return next
}
/** In-place overlay per page; preserves multi-page structure; no retain. */
export function overlayPendingOnTaskPagePages(
pages: readonly GitHubWorkItem[][]
): GitHubWorkItem[][] {
return pages.map((page) => applyPendingTaskPageGitHubMutationsToItems(page))
}

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import {
applyTaskPageGitHubListOps,
buildTaskPageGitHubWorkItemMutationPatch,
loginSetOfUsers,
loginSetsEqual
} from './task-page-github-work-item-mutation-patches'
function baseItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'issue:1',
type: 'issue',
number: 1,
title: 't',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'alice',
repoId: 'repo-1',
assignees: [],
reviewRequests: [],
...overrides
}
}
describe('applyTaskPageGitHubListOps', () => {
it('adds and removes logins', () => {
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
const afterAdd = applyTaskPageGitHubListOps(snapshot, [
{
family: 'assignees',
kind: 'add',
logins: ['bob'],
users: [{ login: 'bob', name: 'Bob', avatarUrl: 'x' }]
}
])
expect(afterAdd.map((u) => u.login)).toEqual(['alice', 'bob'])
const afterRemove = applyTaskPageGitHubListOps(afterAdd, [
{ family: 'assignees', kind: 'remove', logins: ['alice'] }
])
expect(afterRemove.map((u) => u.login)).toEqual(['bob'])
})
it('does not mutate input snapshot arrays', () => {
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
const frozen = [...snapshot]
applyTaskPageGitHubListOps(snapshot, [{ family: 'assignees', kind: 'add', logins: ['bob'] }])
expect(snapshot).toEqual(frozen)
})
})
describe('buildTaskPageGitHubWorkItemMutationPatch', () => {
it('builds merge patch with state merged and autoMerge cleared', () => {
const item = baseItem({
type: 'pr',
state: 'open',
autoMergeEnabled: true
})
const patch = buildTaskPageGitHubWorkItemMutationPatch(item, { type: 'merge' })
expect(patch.kind).toBe('whole')
if (patch.kind !== 'whole') {
return
}
expect(patch.next).toEqual({ state: 'merged', autoMergeEnabled: false })
expect(patch.previous).toEqual({ state: 'open', autoMergeEnabled: true })
})
it('builds autoMerge patch', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
type: 'setAutoMerge',
enabled: true
})
expect(patch.next).toEqual({ autoMergeEnabled: true })
})
it('builds per-login assignee toggle', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem(), {
type: 'toggleAssignee',
user: { login: 'Alice', name: 'A', avatarUrl: '' }
})
expect(patch.kind).toBe('list')
if (patch.kind !== 'list') {
return
}
expect(patch.opKey).toBe('assignees:alice')
expect(patch.listOp.kind).toBe('add')
expect(patch.next.assignees?.map((u) => u.login.toLowerCase())).toEqual(['alice'])
})
it('builds multi-login reviewer batch opKey', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
type: 'addReviewers',
logins: ['bob', 'carol'],
candidates: [
{ login: 'bob', name: null, avatarUrl: '' },
{ login: 'carol', name: null, avatarUrl: '' }
]
})
expect(patch.kind).toBe('list')
if (patch.kind !== 'list') {
return
}
expect(patch.opKey).toBe('reviewRequests:batch:bob,carol')
expect(patch.listOp.logins).toEqual(['bob', 'carol'])
})
})
describe('loginSetsEqual', () => {
it('compares case-insensitive login sets', () => {
expect(
loginSetsEqual(
loginSetOfUsers([{ login: 'Alice', name: null, avatarUrl: '' }]),
loginSetOfUsers([{ login: 'alice', name: null, avatarUrl: '' }])
)
).toBe(true)
})
})

View File

@ -0,0 +1,230 @@
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
import type { TaskPageGitHubCloseAction } from './task-page-github-status-actions'
import {
taskPageGitHubListOpKey,
type PendingListOp,
type TaskPageGitHubListFamily
} from './task-page-github-work-item-mutation-registry'
export type TaskPageGitHubMutationIntent =
| { type: 'setState'; state: 'open' | 'closed'; closeAction?: TaskPageGitHubCloseAction }
| { type: 'toggleAssignee'; user: GitHubAssignableUser }
| { type: 'addReviewers'; logins: string[]; candidates: GitHubAssignableUser[] }
| { type: 'removeReviewers'; logins: string[] }
| { type: 'merge' }
| { type: 'setAutoMerge'; enabled: boolean }
export type TaskPageGitHubWholeFieldPatch = {
kind: 'whole'
opKey: string
previous: Partial<GitHubWorkItem>
next: Partial<GitHubWorkItem>
/** Families touched for quiet dirty-bit / lastConfirmed. */
families: string[]
}
export type TaskPageGitHubListFieldPatch = {
kind: 'list'
opKey: string
family: TaskPageGitHubListFamily
listOp: PendingListOp
previous: Partial<GitHubWorkItem>
next: Partial<GitHubWorkItem>
families: string[]
}
export type TaskPageGitHubBuiltPatch = TaskPageGitHubWholeFieldPatch | TaskPageGitHubListFieldPatch
function freezeUsers(users: readonly GitHubAssignableUser[]): GitHubAssignableUser[] {
return users.map((user) => ({
login: user.login,
name: user.name,
avatarUrl: user.avatarUrl
}))
}
function normalizeLogin(login: string): string {
return login.trim().replace(/^@/, '').toLowerCase()
}
/**
* Apply pending list ops onto a confirmed snapshot.
* Same login keeps only the latest op (callers should pre-collapse if needed).
*/
export function applyTaskPageGitHubListOps(
snapshot: readonly GitHubAssignableUser[],
ops: readonly PendingListOp[]
): GitHubAssignableUser[] {
let list = freezeUsers(snapshot)
for (const op of ops) {
for (let i = 0; i < op.logins.length; i++) {
const login = op.logins[i]
if (op.kind === 'add') {
if (!list.some((user) => user.login.toLowerCase() === login)) {
const candidate = op.users?.[i]
list.push(
candidate
? { login: candidate.login, name: candidate.name, avatarUrl: candidate.avatarUrl }
: { login, name: null, avatarUrl: '' }
)
}
} else {
list = list.filter((user) => user.login.toLowerCase() !== login)
}
}
}
return list
}
export function loginSetOfUsers(users: readonly GitHubAssignableUser[] | undefined): Set<string> {
const set = new Set<string>()
for (const user of users ?? []) {
if (user.login) {
set.add(user.login.toLowerCase())
}
}
return set
}
export function loginSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) {
return false
}
for (const login of a) {
if (!b.has(login)) {
return false
}
}
return true
}
function findUser(
candidates: readonly GitHubAssignableUser[],
loginLower: string
): GitHubAssignableUser | undefined {
return candidates.find((user) => user.login.toLowerCase() === loginLower)
}
/**
* Pure intent frozen previous/next patches against a registry-merged base item.
*/
export function buildTaskPageGitHubWorkItemMutationPatch(
baseItem: GitHubWorkItem,
intent: TaskPageGitHubMutationIntent
): TaskPageGitHubBuiltPatch {
switch (intent.type) {
case 'setState': {
return {
kind: 'whole',
opKey: 'state',
previous: { state: baseItem.state },
next: { state: intent.state },
families: ['state']
}
}
case 'merge': {
return {
kind: 'whole',
opKey: 'merge',
previous: {
state: baseItem.state,
autoMergeEnabled: baseItem.autoMergeEnabled
},
next: {
state: 'merged',
autoMergeEnabled: false
},
families: ['state', 'merge', 'autoMerge']
}
}
case 'setAutoMerge': {
return {
kind: 'whole',
opKey: 'autoMerge',
previous: { autoMergeEnabled: baseItem.autoMergeEnabled },
next: { autoMergeEnabled: intent.enabled },
families: ['autoMerge']
}
}
case 'toggleAssignee': {
const login = normalizeLogin(intent.user.login)
const current = freezeUsers(baseItem.assignees ?? [])
const isOn = current.some((user) => user.login.toLowerCase() === login)
const listOp: PendingListOp = isOn
? { family: 'assignees', kind: 'remove', logins: [login] }
: {
family: 'assignees',
kind: 'add',
logins: [login],
users: [
{
login: intent.user.login,
name: intent.user.name,
avatarUrl: intent.user.avatarUrl
}
]
}
const nextAssignees = applyTaskPageGitHubListOps(current, [listOp])
return {
kind: 'list',
opKey: taskPageGitHubListOpKey('assignees', [login]),
family: 'assignees',
listOp,
previous: { assignees: current },
next: { assignees: nextAssignees },
families: ['assignees']
}
}
case 'addReviewers': {
const logins = intent.logins.map(normalizeLogin).filter(Boolean)
const unique = [...new Set(logins)]
const users = unique.map((login) => {
const fromCandidates = findUser(intent.candidates, login)
const fromCurrent = findUser(baseItem.reviewRequests ?? [], login)
return (
fromCandidates ??
fromCurrent ?? {
login,
name: null,
avatarUrl: ''
}
)
})
const listOp: PendingListOp = {
family: 'reviewRequests',
kind: 'add',
logins: unique,
users: freezeUsers(users)
}
const current = freezeUsers(baseItem.reviewRequests ?? [])
return {
kind: 'list',
opKey: taskPageGitHubListOpKey('reviewRequests', unique),
family: 'reviewRequests',
listOp,
previous: { reviewRequests: current },
next: { reviewRequests: applyTaskPageGitHubListOps(current, [listOp]) },
families: ['reviewRequests']
}
}
case 'removeReviewers': {
const logins = intent.logins.map(normalizeLogin).filter(Boolean)
const unique = [...new Set(logins)]
const listOp: PendingListOp = {
family: 'reviewRequests',
kind: 'remove',
logins: unique
}
const current = freezeUsers(baseItem.reviewRequests ?? [])
return {
kind: 'list',
opKey: taskPageGitHubListOpKey('reviewRequests', unique),
family: 'reviewRequests',
listOp,
previous: { reviewRequests: current },
next: { reviewRequests: applyTaskPageGitHubListOps(current, [listOp]) },
families: ['reviewRequests']
}
}
}
}

View File

@ -0,0 +1,328 @@
import type { GitHubAssignableUser } from '../../../shared/types'
import type {
PendingOp,
StickyHideEntry,
TaskPageGitHubListFamily,
TaskPageGitHubMutationKey
} from './task-page-github-work-item-registry-types'
export type {
PendingListOp,
PendingOp,
StickyHideEntry,
TaskPageGitHubListFamily,
TaskPageGitHubMutationKey
} from './task-page-github-work-item-registry-types'
import {
serializeTaskPageGitHubMutationKey,
taskPageGitHubItemKey,
taskPageGitHubLastConfirmedKey,
taskPageGitHubSnapshotKey
} from './task-page-github-work-item-mutation-keys'
import { clearTaskPageGitHubQuietStates } from './task-page-github-work-item-quiet-state'
export {
getOrCreateQuietRevalidateState,
markTaskPageGitHubFamiliesDirty,
type QuietRevalidateState
} from './task-page-github-work-item-quiet-state'
export {
serializeTaskPageGitHubMutationKey,
taskPageGitHubFamilyDirtyKey,
taskPageGitHubItemKey,
taskPageGitHubLastConfirmedKey,
taskPageGitHubListOpKey,
taskPageGitHubSnapshotKey
} from './task-page-github-work-item-mutation-keys'
type Listener = () => void
const listeners = new Set<Listener>()
const pendingByKey = new Map<string, PendingOp>()
const generations = new Map<string, number>()
const confirmedSnapshots = new Map<string, GitHubAssignableUser[]>()
const lastConfirmedClientValues = new Map<string, unknown>()
/**
* Why: after confirm, pending ops are gone but lastConfirmed/snapshots stay keyed
* by sourceScope. Overlay must still resolve the same scope or authority is lost.
*/
const itemSourceScopeByItemKey = new Map<string, string | null>()
const stickyHideByItemKey = new Map<string, StickyHideEntry>()
const softHiddenItemKeys = new Set<string>()
let mutationQueryKey: string | null = null
export function subscribeTaskPageGitHubMutationRegistry(listener: Listener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function notifyTaskPageGitHubMutationRegistry(): void {
for (const listener of listeners) {
listener()
}
}
export function getTaskPageGitHubSoftHiddenItemKeys(): ReadonlySet<string> {
return softHiddenItemKeys
}
export function getTaskPageGitHubConfirmedAuthorityItemKeys(): ReadonlySet<string> {
const keys = new Set<string>()
for (const itemKey of itemSourceScopeByItemKey.keys()) {
const separator = itemKey.indexOf('\0')
if (
separator >= 0 &&
hasConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
) {
keys.add(itemKey)
}
}
return keys
}
/**
* Drop confirmed-client authority (not in-flight pending). Used when the user
* hard-refreshes so search can adopt for non-pending families (design tier 3).
*/
export function clearTaskPageGitHubConfirmedAuthority(): void {
confirmedSnapshots.clear()
lastConfirmedClientValues.clear()
itemSourceScopeByItemKey.clear()
}
/**
* Sticky hides + confirmed authority are query-scoped. Changing query/repo set
* clears them so a new filter does not inherit membership exits / lastConfirmed
* from a previous search.
*/
export function setTaskPageGitHubMutationQueryKey(queryKey: string): void {
if (mutationQueryKey === queryKey) {
return
}
mutationQueryKey = queryKey
stickyHideByItemKey.clear()
softHiddenItemKeys.clear()
// Why: lastConfirmed is for lag hold within one query; a new search should
// not permanently override rows with the previous filter's mutations.
clearTaskPageGitHubConfirmedAuthority()
// Why: bound growth — quiet lag/dirty state and generation counters accumulate
// per queryKey/opKey over a session. A new query starts fresh, so drop the old
// quiet states and any generation counters with no in-flight pending op (keys
// with a live op must keep their counter so staleness detection stays valid).
clearTaskPageGitHubQuietStates()
for (const serialized of generations.keys()) {
if (!pendingByKey.has(serialized)) {
generations.delete(serialized)
}
}
notifyTaskPageGitHubMutationRegistry()
}
export function isTaskPageGitHubMutationQueryKeyCurrent(queryKey: string): boolean {
return mutationQueryKey === queryKey
}
export function getTaskPageGitHubMutationQueryKey(): string | null {
return mutationQueryKey
}
export function getPendingTaskPageGitHubOp(key: TaskPageGitHubMutationKey): PendingOp | undefined {
return pendingByKey.get(serializeTaskPageGitHubMutationKey(key))
}
export function nextTaskPageGitHubMutationGeneration(key: TaskPageGitHubMutationKey): number {
const serialized = serializeTaskPageGitHubMutationKey(key)
const next = (generations.get(serialized) ?? 0) + 1
generations.set(serialized, next)
return next
}
export function setPendingTaskPageGitHubOp(op: PendingOp): void {
const serialized = serializeTaskPageGitHubMutationKey(op.key)
// Why: whole-field supersede abandons older pending without rollback; list
// ops share the same map entry only when opKey matches (per-login / batch).
pendingByKey.set(serialized, op)
rememberItemSourceScope(op.key.repoId, op.key.itemId, op.key.sourceScope)
}
export function rememberItemSourceScope(
repoId: string,
itemId: string,
sourceScope: string | null
): void {
itemSourceScopeByItemKey.set(taskPageGitHubItemKey(repoId, itemId), sourceScope)
}
/**
* Resolve the sourceScope used for lastConfirmed/snapshot lookups for an item.
* Prefer live pending, then the remembered scope from the last begin/confirm.
*/
export function resolveItemSourceScope(repoId: string, itemId: string): string | null {
const fromPending = getSourceScopeFromPendingOps(repoId, itemId)
if (fromPending !== undefined) {
return fromPending
}
const remembered = itemSourceScopeByItemKey.get(taskPageGitHubItemKey(repoId, itemId))
return remembered !== undefined ? remembered : null
}
export function hasConfirmedAuthorityForItem(repoId: string, itemId: string): boolean {
const sourceScope = resolveItemSourceScope(repoId, itemId)
return (
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees') !== undefined ||
getConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests') !== undefined ||
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'state') !== undefined ||
getLastConfirmedClientValue(sourceScope, repoId, itemId, 'autoMerge') !== undefined
)
}
export function deletePendingTaskPageGitHubOp(
key: TaskPageGitHubMutationKey
): PendingOp | undefined {
const serialized = serializeTaskPageGitHubMutationKey(key)
const existing = pendingByKey.get(serialized)
if (existing) {
pendingByKey.delete(serialized)
}
return existing
}
export function listPendingTaskPageGitHubOpsForItem(
repoId: string,
itemId: string,
sourceScope?: string | null
): PendingOp[] {
const ops: PendingOp[] = []
for (const op of pendingByKey.values()) {
if (op.key.repoId !== repoId || op.key.itemId !== itemId) {
continue
}
if (sourceScope !== undefined && op.key.sourceScope !== sourceScope) {
continue
}
ops.push(op)
}
return ops.sort((a, b) => a.startedAt - b.startedAt)
}
export function hasPendingTaskPageGitHubOpsForItem(repoId: string, itemId: string): boolean {
for (const op of pendingByKey.values()) {
if (op.key.repoId === repoId && op.key.itemId === itemId) {
return true
}
}
return false
}
export function getSourceScopeFromPendingOps(
repoId: string,
itemId: string
): string | null | undefined {
for (const op of pendingByKey.values()) {
if (op.key.repoId === repoId && op.key.itemId === itemId) {
return op.key.sourceScope
}
}
return undefined
}
export function getConfirmedListSnapshot(
sourceScope: string | null,
repoId: string,
itemId: string,
family: TaskPageGitHubListFamily
): GitHubAssignableUser[] | undefined {
return confirmedSnapshots.get(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family))
}
export function setConfirmedListSnapshot(
sourceScope: string | null,
repoId: string,
itemId: string,
family: TaskPageGitHubListFamily,
users: readonly GitHubAssignableUser[]
): void {
rememberItemSourceScope(repoId, itemId, sourceScope)
confirmedSnapshots.set(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family), [...users])
}
export function deleteConfirmedListSnapshot(
sourceScope: string | null,
repoId: string,
itemId: string,
family: TaskPageGitHubListFamily
): void {
confirmedSnapshots.delete(taskPageGitHubSnapshotKey(sourceScope, repoId, itemId, family))
}
export function getLastConfirmedClientValue(
sourceScope: string | null,
repoId: string,
itemId: string,
family: string
): unknown {
return lastConfirmedClientValues.get(
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
)
}
export function setLastConfirmedClientValue(
sourceScope: string | null,
repoId: string,
itemId: string,
family: string,
value: unknown
): void {
rememberItemSourceScope(repoId, itemId, sourceScope)
lastConfirmedClientValues.set(
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family),
value
)
}
export function deleteLastConfirmedClientValue(
sourceScope: string | null,
repoId: string,
itemId: string,
family: string
): void {
lastConfirmedClientValues.delete(
taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
)
}
export function clearConfirmedAuthorityForItem(repoId: string, itemId: string): void {
const sourceScope = resolveItemSourceScope(repoId, itemId)
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, 'assignees')
deleteConfirmedListSnapshot(sourceScope, repoId, itemId, 'reviewRequests')
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, 'state')
deleteLastConfirmedClientValue(sourceScope, repoId, itemId, 'autoMerge')
}
export function getStickyHideEntry(itemKey: string): StickyHideEntry | undefined {
return stickyHideByItemKey.get(itemKey)
}
export function setStickyHideEntry(entry: StickyHideEntry): void {
stickyHideByItemKey.set(entry.itemKey, entry)
}
export function deleteStickyHideEntry(itemKey: string): void {
stickyHideByItemKey.delete(itemKey)
}
export function getAllStickyHideEntries(): ReadonlyMap<string, StickyHideEntry> {
return stickyHideByItemKey
}
export function setSoftHiddenItemKeys(keys: Iterable<string>): void {
softHiddenItemKeys.clear()
for (const key of keys) {
softHiddenItemKeys.add(key)
}
}
export function updateSoftHiddenItemKey(itemKey: string, hide: boolean): void {
if (hide) {
softHiddenItemKeys.add(itemKey)
} else {
softHiddenItemKeys.delete(itemKey)
}
}
export function gcStickyHidesAbsentFromPages(
pageItemKeys: ReadonlySet<string>,
queryKey: string
): void {
for (const [itemKey, entry] of stickyHideByItemKey) {
if (entry.queryKey !== queryKey) {
continue
}
if (!pageItemKeys.has(itemKey)) {
stickyHideByItemKey.delete(itemKey)
softHiddenItemKeys.delete(itemKey)
}
}
}
/** Test-only: wipe module state between unit cases. */
export function resetTaskPageGitHubMutationRegistryForTests(): void {
pendingByKey.clear()
generations.clear()
confirmedSnapshots.clear()
lastConfirmedClientValues.clear()
itemSourceScopeByItemKey.clear()
stickyHideByItemKey.clear()
softHiddenItemKeys.clear()
clearTaskPageGitHubQuietStates()
mutationQueryKey = null
listeners.clear()
}

View File

@ -0,0 +1,373 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubWorkItem } from '../../../shared/types'
import {
adoptQuietSearchFieldsForItem,
advanceTaskPageQuietRevalidateScope,
applyPendingTaskPageGitHubMutationsToItems,
beginTaskPageGitHubWorkItemMutation,
canStartTaskPageGitHubWorkItemMutation,
clearTaskPageGitHubAuthorityAbsentFromLoadedItems,
clearTaskPageGitHubAuthorityThroughGeneration,
confirmTaskPageGitHubWorkItemMutation,
getTaskPageQuietRevalidateBackoffAttempt,
getTaskPageGitHubRevalidatableAuthorityItemKeys,
isTaskPageQuietRevalidateRunCurrent,
isTaskPageQuietRevalidateScopeCurrent,
MAX_LAG_TRAILS,
patchTaskPageGitHubWorkItemPages,
reconcileTaskPagePagesAfterQuietRefresh,
rebuildSoftHiddenFromItemsForTests
} from './task-page-github-work-item-mutations'
import {
getLastConfirmedClientValue,
getOrCreateQuietRevalidateState,
getTaskPageGitHubSoftHiddenItemKeys,
resetTaskPageGitHubMutationRegistryForTests,
setTaskPageGitHubMutationQueryKey,
subscribeTaskPageGitHubMutationRegistry,
taskPageGitHubFamilyDirtyKey,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-registry'
import {
beginTaskPageQuietRevalidateRun,
finishTaskPageQuietRevalidateRun
} from './task-page-github-work-item-quiet-state'
function query(): ParsedTaskQuery {
return {
scope: 'all',
state: 'open',
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
}
function item(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'issue:1',
type: 'issue',
number: 1,
title: 't',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'author',
repoId: 'repo-1',
...overrides
}
}
afterEach(() => resetTaskPageGitHubMutationRegistryForTests())
describe('TaskPage GitHub mutation regressions', () => {
it('invalidates quiet responses after a query changes away and back', () => {
const initial = { queryKey: 'q1', generation: 0 }
const returned = advanceTaskPageQuietRevalidateScope(
advanceTaskPageQuietRevalidateScope(initial, 'q2'),
'q1'
)
expect(isTaskPageQuietRevalidateScopeCurrent(returned, 'q1', initial.generation)).toBe(false)
expect(isTaskPageQuietRevalidateScopeCurrent(returned, 'q1', returned.generation)).toBe(true)
})
it('invalidates an older quiet response after a hard refresh dispatches', () => {
expect(
isTaskPageQuietRevalidateRunCurrent({ queryKey: 'q', generation: 0 }, 'q', 0, 0, 1)
).toBe(false)
})
it('lets a remounted TaskPage take over an orphaned quiet run', () => {
const quiet = getOrCreateQuietRevalidateState('q')
const oldOwner = {}
const newOwner = {}
const oldRun = beginTaskPageQuietRevalidateRun(quiet, oldOwner)
const newRun = beginTaskPageQuietRevalidateRun(quiet, newOwner)
if (oldRun === null || newRun === null) {
throw new Error('Expected both quiet owners to start a run.')
}
expect(finishTaskPageQuietRevalidateRun(quiet, oldOwner, oldRun)).toBe(false)
expect(quiet.inFlight).toBe(true)
expect(finishTaskPageQuietRevalidateRun(quiet, newOwner, newRun)).toBe(true)
expect(quiet.inFlight).toBe(false)
})
it('does not let an exhausted lag key block a newer item retry', () => {
expect(getTaskPageQuietRevalidateBackoffAttempt([MAX_LAG_TRAILS, 1])).toBe(1)
})
it('resets exhausted family lag when a new confirmation arrives', () => {
const base = item()
const confirmState = (state: 'open' | 'closed'): void => {
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base
})
}
confirmState('closed')
const familyKey = taskPageGitHubFamilyDirtyKey(
taskPageGitHubItemKey(base.repoId, base.id),
'state'
)
getOrCreateQuietRevalidateState('q').lagSkipAttempts.set(familyKey, MAX_LAG_TRAILS)
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).not.toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
confirmState('open')
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
})
it('blocks a second same-key write while the first is unresolved', () => {
const base = item()
const input = { item: base, intent: { type: 'setState', state: 'closed' } as const }
expect(canStartTaskPageGitHubWorkItemMutation(input)).toBe(true)
beginTaskPageGitHubWorkItemMutation({
...input,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
expect(canStartTaskPageGitHubWorkItemMutation(input)).toBe(false)
})
it('blocks overlapping list and whole-field writes while allowing disjoint list writes', () => {
const base = item({ reviewRequests: [] })
beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'addReviewers',
logins: ['alice', 'bob'],
candidates: []
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
expect(
canStartTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'removeReviewers', logins: ['alice'] }
})
).toBe(false)
expect(
canStartTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'removeReviewers', logins: ['carol'] }
})
).toBe(true)
const autoMerge = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setAutoMerge', enabled: true },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
expect(autoMerge.opKey).toBe('autoMerge')
expect(canStartTaskPageGitHubWorkItemMutation({ item: base, intent: { type: 'merge' } })).toBe(
false
)
})
it('does not notify subscribers when a soft-hide rebuild is unchanged', () => {
const base = item()
beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
let notifications = 0
const unsubscribe = subscribeTaskPageGitHubMutationRegistry(() => {
notifications += 1
})
rebuildSoftHiddenFromItemsForTests({
query: query(),
queryKey: 'q',
viewerLogin: 'me',
items: [base]
})
unsubscribe()
expect(notifications).toBe(0)
})
it('preserves the visible page when an earlier authority page changes membership', () => {
const pages = [[item({ id: 'issue:1' })], [item({ id: 'issue:2' })], [item({ id: 'issue:3' })]]
const next = reconcileTaskPagePagesAfterQuietRefresh({
pages,
queryKey: 'q',
authorityPage: 0,
authorityItems: [],
membershipChanged: true,
visiblePage: 2,
visibleItems: [item({ id: 'issue:4' })]
})
expect(next).toHaveLength(3)
expect(next[1]).toBeNull()
expect(next[2]?.[0].id).toBe('issue:4')
})
it('does not apply an old query soft-hide after mutation completion', () => {
const patchWorkItem = (): void => {}
const base = item({ state: 'open' })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'open-query',
viewerLogin: 'me',
patchWorkItem
})
setTaskPageGitHubMutationQueryKey('current-open-query')
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'open-query',
viewerLogin: 'me',
item: base,
patchWorkItem
})
expect(getTaskPageGitHubSoftHiddenItemKeys()).not.toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
expect(getOrCreateQuietRevalidateState('current-open-query').dirtyGeneration).toBe(1)
rebuildSoftHiddenFromItemsForTests({
query: query(),
queryKey: 'current-open-query',
viewerLogin: 'me',
items: [base]
})
expect(getTaskPageGitHubSoftHiddenItemKeys()).toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
})
it('hard refresh clears only authority that predates its request', () => {
const patchWorkItem = (): void => {}
const base = item({ state: 'open', autoMergeEnabled: false })
const stateMutation = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(stateMutation.key, stateMutation.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem
})
const fetchGeneration = getOrCreateQuietRevalidateState('q').dirtyGeneration
const autoMergeMutation = beginTaskPageGitHubWorkItemMutation({
item: { ...base, state: 'closed' },
intent: { type: 'setAutoMerge', enabled: true },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(autoMergeMutation.key, autoMergeMutation.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: { ...base, state: 'closed' },
patchWorkItem
})
clearTaskPageGitHubAuthorityThroughGeneration('q', fetchGeneration)
expect(getLastConfirmedClientValue(null, base.repoId, base.id, 'state')).toBeUndefined()
expect(getLastConfirmedClientValue(null, base.repoId, base.id, 'autoMerge')).toBe(true)
expect(getTaskPageGitHubSoftHiddenItemKeys()).not.toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
})
it('releases confirmed authority for rows no longer present on loaded pages', () => {
const base = item({ autoMergeEnabled: false })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setAutoMerge', enabled: true },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
patchWorkItem: () => {}
})
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base
})
clearTaskPageGitHubAuthorityAbsentFromLoadedItems(new Set())
expect(getTaskPageGitHubRevalidatableAuthorityItemKeys('q')).not.toContain(
taskPageGitHubItemKey(base.repoId, base.id)
)
})
it('preserves unavailable list metadata on untouched rows', () => {
const [overlaid] = applyPendingTaskPageGitHubMutationsToItems([
item({ assignees: undefined, reviewRequests: undefined })
])
expect(overlaid.assignees).toBeUndefined()
expect(overlaid.reviewRequests).toBeUndefined()
})
it('patches a later provider page without rebuilding untouched pages', () => {
const firstPage = [item({ id: 'issue:1' })]
const pages = patchTaskPageGitHubWorkItemPages(
[firstPage, [item({ id: 'issue:2' })]],
{ id: 'issue:2', repoId: 'repo-1' },
{ state: 'closed' }
)
expect(pages[0]).toBe(firstPage)
expect(pages[1]?.[0].state).toBe('closed')
})
it('does not claim authority over untouched search fields', () => {
const patches: Partial<GitHubWorkItem>[] = []
const patchWorkItem = (_id: string, patch: Partial<GitHubWorkItem>): void => {
patches.push(patch)
}
const open = item({ state: 'open', assignees: undefined, reviewRequests: undefined })
const closed = item({ state: 'closed', assignees: undefined, reviewRequests: undefined })
for (const serverItem of [open, closed]) {
adoptQuietSearchFieldsForItem({
item: serverItem,
serverItem,
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: 0,
patchWorkItem
})
}
expect(getLastConfirmedClientValue(null, open.repoId, open.id, 'state')).toBeUndefined()
expect(patches.findLast((patch) => patch.state !== undefined)?.state).toBe('closed')
expect(patches.at(-1)?.assignees).toBeUndefined()
})
})

View File

@ -0,0 +1,32 @@
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { TaskPageGitHubMutationIntent } from './task-page-github-work-item-mutation-patches'
import type { TaskPageGitHubMutationKey } from './task-page-github-work-item-mutation-registry'
export type TaskPageGitHubPatchWorkItem = (
itemId: string,
patch: Partial<GitHubWorkItem>,
repoId?: string,
options?: { sourceContext?: TaskSourceContext | null }
) => void
export type BeginTaskPageGitHubWorkItemMutationArgs = {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
/** Derived inside begin from item sourceContext if omitted. */
skipMeQualifiers?: boolean
patchWorkItem: TaskPageGitHubPatchWorkItem
}
export type BeginTaskPageGitHubWorkItemMutationResult = {
generation: number
opKey: string
itemKey: string
families: string[]
key: TaskPageGitHubMutationKey
}

View File

@ -0,0 +1,836 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubWorkItem } from '../../../shared/types'
import {
adoptQuietSearchFieldsForItem,
applyPendingTaskPageGitHubMutationsToItems,
beginTaskPageGitHubWorkItemMutation,
confirmTaskPageGitHubWorkItemMutation,
getRegistryMergedTaskPageGitHubWorkItem,
materializeTaskPageItemList,
overlayPendingOnTaskPagePages,
rollbackTaskPageGitHubWorkItemMutation,
settleQuietSearchRevalidate,
reapplyPendingTaskPageGitHubMutationsToCache,
clearTaskPageGitHubConfirmedAuthority,
getTaskPageGitHubStickyHideForTests
} from './task-page-github-work-item-mutations'
import {
getConfirmedListSnapshot,
getLastConfirmedClientValue,
getOrCreateQuietRevalidateState,
getTaskPageGitHubSoftHiddenItemKeys,
resetTaskPageGitHubMutationRegistryForTests,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-registry'
function query(overrides: Partial<ParsedTaskQuery> = {}): ParsedTaskQuery {
return {
scope: 'all',
state: 'open',
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: '',
...overrides
}
}
function item(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'issue:1',
type: 'issue',
number: 1,
title: 't',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'author',
repoId: 'repo-1',
assignees: [],
reviewRequests: [],
...overrides
}
}
function createPatchRecorder() {
const patches: { id: string; patch: Partial<GitHubWorkItem>; repoId?: string }[] = []
const patchWorkItem = (id: string, patch: Partial<GitHubWorkItem>, repoId?: string): void => {
patches.push({ id, patch, repoId })
}
return { patches, patchWorkItem }
}
beforeEach(() => {
resetTaskPageGitHubMutationRegistryForTests()
})
afterEach(() => {
resetTaskPageGitHubMutationRegistryForTests()
})
describe('TaskPage GitHub work item mutations', () => {
it('alice confirm while bob pending → composed [alice, bob]', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item()
const alice = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'alice', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const bob = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(alice.key, alice.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
const merged = getRegistryMergedTaskPageGitHubWorkItem(base, null)
expect(merged.assignees?.map((u) => u.login.toLowerCase()).sort()).toEqual(['alice', 'bob'])
expect(
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['alice'])
// bob still pending
void bob
})
it('multi-login batch confirm applies all logins; rollback of batch removes all', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ type: 'pr', id: 'pr:1' })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'addReviewers',
logins: ['bob', 'carol'],
candidates: [
{ login: 'bob', name: null, avatarUrl: '' },
{ login: 'carol', name: null, avatarUrl: '' }
]
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
expect(
getRegistryMergedTaskPageGitHubWorkItem(base, null).reviewRequests?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['bob', 'carol'])
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
expect(
getConfirmedListSnapshot(null, 'repo-1', 'pr:1', 'reviewRequests')?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['bob', 'carol'])
// Separate batch rollback path
resetTaskPageGitHubMutationRegistryForTests()
const began2 = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'addReviewers',
logins: ['bob', 'carol'],
candidates: [
{ login: 'bob', name: null, avatarUrl: '' },
{ login: 'carol', name: null, avatarUrl: '' }
]
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
rollbackTaskPageGitHubWorkItemMutation({
key: began2.key,
generation: began2.generation,
patchWorkItem,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base
})
expect(
getRegistryMergedTaskPageGitHubWorkItem(base, null).reviewRequests?.map((u) => u.login) ?? []
).toEqual([])
})
it('matching server list releases authority; next begin keeps server metadata', () => {
const { patches, patchWorkItem } = createPatchRecorder()
// Seed confirmed snapshot [alice, carol] via sequential confirms.
const empty = item({ assignees: [] })
const addAlice = beginTaskPageGitHubWorkItemMutation({
item: empty,
intent: {
type: 'toggleAssignee',
user: { login: 'alice', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(addAlice.key, addAlice.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: empty,
patchWorkItem,
scheduleQuiet: false
})
const withAlice = item({
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
})
const addCarol = beginTaskPageGitHubWorkItemMutation({
item: withAlice,
intent: {
type: 'toggleAssignee',
user: { login: 'carol', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(addCarol.key, addCarol.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: withAlice,
patchWorkItem,
scheduleQuiet: false
})
const server = item({
assignees: [
{ login: 'alice', name: 'Alice', avatarUrl: 'a2' },
{ login: 'carol', name: 'Carol', avatarUrl: 'c2' }
]
})
const quiet = getOrCreateQuietRevalidateState('q')
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
adoptQuietSearchFieldsForItem({
item: server,
serverItem: server,
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
patchWorkItem
})
const snap = getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')
expect(snap).toBeUndefined()
expect(
patches
.findLast((entry) => entry.patch.assignees !== undefined)
?.patch.assignees?.find((u) => u.login === 'carol')?.avatarUrl
).toBe('c2')
beginTaskPageGitHubWorkItemMutation({
item: server,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const merged = getRegistryMergedTaskPageGitHubWorkItem(server, null)
expect(merged.assignees?.map((u) => u.login.toLowerCase()).sort()).toEqual([
'alice',
'bob',
'carol'
])
})
it('K21: thin search after confirmed add does not force-accept', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
})
// confirm alice already in snapshot path via begin+confirm bob on top of alice snapshot
const addBob = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(addBob.key, addBob.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
const quiet = getOrCreateQuietRevalidateState('q')
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
const thin = item({
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
})
const result = adoptQuietSearchFieldsForItem({
item: thin,
serverItem: thin,
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
patchWorkItem
})
expect(result.needTrailing).toBe(true)
expect(
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['alice', 'bob'])
})
it('K21: fat search after confirmed remove does not force-accept', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({
assignees: [
{ login: 'alice', name: null, avatarUrl: '' },
{ login: 'bob', name: null, avatarUrl: '' }
]
})
const removeBob = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(removeBob.key, removeBob.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
expect(
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['alice'])
const quiet = getOrCreateQuietRevalidateState('q')
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
const fat = item({
assignees: [
{ login: 'alice', name: null, avatarUrl: '' },
{ login: 'bob', name: null, avatarUrl: '' }
]
})
adoptQuietSearchFieldsForItem({
item: fat,
serverItem: fat,
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
patchWorkItem
})
expect(
getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')?.map((u) =>
u.login.toLowerCase()
)
).toEqual(['alice'])
})
it('K21: lagging open after close confirm keeps closed + sticky', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ state: 'open' })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('closed')
const itemKey = taskPageGitHubItemKey('repo-1', 'issue:1')
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
expect(getTaskPageGitHubSoftHiddenItemKeys().has(itemKey)).toBe(true)
const quiet = getOrCreateQuietRevalidateState('q')
quiet.fetchStartedAtGeneration = quiet.dirtyGeneration
// Simulate budget exceeded — still no force-accept
quiet.lastConfirmAt = Date.now() - 200_000
for (let i = 0; i < 6; i++) {
adoptQuietSearchFieldsForItem({
item: item({ state: 'open' }),
serverItem: item({ state: 'open' }),
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: quiet.fetchStartedAtGeneration,
patchWorkItem
})
}
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('closed')
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
})
it('K22: confirm close then open under Open clears sticky', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ state: 'open' })
const close = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(close.key, close.generation, {
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
const itemKey = taskPageGitHubItemKey('repo-1', 'issue:1')
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeTruthy()
const reopen = beginTaskPageGitHubWorkItemMutation({
item: { ...base, state: 'closed' },
intent: { type: 'setState', state: 'open' },
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(reopen.key, reopen.generation, {
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
item: { ...base, state: 'closed' },
patchWorkItem,
scheduleQuiet: false
})
expect(getTaskPageGitHubStickyHideForTests(itemKey)).toBeUndefined()
expect(getTaskPageGitHubSoftHiddenItemKeys().has(itemKey)).toBe(false)
})
it('whole-field supersede: stale rollback/confirm no-ops', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item()
const first = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const second = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'open' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
expect(
confirmTaskPageGitHubWorkItemMutation(first.key, first.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
scheduleQuiet: false
})
).toBe('stale')
expect(
rollbackTaskPageGitHubWorkItemMutation({
key: first.key,
generation: first.generation,
patchWorkItem,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base
})
).toBe('stale')
expect(
confirmTaskPageGitHubWorkItemMutation(second.key, second.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
scheduleQuiet: false
})
).toBe('confirmed')
})
it('per-login assignee: A fail after B begin leaves B only', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item()
const a = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'alice', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
rollbackTaskPageGitHubWorkItemMutation({
key: a.key,
generation: a.generation,
patchWorkItem,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base
})
const merged = getRegistryMergedTaskPageGitHubWorkItem(base, null)
expect(merged.assignees?.map((u) => u.login.toLowerCase())).toEqual(['bob'])
})
it('materialize retains pending-omitted row; overlay preserves multi-page', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ state: 'open' })
beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query({ state: 'open' }),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const list = materializeTaskPageItemList({
networkItems: [],
previousItems: [base],
queryKey: 'q'
})
expect(list).toHaveLength(1)
expect(list[0].state).toBe('closed')
const pages = overlayPendingOnTaskPagePages([
[item({ id: 'issue:1', state: 'open' })],
[item({ id: 'issue:2', repoId: 'repo-1', state: 'open' })]
])
expect(pages).toHaveLength(2)
expect(pages[0][0].state).toBe('closed')
expect(pages[1]).toHaveLength(1)
})
it('multi-repo: pending on repo A and B both survive applyPending', () => {
const { patchWorkItem } = createPatchRecorder()
const a = item({ id: 'issue:1', repoId: 'repo-a', state: 'open' })
const b = item({ id: 'issue:1', repoId: 'repo-b', state: 'open' })
beginTaskPageGitHubWorkItemMutation({
item: a,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
beginTaskPageGitHubWorkItemMutation({
item: b,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const applied = applyPendingTaskPageGitHubMutationsToItems([
{ ...a, state: 'open' },
{ ...b, state: 'open' }
])
expect(applied.map((i) => i.state)).toEqual(['closed', 'closed'])
expect(applied.map((i) => i.repoId)).toEqual(['repo-a', 'repo-b'])
})
it('dirty-bit: confirm during quiet R1 does not adopt thinner list', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
})
const bob = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'bob', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(bob.key, bob.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
// Quiet R1 started at generation before carol confirm
const quiet = getOrCreateQuietRevalidateState('q')
const g0 = quiet.dirtyGeneration
quiet.fetchStartedAtGeneration = g0
const carol = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: {
type: 'toggleAssignee',
user: { login: 'carol', name: null, avatarUrl: '' }
},
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(carol.key, carol.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
patchWorkItem,
scheduleQuiet: false
})
expect(quiet.dirtyGeneration).toBeGreaterThan(g0)
const thin = item({
assignees: [{ login: 'alice', name: null, avatarUrl: '' }]
})
const settle = settleQuietSearchRevalidate({
queryKey: 'q',
networkItems: [thin],
fetchStartedAtGeneration: g0,
patchWorkItem
})
expect(settle.needTrailing).toBe(true)
// Snapshot should still reflect confirmed bob (carol may be pending-cleared with snapshot)
const snap = getConfirmedListSnapshot(null, 'repo-1', 'issue:1', 'assignees')
expect(snap?.map((u) => u.login.toLowerCase()).sort()).toEqual(['alice', 'bob', 'carol'])
})
it('stale confirm does not clobber lastConfirmed', () => {
vi.useFakeTimers()
const { patchWorkItem } = createPatchRecorder()
const base = item()
const first = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
const second = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'open' },
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(second.key, second.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
scheduleQuiet: false
})
confirmTaskPageGitHubWorkItemMutation(first.key, first.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: base,
scheduleQuiet: false
})
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('open')
vi.useRealTimers()
})
})
describe('post-confirm authority with non-null sourceScope', () => {
const sourceContext = {
kind: 'task-source' as const,
provider: 'github' as const,
hostId: 'local' as const,
projectId: 'proj',
projectHostSetupId: 'setup',
repoId: 'repo-1'
}
it('holds closed after confirm when overlay sees open network item', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ state: 'open' })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
sourceContext,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
expect(began.key.sourceScope).not.toBeNull()
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: { ...base, state: 'closed' },
patchWorkItem,
sourceContext
})
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
expect(overlaid[0].state).toBe('closed')
})
it('reapply after confirm holds closed when network returns open', () => {
const store = new Map<string, GitHubWorkItem>()
const base = item({ state: 'open' })
store.set(base.id, base)
const patchWorkItem = (id: string, patch: Partial<GitHubWorkItem>): void => {
const current = store.get(id) ?? base
store.set(id, { ...current, ...patch })
}
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
sourceContext,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: { ...base, state: 'closed' },
patchWorkItem,
sourceContext
})
store.set(base.id, item({ state: 'open' }))
reapplyPendingTaskPageGitHubMutationsToCache({
items: [item({ state: 'open' })],
patchWorkItem
})
expect(store.get(base.id)?.state).toBe('closed')
})
it('user-style clearConfirmedAuthority allows network open after hard refresh', () => {
const { patchWorkItem } = createPatchRecorder()
const base = item({ state: 'open' })
const began = beginTaskPageGitHubWorkItemMutation({
item: base,
intent: { type: 'setState', state: 'closed' },
sourceContext,
query: query(),
queryKey: 'q',
viewerLogin: 'me',
skipMeQualifiers: false,
patchWorkItem
})
confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: query(),
queryKey: 'q',
viewerLogin: 'me',
item: { ...base, state: 'closed' },
patchWorkItem,
sourceContext
})
clearTaskPageGitHubConfirmedAuthority()
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
expect(overlaid[0].state).toBe('open')
})
})

View File

@ -0,0 +1,224 @@
import type { GitHubWorkItem } from '../../../shared/types'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../shared/task-source-context'
import {
buildTaskPageGitHubWorkItemMutationPatch,
type TaskPageGitHubMutationIntent
} from './task-page-github-work-item-mutation-patches'
import {
familiesFromPendingOp,
getRegistryMergedTaskPageGitHubWorkItem,
recomputeSoftHideForItem,
rebuildSoftHiddenKeysFromPendingAndSticky,
stripFamilyPendingFromList
} from './task-page-github-work-item-mutation-composition'
import {
getConfirmedListSnapshot,
getPendingTaskPageGitHubOp,
getStickyHideEntry,
listPendingTaskPageGitHubOpsForItem,
nextTaskPageGitHubMutationGeneration,
notifyTaskPageGitHubMutationRegistry,
setConfirmedListSnapshot,
setPendingTaskPageGitHubOp,
setTaskPageGitHubMutationQueryKey,
taskPageGitHubItemKey,
type PendingOp,
type TaskPageGitHubMutationKey
} from './task-page-github-work-item-mutation-registry'
import type {
BeginTaskPageGitHubWorkItemMutationArgs,
BeginTaskPageGitHubWorkItemMutationResult
} from './task-page-github-work-item-mutation-types'
import type { ParsedTaskQuery } from '../../../shared/task-query'
export type {
BeginTaskPageGitHubWorkItemMutationArgs,
BeginTaskPageGitHubWorkItemMutationResult,
TaskPageGitHubPatchWorkItem
} from './task-page-github-work-item-mutation-types'
export {
applyPendingTaskPageGitHubMutationsToItems,
materializeTaskPageItemList,
overlayPendingOnTaskPagePages,
patchTaskPageGitHubWorkItemPages,
reapplyPendingTaskPageGitHubMutationsToCache,
reconcileTaskPagePagesAfterQuietRefresh
} from './task-page-github-work-item-mutation-pages'
export {
adoptQuietSearchFieldsForItem,
advanceTaskPageQuietRevalidateScope,
getTaskPageQuietRevalidateBackoffAttempt,
isTaskPageQuietRevalidateRunCurrent,
isTaskPageQuietRevalidateScopeCurrent,
processTaskPageQuietRevalidateSettle,
settleQuietSearchRevalidate,
LAG_BACKOFF_MS,
LAG_WALL_BUDGET_MS,
MAX_LAG_TRAILS
} from './task-page-github-work-item-quiet-revalidate'
export type { TaskPageQuietRevalidateScope } from './task-page-github-work-item-quiet-revalidate'
export {
clearTaskPageGitHubAuthorityAbsentFromLoadedItems,
clearTaskPageGitHubAuthorityThroughGeneration,
getTaskPageGitHubRevalidatableAuthorityItemKeys
} from './task-page-github-work-item-authority-refresh'
export {
clearTaskPageGitHubConfirmedAuthority,
resolveItemSourceScope
} from './task-page-github-work-item-mutation-registry'
export {
confirmTaskPageGitHubWorkItemMutation,
rollbackTaskPageGitHubWorkItemMutation
} from './task-page-github-work-item-mutation-lifecycle'
export { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition'
export { rebuildSoftHiddenKeysFromPendingAndSticky } from './task-page-github-work-item-mutation-composition'
export { setTaskPageGitHubMutationQueryKey, taskPageGitHubItemKey }
function resolveSourceScope(sourceContext?: TaskSourceContext | null): string | null {
if (sourceContext?.provider === 'github') {
return getTaskSourceCacheScope(sourceContext)
}
return null
}
function resolveTaskPageGitHubMutation(args: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
}) {
const sourceScope = resolveSourceScope(args.sourceContext)
const base = getRegistryMergedTaskPageGitHubWorkItem(args.item, sourceScope)
return {
sourceScope,
built: buildTaskPageGitHubWorkItemMutationPatch(base, args.intent)
}
}
export function canStartTaskPageGitHubWorkItemMutation(args: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
}): boolean {
const { sourceScope, built } = resolveTaskPageGitHubMutation(args)
const key = {
sourceScope,
repoId: args.item.repoId,
itemId: args.item.id,
opKey: built.opKey
}
if (isTaskPageGitHubMutationPendingKey(key)) {
return false
}
const pending = listPendingTaskPageGitHubOpsForItem(args.item.repoId, args.item.id, sourceScope)
if (built.kind === 'list') {
const affectedLogins = new Set(built.listOp.logins)
return !pending.some(
(op) =>
op.listOp?.family === built.family &&
op.listOp.logins.some((login) => affectedLogins.has(login))
)
}
return !pending.some(
(op) =>
!op.listOp && familiesFromPendingOp(op).some((family) => built.families.includes(family))
)
}
export function beginTaskPageGitHubWorkItemMutation(
args: BeginTaskPageGitHubWorkItemMutationArgs
): BeginTaskPageGitHubWorkItemMutationResult {
setTaskPageGitHubMutationQueryKey(args.queryKey)
const { sourceScope, built } = resolveTaskPageGitHubMutation(args)
const skipMeQualifiers = args.skipMeQualifiers ?? false
const key = {
sourceScope,
repoId: args.item.repoId,
itemId: args.item.id,
opKey: built.opKey
}
const generation = nextTaskPageGitHubMutationGeneration(key)
if (built.kind === 'list') {
const existing = getConfirmedListSnapshot(
sourceScope,
args.item.repoId,
args.item.id,
built.family
)
if (!existing) {
const ops = listPendingTaskPageGitHubOpsForItem(args.item.repoId, args.item.id, sourceScope)
const snapshot = stripFamilyPendingFromList(args.item, built.family, ops)
setConfirmedListSnapshot(sourceScope, args.item.repoId, args.item.id, built.family, snapshot)
}
}
const op: PendingOp = {
generation,
key,
previous: built.previous,
next: built.next,
listOp: built.kind === 'list' ? built.listOp : undefined,
skipMeQualifiers,
startedAt: Date.now()
}
setPendingTaskPageGitHubOp(op)
const merged = getRegistryMergedTaskPageGitHubWorkItem(args.item, sourceScope)
const composedFields: Partial<GitHubWorkItem> =
built.kind === 'list'
? built.family === 'assignees'
? { assignees: merged.assignees }
: { reviewRequests: merged.reviewRequests }
: built.next
args.patchWorkItem(args.item.id, composedFields, args.item.repoId, {
sourceContext: args.sourceContext
})
recomputeSoftHideForItem({
item: { ...args.item, ...merged },
sourceScope,
query: args.query,
queryKey: args.queryKey,
viewerLogin: args.viewerLogin,
skipMeQualifiers,
updateSticky: false
})
notifyTaskPageGitHubMutationRegistry()
return {
generation,
opKey: built.opKey,
itemKey: taskPageGitHubItemKey(args.item.repoId, args.item.id),
families: built.families,
key
}
}
export function isTaskPageGitHubMutationPendingKey(key: TaskPageGitHubMutationKey): boolean {
return getPendingTaskPageGitHubOp(key) !== undefined
}
export function getTaskPageGitHubStickyHideForTests(itemKey: string) {
return getStickyHideEntry(itemKey)
}
export function rebuildSoftHiddenFromItemsForTests(args: {
query: ParsedTaskQuery
queryKey: string
viewerLogin: string | null
items: readonly GitHubWorkItem[]
}): void {
rebuildSoftHiddenKeysFromPendingAndSticky(args)
}

View File

@ -0,0 +1,172 @@
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches'
import {
familiesFromPendingOp,
freezeTaskPageGitHubUsers
} from './task-page-github-work-item-mutation-composition'
import {
getConfirmedListSnapshot,
getLastConfirmedClientValue,
getOrCreateQuietRevalidateState,
listPendingTaskPageGitHubOpsForItem,
deleteConfirmedListSnapshot,
deleteLastConfirmedClientValue,
taskPageGitHubFamilyDirtyKey,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-registry'
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
export const MAX_LAG_TRAILS = 5
export const LAG_WALL_BUDGET_MS = 90_000
export const LAG_BACKOFF_MS = [500, 1000, 2000, 4000, 8000] as const
function hasPendingForFamily(
sourceScope: string | null,
repoId: string,
itemId: string,
family: string
): boolean {
for (const op of listPendingTaskPageGitHubOpsForItem(repoId, itemId, sourceScope)) {
if (op.listOp?.family === family) {
return true
}
if (!op.listOp && familiesFromPendingOp(op).includes(family)) {
return true
}
}
return false
}
/**
* Non-regressive field-wise adopt from search (K21). Never force-accept.
*/
export function adoptQuietSearchFieldsForItem(args: {
item: GitHubWorkItem
serverItem: GitHubWorkItem
sourceScope: string | null
queryKey: string
fetchStartedAtGeneration: number
patchWorkItem: TaskPageGitHubPatchWorkItem
sourceContext?: TaskSourceContext | null
}): { needTrailing: boolean } {
const state = getOrCreateQuietRevalidateState(args.queryKey)
const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id)
let needTrailing = false
const G0 = args.fetchStartedAtGeneration
const tryFamily = (
family: string,
adopt: () => void,
matches: () => boolean,
hasClientAuthority: () => boolean,
releaseClientAuthority: () => void
): void => {
if (hasPendingForFamily(args.sourceScope, args.item.repoId, args.item.id, family)) {
return
}
const dirtyAt = state.familyDirtyAt.get(taskPageGitHubFamilyDirtyKey(itemKey, family)) ?? 0
if (dirtyAt > G0) {
needTrailing = true
return
}
const hasAuthority = hasClientAuthority()
if (hasAuthority && !matches()) {
const lagKey = taskPageGitHubFamilyDirtyKey(itemKey, family)
const attempts = (state.lagSkipAttempts.get(lagKey) ?? 0) + 1
state.lagSkipAttempts.set(lagKey, attempts)
const wallExceeded = Date.now() - state.lastConfirmAt > LAG_WALL_BUDGET_MS
if (attempts < MAX_LAG_TRAILS && !wallExceeded) {
needTrailing = true
}
// K21: never force-accept lagging search.
return
}
adopt()
if (hasAuthority) {
releaseClientAuthority()
}
state.lagSkipAttempts.delete(taskPageGitHubFamilyDirtyKey(itemKey, family))
}
tryFamily(
'state',
() => {
args.patchWorkItem(args.item.id, { state: args.serverItem.state }, args.item.repoId, {
sourceContext: args.sourceContext
})
},
() => {
const last = getLastConfirmedClientValue(
args.sourceScope,
args.item.repoId,
args.item.id,
'state'
)
return last === undefined || args.serverItem.state === last
},
() =>
getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state') !==
undefined,
() => deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state')
)
tryFamily(
'autoMerge',
() => {
args.patchWorkItem(
args.item.id,
{ autoMergeEnabled: args.serverItem.autoMergeEnabled },
args.item.repoId,
{ sourceContext: args.sourceContext }
)
},
() => {
const last = getLastConfirmedClientValue(
args.sourceScope,
args.item.repoId,
args.item.id,
'autoMerge'
)
return last === undefined || args.serverItem.autoMergeEnabled === last
},
() =>
getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'autoMerge') !==
undefined,
() =>
deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'autoMerge')
)
for (const family of ['assignees', 'reviewRequests'] as const) {
const serverListValue =
family === 'assignees' ? args.serverItem.assignees : args.serverItem.reviewRequests
if (serverListValue === undefined) {
continue
}
tryFamily(
family,
() => {
const serverList = freezeTaskPageGitHubUsers(serverListValue)
args.patchWorkItem(
args.item.id,
family === 'assignees' ? { assignees: serverList } : { reviewRequests: serverList },
args.item.repoId,
{ sourceContext: args.sourceContext }
)
},
() => {
const snapshot = getConfirmedListSnapshot(
args.sourceScope,
args.item.repoId,
args.item.id,
family
)
if (!snapshot) {
return true
}
return loginSetsEqual(loginSetOfUsers(snapshot), loginSetOfUsers(serverListValue))
},
() =>
getConfirmedListSnapshot(args.sourceScope, args.item.repoId, args.item.id, family) !==
undefined,
() => deleteConfirmedListSnapshot(args.sourceScope, args.item.repoId, args.item.id, family)
)
}
return { needTrailing }
}

View File

@ -0,0 +1,171 @@
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import {
adoptQuietSearchFieldsForItem,
LAG_BACKOFF_MS,
LAG_WALL_BUDGET_MS,
MAX_LAG_TRAILS
} from './task-page-github-work-item-quiet-adopt'
import {
clearConfirmedAuthorityForItem,
gcStickyHidesAbsentFromPages,
getAllStickyHideEntries,
getOrCreateQuietRevalidateState,
hasConfirmedAuthorityForItem,
hasPendingTaskPageGitHubOpsForItem,
notifyTaskPageGitHubMutationRegistry,
resolveItemSourceScope,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-registry'
import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types'
export { adoptQuietSearchFieldsForItem, LAG_BACKOFF_MS, LAG_WALL_BUDGET_MS, MAX_LAG_TRAILS }
export type TaskPageQuietRevalidateScope = { queryKey: string; generation: number }
export function advanceTaskPageQuietRevalidateScope(
scope: TaskPageQuietRevalidateScope,
queryKey: string
): TaskPageQuietRevalidateScope {
return scope.queryKey === queryKey ? scope : { queryKey, generation: scope.generation + 1 }
}
export function isTaskPageQuietRevalidateScopeCurrent(
scope: TaskPageQuietRevalidateScope,
queryKey: string,
generation: number
): boolean {
return scope.queryKey === queryKey && scope.generation === generation
}
export function isTaskPageQuietRevalidateRunCurrent(
scope: TaskPageQuietRevalidateScope,
queryKey: string,
generation: number,
capturedRefreshEpoch: number,
currentRefreshEpoch: number
): boolean {
return (
isTaskPageQuietRevalidateScopeCurrent(scope, queryKey, generation) &&
capturedRefreshEpoch === currentRefreshEpoch
)
}
export function getTaskPageQuietRevalidateBackoffAttempt(attempts: Iterable<number>): number {
const eligible = [...attempts].filter((attempt) => attempt < MAX_LAG_TRAILS)
return eligible.length === 0 ? 0 : Math.max(...eligible)
}
export function settleQuietSearchRevalidate(args: {
queryKey: string
networkItems: readonly GitHubWorkItem[]
fetchStartedAtGeneration: number
patchWorkItem: TaskPageGitHubPatchWorkItem
resolveSourceScope?: (item: GitHubWorkItem) => string | null
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
revalidatedItemKeys?: ReadonlySet<string>
}): { needTrailing: boolean } {
let needTrailing = false
for (const serverItem of args.networkItems) {
const sourceScope =
args.resolveSourceScope?.(serverItem) ??
resolveItemSourceScope(serverItem.repoId, serverItem.id)
const result = adoptQuietSearchFieldsForItem({
item: serverItem,
serverItem,
sourceScope,
queryKey: args.queryKey,
fetchStartedAtGeneration: args.fetchStartedAtGeneration,
patchWorkItem: args.patchWorkItem,
sourceContext: args.sourceContextByRepoId?.get(serverItem.repoId)
})
if (result.needTrailing) {
needTrailing = true
}
}
// Why: do not GC sticky solely because search omitted a row under lag — that
// would unhide a successful close under Open. Keep sticky for omitted rows
// that still have pending or confirmed authority.
const pageKeys = new Set(
args.networkItems.map((item) => taskPageGitHubItemKey(item.repoId, item.id))
)
for (const [itemKey, entry] of getAllStickyHideEntries()) {
if (
entry.queryKey !== args.queryKey ||
pageKeys.has(itemKey) ||
!args.revalidatedItemKeys?.has(itemKey)
) {
continue
}
const separator = itemKey.indexOf('\0')
if (
separator >= 0 &&
!hasPendingTaskPageGitHubOpsForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
) {
clearConfirmedAuthorityForItem(itemKey.slice(0, separator), itemKey.slice(separator + 1))
}
}
const safeGcKeys = new Set(pageKeys)
for (const [itemKey] of getAllStickyHideEntries()) {
if (pageKeys.has(itemKey)) {
continue
}
const sep = itemKey.indexOf('\0')
if (sep < 0) {
continue
}
const repoId = itemKey.slice(0, sep)
const itemId = itemKey.slice(sep + 1)
if (
hasPendingTaskPageGitHubOpsForItem(repoId, itemId) ||
hasConfirmedAuthorityForItem(repoId, itemId)
) {
safeGcKeys.add(itemKey)
}
}
gcStickyHidesAbsentFromPages(safeGcKeys, args.queryKey)
const quiet = getOrCreateQuietRevalidateState(args.queryKey)
// Why: lag counters are aggregated with Math.max across the query, so an orphan
// stuck at MAX (item lagged then left the result set) would disable lag-retry
// for every row. Drop counters for items fully gone (no page/pending/authority).
for (const lagKey of quiet.lagSkipAttempts.keys()) {
const itemKey = lagKey.slice(0, lagKey.lastIndexOf('\0'))
if (safeGcKeys.has(itemKey)) {
continue
}
const sep = itemKey.indexOf('\0')
if (sep < 0) {
continue
}
if (
!hasPendingTaskPageGitHubOpsForItem(itemKey.slice(0, sep), itemKey.slice(sep + 1)) &&
!hasConfirmedAuthorityForItem(itemKey.slice(0, sep), itemKey.slice(sep + 1))
) {
quiet.lagSkipAttempts.delete(lagKey)
}
}
if (quiet.dirtyGeneration > args.fetchStartedAtGeneration) {
needTrailing = true
}
notifyTaskPageGitHubMutationRegistry()
return { needTrailing }
}
export function processTaskPageQuietRevalidateSettle(args: {
queryKey: string
networkItems: readonly GitHubWorkItem[]
patchWorkItem: TaskPageGitHubPatchWorkItem
resolveSourceScope?: (item: GitHubWorkItem) => string | null
sourceContextByRepoId?: ReadonlyMap<string, TaskSourceContext | null | undefined>
revalidatedItemKeys?: ReadonlySet<string>
}): { needTrailing: boolean } {
const state = getOrCreateQuietRevalidateState(args.queryKey)
return settleQuietSearchRevalidate({
queryKey: args.queryKey,
networkItems: args.networkItems,
fetchStartedAtGeneration: state.fetchStartedAtGeneration,
patchWorkItem: args.patchWorkItem,
resolveSourceScope: args.resolveSourceScope,
sourceContextByRepoId: args.sourceContextByRepoId,
revalidatedItemKeys: args.revalidatedItemKeys
})
}

View File

@ -0,0 +1,88 @@
import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys'
export type QuietRevalidateState = {
inFlight: boolean
trailingQueued: boolean
dirtyGeneration: number
fetchStartedAtGeneration: number
familyDirtyAt: Map<string, number>
lagSkipAttempts: Map<string, number>
networkFailureAttempts: number
lastConfirmAt: number
runGeneration: number
runOwner: object | null
}
const quietByQueryKey = new Map<string, QuietRevalidateState>()
export function getOrCreateQuietRevalidateState(queryKey: string): QuietRevalidateState {
let state = quietByQueryKey.get(queryKey)
if (!state) {
state = {
inFlight: false,
trailingQueued: false,
dirtyGeneration: 0,
fetchStartedAtGeneration: 0,
familyDirtyAt: new Map(),
lagSkipAttempts: new Map(),
networkFailureAttempts: 0,
lastConfirmAt: 0,
runGeneration: 0,
runOwner: null
}
quietByQueryKey.set(queryKey, state)
}
return state
}
export function beginTaskPageQuietRevalidateRun(
state: QuietRevalidateState,
owner: object
): number | null {
if (state.inFlight && state.runOwner === owner) {
state.trailingQueued = true
return null
}
state.inFlight = true
state.trailingQueued = false
state.runGeneration += 1
state.runOwner = owner
return state.runGeneration
}
export function finishTaskPageQuietRevalidateRun(
state: QuietRevalidateState,
owner: object,
generation: number
): boolean {
if (state.runOwner !== owner || state.runGeneration !== generation) {
return false
}
state.inFlight = false
state.runOwner = null
return true
}
export function getQuietRevalidateState(queryKey: string): QuietRevalidateState | undefined {
return quietByQueryKey.get(queryKey)
}
export function markTaskPageGitHubFamiliesDirty(
queryKey: string,
itemKey: string,
families: readonly string[]
): void {
const quiet = getOrCreateQuietRevalidateState(queryKey)
quiet.dirtyGeneration += 1
quiet.lastConfirmAt = Date.now()
quiet.networkFailureAttempts = 0
for (const family of families) {
const familyKey = taskPageGitHubFamilyDirtyKey(itemKey, family)
quiet.familyDirtyAt.set(familyKey, quiet.dirtyGeneration)
quiet.lagSkipAttempts.delete(familyKey)
}
}
export function clearTaskPageGitHubQuietStates(): void {
quietByQueryKey.clear()
}

View File

@ -0,0 +1,30 @@
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
export type TaskPageGitHubListFamily = 'assignees' | 'reviewRequests'
export type TaskPageGitHubMutationKey = {
sourceScope: string | null
repoId: string
itemId: string
opKey: string
}
export type PendingListOp = {
family: TaskPageGitHubListFamily
kind: 'add' | 'remove'
logins: string[]
users?: GitHubAssignableUser[]
}
export type PendingOp = {
generation: number
key: TaskPageGitHubMutationKey
previous: Partial<GitHubWorkItem>
next: Partial<GitHubWorkItem>
listOp?: PendingListOp
skipMeQualifiers: boolean
startedAt: number
}
export type StickyHideEntry = {
itemKey: string
sourceScope: string | null
queryKey: string
reason: 'filter_membership'
}

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { shouldSkipLocalViewerQualifiers } from './useTaskPageGitHubWorkItemMutation'
function sourceContext(hostId: TaskSourceContext['hostId']): TaskSourceContext {
return {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId
}
}
describe('TaskPage GitHub work-item mutation host handling', () => {
it('uses the local viewer only for local task sources', () => {
expect(shouldSkipLocalViewerQualifiers(sourceContext('local'))).toBe(false)
expect(shouldSkipLocalViewerQualifiers(sourceContext('ssh:builder'))).toBe(true)
expect(shouldSkipLocalViewerQualifiers(sourceContext('runtime:environment-1'))).toBe(true)
})
it('does not reuse the github.com viewer for a local enterprise host', () => {
expect(
shouldSkipLocalViewerQualifiers({
...sourceContext('local'),
providerIdentity: {
provider: 'github',
owner: 'acme',
repo: 'project',
host: 'github.acme.test'
}
})
).toBe(true)
})
})

View File

@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { parseExecutionHostId } from '../../../shared/execution-host'
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubWorkItem } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { useAppStore } from '@/store'
import {
beginTaskPageGitHubWorkItemMutation,
canStartTaskPageGitHubWorkItemMutation,
confirmTaskPageGitHubWorkItemMutation,
rollbackTaskPageGitHubWorkItemMutation
} from '@/components/task-page-github-work-item-mutations'
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 {
getTaskPageGitHubSoftHiddenItemKeys,
subscribeTaskPageGitHubMutationRegistry,
setTaskPageGitHubMutationQueryKey
} from '@/components/task-page-github-work-item-mutation-registry'
import { useMountedRef } from './useMountedRef'
export type UseTaskPageGitHubWorkItemMutationArgs = {
queryKey: string
query: ParsedTaskQuery
/** Local gh viewer login only; may be null. */
viewerLogin: string | null
patchWorkItem: TaskPageGitHubPatchWorkItem
}
export function shouldSkipLocalViewerQualifiers(sourceContext?: TaskSourceContext | null): boolean {
if (!sourceContext) {
return false
}
// Why: local gh viewer must not evaluate @me soft-hide for SSH/environment rows.
const hostKind = parseExecutionHostId(sourceContext.hostId)?.kind
if (hostKind === 'ssh' || hostKind === 'runtime') {
return true
}
const providerHost =
sourceContext.providerIdentity?.provider === 'github'
? sourceContext.providerIdentity.host?.toLowerCase()
: undefined
return Boolean(providerHost && providerHost !== 'github.com')
}
export function useTaskPageGitHubWorkItemMutation(args: UseTaskPageGitHubWorkItemMutationArgs): {
run: (input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void>
successToast?: string
errorToast: string
serverEntityFromResult?: (result: unknown) => Partial<GitHubWorkItem> | undefined
}) => Promise<'confirmed' | 'rolled_back' | 'stale'>
isIntentPending: (input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
}) => boolean
softHiddenItemKeys: ReadonlySet<string>
} {
const patchWorkItem = args.patchWorkItem
const mountedRef = useMountedRef()
const activeQueryRef = useRef({
query: args.query,
queryKey: args.queryKey,
viewerLogin: args.viewerLogin
})
activeQueryRef.current = {
query: args.query,
queryKey: args.queryKey,
viewerLogin: args.viewerLogin
}
const [softHiddenItemKeys, setSoftHiddenItemKeys] = useState<ReadonlySet<string>>(
() => new Set(getTaskPageGitHubSoftHiddenItemKeys())
)
useEffect(() => {
setTaskPageGitHubMutationQueryKey(args.queryKey)
}, [args.queryKey])
useEffect(() => {
setSoftHiddenItemKeys(new Set(getTaskPageGitHubSoftHiddenItemKeys()))
return subscribeTaskPageGitHubMutationRegistry(() => {
setSoftHiddenItemKeys(new Set(getTaskPageGitHubSoftHiddenItemKeys()))
})
}, [])
const isIntentPending = useCallback(
(input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
}) => !canStartTaskPageGitHubWorkItemMutation(input),
[]
)
const { query, queryKey, viewerLogin } = args
const run = useCallback(
async (input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void>
successToast?: string
errorToast: string
serverEntityFromResult?: (result: unknown) => Partial<GitHubWorkItem> | undefined
}): Promise<'confirmed' | 'rolled_back' | 'stale'> => {
if (!canStartTaskPageGitHubWorkItemMutation(input)) {
return 'stale'
}
const skipMeQualifiers = shouldSkipLocalViewerQualifiers(input.sourceContext)
const began = beginTaskPageGitHubWorkItemMutation({
item: input.item,
intent: input.intent,
sourceContext: input.sourceContext,
query,
queryKey,
viewerLogin,
skipMeQualifiers,
patchWorkItem
})
try {
const result = await input.mutate()
const activeQuery = activeQueryRef.current
const typed = result as { ok?: boolean; error?: string | { message?: string } } | void
if (typed && typeof typed === 'object' && typed.ok === false) {
const rolled = rollbackTaskPageGitHubWorkItemMutation({
key: began.key,
generation: began.generation,
patchWorkItem,
sourceContext: input.sourceContext,
query: activeQuery.query,
queryKey: activeQuery.queryKey,
viewerLogin: activeQuery.viewerLogin,
item: input.item
})
if (rolled === 'rolled_back' && mountedRef.current) {
const message =
typeof typed.error === 'string'
? typed.error
: (typed.error?.message ?? input.errorToast)
toast.error(message)
}
return rolled
}
const serverEntity = input.serverEntityFromResult?.(result)
const confirmed = confirmTaskPageGitHubWorkItemMutation(began.key, began.generation, {
query: activeQuery.query,
queryKey: activeQuery.queryKey,
viewerLogin: activeQuery.viewerLogin,
item: input.item,
serverEntity,
patchWorkItem,
sourceContext: input.sourceContext,
// Quiet revalidate: mark dirty; TaskPage runner is registered separately.
scheduleQuiet: false
})
if (confirmed === 'confirmed') {
if (input.successToast && mountedRef.current) {
toast.success(input.successToast)
}
useAppStore.getState().recordFeatureInteraction('github-tasks')
}
return confirmed
} catch (err) {
const activeQuery = activeQueryRef.current
const rolled = rollbackTaskPageGitHubWorkItemMutation({
key: began.key,
generation: began.generation,
patchWorkItem,
sourceContext: input.sourceContext,
query: activeQuery.query,
queryKey: activeQuery.queryKey,
viewerLogin: activeQuery.viewerLogin,
item: input.item
})
if (rolled === 'rolled_back' && mountedRef.current) {
toast.error(err instanceof Error ? err.message : input.errorToast)
}
return rolled
}
},
[query, queryKey, viewerLogin, mountedRef, patchWorkItem]
)
return { run, isIntentPending, softHiddenItemKeys }
}

View File

@ -5655,6 +5655,26 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
})
})
it('rejects provider-side partial results when completeness is required', async () => {
const store = createTestStore()
mockApi.gh.listWorkItems.mockResolvedValueOnce({
items: [],
sources: {
issues: { owner: 'up', repo: 'r' },
prs: { owner: 'fork', repo: 'r' },
originCandidate: { owner: 'fork', repo: 'r' },
upstreamCandidate: { owner: 'up', repo: 'r' }
},
errors: { issues: { type: 'permission_denied', message: 'no access' } }
})
await expect(
store
.getState()
.fetchWorkItems('repo-id', '/repo', 24, '', { force: true, requireComplete: true })
).rejects.toThrow('partial result')
})
it('force-retry invalidates a still-failing in-flight request instead of deduping onto it', async () => {
// Why: parent design doc §2 acceptance criterion 4 — the [Retry] button
// must re-invoke the fetch with force=true and clear the banner on
@ -6454,6 +6474,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
}
})
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
try {
const repos = [{ repoId: 'github-repo', path: '/server/github-repo' }]
@ -6466,7 +6487,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
expect(result.items).toEqual([{ ...item, repoId: 'github-repo' }])
expect(result.failedCount).toBe(0)
expect(result.githubUnavailable).toBe(true)
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(2)
expect(result.requestFailureCount).toBe(1)
const completeOnly = await store.getState().fetchWorkItemsAcrossRepos(repos, 24, 100, '', {
force: true,
requireComplete: true,
allowStaleFallback: false
})
expect(completeOnly.items).toEqual([])
expect(completeOnly.failedCount).toBe(1)
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(3)
} finally {
consoleWarn.mockRestore()
}
@ -6604,6 +6634,73 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
}
})
it('reports skipped SSH repos when a complete first page is required', async () => {
const store = createTestStore()
mockApi.gh.listWorkItems.mockRejectedValueOnce(
new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
)
const result = await store
.getState()
.fetchWorkItemsAcrossRepos([{ repoId: 'ssh-repo', path: '/server/ssh-repo' }], 24, 100, '', {
requireComplete: true
})
expect(result.failedCount).toBe(1)
expect(result.requestFailureCount).toBe(1)
})
it('reports skipped SSH repos when a complete later page is required', async () => {
const store = createTestStore()
mockApi.gh.listWorkItems.mockRejectedValueOnce(
new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
)
const result = await store
.getState()
.fetchWorkItemsNextPage([{ repoId: 'ssh-repo', path: '/server/ssh-repo' }], 24, 100, '', 1, {
requireComplete: true
})
expect(result.failedCount).toBe(1)
})
it('rejects provider-side partial data from a complete later-page result', async () => {
const store = createTestStore()
mockApi.gh.listWorkItems.mockResolvedValueOnce({
items: [
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Partial',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-05-22T00:00:00Z',
author: 'author'
}
],
sources: {
issues: { owner: 'o', repo: 'r' },
prs: { owner: 'o', repo: 'r' },
originCandidate: { owner: 'o', repo: 'r' },
upstreamCandidate: null
},
errors: { issues: { type: 'permission_denied', message: 'no access' } }
})
const result = await store
.getState()
.fetchWorkItemsNextPage([{ repoId: 'repo-1', path: '/repo' }], 24, 100, '', 2, {
requireComplete: true
})
expect(result.items).toEqual([])
expect(result.failedCount).toBe(1)
expect(result.errorTypes).toEqual(['permission_denied'])
})
it('routes work-item next-page fetches through the active runtime environment', async () => {
const item = {
type: 'pr',
@ -6639,7 +6736,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
24,
100,
'is:open',
1
1,
{ noCache: true }
)
expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled()
@ -6650,7 +6748,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
repo: 'runtime-repo-id',
limit: 24,
query: 'is:open',
page: 1
page: 1,
noCache: true
},
timeoutMs: 30_000
})

View File

@ -621,6 +621,8 @@ export type CacheEntry<T> = {
type FetchOptions = {
force?: boolean
noCache?: boolean
requireComplete?: boolean
allowStaleFallback?: boolean
sourceContext?: TaskSourceContext | null
}
@ -681,6 +683,7 @@ type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
force: boolean
noCache: boolean
requireComplete: boolean
}
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
const prRequestGenerations = new Map<string, number>()
@ -1958,7 +1961,12 @@ export type GitHubSlice = {
displayLimit: number,
query: string,
options?: FetchOptions
) => Promise<{ items: GitHubWorkItem[]; failedCount: number; githubUnavailable: boolean }>
) => Promise<{
items: GitHubWorkItem[]
failedCount: number
githubUnavailable: boolean
requestFailureCount?: number
}>
/** Fetch one numbered provider page. Pagination pages remain renderer-local. */
fetchWorkItemsNextPage: (
repos: {
@ -1970,7 +1978,8 @@ export type GitHubSlice = {
perRepoLimit: number,
displayLimit: number,
query: string,
page: number
page: number,
options?: Pick<FetchOptions, 'noCache' | 'requireComplete'>
) => Promise<{
items: GitHubWorkItem[]
failedCount: number
@ -2651,7 +2660,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const existing = inflightWorkItemsRequests.get(inflightKey)
if (existing) {
// Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too).
if ((options?.force && !existing.force) || (options?.noCache && !existing.noCache)) {
if (
(options?.force && !existing.force) ||
(options?.noCache && !existing.noCache) ||
(options?.requireComplete && !existing.requireComplete)
) {
await existing.promise.catch(() => {})
} else {
return existing.promise
@ -2668,6 +2681,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
})
// Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id.
const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId }))
if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) {
throw new Error('GitHub work-item fetch returned a partial result.')
}
// Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2).
const issuesError = envelope.errors?.issues
// Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools.
@ -2720,7 +2736,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
inflightWorkItemsRequests.set(inflightKey, {
promise: request,
force: Boolean(options?.force),
noCache: Boolean(options?.noCache)
noCache: Boolean(options?.noCache),
requireComplete: Boolean(options?.requireComplete)
})
return request
},
@ -2745,6 +2762,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
// Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute.
// Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote.
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
if (options?.requireComplete) {
requestFailureCount += 1
failedCount += 1
}
skippedSourceCount += 1
return [] as GitHubWorkItem[]
}
@ -2762,7 +2783,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
: getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path)
const cached = get().workItemsCache[key]?.data
if (cached) {
if (cached && options?.allowStaleFallback !== false) {
console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err)
return cached
}
@ -2778,10 +2799,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
requestFailureCount > 0 &&
requestFailureCount === repos.length - skippedSourceCount &&
unavailableFailureCount === requestFailureCount
return { items: merged, failedCount, githubUnavailable }
return {
items: merged,
failedCount,
githubUnavailable,
...(requestFailureCount > 0 ? { requestFailureCount } : {})
}
},
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => {
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { items: [], failedCount: 0, errorTypes: [] }
}
@ -2808,7 +2834,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const envelope = await listGitHubWorkItemsForRepo(requestContext, {
limit: perRepoLimit,
query: query || undefined,
page
page,
...(options?.noCache ? { noCache: true } : {})
})
// Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6).
if (envelope.errors?.issues) {
@ -2836,9 +2863,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
envelope.errors.prs
)
}
if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) {
failedCount += 1
return [] as GitHubWorkItem[]
}
return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId }))
} catch (err) {
if (isGitHubWorkItemsSshRemoteRequiredError(err)) {
if (options?.requireComplete) {
failedCount += 1
}
return [] as GitHubWorkItem[]
}
console.warn(`[workItems] next page ${r.repoId} failed:`, err)