diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts index 983f1860e9c..28b719db371 100644 --- a/src/renderer/src/store/github/comment-fetch-actions.ts +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -6,6 +6,7 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { isFresh, withBoundedCacheEntry } from './cache-policy' import { inflightCommentsRequests } from './request-coordination' +import { mergePRCommentIntoList } from './pr-comment-cache' import { getGitHubRepoSourceSettings, getGitHubSourceTarget, @@ -39,9 +40,10 @@ export const createCommentFetchActions = ( const inflightRequest = inflightCommentsRequests.get(cacheKey) if (inflightRequest) { - return inflightRequest + return inflightRequest.promise } + const postedComments: PRComment[] = [] const request = (async () => { try { const requestContext = getGitHubWorkItemRequestContext( @@ -51,7 +53,7 @@ export const createCommentFetchActions = ( repoPath, options?.sourceContext ) - const comments = + let comments: PRComment[] = requestContext.target.kind === 'environment' ? await callRuntimeRpc( { kind: 'environment', environmentId: requestContext.target.environmentId }, @@ -64,14 +66,22 @@ export const createCommentFetchActions = ( }, { timeoutMs: 30_000 } ) - : ((await window.api.gh.prComments({ + : await window.api.gh.prComments({ repoPath, repoId, prNumber, prRepo: options?.prRepo ?? null, noCache: options?.force, sourceContext: options?.sourceContext - })) as PRComment[]) + }) + if (postedComments.length > 0) { + const currentById = new Map( + (get().commentsCache[cacheKey]?.data ?? []).map((comment) => [comment.id, comment]) + ) + for (const posted of postedComments) { + comments = mergePRCommentIntoList(comments, currentById.get(posted.id) ?? posted) + } + } set((s) => ({ commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { data: comments, @@ -87,7 +97,7 @@ export const createCommentFetchActions = ( } })() - inflightCommentsRequests.set(cacheKey, request) + inflightCommentsRequests.set(cacheKey, { promise: request, postedComments }) return request } }) diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts index 2277446b729..19d66e47c4d 100644 --- a/src/renderer/src/store/github/comment-mutation-actions.ts +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -8,6 +8,7 @@ import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { withBoundedCacheEntry } from './cache-policy' import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { inflightCommentsRequests } from './request-coordination' import { getGitHubRepoSourceSettings, getGitHubSourceTarget, @@ -102,6 +103,7 @@ export const createCommentMutationActions = ( }) } }) + inflightCommentsRequests.get(cacheKey)?.postedComments.push(comment) return { ok: true, comment } }, diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts index bf54367a760..c438ef43f11 100644 --- a/src/renderer/src/store/github/conversation-comment-actions.ts +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -7,6 +7,7 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { withBoundedCacheEntry } from './cache-policy' import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { inflightCommentsRequests } from './request-coordination' import { getGitHubRepoSourceSettings, getGitHubSourceTarget, @@ -89,6 +90,7 @@ export const createConversationCommentActions = ( }) } }) + inflightCommentsRequests.get(cacheKey)?.postedComments.push(result.comment) return result } }) diff --git a/src/renderer/src/store/github/request-coordination.ts b/src/renderer/src/store/github/request-coordination.ts index 0c983876e2c..0cef2c8600a 100644 --- a/src/renderer/src/store/github/request-coordination.ts +++ b/src/renderer/src/store/github/request-coordination.ts @@ -20,6 +20,10 @@ export type InflightChecks = { /** A user act's request may start a stopped WSL distro; a poll's would be refused. */ wakeWslDistro?: boolean } +export type InflightComments = { + promise: Promise + postedComments: PRComment[] +} export type InflightWorkItems = { promise: Promise requestId: number @@ -37,7 +41,7 @@ export type InflightProjectView = { export const inflightPRRequests = new Map() export const inflightIssueRequests = new Map>() export const inflightChecksRequests = new Map() -export const inflightCommentsRequests = new Map>() +export const inflightCommentsRequests = new Map() export const inflightWorkItemsRequests = new Map() export const inflightProjectViewRequests = new Map() export const prRequestGenerations = new Map() diff --git a/src/renderer/src/store/slices/github-pr-comment-read-order.test.ts b/src/renderer/src/store/slices/github-pr-comment-read-order.test.ts new file mode 100644 index 00000000000..e4b482db749 --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-comment-read-order.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { PRComment } from '../../../../shared/github/comment-types' +import { createTestStore, mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' + +const repoPath = '/repo' +const repoId = 'comment-race-repo' +const prRepo = { owner: 'Acme', repo: 'Widgets' } +const options = { repoId, prRepo } +const cacheKey = `${repoId}::pr-comments::acme/widgets::12` +const original: PRComment = { + id: 10, + author: 'reviewer', + authorAvatarUrl: '', + body: 'Before', + createdAt: '2026-10-28T00:00:00Z', + url: '' +} +const posted: PRComment = { + ...original, + id: 11, + reactionSubjectId: 'IC_11', + body: 'Posted reply', + createdAt: '2026-10-28T00:01:00Z' +} +const remoteAdded: PRComment = { + ...original, + id: 12, + body: 'Another reviewer posted this', + createdAt: '2026-10-28T00:00:30Z' +} + +function makeStore(cached = true) { + const store = createTestStore() + store.setState({ + repos: [{ id: repoId, path: repoPath, displayName: 'Repo', badgeColor: '#000', addedAt: 0 }], + commentsCache: cached ? { [cacheKey]: { data: [original], fetchedAt: 1 } } : {} + }) + return store +} + +beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + mockApi.gh.addIssueComment.mockResolvedValue({ ok: true, comment: posted }) + mockApi.gh.addPRReviewCommentReply.mockResolvedValue({ ok: true, comment: posted }) +}) + +it.each([ + ['comment', true], + ['comment', false], + ['reply', true], + ['reply', false] +] as const)('merges a posted %s with complete history with cached=%s', async (kind, cached) => { + const store = makeStore(cached) + const oldRead = Promise.withResolvers() + mockApi.gh.prComments.mockReturnValueOnce(oldRead.promise) + const reading = store.getState().fetchPRComments(repoPath, 12, { ...options, force: true }) + const result = + kind === 'comment' + ? await store.getState().addPRConversationComment(repoPath, 12, posted.body, options) + : await store.getState().addPRReviewCommentReply(repoPath, 12, 10, posted.body, options) + expect(result.ok).toBe(true) + const published = store.getState().commentsCache[cacheKey] + expect(published?.data?.map(({ id }) => id)).toEqual(cached ? [10, 11] : [11]) + oldRead.resolve([original, remoteAdded]) + await expect(reading).resolves.toEqual([original, remoteAdded, posted]) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual([original, remoteAdded, posted]) +}) + +it('keeps a successful reaction on a newly posted reply while adding fetched history', async () => { + const store = makeStore(false) + const oldRead = Promise.withResolvers() + mockApi.gh.prComments.mockReturnValueOnce(oldRead.promise) + mockApi.gh.setPRCommentReaction.mockResolvedValueOnce(true) + const reading = store.getState().fetchPRComments(repoPath, 12, { ...options, force: true }) + await store.getState().addPRReviewCommentReply(repoPath, 12, 10, posted.body, options) + await store.getState().setPRCommentReaction(repoPath, 12, 'IC_11', 'heart', true, options) + oldRead.resolve([original, remoteAdded]) + const displayed = await reading + expect(displayed.map(({ id }) => id)).toEqual([10, 12, 11]) + expect(displayed.find(({ id }) => id === posted.id)?.reactions).toEqual([ + { content: 'heart', count: 1, viewerHasReacted: true } + ]) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual(displayed) +})