refactor: split PullRequestPage.tsx under 400 lines

Move the 5888-line PR page into nested domain modules under
src/renderer/src/components/pull-request-page/ and leave a thin public
barrel. No intentional behavior change.
This commit is contained in:
Jinjing
2026-08-16 19:21:35 -07:00
parent b6d5972ec4
commit 920bd38dbe
54 changed files with 7929 additions and 6037 deletions
@@ -0,0 +1,81 @@
# Split PullRequestPage.tsx under 400 lines
Status: implemented. Source barrel is 4 lines. Largest dest is `files/combined-diff-viewer.tsx` (390).
Hard cap: `src/renderer/src/components/PullRequestPage.tsx` and every dest file `wc -l` <= 400.
No `max-lines` disables. Zero intentional behavior change. Nested domain folders; file names do not repeat the folder name.
## Public barrel (source stays at original path)
`src/renderer/src/components/PullRequestPage.tsx` becomes imports + public API only:
- `export type { ItemDialogTab }`
- `export type { PullRequestPageProjectOrigin }`
- `export { invalidateWorkItemDetailsCacheForKey }`
- `export { default }` from `page/surface.tsx`
## Dest tree
```
src/renderer/src/components/pull-request-page/
page-types.ts
mentions/{query.ts,query.test.ts,options.ts,options.test.ts,textarea.tsx}
presentation/{state-badge.tsx,state-badge.test.ts}
reviewers/{request-actions.ts,picker-row.tsx,picker.tsx,requested-list.tsx,panel.tsx}
cache/{work-item-details.ts,file-content.ts,files-diff-view.ts}
files/{toolbar.tsx,section-state.ts,combined-diff-viewer.tsx}
conversation/{comment-card.tsx,comment-group.tsx,description.tsx,tab.tsx}
actions/{panel.tsx,merge-actions.ts}
comments/{reply-form.tsx,composer.tsx}
checks/{refresh.ts,rerun.ts,details-request.ts,fix-launch.ts,toolbar.tsx,row.tsx,fix-dialog.tsx,tab.tsx}
edit/{issue-updates.ts,section.tsx}
page/{use-details.ts,header.tsx,tabs-shell.tsx,surface.tsx}
```
## Import DAG (no cycles)
`page-types` → mentions, presentation, reviewers, comments, checks, edit, actions, files, conversation, page
`cache` → files, conversation, page
`mentions` → comments, conversation
`presentation` → edit
`reviewers` / `actions` / `checks` / `comments` → conversation, page
`edit` / `files` → page only
## Oversized cuts
| Symbol | Lines | Cut |
|---|---|---|
| PRReviewersPanel | ~700 | request-actions hook + picker-row + picker + requested-list + panel |
| PRFilesCombinedDiffViewer | ~670 | files-diff-view cache + section-state hook + toolbar + viewer |
| ConversationTab | ~651 | comment-card + comment-group + description + tab |
| ChecksTab | ~1023 | refresh/rerun/details-request/fix-launch + toolbar + row + fix-dialog + tab |
| GHEditSection | ~493 | issue-updates hook + section |
| PullRequestPage | ~877 | use-details + header + tabs-shell + surface |
| PRActionsPanel | ~355 + imports | merge-actions hook + panel |
## Host-boundary tests
Update `pull-request-page-host-boundary.test.ts` to read dest files (concatenate a domain when a former function is split). Keep the same string assertions.
Update `github/repro-8784-ghe-avatar-fallback.test.ts` to read `page/header.tsx` (GitHubUserAvatar + authorAvatarUrl).
## Characterization tests (uncovered pure symbols)
- `mentions/query.test.ts` — findMentionQuery
- `mentions/options.test.ts` — buildMentionOptions
- `presentation/state-badge.test.ts` — getStateTone
## Verification
```
pnpm exec vitest run --config config/vitest.config.ts \
src/renderer/src/components/pull-request-page-host-boundary.test.ts \
src/renderer/src/components/pull-request-page \
src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts
pnpm run typecheck:web
pnpm run check:max-lines-ratchet
# after removing the source max-lines disable:
# prune config/max-lines-baseline.txt entry for PullRequestPage.tsx
wc -l src/renderer/src/components/PullRequestPage.tsx \
src/renderer/src/components/pull-request-page/**/*.{ts,tsx}
```
-1
View File
@@ -93,7 +93,6 @@ inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
inline src/renderer/src/components/PullRequestPage.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => {
})
it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => {
const prPage = readFileSync(join(__dirname, '../PullRequestPage.tsx'), 'utf8')
const prPage = readFileSync(join(__dirname, '../pull-request-page/page/header.tsx'), 'utf8')
expect(prPage).toMatch(/GitHubUserAvatar/)
expect(prPage).toMatch(/authorAvatarUrl/)
// Why: author chip must not ignore API avatar_url and only pass login.
@@ -8,6 +8,10 @@ function componentSource(relativePath: string): string {
return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8')
}
function joinedSource(relativePaths: string[]): string {
return relativePaths.map(componentSource).join('\n')
}
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
@@ -16,14 +20,61 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
return source.slice(start, end)
}
const reviewersSource = joinedSource([
'pull-request-page/reviewers/panel.tsx',
'pull-request-page/reviewers/request-actions.ts',
'pull-request-page/reviewers/picker.tsx',
'pull-request-page/reviewers/requested-list.tsx'
])
const editSource = joinedSource([
'pull-request-page/edit/section.tsx',
'pull-request-page/edit/issue-updates.ts'
])
const actionsSource = joinedSource([
'pull-request-page/actions/panel.tsx',
'pull-request-page/actions/merge-actions.ts'
])
const conversationSource = joinedSource([
'pull-request-page/conversation/tab.tsx',
'pull-request-page/conversation/reply.ts',
'pull-request-page/conversation/comment-card.tsx'
])
const detailsSource = joinedSource([
'pull-request-page/page-types.ts',
'pull-request-page/cache/work-item-details.ts',
'pull-request-page/page/use-details.ts',
'pull-request-page/page/surface.tsx'
])
const filesSource = joinedSource([
'pull-request-page/cache/file-content.ts',
'pull-request-page/files/section-loader.ts',
'pull-request-page/files/combined-diff-viewer.tsx',
'pull-request-page/page/viewed-sync.ts'
])
const commentsSource = joinedSource([
'pull-request-page/comments/composer.tsx',
'pull-request-page/conversation/reply.ts',
'pull-request-page/files/line-comment.ts'
])
const checksSource = joinedSource([
'pull-request-page/checks/tab.tsx',
'pull-request-page/checks/refresh.ts',
'pull-request-page/checks/rerun.ts',
'pull-request-page/checks/details-request.ts',
'pull-request-page/checks/details.tsx',
'pull-request-page/checks/row.tsx'
])
describe('PullRequestPage host boundaries', () => {
it('routes reviewer metadata and mutations through the PR repo owner host', () => {
const source = componentSource('PullRequestPage.tsx')
const section = sourceBetween(
source,
'function PRReviewersPanel',
'const WORK_ITEM_DETAILS_CACHE_MAX'
)
const section = reviewersSource
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(section).toContain('useRepoAssigneesBySlug(')
@@ -33,22 +84,21 @@ describe('PullRequestPage host boundaries', () => {
expect(section).toContain('getActiveRuntimeTarget(sourceSettings)')
expect(section).toContain('sourceContext,')
expect(section).toContain(
'patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, {'
'args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {'
)
expect(section).toContain(
'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)'
'const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)'
)
expect(section).toContain("'github.requestPRReviewers'")
expect(section).toContain("'github.removePRReviewers'")
expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)')
expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4)
expect(section.match(/prRepo: args\.reviewRepo/g)).toHaveLength(4)
expect(section).toContain('notifyWorkItemDetailsMutation(')
expect(section).toContain('{ local: false }')
})
it('routes PR edit metadata through the same repo owner host as mutations', () => {
const source = componentSource('PullRequestPage.tsx')
const section = sourceBetween(source, 'function GHEditSection', 'function GHCommentComposer')
const section = editSource
expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)')
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
@@ -61,77 +111,63 @@ describe('PullRequestPage host boundaries', () => {
})
it('source-scopes full-page optimistic work item patches', () => {
const source = componentSource('PullRequestPage.tsx')
const prAssigneesSection = componentSource('github/PRAssigneesPanel.tsx')
const prActionsSection = sourceBetween(
source,
'function PRActionsPanel',
'function CommentReplyForm'
)
const issueEditSection = sourceBetween(
source,
'function GHEditSection',
'function GHCommentComposer'
)
expect(prAssigneesSection).toContain(
'patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext })'
)
expect(prActionsSection).toContain(
expect(actionsSource).toContain(
'patchWorkItem(item.id, { state }, item.repoId, { sourceContext })'
)
expect(issueEditSection).toContain(
'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })'
expect(editSource).toContain(
'args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {'
)
expect(issueEditSection).toContain(
'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })'
expect(editSource).toContain(
'args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {'
)
})
it('routes PR mention metadata through the PR repo owner host', () => {
const source = componentSource('PullRequestPage.tsx')
const section = sourceBetween(source, 'function ConversationTab', 'const mentionOptions')
const section = conversationSource
expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)')
expect(section).toContain('useRepoAssignees(repoPath, item.repoId, sourceSettings)')
})
it('uses source-aware initial details routing and cache identity', () => {
const source = componentSource('PullRequestPage.tsx')
const propsSection = sourceBetween(
source,
'type PullRequestPageProps',
'function findMentionQuery'
)
const propsSection = componentSource('pull-request-page/page-types.ts')
const cacheKeySection = sourceBetween(
source,
'function getWorkItemDetailsCacheKey',
'function touchWorkItemDetailsCache'
componentSource('pull-request-page/cache/work-item-details.ts'),
'export function getWorkItemDetailsCacheKey',
'export function touchWorkItemDetailsCache'
)
const matchInvalidationSection = sourceBetween(
source,
'function invalidateWorkItemDetailsCacheByMatch',
'function patchCachedPRFileViewedState'
componentSource('pull-request-page/cache/work-item-details.ts'),
'export function invalidateWorkItemDetailsCacheByMatch',
'export function patchCachedPRFileViewedState'
)
expect(propsSection).toContain('sourceContext?: TaskSourceContext | null')
expect(source).toContain('lookupGitHubWorkItemDetailsForSource({')
expect(source).toContain('sourceContext,')
expect(detailsSource).toContain('lookupGitHubWorkItemDetailsForSource({')
expect(detailsSource).toContain('sourceContext,')
expect(cacheKeySection).toContain('sourceCacheScope')
expect(source).toContain('getTaskSourceCacheScope(sourceContext)')
expect(detailsSource).toContain('getTaskSourceCacheScope(sourceContext)')
expect(matchInvalidationSection).toContain(
'if (removed) {\n workItemDetailsCacheGeneration += 1'
'if (removed) {\n workItemDetailsCacheGeneration.current += 1'
)
})
it('treats null details as unavailable while preserving empty detail payloads', () => {
const source = componentSource('PullRequestPage.tsx')
const loadedSection = sourceBetween(
source,
componentSource('pull-request-page/page/use-details.ts'),
'const loading = !!cachedEntry?.pending && !cachedEntry?.details',
'// Why: if a cross-window mutation invalidates'
)
const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {')
const resultSection = sourceBetween(
componentSource('pull-request-page/page/use-details.ts'),
'inflight',
'.catch((err) => {'
)
expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)')
expect(loadedSection).not.toContain('fetchedAt > 0')
@@ -141,12 +177,11 @@ describe('PullRequestPage host boundaries', () => {
})
it('routes file viewed mutations through the PR source context', () => {
const source = componentSource('PullRequestPage.tsx')
const helperSection = componentSource('github/github-work-item-comment-mutations.ts')
const changeSection = sourceBetween(
source,
'const handlePRFileViewedChange = useCallback',
'const ownerRepo = parseOwnerRepoFromItemUrl'
componentSource('pull-request-page/page/viewed-sync.ts'),
'export async function syncPullRequestFileViewed',
'args.setPendingViewedPaths((prev) => {'
)
expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)')
@@ -155,32 +190,16 @@ describe('PullRequestPage host boundaries', () => {
expect(helperSection).toContain('sourceContext: args.sourceContext')
expect(helperSection).toContain('{ local: false }')
expect(changeSection).toContain('canUseDetailsRepoContext')
expect(changeSection).toContain('repoPath: repoPath ??')
expect(changeSection).toContain('sourceContext,')
expect(changeSection).toContain('repoPath: args.repoPath ??')
expect(changeSection).toContain('sourceContext: args.sourceContext')
})
it('routes comment mutations through runtime source context when needed', () => {
const source = componentSource('PullRequestPage.tsx')
const helperSection = sourceBetween(
componentSource('github/github-work-item-comment-mutations.ts'),
'function addIssueCommentForRepo',
'function setPRFileViewedForRepo'
)
const fileCommentSection = sourceBetween(
source,
'const handleAddLineComment = useCallback',
'const renderViewedCheckbox = useCallback'
)
const conversationSection = sourceBetween(
source,
'const handleReply = useCallback',
'const rightPanel ='
)
const composerSection = sourceBetween(
source,
'const handleSubmit = useCallback',
'const canSubmitComment ='
)
expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)')
expect(helperSection).toContain("'github.addIssueComment'")
@@ -190,25 +209,28 @@ describe('PullRequestPage host boundaries', () => {
expect(helperSection).toContain('sourceContext: args.sourceContext')
expect(helperSection).toContain('notifyWorkItemDetailsMutation(')
expect(helperSection).toContain('{ local: false }')
expect(fileCommentSection).toContain('sourceContext,')
expect(conversationSection).toContain('sourceContext,')
expect(composerSection).toContain('sourceContext,')
expect(filesSource).toContain('sourceContext,')
expect(conversationSource).toContain('sourceContext,')
expect(commentsSource).toContain('sourceContext,')
})
it('routes PR file contents and runtime viewed invalidations through the PR source context', () => {
const source = componentSource('PullRequestPage.tsx')
const commentMutations = componentSource('github/github-work-item-comment-mutations.ts')
const fileContentsSection = sourceBetween(
source,
'function loadPRFileContents',
'type CachedPRFilesDiffViewState'
componentSource('pull-request-page/cache/file-content.ts'),
'export function loadPRFileContents',
'touchPRFileContentCache(cacheKey, request)'
)
const fileContentsCacheKeySection = sourceBetween(
source,
'function getPRFileContentCacheKey',
'function loadPRFileContents'
componentSource('pull-request-page/cache/file-content.ts'),
'export function getPRFileContentCacheKey',
'export function loadPRFileContents'
)
const listenerSection = sourceBetween(
componentSource('pull-request-page/cache/work-item-details.ts'),
'let workItemMutatedUnsub',
'if (import.meta !== undefined && import.meta.hot)'
)
const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU')
const commentContextSection = sourceBetween(
componentSource('github/CommentCodeContext.tsx'),
'function CommentCodeContext',
@@ -235,65 +257,55 @@ describe('PullRequestPage host boundaries', () => {
})
it('routes check actions through the PR source context', () => {
const source = componentSource('PullRequestPage.tsx')
const checksSection = sourceBetween(source, 'function ChecksTab', 'function MentionTextarea')
expect(checksSection).toContain('sourceContext?: TaskSourceContext | null')
expect(checksSection).toContain('sourceContext,')
expect(checksSection).toContain("'github.prChecks'")
expect(checksSection).toContain("'github.rerunPRChecks'")
expect(checksSection).toContain("'github.prCheckDetails'")
expect(checksSection).toContain(
'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)'
expect(checksSource).toContain('sourceContext?: TaskSourceContext | null')
expect(checksSource).toContain('sourceContext,')
expect(checksSource).toContain("'github.prChecks'")
expect(checksSource).toContain("'github.rerunPRChecks'")
expect(checksSource).toContain("'github.prCheckDetails'")
expect(checksSource).toContain(
'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId)'
)
expect(checksSection).toContain('window.api.gh.prChecks({')
expect(checksSection).toContain('window.api.gh.rerunPRChecks({')
expect(checksSection).toContain('prCheckDetails({')
expect(checksSection).toMatch(
/withGitHubCheckDetailsTimeout\(\(signal\) =>\s*runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/
expect(checksSource).toContain('window.api.gh.prChecks({')
expect(checksSource).toContain('window.api.gh.rerunPRChecks({')
expect(checksSource).toContain('prCheckDetails({')
expect(checksSource).toMatch(
/withGitHubCheckDetailsTimeout\(\(signal\) =>\s*args\.runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/
)
expect(checksSection).toContain('{ timeoutMs: 30_000, signal }')
expect(checksSource).toContain('{ timeoutMs: 30_000, signal }')
})
it('makes failed check detail loads retryable and fences stale settlements', () => {
const source = componentSource('PullRequestPage.tsx')
const checksSection = sourceBetween(source, 'function ChecksTab', 'function MentionTextarea')
expect(checksSection).toContain('const requestId = ++nextCheckDetailsRequestIdRef.current')
expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId')
expect(checksSection).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)')
expect(checksSection).toContain('checksState,\n checks,\n checkDetailsContextKey')
expect(checksSection).toContain('resetGitHubChecksTabForSource(current)')
expect(checksSection).toContain(
'committedChecksContextOwnerRef.current !== refreshContextOwner'
expect(checksSource).toContain('const requestId = ++args.nextCheckDetailsRequestIdRef.current')
expect(checksSource).toContain('settleGitHubChecksTabDetails(current, args.key, requestId')
expect(checksSource).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)')
expect(checksSource).toContain('checksState,\n checks,\n checkDetailsContextKey')
expect(checksSource).toContain('resetGitHubChecksTabForSource(current)')
expect(checksSource).toContain(
'args.committedChecksContextOwnerRef.current !== refreshContextOwner'
)
expect(checksSection).toContain('activeChecksRefreshRequestIdRef.current !== refreshRequestId')
expect(checksSection).toContain('current.contextOwner === refreshContextOwner')
expect(checksSection).toContain(
'const rerunContextOwner = committedChecksContextOwnerRef.current'
expect(checksSource).toContain(
'args.activeChecksRefreshRequestIdRef.current !== refreshRequestId'
)
expect(checksSection).toContain('committedChecksContextOwnerRef.current !== rerunContextOwner')
expect(checksSection).toContain('await handleRefresh(rerunContextOwner)')
expect(checksSection).toContain('!mountedRef.current ||')
expect(checksSection).toContain(
'onClick={() => requestCheckDetails(check, getCheckDetailsKey(check))}'
expect(checksSource).toContain('current.contextOwner === refreshContextOwner')
expect(checksSource).toContain(
'const rerunContextOwner = args.committedChecksContextOwnerRef.current'
)
expect(checksSection).toContain('disabled={state.loading}')
expect(checksSection).toContain('aria-busy={state.loading}')
expect(checksSection).toContain("translate('githubChecks.retrying', 'Retrying…')")
expect(checksSection).toContain(
expect(checksSource).toContain(
'args.committedChecksContextOwnerRef.current !== rerunContextOwner'
)
expect(checksSource).toContain('await args.handleRefresh(rerunContextOwner)')
expect(checksSource).toContain('!args.mountedRef.current ||')
expect(checksSource).toContain('onClick={() => onRetry(check, getCheckDetailsKey(check))}')
expect(checksSource).toContain('disabled={state.loading}')
expect(checksSource).toContain('aria-busy={state.loading}')
expect(checksSource).toContain("translate('githubChecks.retrying', 'Retrying…')")
expect(checksSource).toContain(
"translate('auto.components.PullRequestPage.5df7c41d2a', 'Retry')"
)
})
it('routes edit metadata and mutations through the PR source context', () => {
const source = componentSource('PullRequestPage.tsx')
const editHelperSection = componentSource('github/github-work-item-edit-mutations.ts')
const editSection = sourceBetween(
source,
'function GHEditSection',
'function GHCommentComposer'
)
expect(editHelperSection).toContain("'github.updateIssue'")
expect(editHelperSection).toContain("'github.updatePRState'")
@@ -311,34 +323,27 @@ describe('PullRequestPage host boundaries', () => {
"repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')"
)
expect(editHelperSection).toContain('{ local: false }')
expect(editSection).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(editSection).toContain('sourceContext,')
expect(editSource).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(editSource).toContain('sourceContext,')
})
it('routes merge actions through the repo owner host (#6957)', () => {
const source = componentSource('PullRequestPage.tsx')
const actionsSection = sourceBetween(
source,
'function PRActionsPanel',
'function CommentReplyForm'
)
expect(actionsSection).toContain(
expect(actionsSource).toContain(
'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)'
)
expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)')
expect(actionsSection).toContain(
expect(actionsSource).toContain('getActiveRuntimeTarget(sourceSettings)')
expect(actionsSource).toContain(
'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ==='
)
expect(actionsSection).toContain("'github.mergePR'")
expect(actionsSection).toContain("'github.setPRAutoMerge'")
expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)')
expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null')
expect(actionsSection).toContain(
'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)'
expect(actionsSource).toContain("'github.mergePR'")
expect(actionsSource).toContain("'github.setPRAutoMerge'")
expect(actionsSource).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)')
expect(actionsSource).not.toContain('prRepo: item.prRepo ?? null')
expect(actionsSource).toContain(
'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId)'
)
expect(actionsSection).toContain('sourceContext,')
expect(actionsSection).toContain('notifyWorkItemDetailsMutation(')
expect(actionsSection).toContain('{ local: false }')
expect(actionsSource).toContain('sourceContext,')
expect(actionsSource).toContain('notifyWorkItemDetailsMutation(')
expect(actionsSource).toContain('{ local: false }')
})
})
@@ -0,0 +1,258 @@
import { toast } from 'sonner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations'
import { runPullRequestStateUpdate } from '@/components/github/github-work-item-edit-mutations'
import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context'
import { GITHUB_PR_MERGE_METHOD_LABELS } from '../../../../../shared/github/pull-request-merge-methods'
import type { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods'
import type {
GitHubOwnerRepo,
GitHubPRMergeMethod
} from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { translate } from '@/i18n/i18n'
import type { GitHubPRMergeStatePresentation } from '@/components/github-pr-merge-state'
export async function changePullRequestState(args: {
canMutateState: boolean
statePending: boolean
nextState: 'open' | 'closed'
localState: GitHubWorkItem['state']
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
prRepo: GitHubOwnerRepo | null
confirm: (options: {
title: string
description: string
confirmLabel: string
confirmVariant?: 'destructive' | 'default'
}) => Promise<boolean>
setStatePending: (value: boolean) => void
applyStatePatch: (state: GitHubWorkItem['state']) => void
onMutated: () => void
}): Promise<void> {
if (!args.canMutateState || args.statePending) {
return
}
const label = args.nextState === 'closed' ? 'Close' : 'Reopen'
const confirmed = await args.confirm({
title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', {
value0: label,
value1: args.item.number
}),
description:
args.nextState === 'closed'
? translate(
'auto.components.PullRequestPage.5a65651096',
'This will close the pull request on GitHub.'
)
: translate(
'auto.components.PullRequestPage.3d77438c92',
'This will reopen the pull request on GitHub.'
),
confirmLabel: label,
confirmVariant: args.nextState === 'closed' ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
const previousState = args.localState
args.setStatePending(true)
args.applyStatePatch(args.nextState)
try {
await runPullRequestStateUpdate({
repoPath: args.repoPath,
repoId: args.repoId,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
prRepo: args.prRepo,
updates: { state: args.nextState }
})
toast.success(
args.nextState === 'closed'
? translate('auto.components.PullRequestPage.7aa3b5f706', 'Pull request closed')
: translate('auto.components.PullRequestPage.710e47aa06', 'Pull request reopened')
)
args.onMutated()
} catch (err) {
args.applyStatePatch(previousState)
toast.error(
err instanceof Error
? err.message
: translate('auto.components.PullRequestPage.b8c6cbb8c4', 'Failed to {{value0}} PR', {
value0: label.toLowerCase()
})
)
} finally {
args.setStatePending(false)
}
}
export async function mergePullRequest(args: {
mergeDisabled: boolean
method: GitHubPRMergeMethod
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
prRepo: GitHubOwnerRepo | null
mergeTarget: RuntimeClientTarget
confirm: (options: {
title: string
description: string
confirmLabel: string
}) => Promise<boolean>
setMergePending: (value: boolean) => void
applyStatePatch: (state: GitHubWorkItem['state']) => void
onMutated: () => void
}): Promise<void> {
if (args.mergeDisabled) {
return
}
const label = GITHUB_PR_MERGE_METHOD_LABELS[args.method]
const confirmed = await args.confirm({
title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', {
value0: label,
value1: args.item.number
}),
description: translate(
'auto.components.PullRequestPage.a63b3c159c',
'This will update the pull request on GitHub.'
),
confirmLabel: label
})
if (!confirmed) {
return
}
args.setMergePending(true)
try {
const result =
args.mergeTarget.kind === 'environment'
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.mergePR>>>(
args.mergeTarget,
'github.mergePR',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
prNumber: args.item.number,
method: args.method,
prRepo: args.prRepo
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.mergePR({
repoPath: args.repoPath ?? '',
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
prNumber: args.item.number,
method: args.method,
prRepo: args.prRepo
})
if (!result.ok) {
toast.error(result.error)
return
}
args.applyStatePatch('merged')
if (args.mergeTarget.kind === 'environment') {
notifyWorkItemDetailsMutation(
{
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
type: 'pr',
number: args.item.number
},
{ local: false }
)
}
toast.success(translate('auto.components.PullRequestPage.c57873d721', 'Pull request merged'))
args.onMutated()
} catch {
toast.error(
translate('auto.components.PullRequestPage.aae645d36d', 'Failed to merge pull request')
)
} finally {
args.setMergePending(false)
}
}
export async function setPullRequestAutoMerge(args: {
canMergeWithRepoContext: boolean
mergePresentation: GitHubPRMergeStatePresentation
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
prRepo: GitHubOwnerRepo | null
mergeTarget: RuntimeClientTarget
mergeMethods: ReturnType<typeof resolveGitHubPRMergeMethods>
setMergePending: (value: boolean) => void
onMutated: () => void
}): Promise<void> {
if (!args.canMergeWithRepoContext || !args.mergePresentation.autoMergeAction) {
return
}
const enabled = args.mergePresentation.autoMergeAction.kind === 'enable'
args.setMergePending(true)
try {
const result =
args.mergeTarget.kind === 'environment'
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.setPRAutoMerge>>>(
args.mergeTarget,
'github.setPRAutoMerge',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
prNumber: args.item.number,
enabled,
method: enabled ? args.mergeMethods.defaultMethod : undefined,
prRepo: args.prRepo
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.setPRAutoMerge({
repoPath: args.repoPath ?? '',
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
prNumber: args.item.number,
enabled,
method: enabled ? args.mergeMethods.defaultMethod : undefined,
prRepo: args.prRepo
})
if (!result.ok) {
toast.error(result.error)
return
}
if (args.mergeTarget.kind === 'environment') {
notifyWorkItemDetailsMutation(
{
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
type: 'pr',
number: args.item.number
},
{ local: false }
)
}
toast.success(
enabled
? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled')
: translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled')
)
args.onMutated()
} catch {
toast.error(
enabled
? translate('auto.components.PullRequestPage.d31f4b508c', 'Failed to enable auto-merge')
: translate('auto.components.PullRequestPage.973ef2fac9', 'Failed to disable auto-merge')
)
} finally {
args.setMergePending(false)
}
}
@@ -0,0 +1,244 @@
import React, { useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
ChevronDown,
CircleDot,
ExternalLink,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
LoaderCircle
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getGitHubMutationRoutingSettings } from '@/lib/github-source-runtime-context'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods'
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { WorkItemStateBadge } from '../presentation/state-badge'
import { changePullRequestState, mergePullRequest, setPullRequestAutoMerge } from './merge-actions'
export function PRActionsPanel({
item,
repoPath,
repoId,
sourceContext,
projectOrigin,
localState,
onStateChange,
onMutated
}: {
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
localState: GitHubWorkItem['state']
onStateChange: (state: GitHubWorkItem['state']) => void
onMutated: () => void
}): React.JSX.Element {
const [statePending, setStatePending] = useState(false)
const [mergePending, setMergePending] = useState(false)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const confirm = useConfirmationDialog()
const actionItem = { ...item, state: localState }
const mergePresentation = presentGitHubPRMergeState(actionItem)
const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings)
const sourceSettings = useAppStore(
useShallow((s) =>
getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)
)
)
const mergeTarget = getActiveRuntimeTarget(sourceSettings)
const prRepo = resolvePullRequestRepo(item, projectOrigin)
const canMutateWithRepoContext =
!!repoPath || !!projectOrigin || mergeTarget.kind === 'environment'
const canMutateState = localState !== 'merged' && canMutateWithRepoContext
const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed'
const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment'
const mergeDisabled =
!canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable
const patchProjectRowIfNeeded = useCallback(
(state: GitHubWorkItem['state']) => {
if (!projectOrigin) {
return
}
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state })
},
[patchProjectRowContent, projectOrigin]
)
const applyStatePatch = useCallback(
(state: GitHubWorkItem['state']) => {
onStateChange(state)
patchWorkItem(item.id, { state }, item.repoId, { sourceContext })
patchProjectRowIfNeeded(state)
},
[item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext]
)
return (
<aside className="rounded-lg border border-border/50 bg-card p-3 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<GitPullRequest className="size-3.5 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.1939d0f663', 'Pull request')}
</span>
</div>
<WorkItemStateBadge item={actionItem} />
</div>
<div className="grid gap-2">
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
className={cn(
'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
{mergePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<GitMerge className="size-3.5" />
)}
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{!canMergeWithRepoContext
? translate(
'auto.components.PullRequestPage.eca289e593',
'Merge requires a registered local repo'
)
: mergePresentation.tooltip}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-52">
{mergePresentation.autoMergeAction && (
<DropdownMenuItem
disabled={!canMergeWithRepoContext || mergePending}
onSelect={() =>
void setPullRequestAutoMerge({
canMergeWithRepoContext,
mergePresentation,
item,
repoPath,
repoId,
sourceContext,
prRepo,
mergeTarget,
mergeMethods,
setMergePending,
onMutated
})
}
>
<GitMerge className="size-4" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
)}
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
{mergeMethods.methods.map(({ method, label }) => (
<DropdownMenuItem
key={method}
disabled={mergeDisabled}
onSelect={() =>
void mergePullRequest({
mergeDisabled,
method,
item,
repoPath,
repoId,
sourceContext,
prRepo,
mergeTarget,
confirm,
setMergePending,
applyStatePatch,
onMutated
})
}
>
<GitMerge className="size-4" />
{label}
</DropdownMenuItem>
))}
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
{translate('auto.components.PullRequestPage.7df8d5fc60', 'Open GitHub merge box')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant={nextState === 'closed' ? 'outline' : 'secondary'}
size="sm"
className={cn(
'w-full justify-center gap-2',
nextState === 'closed' &&
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
)}
disabled={!canMutateState || statePending}
onClick={() =>
void changePullRequestState({
canMutateState,
statePending,
nextState,
localState,
item,
repoPath,
repoId,
sourceContext,
projectOrigin,
prRepo,
confirm,
setStatePending,
applyStatePatch,
onMutated
})
}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : nextState === 'closed' ? (
<GitPullRequestClosed className="size-3.5 text-destructive" />
) : (
<CircleDot className="size-3.5" />
)}
{nextState === 'closed'
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
</Button>
</div>
</aside>
)
}
@@ -0,0 +1,153 @@
import type {
GitHubOwnerRepo,
GitHubPRFile,
GitHubPRFileContents
} from '../../../../../shared/github/pull-request-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getGitHubRuntimeRepoId,
getGitHubSourceRuntimeHost
} from '@/lib/github-source-runtime-context'
import {
PR_FILE_CONTENT_CACHE_MAX_BYTES,
getRetainedPRFileContentsByteCount
} from '@/components/github/pr-file-content-size'
// Why: bounded LRU so a session of opening many PR files can't grow this module map without bound.
const PR_FILE_CONTENT_CACHE_MAX = 64
type PRFileContentCacheEntry = {
value: Promise<GitHubPRFileContents> | GitHubPRFileContents
byteCount: number
}
const prFileContentCache = new Map<string, PRFileContentCacheEntry>()
let prFileContentCacheBytes = 0
function touchPRFileContentCache(
key: string,
value: Promise<GitHubPRFileContents> | GitHubPRFileContents
): void {
const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value)
if (retainedByteCount === null) {
const existing = prFileContentCache.get(key)
prFileContentCacheBytes -= existing?.byteCount ?? 0
prFileContentCache.delete(key)
return
}
const existing = prFileContentCache.get(key)
prFileContentCacheBytes -= existing?.byteCount ?? 0
// Why: re-insert moves the key to MRU; Map insertion order makes the oldest key first when evicting.
prFileContentCache.delete(key)
const byteCount = retainedByteCount
prFileContentCache.set(key, { value, byteCount })
prFileContentCacheBytes += byteCount
while (
prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX ||
prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES
) {
const oldest = prFileContentCache.keys().next().value
if (oldest === undefined) {
break
}
const evicted = prFileContentCache.get(oldest)
prFileContentCacheBytes -= evicted?.byteCount ?? 0
prFileContentCache.delete(oldest)
}
}
export function getPRFileContentCacheKey(args: {
repoPath: string
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
file: GitHubPRFile
headSha: string
baseSha: string
}): string {
const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}`
const sourceKey =
args.sourceContext?.provider === 'github'
? `source:${getTaskSourceCacheScope(args.sourceContext)}`
: 'source:local'
return [
repositoryKey,
sourceKey,
args.prNumber,
args.prRepo ? githubRepoIdentityKey(args.prRepo) : '',
args.file.path,
args.file.oldPath ?? '',
args.file.status,
args.headSha,
args.baseSha
].join('\0')
}
export function loadPRFileContents(args: {
repoPath: string
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
file: GitHubPRFile
headSha: string
baseSha: string
}): Promise<GitHubPRFileContents> {
const cacheKey = getPRFileContentCacheKey(args)
const cached = prFileContentCache.get(cacheKey)
if (cached) {
touchPRFileContentCache(cacheKey, cached.value)
return Promise.resolve(cached.value)
}
let request: Promise<GitHubPRFileContents>
const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext)
request = (
runtimeHost
? callRuntimeRpc<GitHubPRFileContents>(
{ kind: 'environment', environmentId: runtimeHost.environmentId },
'github.prFileContents',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId),
prNumber: args.prNumber,
prRepo: args.prRepo ?? null,
path: args.file.path,
oldPath: args.file.oldPath,
status: args.file.status,
headSha: args.headSha,
baseSha: args.baseSha
},
{ timeoutMs: 30_000 }
)
: window.api.gh.prFileContents({
repoPath: args.repoPath,
repoId: args.repoId,
sourceContext: args.sourceContext,
prNumber: args.prNumber,
prRepo: args.prRepo ?? null,
path: args.file.path,
oldPath: args.file.oldPath,
status: args.file.status,
headSha: args.headSha,
baseSha: args.baseSha
})
)
.then((contents) => {
if (prFileContentCache.get(cacheKey)?.value === request) {
touchPRFileContentCache(cacheKey, contents)
}
return contents
})
.catch((err) => {
const cachedRequest = prFileContentCache.get(cacheKey)
if (cachedRequest?.value === request) {
prFileContentCacheBytes -= cachedRequest.byteCount
prFileContentCache.delete(cacheKey)
}
throw err
})
touchPRFileContentCache(cacheKey, request)
return request
}
@@ -0,0 +1,15 @@
import type { DiffSection } from '@/components/editor/diff-section-types'
export type CachedPRFilesDiffViewState = {
entrySignature: string
sections: DiffSection[]
sectionHeights: Record<number, number>
loadedIndices: number[]
scrollTop: number
sideBySide: boolean
fileTreeCollapsed: boolean
activeTreeSectionKey: string | null
}
export const prFilesDiffViewStateCache = new Map<string, CachedPRFilesDiffViewState>()
export const prFilesDiffScrollTopCache = new Map<string, number>()
@@ -0,0 +1,194 @@
import type {
GitHubAssignableUser,
GitHubPRFileViewedState
} from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events'
// SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md.
const WORK_ITEM_DETAILS_CACHE_MAX = 50
export const WORK_ITEM_DETAILS_FRESH_MS = 30_000
export const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.'
export type WorkItemDetailsCacheEntry = {
details: GitHubWorkItemDetails | null
fetchedAt: number
pending?: Promise<GitHubWorkItemDetails | null>
error?: string
}
export const workItemDetailsCache = new Map<string, WorkItemDetailsCacheEntry>()
// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set).
const workItemDetailsCacheListeners = new Set<() => void>()
export function subscribeWorkItemDetailsCache(listener: () => void): () => void {
workItemDetailsCacheListeners.add(listener)
return () => {
workItemDetailsCacheListeners.delete(listener)
}
}
function notifyWorkItemDetailsCache(): void {
for (const listener of workItemDetailsCacheListeners) {
listener()
}
}
export function getWorkItemDetailsCacheKey(args: {
repoPath: string
repoId: string
issueSourcePreference: string | undefined
sourceCacheScope?: string | null
type: 'issue' | 'pr'
number: number
}): string {
// Why: `\0` separator avoids collisions between key fields that may contain `:` or `/`.
const keyParts = args.sourceCacheScope
? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type]
: [args.repoId, args.issueSourcePreference ?? 'auto', args.type]
return [...keyParts, args.number].join('\0')
}
export function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void {
// Why: re-insert moves the key to MRU; Map insertion order keeps the oldest key first when evicting.
workItemDetailsCache.delete(key)
workItemDetailsCache.set(key, entry)
while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) {
const oldest = workItemDetailsCache.keys().next().value
if (oldest === undefined) {
break
}
workItemDetailsCache.delete(oldest)
}
notifyWorkItemDetailsCache()
}
// Why: object (not a number export) so cross-module readers always see later bumps after the split.
export const workItemDetailsCacheGeneration = { current: 0 }
// Exposed so mutation handlers can drop a stale entry after a local mutation (cross-window invalidation arrives via the `gh:workItemMutated` listener below).
export function invalidateWorkItemDetailsCacheForKey(key: string): void {
// Why: bump generation so an in-flight fetch launched before this invalidation won't write its stale result back.
workItemDetailsCacheGeneration.current += 1
const existed = workItemDetailsCache.delete(key)
if (existed) {
notifyWorkItemDetailsCache()
}
}
// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference.
export function invalidateWorkItemDetailsCacheByMatch(args: {
repoPath: string
repoId?: string
type: 'issue' | 'pr'
number: number
}): void {
const suffix = `\0${args.type}\0${args.number}`
const prefix = `${args.repoId ?? args.repoPath}\0`
let removed = false
for (const key of Array.from(workItemDetailsCache.keys())) {
if (key.startsWith(prefix) && key.endsWith(suffix)) {
workItemDetailsCache.delete(key)
removed = true
}
}
if (removed) {
workItemDetailsCacheGeneration.current += 1
notifyWorkItemDetailsCache()
}
}
export function patchCachedPRFileViewedState(
cacheKey: string,
path: string,
viewerViewedState: GitHubPRFileViewedState
): GitHubPRFileViewedState | undefined {
const prev = workItemDetailsCache.get(cacheKey)
const files = prev?.details?.files
if (!prev?.details || !files) {
return undefined
}
let previousState: GitHubPRFileViewedState | undefined
const nextFiles = files.map((file) => {
if (file.path !== path) {
return file
}
previousState = file.viewerViewedState ?? 'UNVIEWED'
return { ...file, viewerViewedState }
})
if (previousState === undefined || previousState === viewerViewedState) {
return previousState
}
touchWorkItemDetailsCache(cacheKey, {
...prev,
details: { ...prev.details, files: nextFiles },
error: undefined
})
return previousState
}
export function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void {
const prev = workItemDetailsCache.get(cacheKey)
if (!prev?.details) {
return
}
touchWorkItemDetailsCache(cacheKey, {
...prev,
details: { ...prev.details, checks },
fetchedAt: Date.now(),
error: undefined
})
}
export function patchCachedPRReviewRequests(
cacheKey: string,
reviewRequests: GitHubAssignableUser[]
): void {
const prev = workItemDetailsCache.get(cacheKey)
if (!prev?.details) {
return
}
touchWorkItemDetailsCache(cacheKey, {
...prev,
details: {
...prev.details,
item: { ...prev.details.item, reviewRequests }
},
fetchedAt: Date.now(),
error: undefined
})
}
export function patchCachedWorkItemBody(cacheKey: string, body: string): void {
const prev = workItemDetailsCache.get(cacheKey)
if (!prev?.details) {
return
}
touchWorkItemDetailsCache(cacheKey, {
...prev,
details: { ...prev.details, body },
fetchedAt: Date.now(),
error: undefined
})
}
// Install once at module load (all dialogs share the cache); track the unsubscribe so Vite HMR doesn't accumulate listeners across reloads.
let workItemMutatedUnsub: (() => void) | undefined
let workItemDetailsCacheEventUnsub: (() => void) | undefined
if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) {
workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => {
invalidateWorkItemDetailsCacheByMatch({
repoPath: payload.repoPath,
repoId: payload.repoId,
type: payload.type,
number: payload.number
})
})
workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => {
invalidateWorkItemDetailsCacheByMatch(payload)
})
}
if (import.meta !== undefined && import.meta.hot) {
import.meta.hot.dispose(() => {
workItemMutatedUnsub?.()
workItemDetailsCacheEventUnsub?.()
})
}
@@ -0,0 +1,98 @@
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout'
import {
beginGitHubChecksTabDetails,
settleGitHubChecksTabDetails,
type CheckDetailsLoadState,
type GitHubChecksTabState
} from '@/components/github-checks-tab-state'
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
export function requestPullRequestCheckDetails(args: {
canUseChecksRepoContext: boolean
check: PRCheckDetail
key: string
nextCheckDetailsRequestIdRef: { current: number }
mountedRef: { current: boolean }
setChecksState: (updater: (current: GitHubChecksTabState) => GitHubChecksTabState) => void
runtimeHost: GitHubRuntimeHost | null
sourceContext?: TaskSourceContext | null
repoId: string | null
repoPath: string | null
item: GitHubWorkItem
prRepo: GitHubOwnerRepo | null
}): void {
if (
!args.canUseChecksRepoContext ||
(!args.check.checkRunId && !args.check.workflowRunId && !args.check.url)
) {
return
}
const requestId = ++args.nextCheckDetailsRequestIdRef.current
const commit = (next: Omit<CheckDetailsLoadState, 'requestId'>): void => {
if (!args.mountedRef.current) {
return
}
args.setChecksState((current) =>
settleGitHubChecksTabDetails(current, args.key, requestId, next)
)
}
args.setChecksState((current) => beginGitHubChecksTabDetails(current, args.key, requestId))
const detailsRequest = withGitHubCheckDetailsTimeout((signal) =>
args.runtimeHost
? callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.prCheckDetails>>>(
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
'github.prCheckDetails',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
checkRunId: args.check.checkRunId,
workflowRunId: args.check.workflowRunId,
checkName: args.check.name,
url: args.check.url,
prRepo: args.prRepo
},
{ timeoutMs: 30_000, signal }
)
: window.api.gh.prCheckDetails({
repoPath: args.repoPath ?? '',
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
checkRunId: args.check.checkRunId,
workflowRunId: args.check.workflowRunId,
checkName: args.check.name,
url: args.check.url,
prRepo: args.prRepo
})
)
void detailsRequest
.then((details) => {
commit({
loading: false,
details,
error: details
? null
: translate(
'auto.components.PullRequestPage.6b1d5ee3e4',
'No inline details are available for this check.'
)
})
})
.catch((err) => {
commit({
loading: false,
details: null,
error:
err instanceof Error
? err.message
: translate(
'auto.components.PullRequestPage.e04c027d98',
'Failed to load check details.'
)
})
})
}
@@ -0,0 +1,229 @@
import React from 'react'
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { getCheckConclusion } from '@/components/pr-check-counts'
import {
formatCheckTimestamp,
getCheckDetailsKey,
getCheckStatusLabel
} from '@/components/github/pr-check-presentation'
import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state'
import { translate } from '@/i18n/i18n'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
export function CheckDetailsPanel({
check,
state,
onRetry
}: {
check: PRCheckDetail
state: CheckDetailsLoadState | undefined
onRetry: (check: PRCheckDetail, key: string) => void
}): React.JSX.Element {
const details = state?.details
const openUrl = details?.detailsUrl ?? details?.url ?? check.url
const startedAt = formatCheckTimestamp(details?.startedAt)
const completedAt = formatCheckTimestamp(details?.completedAt)
const detailsStatusCheck: PRCheckDetail = {
...check,
status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status,
conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
}
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
const hasAnnotations = (details?.annotations.length ?? 0) > 0
const hasJobs = (details?.jobs.length ?? 0) > 0
return (
<div className="mx-2 mb-2 mt-1 min-w-0 rounded-md border border-border/50 bg-muted/20 px-3 py-2">
{state?.loading && !state.error ? (
<div className="flex items-center gap-2 py-2 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')}
</div>
) : (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span>
{translate('auto.components.PullRequestPage.662bc2998d', 'Status:')}{' '}
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
</span>
{startedAt && (
<span>
{translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt}
</span>
)}
{completedAt && (
<span>
{translate('auto.components.PullRequestPage.000f90afcf', 'Completed')} {completedAt}
</span>
)}
{check.checkRunId && (
<span className="font-mono">
{translate('auto.components.PullRequestPage.f01bf79a79', 'check #')}
{check.checkRunId}
</span>
)}
</div>
{state?.error && (
<div role="alert" className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0 break-words text-[12px] text-destructive">{state.error}</div>
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0"
disabled={state.loading}
aria-busy={state.loading}
onClick={() => onRetry(check, getCheckDetailsKey(check))}
>
<RefreshCw className={cn('size-3', state.loading && 'animate-spin')} />
{state.loading
? translate('githubChecks.retrying', 'Retrying…')
: translate('auto.components.PullRequestPage.5df7c41d2a', 'Retry')}
</Button>
</div>
)}
{hasOutput && (
<div className="min-w-0 rounded-md border border-border/40 bg-background/70 px-2.5 py-2">
{details?.title && (
<div className="mb-1 text-[12px] font-medium text-foreground">{details.title}</div>
)}
{details?.summary && (
<CommentMarkdown
content={details.summary}
variant="document"
className="min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
{details?.text && (
<CommentMarkdown
content={details.text}
variant="document"
className="mt-2 min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
</div>
)}
{hasAnnotations && (
<div className="min-w-0 rounded-md border border-border/40 bg-background/70">
<div className="border-b border-border/40 px-2.5 py-1.5 text-[11px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.8432d17901', 'Annotations')}
</div>
<div className="flex flex-col">
{details!.annotations.map((annotation, index) => (
<div
key={`${annotation.path ?? 'annotation'}-${index}`}
className={cn(
'min-w-0 px-2.5 py-2 text-[12px]',
index > 0 && 'border-t border-border/30'
)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{annotation.path ??
translate('auto.components.PullRequestPage.35a0573f41', 'Annotation')}
{annotation.startLine ? `:${annotation.startLine}` : ''}
</span>
{annotation.annotationLevel && (
<span className="shrink-0 text-[11px] text-muted-foreground">
{annotation.annotationLevel}
</span>
)}
</div>
{annotation.title && (
<div className="mt-1 text-[12px] font-medium text-foreground">
{annotation.title}
</div>
)}
<div className="mt-1 break-words text-[12px] text-foreground">
{annotation.message}
</div>
{annotation.rawDetails && (
<pre className="mt-1 whitespace-pre-wrap rounded bg-muted/40 p-2 font-mono text-[11px] text-muted-foreground">
{annotation.rawDetails}
</pre>
)}
</div>
))}
</div>
</div>
)}
{hasJobs && (
<div className="min-w-0 rounded-md border border-border/40 bg-background/70">
<div className="border-b border-border/40 px-2.5 py-1.5 text-[11px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.7720c9c3f5', 'Jobs')}
</div>
<div className="flex flex-col">
{details!.jobs.map((job, index) => (
<div
key={`${job.name}-${index}`}
className={cn('min-w-0 px-2.5 py-2', index > 0 && 'border-t border-border/30')}
>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-foreground">
{job.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{job.conclusion ??
job.status ??
translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')}
</span>
</div>
{job.steps.length > 0 && (
<div className="mt-1 grid gap-1">
{job.steps.map((step) => (
<div
key={step.name}
className="flex min-w-0 items-center gap-2 text-[11px] text-muted-foreground"
>
<span className="min-w-0 flex-1 truncate">{step.name}</span>
<span className="shrink-0">{step.conclusion ?? step.status}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
<div className="text-[12px] text-muted-foreground">
{getCheckConclusion(check) === 'action_required'
? translate(
'auto.components.PullRequestPage.checkActionRequiredHint',
'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.'
)
: translate(
'auto.components.PullRequestPage.1550675e5f',
'No inline output is available for this check.'
)}
</div>
)}
{openUrl && (
<div>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => window.api.shell.openUrl(openUrl)}
>
{translate('auto.components.PullRequestPage.1b14d0a69c', 'Open in GitHub')}
<ExternalLink className="size-3" />
</Button>
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,77 @@
import React from 'react'
import { toast } from 'sonner'
import type { useAppStore } from '@/store'
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
import type { SourceControlActionRecipe } from '../../../../../shared/source-control-ai-actions'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { saveFixChecksActionDefault, startFixChecksFromDialog } from './fix-launch'
export function ChecksFixDialog({
fixChecksComposerPrompt,
setFixChecksComposerPrompt,
fixChecksRecipe,
fixChecksLaunchPlatform,
repoConnectionId,
targetRepoId,
item,
updateSettings,
updateRepo
}: {
fixChecksComposerPrompt: string | null
setFixChecksComposerPrompt: (value: string | null) => void
fixChecksRecipe: SourceControlActionRecipe
fixChecksLaunchPlatform: NodeJS.Platform
repoConnectionId: string | null
targetRepoId: string | null
item: GitHubWorkItem
updateSettings: ReturnType<typeof useAppStore.getState>['updateSettings']
updateRepo: ReturnType<typeof useAppStore.getState>['updateRepo']
}): React.JSX.Element {
return (
<SourceControlAgentActionDialog
open={fixChecksComposerPrompt !== null}
onOpenChange={(open) => {
if (!open) {
setFixChecksComposerPrompt(null)
}
}}
actionId="fixChecks"
title={translate('auto.components.PullRequestPage.a053bdd082', 'Fix Broken Checks With AI')}
description={translate(
'auto.components.PullRequestPage.ddfd42f460',
'Review the prompt before starting an agent.'
)}
baseCommandInput={fixChecksComposerPrompt ?? ''}
connectionId={repoConnectionId}
repoId={targetRepoId}
promptDelivery="submit-after-ready"
launchPlatform={fixChecksLaunchPlatform}
launchSource="task_page"
savedAgentId={readSourceControlLaunchRecipeAgentId(fixChecksRecipe)}
savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null}
savedAgentArgs={fixChecksRecipe.agentArgs ?? null}
onSaveAgentDefault={(target, actionId, recipe) =>
saveFixChecksActionDefault(target, actionId, recipe, updateSettings, updateRepo)
}
onLaunched={() => {
toast.success(
translate(
'auto.components.PullRequestPage.85e62c5266',
'Started an AI agent for the broken checks.'
)
)
}}
onStart={({ agent, commandInput, agentArgs }) =>
startFixChecksFromDialog({
targetRepoId,
item,
agent,
commandInput,
agentArgs
})
}
/>
)
}
@@ -0,0 +1,138 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { buildFixBrokenChecksPrompt } from '@/components/pr-checks-fix-prompt'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../../shared/source-control-ai-recipe-save'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../../shared/source-control-ai-actions'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
type StoreState = ReturnType<typeof useAppStore.getState>
export async function saveFixChecksActionDefault(
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe,
updateSettings: StoreState['updateSettings'],
updateRepo: StoreState['updateRepo']
): Promise<void> {
const state = useAppStore.getState()
const latestSettings = state.settings
if (!latestSettings) {
throw new Error('Settings are not loaded.')
}
const latestRepo =
target.type === 'repo'
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
: null
const result = saveSourceControlActionRecipe({
target,
settings: latestSettings,
repo: latestRepo,
actionId,
recipe
})
if ('sourceControlAi' in result) {
await updateSettings({ sourceControlAi: result.sourceControlAi })
return
}
await updateRepo(result.target.repoId, result.update)
}
export async function startFixChecksFromDialog(args: {
targetRepoId: string | null
item: GitHubWorkItem
agent: Parameters<typeof launchWorkItemDirect>[0]['agentOverride']
commandInput: string
agentArgs: string
}): Promise<boolean> {
if (!args.targetRepoId) {
return false
}
return await launchWorkItemDirect({
item: { ...args.item, repoId: args.targetRepoId, pasteContent: args.commandInput },
repoId: args.targetRepoId,
launchSource: 'task_page',
telemetrySource: 'sidebar',
promptDelivery: 'submit-after-ready',
agentOverride: args.agent,
agentArgs: args.agentArgs,
openModalFallback: () => {
toast.error(
translate(
'auto.components.PullRequestPage.c4c02ea23e',
'Unable to create a fix workspace automatically.'
)
)
}
})
}
export async function fixBrokenPullRequestChecks(args: {
targetRepoId: string | null
fixingChecks: boolean
failedChecks: PRCheckDetail[]
item: GitHubWorkItem
list: PRCheckDetail[]
setFixingChecks: (value: boolean) => void
setFixChecksComposerPrompt: (value: string | null) => void
}): Promise<void> {
if (!args.targetRepoId || args.fixingChecks) {
return
}
if (args.failedChecks.length === 0) {
toast.message(
translate('auto.components.PullRequestPage.51c65c0265', 'No broken checks to fix.')
)
return
}
const basePrompt = buildFixBrokenChecksPrompt({
reviewKind: 'PR',
reviewNumber: args.item.number,
reviewTitle: args.item.title,
reviewUrl: args.item.url,
checks: args.list
})
args.setFixingChecks(true)
try {
const started = await startFixChecksAgent({
item: args.item,
repoId: args.targetRepoId,
basePrompt,
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: () => {
args.setFixChecksComposerPrompt(basePrompt)
}
})
if (started) {
toast.success(
translate(
'auto.components.PullRequestPage.85e62c5266',
'Started an AI agent for the broken checks.'
)
)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('Failed to start fix checks agent', err)
toast.error(
translate(
'auto.components.PullRequestPage.98583589c6',
'Failed to start an AI agent for the broken checks: {{value0}}',
{ value0: message }
)
)
} finally {
args.setFixingChecks(false)
}
}
@@ -0,0 +1,135 @@
import React from 'react'
import { CircleDashed, LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { CHECK_COLOR } from '@/components/right-sidebar/checks-panel-content'
import { getCheckCountChips, type getCheckCounts } from '@/components/pr-check-counts'
import { getCheckDetailsKey } from '@/components/github/pr-check-presentation'
import { translate } from '@/i18n/i18n'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
export function ChecksTabLayouts({
loading,
listLength,
variant,
compactHeader,
actions,
SummaryIcon,
summaryColor,
summaryLabel,
counts,
sorted,
renderCheckRow,
fixChecksAgentDialog
}: {
loading: boolean
listLength: number
variant: 'compact' | 'page'
compactHeader: React.JSX.Element
actions: React.JSX.Element
SummaryIcon: React.ComponentType<{ className?: string }>
summaryColor: string
summaryLabel: string
counts: ReturnType<typeof getCheckCounts>
sorted: PRCheckDetail[]
renderCheckRow: (check: PRCheckDetail) => React.JSX.Element
fixChecksAgentDialog: React.JSX.Element
}): React.JSX.Element {
if (loading && listLength === 0) {
return (
<>
{variant === 'compact' ? compactHeader : null}
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
</>
)
}
if (listLength === 0) {
if (variant === 'page') {
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.45877f5089', 'No checks found')}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.PullRequestPage.3912daf310',
'This pull request has no reported checks yet.'
)}
</span>
</div>
{actions}
</div>
</div>
)
}
return (
<>
{compactHeader}
<div className="flex flex-col items-center justify-center gap-1 px-4 py-6 text-center">
<CircleDashed className="size-4 text-muted-foreground/60" />
<div className="text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.a18d01cda3', 'No checks reported yet')}
</div>
</div>
</>
)
}
if (variant === 'page') {
const countChips = getCheckCountChips(counts)
return (
<>
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<SummaryIcon
className={cn(
'size-4 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">
{summaryLabel}
</span>
{countChips.length > 1 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{countChips.map((chip, i) => (
<React.Fragment key={chip.tone}>
{i > 0 && <span className="opacity-40">·</span>}
<span className={CHECK_COLOR[chip.tone]}>{chip.label}</span>
</React.Fragment>
))}
</span>
)}
</div>
{actions}
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
{sorted.map((check, index) => (
<div
key={getCheckDetailsKey(check)}
className={cn(index > 0 && 'border-t border-border/40')}
>
{renderCheckRow(check)}
</div>
))}
</div>
</div>
{fixChecksAgentDialog}
</>
)
}
return (
<>
{compactHeader}
<div className="max-h-[280px] overflow-y-auto p-1 scrollbar-sleek">
{sorted.map(renderCheckRow)}
</div>
{fixChecksAgentDialog}
</>
)
}
@@ -0,0 +1,117 @@
import { toast } from 'sonner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
import {
resetGitHubChecksTabForSource,
updateGitHubChecksTabLocalChecks,
type GitHubChecksTabState
} from '@/components/github-checks-tab-state'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
export async function refreshPullRequestChecks(args: {
canUseChecksRepoContext: boolean
expectedContextOwner?: object
committedChecksContextOwnerRef: { current: object }
nextChecksRefreshRequestIdRef: { current: number }
activeChecksRefreshRequestIdRef: { current: number | null }
setRefreshingOwner: (
value:
| { contextOwner: object; requestId: number }
| null
| ((
current: { contextOwner: object; requestId: number } | null
) => { contextOwner: object; requestId: number } | null)
) => void
setChecksState: (updater: (current: GitHubChecksTabState) => GitHubChecksTabState) => void
runtimeHost: GitHubRuntimeHost | null
sourceContext?: TaskSourceContext | null
repoId: string | null
repoPath: string | null
item: GitHubWorkItem
headSha: string | undefined
prRepo: GitHubOwnerRepo | null
mountedRef: { current: boolean }
onChecksUpdated: (checks: PRCheckDetail[]) => void
}): Promise<PRCheckDetail[] | null> {
if (!args.canUseChecksRepoContext) {
toast.error(
translate(
'auto.components.PullRequestPage.c057f2fcb0',
'Unable to refresh checks without a repository path.'
)
)
return null
}
const refreshContextOwner =
args.expectedContextOwner ?? args.committedChecksContextOwnerRef.current
if (args.committedChecksContextOwnerRef.current !== refreshContextOwner) {
return null
}
const refreshRequestId = ++args.nextChecksRefreshRequestIdRef.current
args.activeChecksRefreshRequestIdRef.current = refreshRequestId
args.setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId })
try {
const nextChecks = (await (args.runtimeHost
? callRuntimeRpc<PRCheckDetail[]>(
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
'github.prChecks',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
prNumber: args.item.number,
headSha: args.headSha,
prRepo: args.prRepo,
noCache: true
},
{ timeoutMs: 30_000 }
)
: window.api.gh.prChecks({
repoPath: args.repoPath ?? '',
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
prNumber: args.item.number,
headSha: args.headSha,
prRepo: args.prRepo,
noCache: true
}))) as PRCheckDetail[]
if (
!args.mountedRef.current ||
args.committedChecksContextOwnerRef.current !== refreshContextOwner ||
args.activeChecksRefreshRequestIdRef.current !== refreshRequestId
) {
return null
}
args.setChecksState((current) =>
current.contextOwner === refreshContextOwner
? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks)
: current
)
args.onChecksUpdated(nextChecks)
return nextChecks
} catch (err) {
if (
args.mountedRef.current &&
args.committedChecksContextOwnerRef.current === refreshContextOwner &&
args.activeChecksRefreshRequestIdRef.current === refreshRequestId
) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks')
)
}
return null
} finally {
if (args.activeChecksRefreshRequestIdRef.current === refreshRequestId) {
args.activeChecksRefreshRequestIdRef.current = null
}
if (args.mountedRef.current) {
args.setRefreshingOwner((current) =>
current?.requestId === refreshRequestId ? null : current
)
}
}
}
@@ -0,0 +1,86 @@
import { toast } from 'sonner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
export async function rerunPullRequestChecks(args: {
canUseChecksRepoContext: boolean
rerunning: boolean
committedChecksContextOwnerRef: { current: object }
setRerunningOwner: (value: object | null | ((current: object | null) => object | null)) => void
runtimeHost: GitHubRuntimeHost | null
sourceContext?: TaskSourceContext | null
repoId: string | null
repoPath: string | null
item: GitHubWorkItem
headSha: string | undefined
prRepo: GitHubOwnerRepo | null
failedOnly: boolean
mountedRef: { current: boolean }
handleRefresh: (expectedContextOwner?: object) => Promise<PRCheckDetail[] | null>
}): Promise<void> {
if (!args.canUseChecksRepoContext || args.rerunning) {
return
}
const rerunContextOwner = args.committedChecksContextOwnerRef.current
args.setRerunningOwner(rerunContextOwner)
try {
const result = args.runtimeHost
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.rerunPRChecks>>>(
{ kind: 'environment', environmentId: args.runtimeHost.environmentId },
'github.rerunPRChecks',
{
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? args.item.repoId),
prNumber: args.item.number,
headSha: args.headSha,
failedOnly: args.failedOnly,
prRepo: args.prRepo
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.rerunPRChecks({
repoPath: args.repoPath ?? '',
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
prNumber: args.item.number,
headSha: args.headSha,
failedOnly: args.failedOnly,
prRepo: args.prRepo
})
if (
!args.mountedRef.current ||
args.committedChecksContextOwnerRef.current !== rerunContextOwner
) {
return
}
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(
result.count === 1
? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested')
: translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested')
)
await args.handleRefresh(rerunContextOwner)
} catch (err) {
if (
args.mountedRef.current &&
args.committedChecksContextOwnerRef.current === rerunContextOwner
) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks')
)
}
} finally {
if (args.mountedRef.current) {
args.setRerunningOwner((current) => (current === rerunContextOwner ? null : current))
}
}
}
@@ -0,0 +1,59 @@
import React from 'react'
import { ChevronDown, CircleDashed } from 'lucide-react'
import { cn } from '@/lib/utils'
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
import { getCheckConclusion } from '@/components/pr-check-counts'
import { getCheckDetailsKey, getCheckStatusLabel } from '@/components/github/pr-check-presentation'
import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { CheckDetailsPanel } from './details'
export function CheckRow({
check,
variant,
expanded,
detailsState,
onToggle,
onRetryDetails
}: {
check: PRCheckDetail
variant: 'compact' | 'page'
expanded: boolean
detailsState: CheckDetailsLoadState | undefined
onToggle: (check: PRCheckDetail) => void
onRetryDetails: (check: PRCheckDetail, key: string) => void
}): React.JSX.Element {
const conclusion = getCheckConclusion(check)
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
const statusLabel = getCheckStatusLabel(check)
const key = getCheckDetailsKey(check)
return (
<div key={key} className="min-w-0">
<button
type="button"
onClick={() => onToggle(check)}
aria-expanded={expanded}
className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-md text-left transition',
variant === 'page' ? 'px-3 py-2.5 hover:bg-accent/60' : 'px-2 py-1.5 hover:bg-muted/40'
)}
>
<ChevronDown
className={cn(
'size-3 shrink-0 text-muted-foreground transition-transform',
!expanded && '-rotate-90'
)}
/>
<Icon
className={cn('size-3.5 shrink-0', color, conclusion === 'pending' && 'animate-spin')}
/>
<span className="min-w-0 flex-1 truncate text-[12px] text-foreground">{check.name}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
</button>
{expanded && (
<CheckDetailsPanel check={check} state={detailsState} onRetry={onRetryDetails} />
)}
</div>
)
}
@@ -0,0 +1,358 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { CircleDashed } from 'lucide-react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
import {
createGitHubChecksTabState,
resolveGitHubChecksTabState,
toggleGitHubChecksTabExpandedKey
} from '@/components/github-checks-tab-state'
import { getCheckDetailsKey } from '@/components/github/pr-check-presentation'
import { getCheckCounts, getChecksSummaryLabel } from '@/components/pr-check-counts'
import { getBrokenChecks } from '@/components/pr-checks-fix-prompt'
import { sortChecksBySeverity } from '../../../../../shared/pr-check-severity-order'
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
import {
canUseGitHubRepoContext,
getGitHubSourceRuntimeHost
} from '@/lib/github-source-runtime-context'
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai'
import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { refreshPullRequestChecks } from './refresh'
import { rerunPullRequestChecks } from './rerun'
import { requestPullRequestCheckDetails } from './details-request'
import { fixBrokenPullRequestChecks } from './fix-launch'
import { ChecksCompactHeader, buildChecksToolbar } from './toolbar'
import { CheckRow } from './row'
import { ChecksFixDialog } from './fix-dialog'
import { ChecksTabLayouts } from './layouts'
export function ChecksTab({
item,
repoPath,
repoId,
sourceContext,
headSha,
checks,
loading,
variant = 'compact',
onChecksUpdated
}: {
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
headSha: string | undefined
checks: GitHubWorkItemDetails['checks']
loading: boolean
variant?: 'compact' | 'page'
onChecksUpdated: (checks: PRCheckDetail[]) => void
}): React.JSX.Element {
const targetRepoId = repoId ?? item.repoId
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const updateRepo = useAppStore((s) => s.updateRepo)
const repo = useAppStore((s) =>
targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null
)
const [fixingChecks, setFixingChecks] = useState(false)
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
const mountedRef = useMountedRef()
const prRepo = useMemo(() => resolvePullRequestRepo(item), [item])
const nextCheckDetailsRequestIdRef = useRef(0)
const checkDetailsContextKey = [
sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local',
repoId ?? item.repoId ?? '',
repoPath ?? '',
prRepo ? githubRepoIdentityKey(prRepo) : '',
item.id,
item.number,
headSha ?? ''
].join('\0')
const [checksState, setChecksState] = useState(() =>
createGitHubChecksTabState(checks, checkDetailsContextKey)
)
const resolvedChecksState = resolveGitHubChecksTabState(
checksState,
checks,
checkDetailsContextKey
)
const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner)
const nextChecksRefreshRequestIdRef = useRef(0)
const activeChecksRefreshRequestIdRef = useRef<number | null>(null)
const [refreshingOwner, setRefreshingOwner] = useState<{
contextOwner: object
requestId: number
} | null>(null)
const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner
const [rerunningOwner, setRerunningOwner] = useState<object | null>(null)
const rerunning = rerunningOwner === resolvedChecksState.contextOwner
useLayoutEffect(() => {
committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner
}, [resolvedChecksState.contextOwner])
if (resolvedChecksState !== checksState) {
// Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show.
setChecksState(resolvedChecksState)
}
const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState
const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks])
const fixChecksRecipe = useMemo(
() =>
resolveSourceControlActionRecipe({
settings,
repo,
actionId: 'fixChecks'
}),
[repo, settings]
)
const fixChecksLaunchPlatform = useMemo(
() =>
resolveSourceControlLaunchPlatform({
connectionId: repo?.connectionId ?? null,
worktreePath: repo?.path ?? null,
projectRuntime: repo?.connectionId
? undefined
: getLocalRepoProjectExecutionRuntimeContext(
useAppStore.getState(),
repo?.id,
CLIENT_PLATFORM
)
}),
[repo?.connectionId, repo?.id, repo?.path]
)
const runtimeHost = getGitHubSourceRuntimeHost(sourceContext)
const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
const sorted = sortChecksBySeverity(list)
const failedChecks = getBrokenChecks(list)
const counts = getCheckCounts(list)
const summaryLabel = getChecksSummaryLabel(list)
// Why: keying the green tick off `list.length` painted an all-neutral PR green above the words
// "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does.
const SummaryIcon =
counts.failing > 0
? CHECK_ICON.failure
: counts.needsAction > 0
? CHECK_ICON.action_required
: counts.pending > 0
? CHECK_ICON.pending
: counts.passing > 0
? CHECK_ICON.success
: CircleDashed
const summaryColor =
counts.failing > 0
? CHECK_COLOR.failure
: counts.needsAction > 0
? CHECK_COLOR.action_required
: counts.pending > 0
? CHECK_COLOR.pending
: counts.passing > 0
? CHECK_COLOR.success
: 'text-muted-foreground'
const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0)
const handleRefresh = useCallback(
async (expectedContextOwner?: object) =>
refreshPullRequestChecks({
canUseChecksRepoContext,
expectedContextOwner,
committedChecksContextOwnerRef,
nextChecksRefreshRequestIdRef,
activeChecksRefreshRequestIdRef,
setRefreshingOwner,
setChecksState,
runtimeHost,
sourceContext,
repoId,
repoPath,
item,
headSha,
prRepo,
mountedRef,
onChecksUpdated
}),
[
canUseChecksRepoContext,
headSha,
item,
mountedRef,
onChecksUpdated,
runtimeHost,
prRepo,
repoId,
repoPath,
sourceContext
]
)
const handleRerun = useCallback(
async (failedOnly: boolean) =>
rerunPullRequestChecks({
canUseChecksRepoContext,
rerunning,
committedChecksContextOwnerRef,
setRerunningOwner,
runtimeHost,
sourceContext,
repoId,
repoPath,
item,
headSha,
prRepo,
failedOnly,
mountedRef,
handleRefresh
}),
[
canUseChecksRepoContext,
handleRefresh,
headSha,
item,
mountedRef,
prRepo,
runtimeHost,
rerunning,
repoId,
repoPath,
sourceContext
]
)
const requestCheckDetails = useCallback(
(check: PRCheckDetail, key: string): void => {
requestPullRequestCheckDetails({
canUseChecksRepoContext,
check,
key,
nextCheckDetailsRequestIdRef,
mountedRef,
setChecksState,
runtimeHost,
sourceContext,
repoId,
repoPath,
item,
prRepo
})
},
[
canUseChecksRepoContext,
item,
mountedRef,
runtimeHost,
prRepo,
repoId,
repoPath,
sourceContext
]
)
const handleToggleCheckDetails = useCallback(
(check: PRCheckDetail): void => {
const key = getCheckDetailsKey(check)
setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key))
if (detailsByCheckKey[key]) {
return
}
requestCheckDetails(check, key)
},
[detailsByCheckKey, requestCheckDetails]
)
const { refreshAction, rerunAction, secondaryActions, actions } = buildChecksToolbar({
variant,
canUseChecksRepoContext,
refreshing,
rerunning,
fixingChecks,
canFixBrokenChecks,
failedChecksCount: failedChecks.length,
listLength: list.length,
onRefresh: () => {
void handleRefresh()
},
onFix: () => {
void fixBrokenPullRequestChecks({
targetRepoId,
fixingChecks,
failedChecks,
item,
list,
setFixingChecks,
setFixChecksComposerPrompt
})
},
onRerun: (failedOnly) => {
void handleRerun(failedOnly)
}
})
const compactHeader = (
<ChecksCompactHeader
SummaryIcon={SummaryIcon}
summaryColor={summaryColor}
summaryLabel={summaryLabel}
counts={counts}
listLength={list.length}
refreshAction={refreshAction}
rerunAction={rerunAction}
secondaryActions={secondaryActions}
/>
)
const fixChecksAgentDialog = (
<ChecksFixDialog
fixChecksComposerPrompt={fixChecksComposerPrompt}
setFixChecksComposerPrompt={setFixChecksComposerPrompt}
fixChecksRecipe={fixChecksRecipe}
fixChecksLaunchPlatform={fixChecksLaunchPlatform}
repoConnectionId={repo?.connectionId ?? null}
targetRepoId={targetRepoId}
item={item}
updateSettings={updateSettings}
updateRepo={updateRepo}
/>
)
const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => {
const key = getCheckDetailsKey(check)
return (
<CheckRow
key={key}
check={check}
variant={variant}
expanded={expandedCheckKey === key}
detailsState={detailsByCheckKey[key]}
onToggle={handleToggleCheckDetails}
onRetryDetails={requestCheckDetails}
/>
)
}
return (
<ChecksTabLayouts
loading={loading}
listLength={list.length}
variant={variant}
compactHeader={compactHeader}
actions={actions}
SummaryIcon={SummaryIcon}
summaryColor={summaryColor}
summaryLabel={summaryLabel}
counts={counts}
sorted={sorted}
renderCheckRow={renderCheckRow}
fixChecksAgentDialog={fixChecksAgentDialog}
/>
)
}
@@ -0,0 +1,203 @@
import React from 'react'
import { ChevronDown, LoaderCircle, RefreshCw, Wrench } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
export function buildChecksToolbar({
variant,
canUseChecksRepoContext,
refreshing,
rerunning,
fixingChecks,
canFixBrokenChecks,
failedChecksCount,
listLength,
onRefresh,
onFix,
onRerun
}: {
variant: 'compact' | 'page'
canUseChecksRepoContext: boolean
refreshing: boolean
rerunning: boolean
fixingChecks: boolean
canFixBrokenChecks: boolean
failedChecksCount: number
listLength: number
onRefresh: () => void
onFix: () => void
onRerun: (failedOnly: boolean) => void
}): {
refreshAction: React.JSX.Element
fixBrokenChecksAction: React.JSX.Element | null
rerunAction: React.JSX.Element | null
secondaryActions: React.JSX.Element | null
actions: React.JSX.Element
} {
const refreshAction = (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 shrink-0"
disabled={!canUseChecksRepoContext || refreshing}
onClick={onRefresh}
aria-label={translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')}
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')}
</TooltipContent>
</Tooltip>
)
const fixBrokenChecksAction =
failedChecksCount > 0 || fixingChecks ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!canFixBrokenChecks || fixingChecks}
onClick={onFix}
>
{fixingChecks ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<Wrench className="size-3" />
)}
{variant === 'compact'
? translate('auto.components.PullRequestPage.c808db1dd1', 'Fix checks')
: translate('auto.components.PullRequestPage.a4541fd3db', 'Fix broken checks')}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.PullRequestPage.0fa8b8faec',
'Start the default AI agent on these checks'
)}
</TooltipContent>
</Tooltip>
) : null
const rerunAction =
listLength > 0 || rerunning ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!canUseChecksRepoContext || rerunning || listLength === 0}
>
{rerunning ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{translate('auto.components.PullRequestPage.522d9353e1', 'Rerun')}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
disabled={failedChecksCount === 0 || rerunning}
onSelect={() => onRerun(true)}
>
<RefreshCw className="size-4" />
{translate('auto.components.PullRequestPage.68605516dd', 'Rerun failed checks')}
</DropdownMenuItem>
<DropdownMenuItem disabled={rerunning} onSelect={() => onRerun(false)}>
<RefreshCw className="size-4" />
{translate('auto.components.PullRequestPage.54cddd1858', 'Rerun all checks')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null
const secondaryActions =
variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction ||
rerunAction ? (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{fixBrokenChecksAction}
{variant === 'page' ? rerunAction : null}
</div>
) : null
const actions = (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{refreshAction}
{fixBrokenChecksAction}
{rerunAction}
</div>
)
return { refreshAction, fixBrokenChecksAction, rerunAction, secondaryActions, actions }
}
export function ChecksCompactHeader({
SummaryIcon,
summaryColor,
summaryLabel,
counts,
listLength,
refreshAction,
rerunAction,
secondaryActions
}: {
SummaryIcon: React.ComponentType<{ className?: string }>
summaryColor: string
summaryLabel: string
counts: { pending: number; failing: number }
listLength: number
refreshAction: React.JSX.Element
rerunAction: React.JSX.Element | null
secondaryActions: React.JSX.Element | null
}): React.JSX.Element {
return (
<div className="border-b border-border/50 px-3 py-2">
<div className="flex min-w-0 items-start gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<SummaryIcon
className={cn(
'mt-0.5 size-3.5 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium leading-5 text-foreground">
{translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')}
</div>
{listLength > 0 && (
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{summaryLabel}
</div>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{refreshAction}
{listLength > 0 && (
<div className="[&_button]:h-7 [&_button]:px-2 [&_button]:text-[11px]">
{rerunAction}
</div>
)}
</div>
</div>
{secondaryActions ? (
<div className="mt-2 flex min-w-0 justify-end">{secondaryActions}</div>
) : null}
</div>
)
}
@@ -0,0 +1,171 @@
import React, { useCallback, useRef, useState } from 'react'
import { LoaderCircle, Send } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
} from '@/lib/comment-body-submit-state'
import { addIssueCommentForRepo } from '@/components/github/github-work-item-comment-mutations'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { MentionOption } from '../page-types'
import { MentionTextarea } from '../mentions/textarea'
export function GHCommentComposer({
className,
repoPath,
repoId,
sourceContext,
issueNumber,
itemType,
prRepo,
mentionOptions,
onCommentAdded
}: {
className?: string
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
issueNumber: number
itemType: 'issue' | 'pr'
prRepo?: GitHubOwnerRepo | null
mentionOptions: MentionOption[]
onCommentAdded: (comment: PRComment) => void
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const mountedRef = useMountedRef()
const autoGrow = useCallback(() => {
const el = textareaRef.current
if (!el) {
return
}
el.style.height = 'auto'
el.style.height = `${Math.max(80, Math.min(el.scrollHeight, 240))}px`
}, [])
const handleSubmit = useCallback(async () => {
const bodyState = getCommentBodySubmitState(body)
if (bodyState.status === 'empty') {
return
}
if (bodyState.status === 'too-large-leading-whitespace') {
toast.error(
translate(
'auto.components.PullRequestPage.commentTooLarge',
'Comment is too large to submit safely.'
)
)
return
}
setSubmitting(true)
try {
const result = await addIssueCommentForRepo({
repoPath,
repoId: repoId ?? undefined,
sourceContext,
number: issueNumber,
body: bodyState.body,
type: itemType,
prRepo
})
if (!mountedRef.current) {
return
}
if (result.ok) {
setBody('')
requestAnimationFrame(autoGrow)
// Why: use GitHub's returned comment so the optimistic row shows the real login/avatar without a reopen.
onCommentAdded(result.comment)
} else {
toast.error(
result.error ??
translate('auto.components.PullRequestPage.1208347ac0', 'Failed to add comment')
)
}
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.PullRequestPage.1208347ac0', 'Failed to add comment')
)
}
} finally {
if (mountedRef.current) {
setSubmitting(false)
}
}
}, [
autoGrow,
body,
mountedRef,
repoPath,
repoId,
sourceContext,
issueNumber,
itemType,
prRepo,
onCommentAdded
])
const canSubmitComment = hasBoundedCommentBodyText(body)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isScreenSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className={cn('relative', className)}>
<MentionTextarea
textareaRef={textareaRef}
value={body}
onValueChange={(nextValue) => {
setBody(nextValue)
requestAnimationFrame(autoGrow)
}}
onKeyDown={handleKeyDown}
placeholder={translate('auto.components.PullRequestPage.d2030fc8cd', 'Add a comment…')}
rows={4}
mentionOptions={mentionOptions}
wrapperClassName="flex min-h-20 w-full items-stretch"
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 pb-12 pr-12 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-sm"
onClick={handleSubmit}
disabled={!canSubmitComment || submitting}
className="absolute bottom-3 right-3 shadow-sm"
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
>
{submitting ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -0,0 +1,102 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
} from '@/lib/comment-body-submit-state'
import { translate } from '@/i18n/i18n'
import type { MentionOption } from '../page-types'
import { MentionTextarea } from '../mentions/textarea'
export function CommentReplyForm({
className,
placeholder,
mentionOptions,
onCancel,
onSubmit
}: {
className?: string
placeholder: string
mentionOptions: MentionOption[]
onCancel: () => void
onSubmit: (body: string) => Promise<boolean>
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const mountedRef = useMountedRef()
useEffect(() => {
textareaRef.current?.focus()
}, [])
const submit = useCallback(async () => {
const bodyState = getCommentBodySubmitState(body)
if (bodyState.status === 'empty' || submitting) {
return
}
if (bodyState.status === 'too-large-leading-whitespace') {
toast.error(
translate(
'auto.components.PullRequestPage.commentTooLarge',
'Comment is too large to submit safely.'
)
)
return
}
setSubmitting(true)
try {
const ok = await onSubmit(bodyState.body)
if (!mountedRef.current) {
return
}
if (ok) {
setBody('')
}
} finally {
if (mountedRef.current) {
setSubmitting(false)
}
}
}, [body, mountedRef, onSubmit, submitting])
const canSubmitReply = hasBoundedCommentBodyText(body)
return (
<div className={cn('rounded-md border border-border/50 bg-background/60 p-2', className)}>
<MentionTextarea
textareaRef={textareaRef}
value={body}
onValueChange={setBody}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
return
}
if (isScreenSubmitShortcut(e)) {
e.preventDefault()
void submit()
}
}}
placeholder={placeholder}
rows={3}
mentionOptions={mentionOptions}
className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="mt-2 flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>
{translate('auto.components.PullRequestPage.6591b1fa82', 'Cancel')}
</Button>
<Button size="sm" disabled={!canSubmitReply || submitting} onClick={() => void submit()}>
{submitting
? translate('auto.components.PullRequestPage.894cfd884b', 'Posting…')
: translate('auto.components.PullRequestPage.f119e5f5ef', 'Reply')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,186 @@
import React from 'react'
import { ExternalLink, MessageSquarePlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import {
PR_COMMENT_OPEN_AUTHOR_CLASS,
PR_COMMENT_RESOLVED_AUTHOR_CLASS,
PR_COMMENT_RESOLVED_CONTAINER_CLASS
} from '@/lib/pr-comment-resolution-classes'
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
import { CommentCodeContext } from '@/components/github/CommentCodeContext'
import { CommentReactions } from '@/components/github/CommentReactions'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { MentionOption } from '../page-types'
import { loadPRFileContents } from '../cache/file-content'
import { CommentReplyForm } from '../comments/reply-form'
export function ConversationCommentCard({
comment,
isReply = false,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
files,
headSha,
baseSha,
markdownGitHubRepo,
mentionOptions,
resolvedReplyingTo,
onToggleReply,
onSubmitReply
}: {
comment: PRComment
isReply?: boolean
repoPath: string | null
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo: GitHubOwnerRepo | null
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
mentionOptions: MentionOption[]
resolvedReplyingTo: number | null
onToggleReply: (commentId: number) => void
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
}): React.JSX.Element {
return (
<div
key={comment.id}
className={cn(
'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card shadow-xs',
isReply && 'ml-6 max-w-[calc(100%-1.5rem)]',
comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS
)}
>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt={comment.author}
className="size-5 shrink-0 rounded-full"
/>
) : (
<div className="size-5 shrink-0 rounded-full bg-muted" />
)}
<span
className={cn(
'min-w-0 truncate text-[13px] font-semibold',
comment.isResolved ? PR_COMMENT_RESOLVED_AUTHOR_CLASS : PR_COMMENT_OPEN_AUTHOR_CLASS
)}
>
{comment.author}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
· {formatRelativeTime(comment.createdAt)}
</span>
{comment.path && (
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/70">
{comment.path.split('/').pop()}
{comment.line
? translate('auto.components.PullRequestPage.34b9f7c264', ':L{{value0}}', {
value0: comment.line
})
: ''}
</span>
)}
{comment.isResolved && (
<span className="rounded-full border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground">
{translate('auto.components.PullRequestPage.76b2a0ac5b', 'resolved')}
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="size-7"
onClick={() => onToggleReply(comment.id)}
aria-label={translate(
'auto.components.PullRequestPage.d6c6679de7',
'Reply to comment'
)}
>
<MessageSquarePlus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.d6c6679de7', 'Reply to comment')}
</TooltipContent>
</Tooltip>
{comment.url && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7"
onClick={() => window.api.shell.openUrl(comment.url)}
aria-label={translate(
'auto.components.PullRequestPage.0ac19bb52e',
'Open comment on GitHub'
)}
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.0ac19bb52e', 'Open comment on GitHub')}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="min-w-0 px-3 py-2">
<CommentCodeContext
comment={comment}
repoPath={repoPath}
repoId={repoId}
sourceContext={sourceContext}
prNumber={prNumber}
prRepo={prRepo}
files={files}
headSha={headSha}
baseSha={baseSha}
loadPRFileContents={loadPRFileContents}
/>
<CommentMarkdown
content={comment.body}
variant="document"
githubRepo={markdownGitHubRepo}
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
<CommentReactions reactions={comment.reactions} />
{resolvedReplyingTo === comment.id && (
<CommentReplyForm
className="mt-3"
placeholder={
comment.path
? translate(
'auto.components.PullRequestPage.408e634fbb',
'Reply in this review thread'
)
: translate('auto.components.PullRequestPage.31a7b202f2', 'Reply to @{{value0}}', {
value0: comment.author
})
}
mentionOptions={mentionOptions}
onCancel={() => onToggleReply(comment.id)}
onSubmit={(replyBody) => onSubmitReply(comment, replyBody)}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,110 @@
import React from 'react'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import {
getPRCommentGroupCount,
getPRCommentGroupId,
getPRCommentGroupRoot,
isResolvedPRCommentGroup,
type PRCommentGroup
} from '../../../../../shared/pr-comment-groups'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { MentionOption } from '../page-types'
import { ConversationCommentCard } from './comment-card'
export function ConversationCommentGroup({
group,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
files,
headSha,
baseSha,
markdownGitHubRepo,
mentionOptions,
resolvedReplyingTo,
onToggleReply,
onSubmitReply
}: {
group: PRCommentGroup
repoPath: string | null
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo: GitHubOwnerRepo | null
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
mentionOptions: MentionOption[]
resolvedReplyingTo: number | null
onToggleReply: (commentId: number) => void
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
}): React.JSX.Element {
const cardProps = {
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
files,
headSha,
baseSha,
markdownGitHubRepo,
mentionOptions,
resolvedReplyingTo,
onToggleReply,
onSubmitReply
}
const cards =
group.kind === 'thread'
? [
<ConversationCommentCard key={group.root.id} comment={group.root} {...cardProps} />,
...group.replies.map((reply) => (
<ConversationCommentCard key={reply.id} comment={reply} isReply {...cardProps} />
))
]
: [<ConversationCommentCard key={group.comment.id} comment={group.comment} {...cardProps} />]
if (!isResolvedPRCommentGroup(group)) {
return (
<div key={getPRCommentGroupId(group)} className="flex min-w-0 flex-col gap-3">
{cards}
</div>
)
}
const root = getPRCommentGroupRoot(group)
const count = getPRCommentGroupCount(group)
return (
<Accordion key={getPRCommentGroupId(group)} type="single" collapsible>
<AccordionItem
value={getPRCommentGroupId(group)}
className="rounded-lg border border-border/40 bg-card"
>
<AccordionTrigger className="px-3 py-2 text-[13px] text-muted-foreground hover:bg-accent/30">
<span className="min-w-0 truncate">
{translate('auto.components.PullRequestPage.f4fe47c2bb', 'Resolved')}{' '}
{group.kind === 'thread'
? translate('auto.components.PullRequestPage.345b68254c', 'thread')
: translate('auto.components.PullRequestPage.e01e34f5fa', 'comment')}{' '}
{translate('auto.components.PullRequestPage.3c891789f6', 'by')} {root.author}
{count > 1 ? ` (${count})` : ''}
</span>
</AccordionTrigger>
<AccordionContent className="flex min-w-0 flex-col gap-3 px-3 pb-3 pt-0">
{cards}
</AccordionContent>
</AccordionItem>
</Accordion>
)
}
@@ -0,0 +1,130 @@
import React from 'react'
import { MessageSquare } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
getPRCommentAudienceEmptyLabel,
getPrCommentAudienceFilters
} from '@/lib/pr-comment-audience-labels'
import { getPRCommentGroupId, type PRCommentGroup } from '../../../../../shared/pr-comment-groups'
import type { PRCommentAudienceFilter } from '../../../../../shared/pr-comment-audience'
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { MentionOption } from '../page-types'
import { translate } from '@/i18n/i18n'
import { ConversationCommentGroup } from './comment-group'
export function ConversationCommentsList({
itemType,
comments,
visibleComments,
visibleCommentGroups,
commentFilter,
commentCounts,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
files,
headSha,
baseSha,
markdownGitHubRepo,
mentionOptions,
resolvedReplyingTo,
onFilterChange,
onToggleReply,
onSubmitReply
}: {
itemType: 'issue' | 'pr'
comments: PRComment[]
visibleComments: PRComment[]
visibleCommentGroups: PRCommentGroup[]
commentFilter: PRCommentAudienceFilter
commentCounts: Record<PRCommentAudienceFilter, number>
repoPath: string | null
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo: GitHubOwnerRepo | null
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
mentionOptions: MentionOption[]
resolvedReplyingTo: number | null
onFilterChange: (value: PRCommentAudienceFilter) => void
onToggleReply: (commentId: number) => void
onSubmitReply: (comment: PRComment, replyBody: string) => Promise<boolean>
}): React.JSX.Element {
return (
<>
<div className="flex items-center gap-2 pt-1">
<MessageSquare className="size-4 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.3463d10a63', 'Comments')}
</span>
{comments.length > 0 && (
<span className="rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{comments.length}
</span>
)}
</div>
{itemType === 'pr' && comments.length > 0 && (
<div className="grid grid-cols-3 rounded-lg border border-border/50 bg-background p-0.5">
{getPrCommentAudienceFilters().map((filter) => {
const isActive = commentFilter === filter.value
return (
<button
key={filter.value}
type="button"
className={cn(
'flex h-8 items-center justify-center gap-1 rounded-md px-2 text-[12px] font-medium text-muted-foreground transition-colors',
isActive && 'bg-muted text-foreground'
)}
aria-pressed={isActive}
onClick={() => onFilterChange(filter.value)}
>
<span>{filter.label}</span>
<span className="tabular-nums">{commentCounts[filter.value]}</span>
</button>
)
})}
</div>
)}
{comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
{translate('auto.components.PullRequestPage.d2d589556c', 'No comments yet.')}
</div>
) : visibleComments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground">
{getPRCommentAudienceEmptyLabel(commentFilter)}
</div>
) : (
<div className="flex min-w-0 flex-col gap-3">
{visibleCommentGroups.map((group) => (
<ConversationCommentGroup
key={getPRCommentGroupId(group)}
group={group}
repoPath={repoPath}
repoId={repoId}
sourceContext={sourceContext}
prNumber={prNumber}
prRepo={prRepo}
files={files}
headSha={headSha}
baseSha={baseSha}
markdownGitHubRepo={markdownGitHubRepo}
mentionOptions={mentionOptions}
resolvedReplyingTo={resolvedReplyingTo}
onToggleReply={onToggleReply}
onSubmitReply={onSubmitReply}
/>
))}
</div>
)}
</>
)
}
@@ -0,0 +1,152 @@
import React from 'react'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
import { translate } from '@/i18n/i18n'
import type { MentionOption } from '../page-types'
import { MentionTextarea } from '../mentions/textarea'
export function ConversationDescription({
authorLabel,
updatedAt,
canEditBody,
loading,
detailsLoaded,
bodyEditing,
bodySaving,
bodyChanged,
body,
resolvedBodyDraft,
bodyTextareaRef,
mentionOptions,
markdownGitHubRepo,
onCancelEdit,
onStartEdit,
onSave,
onDraftChange
}: {
authorLabel: string
updatedAt: string
canEditBody: boolean
loading: boolean
detailsLoaded: boolean
bodyEditing: boolean
bodySaving: boolean
bodyChanged: boolean
body: string
resolvedBodyDraft: string
bodyTextareaRef: React.RefObject<HTMLTextAreaElement | null>
mentionOptions: MentionOption[]
markdownGitHubRepo: { owner: string; repo: string; host?: string } | null
onCancelEdit: () => void
onStartEdit: () => void
onSave: () => void
onDraftChange: (value: string) => void
}): React.JSX.Element {
return (
<div className="rounded-lg border border-border/50 bg-card shadow-xs">
<div className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">{authorLabel}</span>
<span>
{translate('auto.components.PullRequestPage.169a93b29a', 'updated')}{' '}
{formatRelativeTime(updatedAt)}
</span>
{canEditBody && !loading && detailsLoaded ? (
bodyEditing ? (
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
disabled={bodySaving}
onClick={onCancelEdit}
>
<X className="size-3.5" />
{translate('auto.components.PullRequestPage.6591b1fa82', 'Cancel')}
</Button>
<Button
type="button"
size="xs"
className="gap-1.5"
disabled={bodySaving || !bodyChanged}
onClick={onSave}
>
{bodySaving ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
{translate('auto.components.PullRequestPage.4a337ac05f', 'Save')}
</Button>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-auto size-7"
onClick={onStartEdit}
aria-label={translate(
'auto.components.PullRequestPage.da9aaa8bcf',
'Edit description'
)}
>
<Pencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.da9aaa8bcf', 'Edit description')}
</TooltipContent>
</Tooltip>
)
) : null}
</div>
<div className="px-4 py-4 text-[14px] leading-relaxed text-foreground">
{loading && !detailsLoaded ? (
<div className="flex items-center justify-center py-5">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : bodyEditing ? (
<MentionTextarea
textareaRef={bodyTextareaRef}
value={resolvedBodyDraft}
onValueChange={onDraftChange}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancelEdit()
return
}
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
onSave()
}
}}
placeholder={translate('auto.components.PullRequestPage.778683ec84', 'Description')}
rows={12}
mentionOptions={mentionOptions}
wrapperClassName="flex min-h-64 w-full items-stretch"
className="scrollbar-sleek block min-h-64 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
) : body.trim() ? (
<CommentMarkdown
content={body}
variant="document"
githubRepo={markdownGitHubRepo}
className="min-w-0 max-w-full overflow-hidden break-words text-[14px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
) : (
<span className="italic text-muted-foreground">
{translate('auto.components.PullRequestPage.c8ea6c7c4c', 'No description provided.')}
</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,77 @@
import { toast } from 'sonner'
import {
attachPRReviewReplyParent,
canPostPRReviewThreadReply
} from '@/components/right-sidebar/pr-comments-ai-launch-ack'
import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body'
import {
addIssueCommentForRepo,
addPRReviewCommentReplyForRepo
} from '@/components/github/github-work-item-comment-mutations'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
export async function postConversationReply(args: {
canUseRepoMutationContext: boolean
item: GitHubWorkItem
repoPath: string | null
sourceContext?: TaskSourceContext | null
prRepo: GitHubOwnerRepo | null
comment: PRComment
replyBody: string
onCommentAdded: (comment: PRComment) => void
onReplied: () => void
}): Promise<boolean> {
if (!args.canUseRepoMutationContext) {
toast.error(
translate(
'auto.components.PullRequestPage.6885c619e7',
'Unable to reply without a repository path.'
)
)
return false
}
// Why: nest under review threads (path/threadId/discussion_r); never post a
// separate top-level conversation comment for those.
const isReviewThreadReply = args.item.type === 'pr' && canPostPRReviewThreadReply(args.comment)
const result = isReviewThreadReply
? await addPRReviewCommentReplyForRepo({
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
prNumber: args.item.number,
prRepo: args.prRepo,
commentId: args.comment.id,
body: args.replyBody,
threadId: args.comment.threadId,
path: args.comment.path,
line: args.comment.line
})
: await addIssueCommentForRepo({
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
number: args.item.number,
// Why: a GitHub App login carries a [bot] suffix that never resolves as a mention.
body: buildPRCommentConversationReplyBody(args.comment.author, args.replyBody),
type: args.item.type,
prRepo: args.prRepo
})
if (!result.ok) {
toast.error(
result.error ||
translate('auto.components.PullRequestPage.5821aab360', 'Failed to post reply.')
)
return false
}
args.onCommentAdded(
isReviewThreadReply ? attachPRReviewReplyParent(result.comment, args.comment) : result.comment
)
args.onReplied()
toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.'))
return true
}
@@ -0,0 +1,367 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useRepoAssignees } from '@/hooks/useIssueMetadata'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
import {
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
type PRCommentAudienceFilter
} from '../../../../../shared/pr-comment-audience'
import { groupPRComments } from '../../../../../shared/pr-comment-groups'
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
import { runWorkItemBodyUpdate } from '@/components/github/github-work-item-edit-mutations'
import {
parseOwnerRepoFromItemUrl,
resolvePullRequestRepo
} from '@/components/github/github-work-item-identity'
import {
resolveGitHubBodyDraft,
shouldSyncGitHubBodyDraft
} from '@/components/github-body-draft-state'
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type {
GitHubAssignableUser,
GitHubPRFile
} from '../../../../../shared/github/pull-request-types'
import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { buildMentionOptions } from '../mentions/options'
import { PRActionsPanel } from '../actions/panel'
import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel'
import { PRReviewersPanel } from '../reviewers/panel'
import { ChecksTab } from '../checks/tab'
import { GHCommentComposer } from '../comments/composer'
import { ConversationDescription } from './description'
import { ConversationCommentsList } from './comments-list'
import { postConversationReply } from './reply'
export function ConversationTab({
item,
repoPath,
repoId,
sourceContext,
body,
comments,
files,
headSha,
baseSha,
loading,
detailsLoaded,
checks,
participants: detailsParticipants,
localState,
onStateChange,
projectOrigin,
onMutated,
onChecksUpdated,
onBodyUpdated,
onCommentAdded,
onReviewersRequested
}: {
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
body: string
comments: PRComment[]
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
loading: boolean
detailsLoaded: boolean
checks: GitHubWorkItemDetails['checks']
participants: GitHubAssignableUser[]
localState: GitHubWorkItem['state']
onStateChange: (state: GitHubWorkItem['state']) => void
projectOrigin: PullRequestPageProjectOrigin | undefined
onMutated: () => void
onChecksUpdated: (checks: PRCheckDetail[]) => void
onBodyUpdated: (body: string) => void
onCommentAdded: (comment: PRComment) => void
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const authorLabel = item.author ?? 'unknown'
const [replyingTo, setReplyingTo] = useState<number | null>(null)
const [commentFilter, setCommentFilter] = useState<PRCommentAudienceFilter>('all')
const [bodyDraft, setBodyDraft] = useState(body)
const [bodyEditing, setBodyEditing] = useState(false)
const [bodySaving, setBodySaving] = useState(false)
const bodyTextareaRef = useRef<HTMLTextAreaElement>(null)
const bodyTextareaFocusFrameRef = useRef<number | null>(null)
const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings)
const botAuthorOverrides = usePRBotAuthorOverrides()
const commentCounts = useMemo(
() => getPRCommentAudienceCounts(comments, botAuthorOverrides),
[botAuthorOverrides, comments]
)
const visibleComments = useMemo(
() => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides),
[botAuthorOverrides, commentFilter, comments]
)
const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
const mentionOptions = useMemo(
() =>
buildMentionOptions({
item,
comments,
participants: detailsParticipants,
assignableUsers: repoAssignees.data
}),
[comments, detailsParticipants, item, repoAssignees.data]
)
const cancelBodyTextareaFocusFrame = useCallback((): void => {
if (bodyTextareaFocusFrameRef.current !== null) {
cancelAnimationFrame(bodyTextareaFocusFrameRef.current)
bodyTextareaFocusFrameRef.current = null
}
}, [])
if (resolvedReplyingTo !== replyingTo) {
// Why: clear before paint when filters/refetches hide the reply target, so a stale composer doesn't flash for the wrong comment set.
setReplyingTo(resolvedReplyingTo)
}
const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing)
if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) {
// Why: reconcile before paint so a background body refresh while the editor is closed doesn't show a stale draft on reopen.
setBodyDraft(resolvedBodyDraft)
}
useEffect(() => {
if (!bodyEditing) {
cancelBodyTextareaFocusFrame()
return cancelBodyTextareaFocusFrame
}
cancelBodyTextareaFocusFrame()
bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => {
bodyTextareaFocusFrameRef.current = null
bodyTextareaRef.current?.focus()
})
return cancelBodyTextareaFocusFrame
}, [bodyEditing, cancelBodyTextareaFocusFrame])
const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin])
const markdownGitHubRepo = useMemo(
() =>
projectOrigin
? { owner: projectOrigin.owner, repo: projectOrigin.repo, host: projectOrigin.host }
: bodySlug,
[bodySlug, projectOrigin]
)
const canEditBody =
item.type === 'pr'
? Boolean(projectOrigin || bodySlug)
: Boolean(projectOrigin || canUseRepoMutationContext)
const bodyChanged = resolvedBodyDraft !== body
const handleSaveBody = useCallback(async (): Promise<void> => {
if (bodySaving || !bodyChanged) {
setBodyEditing(false)
return
}
setBodySaving(true)
try {
await runWorkItemBodyUpdate({
item,
repoPath,
sourceContext,
projectOrigin,
body: resolvedBodyDraft,
parsedSlug: bodySlug
})
onBodyUpdated(resolvedBodyDraft)
setBodyEditing(false)
toast.success(translate('auto.components.PullRequestPage.9b4190dc98', 'Description updated.'))
} catch (err) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.PullRequestPage.d94810f652', 'Failed to update description.')
)
} finally {
setBodySaving(false)
}
}, [
bodyChanged,
resolvedBodyDraft,
bodySaving,
bodySlug,
item,
onBodyUpdated,
projectOrigin,
repoPath,
sourceContext
])
const handleReply = useCallback(
async (comment: PRComment, replyBody: string): Promise<boolean> =>
postConversationReply({
canUseRepoMutationContext,
item,
repoPath,
sourceContext,
prRepo,
comment,
replyBody,
onCommentAdded,
onReplied: () => setReplyingTo(null)
}),
[canUseRepoMutationContext, item, onCommentAdded, prRepo, repoPath, sourceContext]
)
const rightPanel =
item.type === 'pr' ? (
<div className="flex h-fit flex-col gap-5 xl:sticky xl:top-4">
<PRActionsPanel
item={item}
repoPath={repoPath}
repoId={item.repoId}
sourceContext={sourceContext}
projectOrigin={projectOrigin}
localState={localState}
onStateChange={onStateChange}
onMutated={onMutated}
/>
<PRAssigneesPanel
item={item}
repoPath={repoPath}
projectOrigin={projectOrigin}
sourceContext={sourceContext}
onMutated={onMutated}
/>
<PRReviewersPanel
item={item}
loading={loading}
repoPath={repoPath}
sourceContext={sourceContext}
projectOrigin={projectOrigin}
onReviewersRequested={onReviewersRequested}
/>
<aside className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
<ChecksTab
item={item}
repoPath={repoPath}
repoId={item.repoId}
sourceContext={sourceContext}
headSha={headSha}
checks={checks}
loading={loading || !detailsLoaded}
onChecksUpdated={onChecksUpdated}
/>
</aside>
</div>
) : null
return (
<div
className={cn(
'grid min-w-0 gap-5 px-4 py-4',
// Why: on narrow windows the drawer is near full-width, so keep PR controls beside the conversation, not below long threads.
item.type === 'pr' && 'grid-cols-[minmax(0,1fr)_300px]'
)}
>
<div className="flex min-w-0 flex-col gap-4">
<ConversationDescription
authorLabel={authorLabel}
updatedAt={item.updatedAt}
canEditBody={canEditBody}
loading={loading}
detailsLoaded={detailsLoaded}
bodyEditing={bodyEditing}
bodySaving={bodySaving}
bodyChanged={bodyChanged}
body={body}
resolvedBodyDraft={resolvedBodyDraft}
bodyTextareaRef={bodyTextareaRef}
mentionOptions={mentionOptions}
markdownGitHubRepo={markdownGitHubRepo}
onCancelEdit={() => {
setBodyDraft(body)
setBodyEditing(false)
}}
onStartEdit={() => {
setBodyDraft(body)
setBodyEditing(true)
}}
onSave={() => {
void handleSaveBody()
}}
onDraftChange={setBodyDraft}
/>
{detailsLoaded ? (
<ConversationCommentsList
itemType={item.type}
comments={comments}
visibleComments={visibleComments}
visibleCommentGroups={visibleCommentGroups}
commentFilter={commentFilter}
commentCounts={commentCounts}
repoPath={repoPath}
repoId={item.repoId}
sourceContext={sourceContext}
prNumber={item.number}
prRepo={prRepo}
files={files}
headSha={headSha}
baseSha={baseSha}
markdownGitHubRepo={markdownGitHubRepo}
mentionOptions={mentionOptions}
resolvedReplyingTo={resolvedReplyingTo}
onFilterChange={setCommentFilter}
onToggleReply={(commentId) =>
setReplyingTo((current) => (current === commentId ? null : commentId))
}
onSubmitReply={handleReply}
/>
) : null}
{detailsLoaded && canUseRepoMutationContext && (
<GHCommentComposer
className="mt-1"
repoPath={repoPath ?? ''}
repoId={item.repoId}
sourceContext={sourceContext}
issueNumber={item.number}
itemType={item.type}
prRepo={prRepo}
mentionOptions={mentionOptions}
onCommentAdded={onCommentAdded}
/>
)}
</div>
{rightPanel}
</div>
)
}
@@ -0,0 +1,235 @@
import { toast } from 'sonner'
import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { PullRequestPageProjectOrigin } from '../page-types'
type IssueUpdateRun = (
key: string,
spec: {
mutate: () => Promise<unknown>
onOptimistic: () => void
onRevert: () => void
onSuccess: () => void
onError: (err: string) => void
}
) => void
export function changeIssueState(args: {
newState: 'open' | 'closed'
localState: GitHubWorkItem['state']
item: GitHubWorkItem
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
run: IssueUpdateRun
onStateChange: (state: GitHubWorkItem['state']) => void
patchWorkItem: (
id: string,
patch: Partial<GitHubWorkItem>,
repoId?: string | null,
options?: { sourceContext?: TaskSourceContext | null }
) => void
patchProjectRowIfNeeded: (patch: { state: GitHubWorkItem['state'] }) => void
onMutated: () => void
}): void {
if (args.newState === args.localState) {
return
}
const prevState = args.localState
args.run('state', {
mutate: () =>
runIssueUpdate({
repoId: args.item.repoId,
repoPath: args.repoPath,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
updates: { state: args.newState }
}),
onOptimistic: () => {
args.onStateChange(args.newState)
args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ state: args.newState })
},
onRevert: () => {
args.onStateChange(prevState)
args.patchWorkItem(args.item.id, { state: prevState }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ state: prevState })
},
onSuccess: () => {
args.patchWorkItem(args.item.id, { state: args.newState }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ state: args.newState })
args.onMutated()
},
onError: (err) => toast.error(err)
})
}
export function toggleIssueLabel(args: {
label: string
localLabels: string[]
item: GitHubWorkItem
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
run: IssueUpdateRun
onLabelsChange: (labels: string[]) => void
patchWorkItem: (
id: string,
patch: Partial<GitHubWorkItem>,
repoId?: string | null,
options?: { sourceContext?: TaskSourceContext | null }
) => void
patchProjectRowIfNeeded: (patch: { labels: string[] }) => void
onMutated: () => void
}): void {
const isAdding = !args.localLabels.includes(args.label)
const prevLabels = args.localLabels
const newLabels = isAdding
? [...prevLabels, args.label]
: prevLabels.filter((l) => l !== args.label)
if (isAdding) {
args.run('labels', {
mutate: () =>
runIssueUpdate({
repoId: args.item.repoId,
repoPath: args.repoPath,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
updates: { addLabels: [args.label] }
}),
onOptimistic: () => {
args.onLabelsChange(newLabels)
args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ labels: newLabels })
},
onSuccess: () => {
args.onMutated()
},
onRevert: () => {
args.onLabelsChange(prevLabels)
args.patchWorkItem(args.item.id, { labels: prevLabels }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ labels: prevLabels })
},
onError: (err) => toast.error(err)
})
return
}
args.run('labels', {
mutate: () =>
runIssueUpdate({
repoId: args.item.repoId,
repoPath: args.repoPath,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
updates: { removeLabels: [args.label] }
}),
onOptimistic: () => {
args.onLabelsChange(newLabels)
args.patchWorkItem(args.item.id, { labels: newLabels }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ labels: newLabels })
},
onRevert: () => {
args.onLabelsChange(prevLabels)
args.patchWorkItem(args.item.id, { labels: prevLabels }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.patchProjectRowIfNeeded({ labels: prevLabels })
},
onSuccess: () => {
args.onMutated()
},
onError: (err) => toast.error(err)
})
}
export function toggleIssueAssignee(args: {
login: string
localAssignees: string[]
assigneesItemKey: string
editedAssigneesItemKeyRef: { current: string | null }
item: GitHubWorkItem
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
run: IssueUpdateRun
setLocalAssignees: (value: string[]) => void
patchProjectRowIfNeeded: (patch: { assignees: string[] }) => void
onMutated: () => void
}): void {
const isAssigned = args.localAssignees.includes(args.login)
const prevAssignees = args.localAssignees
const newAssignees = isAssigned
? prevAssignees.filter((l) => l !== args.login)
: [...prevAssignees, args.login]
// Why: scope the optimistic guard to this repo item so switching items doesn't suppress the next item's assignee sync.
args.editedAssigneesItemKeyRef.current = args.assigneesItemKey
if (isAssigned) {
args.run('assignees', {
mutate: () =>
runIssueUpdate({
repoId: args.item.repoId,
repoPath: args.repoPath,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
updates: { removeAssignees: [args.login] }
}),
onOptimistic: () => {
args.setLocalAssignees(newAssignees)
args.patchProjectRowIfNeeded({ assignees: newAssignees })
},
onRevert: () => {
args.setLocalAssignees(prevAssignees)
args.patchProjectRowIfNeeded({ assignees: prevAssignees })
},
onSuccess: () => {
args.onMutated()
},
onError: (err) => toast.error(err)
})
return
}
args.run('assignees', {
mutate: () =>
runIssueUpdate({
repoId: args.item.repoId,
repoPath: args.repoPath,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
number: args.item.number,
updates: { addAssignees: [args.login] }
}),
onOptimistic: () => {
args.setLocalAssignees(newAssignees)
args.patchProjectRowIfNeeded({ assignees: newAssignees })
},
onSuccess: () => {
args.onMutated()
},
onRevert: () => {
args.setLocalAssignees(prevAssignees)
args.patchProjectRowIfNeeded({ assignees: prevAssignees })
},
onError: (err) => toast.error(err)
})
}
@@ -0,0 +1,369 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ArrowRight, ChevronDown, CircleDashed, CircleDot, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { useImmediateMutation, useRepoAssignees, useRepoLabels } from '@/hooks/useIssueMetadata'
import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { getStateLabel } from '@/components/github/work-item-state-presentation'
import { translate } from '@/i18n/i18n'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { getStateTone } from '../presentation/state-badge'
import { changeIssueState, toggleIssueAssignee, toggleIssueLabel } from './issue-updates'
export function GHEditSection({
item,
repoPath,
repoId,
sourceContext,
projectOrigin,
localState,
localLabels,
onStateChange,
onLabelsChange,
onMutated,
assignees,
onUse
}: {
item: GitHubWorkItem
repoPath: string | null
repoId: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
localState: GitHubWorkItem['state']
localLabels: string[]
onStateChange: (state: GitHubWorkItem['state']) => void
onLabelsChange: (labels: string[]) => void
/** Called after a successful issue mutation so the parent can invalidate its details cache; otherwise a reopen within FRESH_MS paints pre-mutation data. */
onMutated: () => void
assignees: string[]
onUse: (item: GitHubWorkItem) => void
}): React.JSX.Element | null {
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
const [localAssignees, setLocalAssignees] = useState<string[]>(assignees)
const editedAssigneesItemKeyRef = useRef<string | null>(null)
const assigneesItemKey = `${item.repoId}\0${item.id}`
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const { isPending, run } = useImmediateMutation()
// Why: patchWorkItem only updates workItemsCache; Project-view rows also need projectViewCache patched or the table stays stale. See docs/design/github-project-view-tasks.md.
const patchProjectRowIfNeeded = useCallback(
(patch: Parameters<typeof patchProjectRowContent>[2]) => {
if (!projectOrigin) {
return
}
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch)
},
[projectOrigin, patchProjectRowContent]
)
// Why: with projectOrigin set, read labels/assignees from the row's repo (not workspace path) so popovers match where writes target.
const slugOwner = projectOrigin?.owner ?? null
const slugRepo = projectOrigin?.repo ?? null
const repoLabelsByPath = useRepoLabels(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId,
sourceSettings
)
const repoLabelsBySlug = useRepoLabelsBySlug(
slugOwner,
slugRepo,
sourceSettings,
projectOrigin?.host
)
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
const repoAssigneesByPath = useRepoAssignees(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId,
sourceSettings
)
const repoAssigneesBySlug = useRepoAssigneesBySlug(
slugOwner,
slugRepo,
assignees,
sourceSettings,
projectOrigin?.host
)
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
// Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes.
useEffect(() => {
if (editedAssigneesItemKeyRef.current === assigneesItemKey) {
return
}
setLocalAssignees(assignees)
}, [assigneesItemKey, assignees])
if (item.type === 'pr') {
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* State */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'group/status inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
getStateTone({ ...item, state: localState })
)}
>
{getStateLabel({ ...item, state: localState })}
<ChevronDown className="size-2.5 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
<button
type="button"
onClick={() =>
changeIssueState({
newState: 'open',
localState,
item,
repoPath,
sourceContext,
projectOrigin,
run,
onStateChange,
patchWorkItem,
patchProjectRowIfNeeded,
onMutated
})
}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-emerald-500" />
{translate('auto.components.PullRequestPage.7b8f6bf6d8', 'Open')}
</button>
<button
type="button"
onClick={() =>
changeIssueState({
newState: 'closed',
localState,
item,
repoPath,
sourceContext,
projectOrigin,
run,
onStateChange,
patchWorkItem,
patchProjectRowIfNeeded,
onMutated
})
}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CircleDashed className="size-3 text-rose-500" />
{translate('auto.components.PullRequestPage.b936cc51a4', 'Closed')}
</button>
</PopoverContent>
</Popover>
{/* Labels */}
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('labels') || repoLabels.loading}
className="group/labels inline-flex items-center gap-1 rounded-full border border-border/30 bg-muted/20 px-2 py-0.5 text-[11px] transition hover:brightness-125 hover:ring-1 hover:ring-white/10 disabled:opacity-50"
>
{localLabels.length === 0 ? (
<span className="text-muted-foreground">
{translate('auto.components.PullRequestPage.bc215fea4d', '+ Label')}
</span>
) : (
localLabels.map((name) => (
<span key={name} className="text-[10px] text-muted-foreground">
{name}
</span>
))
)}
{isPending('labels') ? (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
) : (
<ChevronDown className="size-2.5 opacity-50" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() =>
toggleIssueLabel({
label,
localLabels,
item,
repoPath,
sourceContext,
projectOrigin,
run,
onLabelsChange,
patchWorkItem,
patchProjectRowIfNeeded,
onMutated
})
}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Assignees */}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('assignees') || repoAssignees.loading}
className="group/assignees inline-flex items-center gap-1 rounded-full border border-border/30 bg-muted/20 px-2 py-0.5 text-[11px] transition hover:brightness-125 hover:ring-1 hover:ring-white/10 disabled:opacity-50"
>
{localAssignees.length === 0 ? (
<span className="text-muted-foreground">
{translate('auto.components.PullRequestPage.14c9fc70ed', '+ Assignee')}
</span>
) : (
localAssignees.map((login) => (
<span key={login} className="text-[10px] text-muted-foreground">
{login}
</span>
))
)}
{isPending('assignees') ? (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
) : (
<ChevronDown className="size-2.5 opacity-50" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() =>
toggleIssueAssignee({
login: user.login,
localAssignees,
assigneesItemKey,
editedAssigneesItemKeyRef,
item,
repoPath,
sourceContext,
projectOrigin,
run,
setLocalAssignees,
patchProjectRowIfNeeded,
onMutated
})
}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
<Button
size="sm"
onClick={() => onUse(item)}
className="ml-auto gap-2"
aria-label={translate(
'auto.components.PullRequestPage.61452f2143',
'Start workspace from issue'
)}
>
{translate('auto.components.PullRequestPage.61452f2143', 'Start workspace from issue')}
<ArrowRight className="size-4" />
</Button>
</div>
)
}
@@ -0,0 +1,390 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
import {
CombinedDiffFileTree,
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from '@/components/editor/CombinedDiffFileTree'
import {
getDiffSectionEstimatedHeight,
isIntrinsicHeightImageDiff
} from '@/components/editor/diff-section-layout'
import type { DiffSection } from '@/components/editor/diff-section-types'
import {
getCombinedDiffBranchEntriesInTreeOrder,
type CombinedDiffFileTreeEntry
} from '@/components/editor/combined-diff-file-tree-model'
import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox'
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
import {
PR_DIFF_OVERSCAN,
getPRFileSectionKey,
gitHubPRFileToBranchEntry,
type PRFilesCombinedDiffViewerProps
} from '@/components/github/pr-file-diff-mapping'
import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types'
import { prFilesDiffScrollTopCache, prFilesDiffViewStateCache } from '../cache/files-diff-view'
import { PRFilesDiffToolbar } from './toolbar'
import { addPullRequestLineComment } from './line-comment'
import { usePRFileSectionLoader } from './section-loader'
import { usePRFilesDiffViewPersistence } from './view-restore'
import { buildInlineReviewComments } from './inline-comments'
export function PRFilesCombinedDiffViewer({
files,
comments,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
prUrl,
headSha,
baseSha,
pendingViewedPaths,
onCommentAdded,
onViewedChange
}: PRFilesCombinedDiffViewerProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const entriesCacheRef = useRef<{
signature: string
entries: GitBranchChangeEntry[]
} | null>(null)
const diffEntrySignature = useMemo(
() =>
JSON.stringify(
files.map((file) => ({
path: file.path,
oldPath: file.oldPath ?? null,
status: file.status,
additions: file.additions,
deletions: file.deletions,
isBinary: file.isBinary
}))
),
[files]
)
const entries = useMemo(() => {
if (entriesCacheRef.current?.signature === diffEntrySignature) {
return entriesCacheRef.current.entries
}
const nextEntries = getCombinedDiffBranchEntriesInTreeOrder(
'commit',
files.map(gitHubPRFileToBranchEntry)
)
entriesCacheRef.current = {
signature: diffEntrySignature,
entries: nextEntries
}
return nextEntries
}, [diffEntrySignature, files])
const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files])
const inlineReviewComments = useMemo(
() => buildInlineReviewComments(comments, repoId, prNumber),
[comments, prNumber, repoId]
)
const entrySignature = useMemo(
() =>
JSON.stringify({
repoId,
prNumber,
prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null,
headSha: headSha ?? null,
baseSha: baseSha ?? null,
files: diffEntrySignature
}),
[baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId]
)
const viewStateKey = useMemo(
() => [repoId || repoPath, prNumber, prRepo ? githubRepoIdentityKey(prRepo) : ''].join('\0'),
[prNumber, prRepo, repoId, repoPath]
)
const [sections, setSections] = useState<DiffSection[]>([])
const [sideBySide, setSideBySide] = useState(false)
const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false)
const [sectionHeights, setSectionHeights] = useState<Record<number, number>>({})
const [activeTreeSectionKey, setActiveTreeSectionKey] = useState<string | null>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingRestoreScrollTopRef = useRef<number | null>(null)
const loadedIndicesRef = useRef<Set<number>>(new Set())
const loadingIndicesRef = useRef<Set<number>>(new Set())
const sectionsRef = useRef<DiffSection[]>([])
const generationRef = useRef(0)
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
const handleSectionSaveRef = useRef<(index: number) => Promise<void>>(async () => {})
sectionsRef.current = sections
useEffect(() => {
// Why: bump generation so stale async diff loads from the previous view can't patch the restored sections.
generationRef.current += 1
const cached = prFilesDiffViewStateCache.get(viewStateKey)
if (cached && cached.entrySignature === entrySignature) {
const restoredSections = cached.sections
loadedIndicesRef.current = new Set(
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
)
loadingIndicesRef.current.clear()
setSections(restoredSections)
setSectionHeights(cached.sectionHeights)
setSideBySide(cached.sideBySide)
setFileTreeCollapsed(cached.fileTreeCollapsed)
setActiveTreeSectionKey(cached.activeTreeSectionKey)
pendingRestoreScrollTopRef.current =
prFilesDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
return
}
loadedIndicesRef.current.clear()
loadingIndicesRef.current.clear()
pendingRestoreScrollTopRef.current = prFilesDiffScrollTopCache.get(viewStateKey) ?? null
setSectionHeights({})
setActiveTreeSectionKey(null)
setSections(
entries.map((entry) => ({
key: getPRFileSectionKey(entry.path),
path: entry.path,
oldPath: entry.oldPath,
status: entry.status,
added: entry.added,
removed: entry.removed,
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: true,
error: undefined,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null
}))
)
}, [entries, entrySignature, viewStateKey])
const { loadSection, retrySection, toggleSection, setAllSectionsCollapsed } =
usePRFileSectionLoader({
sectionsRef,
loadedIndicesRef,
loadingIndicesRef,
generationRef,
fileByPath,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
headSha,
baseSha,
setSections,
setSectionHeights
})
const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed)
const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections])
const viewedSectionKeys = useMemo(
() => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))),
[files]
)
const virtualizer = useVirtualizer({
count: sections.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => {
const section = sections[index]
if (!section) {
return 88
}
return getDiffSectionEstimatedHeight({
collapsed: section.collapsed,
measuredContentHeight: sectionHeights[index],
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
changedLineCount:
section.added === undefined && section.removed === undefined
? undefined
: (section.added ?? 0) + (section.removed ?? 0),
useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult),
isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true,
lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined
})
},
overscan: PR_DIFF_OVERSCAN,
getItemKey: (index) => {
const section = sections[index]
return section
? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}`
: `${index}:${entrySignature}`
}
})
useLayoutEffect(() => {
virtualizer.measure()
}, [sideBySide, virtualizer])
usePRFilesDiffViewPersistence({
sections,
entriesLength: entries.length,
viewStateKey,
entrySignature,
sectionHeights,
sideBySide,
fileTreeCollapsed,
activeTreeSectionKey,
loadedIndicesRef,
scrollContainerRef,
pendingRestoreScrollTopRef
})
const handleTreeNavigate = useCallback(
(entry: CombinedDiffFileTreeEntry) => {
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
mode: 'commit',
entry,
sections: sectionsRef.current,
sectionIndexByKey,
toggleSection,
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
})
if (navigatedIndex !== null) {
setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null)
}
},
[sectionIndexByKey, toggleSection, virtualizer]
)
const openFilesOnGitHub = useCallback(() => {
void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`)
}, [prUrl])
const handleAddLineComment = useCallback(
async (
section: DiffSection,
{
lineNumber,
startLine,
body
}: {
lineNumber: number
startLine?: number
body: string
}
) =>
addPullRequestLineComment({
headSha,
section,
lineNumber,
startLine,
body,
repoPath,
repoId,
sourceContext,
prNumber,
prRepo,
onCommentAdded
}),
[headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext]
)
const renderViewedCheckbox = useCallback(
(section: DiffSection) => {
const file = fileByPath.get(section.path)
if (!file) {
return null
}
const viewed = isPRFileViewed(file)
const pending = pendingViewedPaths.has(file.path)
return (
<PRViewedCheckbox
checked={viewed}
pending={pending}
filePath={file.path}
onToggle={() => {
if (!pending) {
void onViewedChange(file.path, !viewed)
}
}}
/>
)
},
[fileByPath, onViewedChange, pendingViewedPaths]
)
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<PRFilesDiffToolbar
files={files}
fileTreeCollapsed={fileTreeCollapsed}
allSectionsCollapsed={allSectionsCollapsed}
sideBySide={sideBySide}
onShowFileTree={() => setFileTreeCollapsed(false)}
onToggleAllCollapsed={() => setAllSectionsCollapsed(!allSectionsCollapsed)}
onToggleSideBySide={() => setSideBySide((prev) => !prev)}
/>
<div className="flex min-h-0 flex-1">
<CombinedDiffFileTree
mode="commit"
worktreePath={repoPath}
entries={entries}
sectionIndexByKey={sectionIndexByKey}
activeSectionKey={activeTreeSectionKey}
viewedSectionKeys={viewedSectionKeys}
collapsed={fileTreeCollapsed}
onCollapsedChange={setFileTreeCollapsed}
onNavigate={handleTreeNavigate}
/>
<div ref={scrollContainerRef} className="min-w-0 flex-1 overflow-auto scrollbar-editor">
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
return null
}
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ top: `${virtualItem.start}px` }}
>
<DiffSectionItem
section={section}
index={virtualItem.index}
isBranchMode={false}
sideBySide={sideBySide}
isDark={isDark}
settings={settings}
sectionHeight={sectionHeights[virtualItem.index]}
worktreeId={`github-pr:${repoId}:${prNumber}`}
inlineComments={inlineReviewComments}
loadSection={loadSection}
retrySection={retrySection}
toggleSection={toggleSection}
openSection={openFilesOnGitHub}
openSectionTitle="Open files on GitHub"
renderHeaderTrailingContent={renderViewedCheckbox}
onAddLineComment={handleAddLineComment}
addLineCommentLabel="Comment"
addLineCommentPlaceholder="Add a review comment"
getCommentableLineNumbers={(current) =>
fileByPath.get(current.path)?.reviewCommentLineNumbers
}
setSectionHeights={setSectionHeights}
setSections={setSections}
modifiedEditorsRef={modifiedEditorsRef}
handleSectionSaveRef={handleSectionSaveRef}
/>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator'
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
import type { PRComment } from '../../../../../shared/github/comment-types'
export function buildInlineReviewComments(
comments: PRComment[],
repoId: string,
prNumber: number
): DecoratedDiffComment[] {
return comments.flatMap((comment): DecoratedDiffComment[] => {
// Why: outdated threads keep originalLine for the sidebar, but rendering it inline can attach the comment to unrelated current code.
if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') {
return []
}
const createdAtMs = new Date(comment.createdAt).getTime()
return [
{
id: `github-pr-comment:${comment.id}`,
worktreeId: `github-pr:${repoId}:${prNumber}`,
filePath: comment.path,
source: 'diff',
startLine: comment.startLine,
lineNumber: comment.line,
body: comment.body,
createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(),
side: 'modified',
author: comment.author,
authorAvatarUrl: comment.authorAvatarUrl,
createdAtLabel: formatRelativeTime(comment.createdAt),
url: comment.url,
canDelete: false,
canEdit: false
}
]
})
}
@@ -0,0 +1,53 @@
import { toast } from 'sonner'
import { addPRReviewCommentForRepo } from '@/components/github/github-work-item-comment-mutations'
import { translate } from '@/i18n/i18n'
import type { DiffSection } from '@/components/editor/diff-section-types'
import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
export async function addPullRequestLineComment(args: {
headSha: string | undefined
section: DiffSection
lineNumber: number
startLine?: number
body: string
repoPath: string
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
onCommentAdded: (comment: PRComment) => void
}): Promise<boolean> {
if (!args.headSha) {
toast.error(
translate(
'auto.components.PullRequestPage.d8c3ba91c4',
'Unable to comment without the PR head SHA.'
)
)
return false
}
const result = await addPRReviewCommentForRepo({
repoPath: args.repoPath,
repoId: args.repoId,
sourceContext: args.sourceContext,
prNumber: args.prNumber,
prRepo: args.prRepo,
commitId: args.headSha,
path: args.section.path,
line: args.lineNumber,
startLine: args.startLine,
body: args.body
})
if (!result.ok) {
toast.error(
result.error ||
translate('auto.components.PullRequestPage.19628e058d', 'Failed to add review comment.')
)
return false
}
args.onCommentAdded(result.comment)
toast.success(translate('auto.components.PullRequestPage.eff839f438', 'Review comment added.'))
return true
}
@@ -0,0 +1,220 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { translate } from '@/i18n/i18n'
import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache'
import {
getPRFileContentsRenderLimit,
getPRFileDiffResult
} from '@/components/github/pr-file-diff-mapping'
import {
getStoredTextDiffContent,
getStoredTextDiffResult
} from '@/components/editor/large-diff-section-content'
import type { DiffSection } from '@/components/editor/diff-section-types'
import type { GitDiffResult } from '../../../../../shared/git-diff-compare-types'
import type {
GitHubOwnerRepo,
GitHubPRFile,
GitHubPRFileContents
} from '../../../../../shared/github/pull-request-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import { loadPRFileContents } from '../cache/file-content'
export function usePRFileSectionLoader(args: {
sectionsRef: MutableRefObject<DiffSection[]>
loadedIndicesRef: MutableRefObject<Set<number>>
loadingIndicesRef: MutableRefObject<Set<number>>
generationRef: MutableRefObject<number>
fileByPath: Map<string, GitHubPRFile>
repoPath: string
repoId: string
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
headSha: string | undefined
baseSha: string | undefined
setSections: Dispatch<SetStateAction<DiffSection[]>>
setSectionHeights: Dispatch<SetStateAction<Record<number, number>>>
}): {
loadSection: (index: number) => void
retrySection: (index: number) => void
toggleSection: (index: number) => void
setAllSectionsCollapsed: (collapsed: boolean) => void
} {
const loadSection = useCallback(
(index: number) => {
const section = args.sectionsRef.current[index]
if (!section || section.collapsed) {
return
}
if (args.loadedIndicesRef.current.has(index) || args.loadingIndicesRef.current.has(index)) {
return
}
const file = args.fileByPath.get(section.path)
if (!file) {
return
}
const generation = args.generationRef.current
args.loadingIndicesRef.current.add(index)
const load = async (): Promise<{
result: GitDiffResult
resultContents?: GitHubPRFileContents
error?: string
}> => {
if (file.isBinary) {
return {
result: {
kind: 'binary',
originalContent: '',
modifiedContent: '',
originalIsBinary: true,
modifiedIsBinary: true
}
}
}
if (!args.headSha || !args.baseSha) {
return {
result: {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
},
error: translate(
'auto.components.PullRequestPage.74660bd80b',
'Diff unavailable because the PR commit SHAs are missing.'
)
}
}
const contents = await loadPRFileContents({
repoPath: args.repoPath,
repoId: args.repoId,
sourceContext: args.sourceContext,
prNumber: args.prNumber,
prRepo: args.prRepo,
file,
headSha: args.headSha,
baseSha: args.baseSha
})
return { result: getPRFileDiffResult(contents), resultContents: contents }
}
load()
.catch((error) => ({
result: {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
} as GitDiffResult,
resultContents: undefined,
error: error instanceof Error ? error.message : 'Failed to load diff.'
}))
.then(({ result, resultContents, error }) => {
args.loadingIndicesRef.current.delete(index)
if (args.generationRef.current !== generation) {
return
}
const largeDiffRenderLimit =
!error && result.kind === 'text' && resultContents
? getPRFileContentsRenderLimit(resultContents)
: null
const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit)
const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit)
args.loadedIndicesRef.current.add(index)
args.setSections((prev) =>
prev.map((current, currentIndex) =>
currentIndex === index
? {
...current,
diffResult: storedResult,
originalContent: storedContent.originalContent,
modifiedContent: storedContent.modifiedContent,
loading: false,
error,
largeDiffRenderLimit
}
: current
)
)
})
},
[
args.baseSha,
args.fileByPath,
args.headSha,
args.prNumber,
args.prRepo,
args.repoId,
args.repoPath,
args.sourceContext,
args.generationRef,
args.loadedIndicesRef,
args.loadingIndicesRef,
args.sectionsRef,
args.setSections
]
)
const retrySection = useCallback(
(index: number) => {
args.loadedIndicesRef.current.delete(index)
args.loadingIndicesRef.current.delete(index)
args.setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
args.setSections((prev) =>
prev.map((section, sectionIndex) =>
sectionIndex === index
? {
...section,
diffResult: null,
originalContent: '',
modifiedContent: '',
loading: true,
error: undefined,
largeDiffRenderLimit: null
}
: section
)
)
loadSection(index)
},
[
args.loadedIndicesRef,
args.loadingIndicesRef,
args.setSectionHeights,
args.setSections,
loadSection
]
)
const toggleSection = useCallback(
(index: number) => {
const shouldLoadAfterExpand = args.sectionsRef.current[index]?.collapsed ?? false
args.setSections((prev) =>
prev.map((section, sectionIndex) =>
sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section
)
)
if (shouldLoadAfterExpand) {
window.requestAnimationFrame(() => loadSection(index))
}
},
[args.sectionsRef, args.setSections, loadSection]
)
const setAllSectionsCollapsed = useCallback(
(collapsed: boolean) => {
args.setSections((prev) => prev.map((section) => ({ ...section, collapsed })))
if (!collapsed) {
window.requestAnimationFrame(() => {
args.sectionsRef.current.forEach((_, index) => loadSection(index))
})
}
},
[args.sectionsRef, args.setSections, loadSection]
)
return { loadSection, retrySection, toggleSection, setAllSectionsCollapsed }
}
@@ -0,0 +1,77 @@
import React from 'react'
import { PanelLeftOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
import { translate } from '@/i18n/i18n'
import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types'
export function PRFilesDiffToolbar({
files,
fileTreeCollapsed,
allSectionsCollapsed,
sideBySide,
onShowFileTree,
onToggleAllCollapsed,
onToggleSideBySide
}: {
files: GitHubPRFile[]
fileTreeCollapsed: boolean
allSectionsCollapsed: boolean
sideBySide: boolean
onShowFileTree: () => void
onToggleAllCollapsed: () => void
onToggleSideBySide: () => void
}): React.JSX.Element {
return (
<div className="sticky top-0 z-20 flex shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-3 py-1.5">
<div className="flex min-w-0 items-center gap-2">
{fileTreeCollapsed && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.PullRequestPage.319cf2d54b',
'Show file tree'
)}
onClick={onShowFileTree}
>
<PanelLeftOpen className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.PullRequestPage.319cf2d54b', 'Show file tree')}
</TooltipContent>
</Tooltip>
)}
<span className="truncate text-xs text-muted-foreground">
{files.filter(isPRFileViewed).length} / {files.length}{' '}
{translate('auto.components.PullRequestPage.89e80af1c7', 'files viewed')}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="w-20 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={onToggleAllCollapsed}
>
{allSectionsCollapsed
? translate('auto.components.PullRequestPage.eb722a5a8c', 'Expand All')
: translate('auto.components.PullRequestPage.dd94111c18', 'Collapse All')}
</button>
<button
type="button"
className="w-24 rounded border border-border px-2 py-0.5 text-center text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={onToggleSideBySide}
>
{sideBySide
? translate('auto.components.PullRequestPage.e5f4a24f78', 'Inline')
: translate('auto.components.PullRequestPage.1378d79e83', 'Side by Side')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,118 @@
import { useEffect, useLayoutEffect, type MutableRefObject, type RefObject } from 'react'
import { setWithLRU } from '@/lib/scroll-cache'
import type { DiffSection } from '@/components/editor/diff-section-types'
import { prFilesDiffScrollTopCache, prFilesDiffViewStateCache } from '../cache/files-diff-view'
export function usePRFilesDiffViewPersistence(args: {
sections: DiffSection[]
entriesLength: number
viewStateKey: string
entrySignature: string
sectionHeights: Record<number, number>
sideBySide: boolean
fileTreeCollapsed: boolean
activeTreeSectionKey: string | null
loadedIndicesRef: MutableRefObject<Set<number>>
scrollContainerRef: RefObject<HTMLDivElement | null>
pendingRestoreScrollTopRef: MutableRefObject<number | null>
}): void {
useEffect(() => {
if (args.sections.length === 0 && args.entriesLength > 0) {
return
}
const preservedScrollTop =
prFilesDiffScrollTopCache.get(args.viewStateKey) ??
args.scrollContainerRef.current?.scrollTop ??
0
setWithLRU(prFilesDiffViewStateCache, args.viewStateKey, {
entrySignature: args.entrySignature,
sections: args.sections,
sectionHeights: args.sectionHeights,
loadedIndices: Array.from(args.loadedIndicesRef.current).filter(
(index) => !args.sections[index]?.loading
),
scrollTop: preservedScrollTop,
sideBySide: args.sideBySide,
fileTreeCollapsed: args.fileTreeCollapsed,
activeTreeSectionKey: args.activeTreeSectionKey
})
}, [
args.activeTreeSectionKey,
args.entriesLength,
args.entrySignature,
args.fileTreeCollapsed,
args.loadedIndicesRef,
args.scrollContainerRef,
args.sectionHeights,
args.sections,
args.sideBySide,
args.viewStateKey
])
useLayoutEffect(() => {
const container = args.scrollContainerRef.current
if (!container) {
return
}
const updateCachedScrollPosition = (): void => {
const existing = prFilesDiffViewStateCache.get(args.viewStateKey)
setWithLRU(prFilesDiffScrollTopCache, args.viewStateKey, container.scrollTop)
if (!existing || existing.entrySignature !== args.entrySignature) {
return
}
setWithLRU(prFilesDiffViewStateCache, args.viewStateKey, {
...existing,
scrollTop: container.scrollTop
})
}
container.addEventListener('scroll', updateCachedScrollPosition)
return () => {
updateCachedScrollPosition()
container.removeEventListener('scroll', updateCachedScrollPosition)
}
}, [args.entrySignature, args.scrollContainerRef, args.viewStateKey])
useLayoutEffect(() => {
const container = args.scrollContainerRef.current
const targetScrollTop = args.pendingRestoreScrollTopRef.current
if (!container || targetScrollTop === null) {
return
}
let frameId = 0
let attempts = 0
const restoreScrollPosition = (): void => {
const liveContainer = args.scrollContainerRef.current
const liveTarget = args.pendingRestoreScrollTopRef.current
if (!liveContainer || liveTarget === null) {
return
}
const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight)
const nextScrollTop = Math.min(liveTarget, maxScrollTop)
liveContainer.scrollTop = nextScrollTop
setWithLRU(prFilesDiffScrollTopCache, args.viewStateKey, nextScrollTop)
if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) {
args.pendingRestoreScrollTopRef.current = null
return
}
attempts += 1
if (attempts < 30) {
frameId = window.requestAnimationFrame(restoreScrollPosition)
}
}
restoreScrollPosition()
return () => window.cancelAnimationFrame(frameId)
}, [
args.pendingRestoreScrollTopRef,
args.scrollContainerRef,
args.sectionHeights,
args.sections,
args.viewStateKey
])
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { buildMentionOptions } from './options'
const item = {
author: 'octocat',
type: 'pr'
} as GitHubWorkItem
function comment(author: string, authorAvatarUrl?: string): PRComment {
return { author, authorAvatarUrl } as PRComment
}
function user(login: string, extras: Partial<GitHubAssignableUser> = {}): GitHubAssignableUser {
return { login, name: extras.name ?? null, avatarUrl: extras.avatarUrl ?? '' }
}
describe('buildMentionOptions', () => {
it('includes the item author, commenters, participants, and assignable users', () => {
const options = buildMentionOptions({
item,
comments: [comment('reviewer', 'https://avatars/reviewer')],
participants: [user('helper', { name: 'Helper' })],
assignableUsers: [user('teammate')]
})
expect(options.map((option) => option.login)).toEqual([
'octocat',
'reviewer',
'helper',
'teammate'
])
expect(options[0]?.source).toBe('PR author')
expect(options[1]?.avatarUrl).toBe('https://avatars/reviewer')
expect(options[2]?.name).toBe('Helper')
})
it('skips ghost and fills missing avatar/name on a later duplicate login', () => {
const options = buildMentionOptions({
item,
comments: [comment('ghost'), comment('octocat', 'https://avatars/octocat')],
participants: [user('octocat', { name: 'Mona' })],
assignableUsers: []
})
expect(options).toHaveLength(1)
expect(options[0]).toMatchObject({
login: 'octocat',
avatarUrl: 'https://avatars/octocat',
name: 'Mona'
})
})
})
@@ -0,0 +1,53 @@
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { MentionOption } from '../page-types'
export function buildMentionOptions({
item,
comments,
participants,
assignableUsers
}: {
item: GitHubWorkItem
comments: PRComment[]
participants: GitHubAssignableUser[]
assignableUsers: GitHubAssignableUser[]
}): MentionOption[] {
const byLogin = new Map<string, MentionOption>()
const add = (
login: string | null | undefined,
source: string,
avatarUrl?: string,
name?: string | null
): void => {
if (!login || login === 'ghost') {
return
}
const key = login.toLowerCase()
const existing = byLogin.get(key)
if (existing) {
if (!existing.avatarUrl && avatarUrl) {
existing.avatarUrl = avatarUrl
}
if (!existing.name && name) {
existing.name = name
}
return
}
byLogin.set(key, { login, source, avatarUrl, name })
}
add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author')
for (const comment of comments) {
add(comment.author, 'Commenter', comment.authorAvatarUrl)
}
for (const user of participants) {
add(user.login, 'Participant', user.avatarUrl, user.name)
}
for (const user of assignableUsers) {
add(user.login, 'Team member', user.avatarUrl, user.name)
}
return Array.from(byLogin.values())
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { findMentionQuery } from './query'
describe('findMentionQuery', () => {
it('returns the @query immediately before the caret', () => {
expect(findMentionQuery('hi @octo', 8)).toEqual({ atIndex: 3, query: 'octo' })
})
it('returns an empty query when the caret is right after @', () => {
expect(findMentionQuery('hi @', 4)).toEqual({ atIndex: 3, query: '' })
})
it('returns null when there is no mention token at the caret', () => {
expect(findMentionQuery('hello world', 11)).toBeNull()
expect(findMentionQuery('email@host', 10)).toBeNull()
expect(findMentionQuery('hi @octo more', 13)).toBeNull()
})
it('allows a mention after punctuation that starts a token', () => {
expect(findMentionQuery('(@octo', 6)).toEqual({ atIndex: 1, query: 'octo' })
})
})
@@ -0,0 +1,14 @@
import type { MentionQuery } from '../page-types'
export function findMentionQuery(value: string, caret: number): MentionQuery | null {
const beforeCaret = value.slice(0, caret)
const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret)
if (!match) {
return null
}
const query = match[2] ?? ''
return {
atIndex: beforeCaret.length - query.length - 1,
query
}
}
@@ -0,0 +1,148 @@
import React, { useCallback, useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter'
import type { MentionOption, MentionQuery } from '../page-types'
import { findMentionQuery } from './query'
export function MentionTextarea({
value,
onValueChange,
onKeyDown,
placeholder,
rows,
className,
wrapperClassName,
mentionOptions,
textareaRef
}: {
value: string
onValueChange: (value: string) => void
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
placeholder: string
rows: number
className?: string
wrapperClassName?: string
mentionOptions: MentionOption[]
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}): React.JSX.Element {
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const suggestions = useMemo(
() => (mentionQuery ? filterGitHubMentionOptions(mentionOptions, mentionQuery.query) : []),
[mentionOptions, mentionQuery]
)
const showSuggestions = mentionQuery !== null && suggestions.length > 0
const syncMentionQuery = useCallback((textarea: HTMLTextAreaElement): void => {
const nextQuery = findMentionQuery(textarea.value, textarea.selectionStart)
setMentionQuery(nextQuery)
setActiveIndex(0)
}, [])
const insertMention = useCallback(
(option: MentionOption): void => {
const textarea = textareaRef.current
const caret = textarea?.selectionStart ?? value.length
const query = textarea ? findMentionQuery(value, caret) : mentionQuery
if (!query) {
return
}
const suffix = value[caret] && !/\s/.test(value[caret]) ? ' ' : ''
const inserted = `@${option.login}${suffix}`
const nextValue = `${value.slice(0, query.atIndex)}${inserted}${value.slice(caret)}`
const nextCaret = query.atIndex + inserted.length
onValueChange(nextValue)
setMentionQuery(null)
requestAnimationFrame(() => {
textarea?.focus()
textarea?.setSelectionRange(nextCaret, nextCaret)
})
},
[mentionQuery, onValueChange, textareaRef, value]
)
return (
<div className={cn('relative min-w-0 flex-1', wrapperClassName)}>
{showSuggestions && (
<div className="absolute right-0 bottom-[calc(100%+6px)] left-0 z-50 max-h-64 overflow-y-auto rounded-md border border-border/70 bg-popover p-1 text-popover-foreground shadow-lg scrollbar-sleek">
{suggestions.map((option, index) => (
<button
key={option.login}
type="button"
onMouseDown={(event) => {
event.preventDefault()
insertMention(option)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px]',
index === activeIndex && 'bg-accent text-accent-foreground'
)}
>
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
) : (
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
{option.login.slice(0, 1).toUpperCase()}
</div>
)}
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="shrink-0 font-medium">@{option.login}</span>
{option.name && (
<>
<span className="shrink-0 text-muted-foreground">|</span>
<span className="truncate text-muted-foreground">{option.name}</span>
</>
)}
<span className="shrink-0 text-muted-foreground">|</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{option.source}</span>
</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
value={value}
onChange={(event) => {
onValueChange(event.target.value)
syncMentionQuery(event.currentTarget)
}}
onClick={(event) => syncMentionQuery(event.currentTarget)}
onKeyUp={(event) => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) {
syncMentionQuery(event.currentTarget)
}
}}
onBlur={() => setMentionQuery(null)}
onKeyDown={(event) => {
if (showSuggestions) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((current) => (current + 1) % suggestions.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((current) => (current - 1 + suggestions.length) % suggestions.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
insertMention(suggestions[activeIndex] ?? suggestions[0])
return
}
if (event.key === 'Escape') {
event.preventDefault()
setMentionQuery(null)
return
}
}
onKeyDown?.(event)
}}
placeholder={placeholder}
rows={rows}
className={className}
/>
</div>
)
}
@@ -0,0 +1,39 @@
import type { GitHubAssignableUser } from '../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type {
GitHubWorkItemProjectOrigin,
ItemDialogTab
} from '@/components/github/github-work-item-identity'
export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin
export type MentionOption = {
login: string
name?: string | null
avatarUrl?: string
source: string
}
export type MentionQuery = {
atIndex: number
query: string
}
export type PullRequestPageProps = {
workItem: GitHubWorkItem | null
repoPath: string | null
repoId?: string | null
sourceContext?: TaskSourceContext | null
initialTab?: ItemDialogTab
backLabel?: string
/** Called when the user clicks the primary CTA to start work from this item. */
onUse: (item: GitHubWorkItem) => void
onReviewRequestsChange?: (
itemKey: { id: string; repoId: string },
reviewRequests: GitHubAssignableUser[]
) => void
onClose: () => void
/** Optional Project-origin context; when set, slug-addressed IPCs route writes to the row's repo instead of `repoPath` (both may be set — slug wins for writes). */
projectOrigin?: PullRequestPageProjectOrigin
}
@@ -0,0 +1,274 @@
import React from 'react'
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
ChevronLeft,
Copy,
ExternalLink,
FolderKanban,
Plus
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity'
import { formatRelativeTime, getStateLabel } from '@/components/github/work-item-state-presentation'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
export function PullRequestPageHeader({
workItem,
displayWorkItem,
backLabel,
onClose,
linkCopied,
setLinkCopyButtonRef,
onCopyLink,
attachedWorkspace,
attachedWorkspaceLabel,
localState,
Icon,
onOpenOrUsePR,
onUseWorkItem
}: {
workItem: GitHubWorkItem
displayWorkItem: GitHubWorkItem | null
backLabel: string
onClose: () => void
linkCopied: boolean
setLinkCopyButtonRef: (node: HTMLButtonElement | null) => void
onCopyLink: () => void
attachedWorkspace: unknown
attachedWorkspaceLabel: string | null
localState: GitHubWorkItem['state']
Icon: React.ComponentType<{ className?: string }>
onOpenOrUsePR: () => void
onUseWorkItem: () => void
}): React.JSX.Element {
const ownerRepo = parseOwnerRepoFromItemUrl(workItem.url)
const headBranch = workItem.branchName
const baseBranch = workItem.baseRefName
const stateBadgeTone =
localState === 'merged'
? 'bg-purple-600 text-white'
: localState === 'draft'
? 'bg-slate-500 text-white'
: localState === 'closed'
? 'bg-rose-600 text-white'
: 'bg-emerald-600 text-white'
const stateBadgeLabel = getStateLabel({ ...workItem, state: localState })
return (
<>
{/* Row 1: page header strip — breadcrumb-style row mirroring Primer canvas-subtle */}
<div className="flex-none border-b border-border/60 bg-muted/30 px-6 py-2.5">
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
className="-ml-2 h-7 gap-1 px-2 text-muted-foreground hover:text-foreground"
aria-label={backLabel}
>
<ChevronLeft className="size-4" />
{backLabel}
</Button>
<span className="text-muted-foreground/40">·</span>
{ownerRepo ? (
<>
<span className="truncate">
<span className="text-muted-foreground">{ownerRepo.owner}</span>
<span className="mx-1 text-muted-foreground/40">/</span>
<span className="font-medium text-foreground">{ownerRepo.repo}</span>
</span>
<span className="text-muted-foreground/40">·</span>
</>
) : null}
<span className="font-mono text-muted-foreground">#{workItem.number}</span>
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={setLinkCopyButtonRef}
type="button"
variant="ghost"
size="icon-sm"
onClick={onCopyLink}
aria-label={translate(
'auto.components.PullRequestPage.347034903a',
'Copy GitHub link'
)}
>
{linkCopied ? (
<Check className="size-4 text-emerald-500" />
) : (
<Copy className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{linkCopied
? translate('auto.components.PullRequestPage.3b6886b2ee', 'Copied')
: translate('auto.components.PullRequestPage.347034903a', 'Copy GitHub link')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => window.api.shell.openUrl(workItem.url)}
aria-label={translate(
'auto.components.PullRequestPage.8ecda455a0',
'Open on GitHub'
)}
>
<ExternalLink className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.PullRequestPage.8ecda455a0', 'Open on GitHub')}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Row 2: PR title block — large weight-400 title + state row, mirrors Primer pr-title-block */}
<div className="flex-none border-b border-border/60 px-6 py-5">
<div className="flex items-start gap-4">
<h1 className="min-w-0 flex-1 text-[26px] font-medium leading-snug text-foreground">
<span className="break-words">{workItem.title}</span>
<span className="ml-2 align-baseline text-[20px] font-normal text-muted-foreground/70">
#{workItem.number}
</span>
</h1>
<div className="flex shrink-0 items-center gap-2">
{/* Why: Orca's signature affordance — keep primary so it stands out against GitHub's familiar surface. */}
<DropdownMenu modal={false}>
<ButtonGroup>
<Button
type="button"
onClick={onOpenOrUsePR}
className="w-[180px] justify-center gap-1.5 whitespace-nowrap"
aria-label={
attachedWorkspace
? translate(
'auto.components.PullRequestPage.a459866967',
'Resume workspace attached to PR'
)
: translate(
'auto.components.PullRequestPage.25690a3855',
'Start workspace from PR'
)
}
>
{attachedWorkspace
? translate('auto.components.PullRequestPage.c9e7094a7b', 'Resume workspace')
: translate('auto.components.PullRequestPage.71a3c0f9d2', 'Start workspace')}
<ArrowRight className="size-4" />
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
aria-label={translate(
'auto.components.PullRequestPage.57c13a5aa4',
'More PR workspace actions'
)}
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
</ButtonGroup>
<DropdownMenuContent align="end">
{attachedWorkspace ? (
<DropdownMenuItem onSelect={onUseWorkItem}>
<Plus className="size-4" />
{translate('auto.components.PullRequestPage.1a2570e18e', 'Start new workspace')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(workItem.url)}>
<ExternalLink className="size-4" />
{translate('auto.components.PullRequestPage.8ecda455a0', 'Open on GitHub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-2.5 gap-y-2 text-[13px] text-muted-foreground">
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[12px] font-medium',
stateBadgeTone
)}
>
<Icon className="size-3.5" />
{stateBadgeLabel}
</span>
<span className="flex min-w-0 items-center gap-1.5">
{workItem.author ? (
<GitHubUserAvatar
login={workItem.author}
avatarUrl={displayWorkItem?.authorAvatarUrl ?? workItem.authorAvatarUrl}
className="size-5"
/>
) : null}
<span className="font-semibold text-foreground">
{workItem.author ??
translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')}
</span>
</span>
{/* Why: base ← head scans faster than prose and matches how reviewers think about merge direction. */}
<span className="flex flex-wrap items-center gap-1.5">
{baseBranch ? (
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[12px] text-foreground">
{baseBranch}
</span>
) : (
<span className="italic">
{translate('auto.components.PullRequestPage.c44b70352b', 'base branch')}
</span>
)}
<ArrowLeft className="size-3.5 shrink-0 text-muted-foreground/70" />
{headBranch ? (
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[12px] text-foreground">
{headBranch}
</span>
) : (
<span className="italic">
{translate('auto.components.PullRequestPage.00b7b82329', 'head branch')}
</span>
)}
</span>
<span className="text-muted-foreground/40">·</span>
<span className="text-muted-foreground/80">
{translate('auto.components.PullRequestPage.dd5d9a4f17', 'updated {{value0}}', {
value0: formatRelativeTime(workItem.updatedAt)
})}
</span>
{attachedWorkspaceLabel ? (
<>
<span className="text-muted-foreground/40">·</span>
<span className="inline-flex min-w-0 items-center gap-1.5">
<FolderKanban className="size-3.5 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
</>
) : null}
</div>
</div>
</>
)
}
@@ -0,0 +1,384 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
CircleDot,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
GitPullRequestDraft
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
import {
findGithubPrWorkspaceAttachment,
getGithubPrWorkspaceAttachmentLabel
} from '@/lib/github-work-item-workspace-attachment'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
clearGitHubLinkCopied,
createGitHubLinkCopyState,
markGitHubLinkCopied,
resolveGitHubLinkCopyState
} from '@/components/github-link-copy-state'
import { normalizeItemDialogTab } from '@/components/github/github-work-item-identity'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { PullRequestPageProps } from '../page-types'
import {
invalidateWorkItemDetailsCacheByMatch,
invalidateWorkItemDetailsCacheForKey,
patchCachedPRChecks,
patchCachedPRReviewRequests,
patchCachedWorkItemBody
} from '../cache/work-item-details'
import { GHEditSection } from '../edit/section'
import { usePullRequestDetails } from './use-details'
import { PullRequestPageHeader } from './header'
import { PullRequestPageTabs } from './tabs-shell'
import { syncPullRequestFileViewed } from './viewed-sync'
export default function PullRequestPage({
workItem,
repoPath,
repoId,
sourceContext,
initialTab,
backLabel = 'Pull requests',
projectOrigin,
onUse,
onReviewRequestsChange,
onClose
}: PullRequestPageProps): React.JSX.Element {
// Why: this component is page-only — the sheet variant lives in GitHubItemDialog.
const workItemId = workItem?.id
const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab))
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
const [linkCopyState, setLinkCopyState] = useState(() => createGitHubLinkCopyState(workItemId))
const resolvedLinkCopyState = resolveGitHubLinkCopyState(linkCopyState, workItemId)
if (resolvedLinkCopyState !== linkCopyState) {
// Why: reconcile before paint so switching items doesn't flash the previous item's copied indicator.
setLinkCopyState(resolvedLinkCopyState)
}
const linkCopied = resolvedLinkCopyState.copied
const workItemState = workItem?.state
const workItemLabels = workItem?.labels
const workItemType = workItem?.type
const effectiveRepoId = repoId ?? workItem?.repoId ?? null
const allWorktrees = useAllWorktrees()
const attachedWorkspace = useMemo(
() =>
workItem?.type === 'pr'
? findGithubPrWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number)
: null,
[allWorktrees, effectiveRepoId, workItem]
)
const attachedWorkspaceLabel = attachedWorkspace
? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace)
: null
// Why: key must include issue source preference so origin/upstream toggles for the same issue number don't read back the wrong repo's details.
const issueSourcePreference = useAppStore((s) => {
if (!repoPath && !effectiveRepoId) {
return undefined
}
return s.repos.find((r) => (effectiveRepoId ? r.id === effectiveRepoId : r.path === repoPath))
?.issueSourcePreference
})
const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
const { details, loading, error, detailsLoaded, detailsCacheKey, appendOptimisticComment } =
usePullRequestDetails({
workItem,
repoPath,
effectiveRepoId,
sourceContext,
issueSourcePreference,
canUseDetailsRepoContext
})
// Why: reset lifted edit state on item switch or when the same item gets an optimistic cache patch from the table.
useEffect(() => {
if (workItemState && workItemLabels) {
setLocalState(workItemState)
setLocalLabels(workItemLabels)
}
}, [workItemId, workItemState, workItemLabels])
useEffect(() => {
const nextTab = workItemType === 'pr' ? (initialTab ?? 'conversation') : 'conversation'
setTab(nextTab)
}, [workItemId, workItemType, initialTab])
const handleUseWorkItem = useCallback((): void => {
if (!workItem) {
return
}
const targetRepoId = effectiveRepoId
onUse(
targetRepoId && targetRepoId !== workItem.repoId
? { ...workItem, repoId: targetRepoId }
: workItem
)
}, [effectiveRepoId, onUse, workItem])
const handleOpenOrUsePR = useCallback((): void => {
if (!workItem) {
return
}
const targetRepoId = effectiveRepoId
const currentAttached = findGithubPrWorkspaceAttachment(
useAppStore.getState().allWorktrees(),
targetRepoId,
workItem.number
)
if (!currentAttached) {
handleUseWorkItem()
return
}
const result = activateAndRevealWorktree(currentAttached.id)
if (result === false) {
toast.error(
translate(
'auto.components.PullRequestPage.61bfc81ada',
'Unable to open the workspace attached to this pull request.'
)
)
}
}, [effectiveRepoId, handleUseWorkItem, workItem])
// Why: Radix can leave `pointer-events: none` on <body> when opening right after another overlay closes, killing header clicks; poll a few frames to clear it.
useEffect(() => {
if (!workItem) {
return
}
let cancelled = false
let count = 0
let frameId: number | null = null
const tick = (): void => {
frameId = null
if (cancelled) {
return
}
if (document.body.style.pointerEvents === 'none') {
document.body.style.pointerEvents = ''
}
if (count++ < 5) {
frameId = requestAnimationFrame(tick)
}
}
tick()
return () => {
cancelled = true
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
}
}, [workItem])
// Why: icon must track resolved state so a merged PR reads as merged, not as the open-PR glyph.
const Icon =
workItem?.type === 'pr'
? localState === 'merged'
? GitMerge
: localState === 'closed'
? GitPullRequestClosed
: localState === 'draft'
? GitPullRequestDraft
: GitPullRequest
: CircleDot
const displayWorkItem = useMemo<GitHubWorkItem | null>(() => {
if (!workItem) {
return null
}
if (!details?.item) {
return workItem
}
return { ...workItem, ...details.item, repoId: workItem.repoId }
}, [details?.item, workItem])
useEffect(() => {
if (!workItem || details?.item.reviewRequests === undefined) {
return
}
// Why: PR details can carry fresher reviewer metadata than the list row; push it back so the Tasks review chip isn't stale.
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
details.item.reviewRequests
)
}, [details?.item.reviewRequests, onReviewRequestsChange, workItem])
const body = details?.body ?? ''
const comments = details?.comments ?? []
const files = details?.files ?? []
const filesUnavailable = details?.filesUnavailable ?? false
const checks = details?.checks ?? []
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => new Set())
// Why: clipboard IPC can resolve after unmount; skip copied-state feedback rather than start a reset timer on a stale surface.
const linkCopyMountedRef = useRef(false)
const linkCopiedResetTimerRef = useRef<number | null>(null)
const clearLinkCopiedResetTimer = useCallback((): void => {
if (linkCopiedResetTimerRef.current === null) {
return
}
window.clearTimeout(linkCopiedResetTimerRef.current)
linkCopiedResetTimerRef.current = null
}, [])
const setLinkCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
linkCopyMountedRef.current = node !== null
if (node === null) {
// Why: clear the copied-state timer on ref detach instead of via a passive cleanup Effect.
clearLinkCopiedResetTimer()
}
},
[clearLinkCopiedResetTimer]
)
const handleCopyWorkItemLink = useCallback(async (): Promise<void> => {
if (!workItem) {
return
}
try {
// Why: Electron clipboard IPC works even when browser clipboard APIs lose focus/activation in nested overlays.
await window.api.ui.writeClipboardText(workItem.url)
if (!linkCopyMountedRef.current) {
return
}
clearLinkCopiedResetTimer()
const copiedWorkItemId = workItem.id
setLinkCopyState(markGitHubLinkCopied(copiedWorkItemId))
linkCopiedResetTimerRef.current = window.setTimeout(() => {
linkCopiedResetTimerRef.current = null
setLinkCopyState((current) => clearGitHubLinkCopied(current, copiedWorkItemId))
}, 1500)
toast.success(translate('auto.components.PullRequestPage.992e799227', 'GitHub link copied'))
} catch {
toast.error(
translate('auto.components.PullRequestPage.e0b15c793f', 'Failed to copy GitHub link')
)
}
}, [clearLinkCopiedResetTimer, workItem])
const invalidateCurrentDetailsCache = useCallback((): void => {
if (!workItem) {
return
}
// Why: local repos invalidate all source-preference variants; runtime-only entries need their exact source-scoped key (no local path).
if (repoPath) {
invalidateWorkItemDetailsCacheByMatch({
repoPath,
repoId: effectiveRepoId ?? undefined,
type: workItem.type,
number: workItem.number
})
return
}
if (detailsCacheKey) {
invalidateWorkItemDetailsCacheForKey(detailsCacheKey)
}
}, [detailsCacheKey, effectiveRepoId, repoPath, workItem])
const handlePRFileViewedChange = useCallback(
async (path: string, viewed: boolean): Promise<boolean> =>
syncPullRequestFileViewed({
canUseDetailsRepoContext,
details,
workItem,
path,
viewed,
detailsCacheKey,
repoPath,
sourceContext,
projectOrigin,
setPendingViewedPaths
}),
[
canUseDetailsRepoContext,
details,
detailsCacheKey,
projectOrigin,
repoPath,
sourceContext,
workItem
]
)
const content = workItem ? (
<div className="flex h-full min-h-0 flex-col">
<PullRequestPageHeader
workItem={workItem}
displayWorkItem={displayWorkItem}
backLabel={backLabel}
onClose={onClose}
linkCopied={linkCopied}
setLinkCopyButtonRef={setLinkCopyButtonRef}
onCopyLink={() => {
void handleCopyWorkItemLink()
}}
attachedWorkspace={attachedWorkspace}
attachedWorkspaceLabel={attachedWorkspaceLabel}
localState={localState}
Icon={Icon}
onOpenOrUsePR={handleOpenOrUsePR}
onUseWorkItem={handleUseWorkItem}
/>
{(canUseDetailsRepoContext || projectOrigin) && (
<GHEditSection
item={workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
sourceContext={sourceContext}
projectOrigin={projectOrigin}
localState={localState}
localLabels={localLabels}
onStateChange={setLocalState}
onLabelsChange={setLocalLabels}
onMutated={invalidateCurrentDetailsCache}
assignees={details?.assignees ?? []}
onUse={onUse}
/>
)}
<div className="min-h-0 flex-1">
{error ? (
<div className="px-4 py-6 text-[12px] text-destructive">{error}</div>
) : (
<PullRequestPageTabs
tab={tab}
onTabChange={setTab}
workItem={workItem}
displayWorkItem={displayWorkItem}
repoPath={repoPath}
effectiveRepoId={effectiveRepoId}
sourceContext={sourceContext}
projectOrigin={projectOrigin}
body={body}
comments={comments}
files={files}
filesUnavailable={filesUnavailable}
checks={checks}
loading={loading}
detailsLoaded={detailsLoaded}
details={details}
localState={localState}
setLocalState={setLocalState}
detailsCacheKey={detailsCacheKey}
pendingViewedPaths={pendingViewedPaths}
invalidateCurrentDetailsCache={invalidateCurrentDetailsCache}
appendOptimisticComment={appendOptimisticComment}
handlePRFileViewedChange={handlePRFileViewedChange}
onReviewRequestsChange={onReviewRequestsChange}
patchCachedPRChecks={patchCachedPRChecks}
patchCachedWorkItemBody={patchCachedWorkItemBody}
patchCachedPRReviewRequests={patchCachedPRReviewRequests}
/>
)}
</div>
</div>
) : null
return <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">{content}</div>
}
@@ -0,0 +1,226 @@
import React from 'react'
import { FileText, ListChecks, LoaderCircle, MessageSquare, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
resolvePullRequestRepo,
type ItemDialogTab
} from '@/components/github/github-work-item-identity'
import { translate } from '@/i18n/i18n'
import type {
GitHubAssignableUser,
GitHubPRFile
} from '../../../../../shared/github/pull-request-types'
import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../../../shared/github/work-item-types'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { ConversationTab } from '../conversation/tab'
import { ChecksTab } from '../checks/tab'
import { PRFilesCombinedDiffViewer } from '../files/combined-diff-viewer'
export function PullRequestPageTabs({
tab,
onTabChange,
workItem,
displayWorkItem,
repoPath,
effectiveRepoId,
sourceContext,
projectOrigin,
body,
comments,
files,
filesUnavailable,
checks,
loading,
detailsLoaded,
details,
localState,
setLocalState,
detailsCacheKey,
pendingViewedPaths,
invalidateCurrentDetailsCache,
appendOptimisticComment,
handlePRFileViewedChange,
onReviewRequestsChange,
patchCachedPRChecks,
patchCachedWorkItemBody,
patchCachedPRReviewRequests
}: {
tab: ItemDialogTab
onTabChange: (value: ItemDialogTab) => void
workItem: GitHubWorkItem
displayWorkItem: GitHubWorkItem | null
repoPath: string | null
effectiveRepoId: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
body: string
comments: PRComment[]
files: GitHubPRFile[]
filesUnavailable: boolean
checks: PRCheckDetail[]
loading: boolean
detailsLoaded: boolean
details: GitHubWorkItemDetails | null
localState: GitHubWorkItem['state']
setLocalState: (state: GitHubWorkItem['state']) => void
detailsCacheKey: string | null
pendingViewedPaths: Set<string>
invalidateCurrentDetailsCache: () => void
appendOptimisticComment: (comment: PRComment) => void
handlePRFileViewedChange: (path: string, viewed: boolean) => Promise<boolean>
onReviewRequestsChange?: (
itemKey: { id: string; repoId: string },
reviewRequests: GitHubAssignableUser[]
) => void
patchCachedPRChecks: (cacheKey: string, checks: PRCheckDetail[]) => void
patchCachedWorkItemBody: (cacheKey: string, body: string) => void
patchCachedPRReviewRequests: (cacheKey: string, reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
return (
<Tabs
value={tab}
onValueChange={(value) => onTabChange(value as ItemDialogTab)}
className="flex h-full min-h-0 flex-col gap-0"
>
{/* Why: the line variant already underlines the active tab via ::after; a second border would box the trigger. */}
<TabsList
variant="line"
className="mx-0 justify-start gap-2 border-b border-border/60 bg-transparent px-6"
>
<TabsTrigger value="conversation" className="px-3 py-2.5">
<MessageSquare className="size-3.5" />
{translate('auto.components.PullRequestPage.9e8d45700e', 'Conversation')}
</TabsTrigger>
<TabsTrigger value="checks" className="px-3 py-2.5">
<ListChecks className="size-3.5" />
{translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')}
{checks.length > 0 && (
<span className="ml-1 rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground">
{checks.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="files" className="px-3 py-2.5">
<FileText className="size-3.5" />
{translate('auto.components.PullRequestPage.4d18310d55', 'Files changed')}
{files.length > 0 && (
<span className="ml-1 rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground">
{files.length}
</span>
)}
</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<TabsContent value="conversation" className="mt-0">
<ConversationTab
item={displayWorkItem ?? workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
sourceContext={sourceContext}
body={body}
comments={comments}
files={files}
headSha={details?.headSha}
baseSha={details?.baseSha}
loading={loading}
detailsLoaded={detailsLoaded}
checks={checks}
participants={details?.participants ?? []}
localState={localState}
onStateChange={setLocalState}
projectOrigin={projectOrigin}
onMutated={invalidateCurrentDetailsCache}
onChecksUpdated={(nextChecks) => {
if (detailsCacheKey) {
patchCachedPRChecks(detailsCacheKey, nextChecks)
}
}}
onBodyUpdated={(nextBody) => {
if (detailsCacheKey) {
patchCachedWorkItemBody(detailsCacheKey, nextBody)
}
}}
onCommentAdded={appendOptimisticComment}
onReviewersRequested={(nextReviewRequests) => {
if (detailsCacheKey) {
patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests)
}
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
nextReviewRequests
)
}}
/>
</TabsContent>
<TabsContent value="checks" className="mt-0">
<ChecksTab
item={workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
sourceContext={sourceContext}
headSha={details?.headSha}
checks={checks}
loading={loading || !detailsLoaded}
variant="page"
onChecksUpdated={(nextChecks) => {
if (detailsCacheKey) {
patchCachedPRChecks(detailsCacheKey, nextChecks)
}
}}
/>
</TabsContent>
<TabsContent value="files" className="mt-0 h-full min-h-0 overflow-hidden">
{loading && files.length === 0 ? (
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : filesUnavailable && files.length === 0 ? (
// Why: fetch failed (rate limit/auth/unresolved remote); offer retry instead of implying the PR is empty.
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<div className="text-[12px] text-muted-foreground">
{translate(
'auto.components.PullRequestPage.filesUnavailable',
"Couldn't load changed files."
)}
</div>
<Button variant="outline" size="sm" onClick={invalidateCurrentDetailsCache}>
<RefreshCw className="size-3.5" />
{translate('auto.components.PullRequestPage.filesRetry', 'Retry')}
</Button>
</div>
) : files.length === 0 ? (
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.6ad2c1ab9c', 'No files changed.')}
</div>
) : (
<PRFilesCombinedDiffViewer
files={files}
comments={comments}
repoPath={repoPath ?? ''}
repoId={effectiveRepoId ?? ''}
sourceContext={sourceContext}
prNumber={workItem.number}
prRepo={resolvePullRequestRepo(workItem, projectOrigin)}
prUrl={workItem.url}
headSha={details?.headSha}
baseSha={details?.baseSha}
pendingViewedPaths={pendingViewedPaths}
onCommentAdded={appendOptimisticComment}
onViewedChange={handlePRFileViewedChange}
/>
)}
</TabsContent>
</div>
</Tabs>
)
}
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup'
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../../../shared/github/work-item-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
import {
WORK_ITEM_DETAILS_FRESH_MS,
WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE,
getWorkItemDetailsCacheKey,
subscribeWorkItemDetailsCache,
touchWorkItemDetailsCache,
workItemDetailsCache,
workItemDetailsCacheGeneration
} from '../cache/work-item-details'
export function usePullRequestDetails(args: {
workItem: GitHubWorkItem | null
repoPath: string | null
effectiveRepoId: string | null
sourceContext?: TaskSourceContext | null
issueSourcePreference: string | undefined
canUseDetailsRepoContext: boolean
}): {
details: GitHubWorkItemDetails | null
loading: boolean
error: string | null
detailsLoaded: boolean
detailsCacheKey: string | null
appendOptimisticComment: (comment: PRComment) => void
} {
const {
workItem,
repoPath,
effectiveRepoId,
sourceContext,
issueSourcePreference,
canUseDetailsRepoContext
} = args
const detailsCacheKey = useMemo(() => {
if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) {
return null
}
return getWorkItemDetailsCacheKey({
repoPath: repoPath ?? '',
repoId: effectiveRepoId,
issueSourcePreference,
sourceCacheScope:
sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null,
type: workItem.type,
number: workItem.number
})
}, [
canUseDetailsRepoContext,
repoPath,
effectiveRepoId,
sourceContext,
workItem,
issueSourcePreference
])
// Why: hold optimistically-added comments so they merge into the fetch result instead of being overwritten.
const optimisticCommentsRef = useRef<PRComment[]>([])
// Why: track last fetched item to distinguish reopen from switch — reopen must preserve optimistic comments since gh's 60s cache omits the just-posted one.
const prevItemIdRef = useRef<string | null>(null)
// Why: subscribe to the module cache so reopening a cached item paints synchronously; writes replace entry identity (delete+set), so Map.get is a stable snapshot.
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
useCallback(
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined),
[detailsCacheKey]
)
)
// Why: bumped on cold open (no cached details) so the details memo re-runs and surfaces the optimistic comment via the loading shell; cache-notify handles the warm case.
const [optimisticTick, setOptimisticTick] = useState(0)
// Why: merge optimistic comments into cached details; keyed off stable cachedEntry identity (not the per-render ref array) to avoid needless recompute.
const details = useMemo<GitHubWorkItemDetails | null>(() => {
const cachedDetails = cachedEntry?.details ?? null
const opt = optimisticCommentsRef.current
if (!cachedDetails) {
// Why: on a cold open details may still be loading; surface optimistic comments via a minimal shell so a just-posted comment isn't held invisibly in the ref.
if (opt.length > 0 && workItem) {
return { item: workItem, body: '', comments: [...opt] }
}
return null
}
if (opt.length === 0) {
return cachedDetails
}
const ids = new Set(cachedDetails.comments.map((c) => c.id))
const missing = opt.filter((c) => !ids.has(c.id))
if (missing.length === 0) {
return cachedDetails
}
return {
...cachedDetails,
comments: [...cachedDetails.comments, ...missing]
}
// Why: optimisticTick isn't read in the body but is the rerender signal for cold-open writes (memo reads a ref); removing it breaks the optimistic shell.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedEntry, workItem, optimisticTick])
const loading = !!cachedEntry?.pending && !cachedEntry?.details
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
const detailsLoaded = Boolean(cachedEntry?.details)
// Why: if a cross-window mutation invalidates the open drawer's entry (cachedEntry undefined, fetch deps unchanged), bump a tick so it refetches.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
if (workItem && detailsCacheKey && !cachedEntry) {
setRefetchTick((n) => n + 1)
}
}, [workItem, detailsCacheKey, cachedEntry])
useEffect(() => {
if (!workItem || !effectiveRepoId || !detailsCacheKey || !canUseDetailsRepoContext) {
return
}
// Why: only clear optimistic comments on a genuine item switch; on reopen gh's 60s cache omits the just-posted comment, so preserve the ref for re-merge.
if (workItem.id !== prevItemIdRef.current) {
optimisticCommentsRef.current = []
}
prevItemIdRef.current = workItem.id
const cached = workItemDetailsCache.get(detailsCacheKey)
const now = Date.now()
const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS
if (hasFreshData) {
return
}
// Why: dedupe concurrent opens on the same key so a rapid close→reopen shares one in-flight promise instead of racing two `gh` subprocesses.
const inflight: Promise<GitHubWorkItemDetails | null> =
cached?.pending ??
lookupGitHubWorkItemDetailsForSource({
repoPath: repoPath ?? '',
repoId: effectiveRepoId,
sourceContext,
number: workItem.number,
type: workItem.type
})
// Why: snapshot the generation so a mid-flight invalidation (generation advance) blocks writing a stale result back.
const launchedAtGeneration = workItemDetailsCacheGeneration.current
if (!cached?.pending) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: cached?.details ?? null,
fetchedAt: cached?.fetchedAt ?? 0,
pending: inflight,
error: cached?.error
})
}
inflight
.then((result) => {
const invalidatedMidFlight = workItemDetailsCacheGeneration.current !== launchedAtGeneration
const prev = workItemDetailsCache.get(detailsCacheKey)
if (invalidatedMidFlight && prev?.pending !== inflight) {
// Why: entry was deliberately dropped (or later repopulated) — don't recreate or clobber it.
return
}
// Why: null means unavailable/not found, not loaded empty content.
if (result === null && prev?.details) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: prev.details,
fetchedAt: prev.fetchedAt,
error: undefined
})
} else if (result === null) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: null,
fetchedAt: 0,
error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE
})
} else {
touchWorkItemDetailsCache(detailsCacheKey, {
details: result,
fetchedAt: Date.now(),
error: undefined
})
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load details'
const invalidatedMidFlight = workItemDetailsCacheGeneration.current !== launchedAtGeneration
const prev = workItemDetailsCache.get(detailsCacheKey)
if (invalidatedMidFlight && prev?.pending !== inflight) {
return
}
// Why: stale-on-error — keep cached data, drop the pending promise so next open retries; surface the error only when nothing is cached.
touchWorkItemDetailsCache(detailsCacheKey, {
details: prev?.details ?? null,
fetchedAt: prev?.fetchedAt ?? 0,
error: message
})
})
}, [
canUseDetailsRepoContext,
repoPath,
effectiveRepoId,
sourceContext,
workItem,
detailsCacheKey,
refetchTick
])
const appendOptimisticComment = useCallback(
(comment: PRComment) => {
// Why: skip refreshDetails() — gh api --cache 60s returns stale data that would overwrite the optimistic comment.
optimisticCommentsRef.current.push(comment)
// Why: write through the shared cache so subscribers re-render; fetchedAt=0 forces a background refresh next open for server-side fields.
if (detailsCacheKey) {
const prev = workItemDetailsCache.get(detailsCacheKey)
if (prev?.details) {
const ids = new Set(prev.details.comments.map((c) => c.id))
if (!ids.has(comment.id)) {
touchWorkItemDetailsCache(detailsCacheKey, {
details: {
...prev.details,
comments: [...prev.details.comments, comment]
},
fetchedAt: 0,
error: undefined
})
return
}
}
}
// Why: cache empty (still loading) so no write/notify above; bump local state so the memo re-runs and surfaces the optimistic comment.
setOptimisticTick((n) => n + 1)
},
[detailsCacheKey]
)
return { details, loading, error, detailsLoaded, detailsCacheKey, appendOptimisticComment }
}
@@ -0,0 +1,76 @@
import { toast } from 'sonner'
import { setPRFileViewedForRepo } from '@/components/github/github-work-item-comment-mutations'
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
import { translate } from '@/i18n/i18n'
import type { GitHubPRFileViewedState } from '../../../../../shared/github/pull-request-types'
import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { patchCachedPRFileViewedState } from '../cache/work-item-details'
export async function syncPullRequestFileViewed(args: {
canUseDetailsRepoContext: boolean
details: GitHubWorkItemDetails | null
workItem: GitHubWorkItem | null
path: string
viewed: boolean
detailsCacheKey: string | null
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: PullRequestPageProjectOrigin | undefined
setPendingViewedPaths: (updater: (prev: Set<string>) => Set<string>) => void
}): Promise<boolean> {
if (
!args.canUseDetailsRepoContext ||
!args.details?.pullRequestId ||
!args.workItem ||
args.workItem.type !== 'pr'
) {
toast.error(
translate(
'auto.components.PullRequestPage.996a1897d2',
'Unable to sync viewed state for this pull request.'
)
)
return false
}
args.setPendingViewedPaths((prev) => new Set(prev).add(args.path))
const nextState: GitHubPRFileViewedState = args.viewed ? 'VIEWED' : 'UNVIEWED'
const previousState = args.detailsCacheKey
? patchCachedPRFileViewedState(args.detailsCacheKey, args.path, nextState)
: undefined
try {
const ok = await setPRFileViewedForRepo({
repoId: args.workItem.repoId,
repoPath: args.repoPath ?? '',
sourceContext: args.sourceContext,
prNumber: args.workItem.number,
prRepo: resolvePullRequestRepo(args.workItem, args.projectOrigin),
pullRequestId: args.details.pullRequestId,
path: args.path,
viewed: args.viewed
})
if (!ok) {
if (args.detailsCacheKey && previousState) {
patchCachedPRFileViewedState(args.detailsCacheKey, args.path, previousState)
}
toast.error(
translate(
'auto.components.PullRequestPage.5a01ca7253',
'Failed to sync viewed state with GitHub.'
)
)
return false
}
return true
} finally {
args.setPendingViewedPaths((prev) => {
const next = new Set(prev)
next.delete(args.path)
return next
})
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { getStateTone } from './state-badge'
function item(type: GitHubWorkItem['type'], state: GitHubWorkItem['state']): GitHubWorkItem {
return { type, state } as GitHubWorkItem
}
describe('getStateTone', () => {
it('uses purple/slate/rose/emerald for pull request states', () => {
expect(getStateTone(item('pr', 'merged'))).toContain('purple')
expect(getStateTone(item('pr', 'draft'))).toContain('slate')
expect(getStateTone(item('pr', 'closed'))).toContain('rose')
expect(getStateTone(item('pr', 'open'))).toContain('emerald')
})
it('uses rose for a closed issue and emerald otherwise', () => {
expect(getStateTone(item('issue', 'closed'))).toContain('rose')
expect(getStateTone(item('issue', 'open'))).toContain('emerald')
})
})
@@ -0,0 +1,43 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { getStateLabel } from '@/components/github/work-item-state-presentation'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
export function getStateTone(item: GitHubWorkItem): string {
if (item.type === 'pr') {
if (item.state === 'merged') {
return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300'
}
if (item.state === 'draft') {
return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300'
}
if (item.state === 'closed') {
return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
}
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
}
if (item.state === 'closed') {
return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
}
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
}
export function WorkItemStateBadge({
item,
className
}: {
item: GitHubWorkItem
className?: string
}): React.JSX.Element {
return (
<span
className={cn(
'inline-flex h-5 items-center rounded-full border px-2 text-[11px] font-medium',
getStateTone(item),
className
)}
>
{getStateLabel(item)}
</span>
)
}
@@ -0,0 +1,322 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { useRepoAssignees } from '@/hooks/useIssueMetadata'
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity'
import { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display'
import { mergeReviewerSuggestions } from '@/components/github/work-item-state-presentation'
import {
filterGitHubPRReviewerCandidates,
getGitHubPRReviewerQueryState
} from '@/components/github/github-pr-reviewer-candidate-filter'
import { translate } from '@/i18n/i18n'
import type { PullRequestPageProjectOrigin } from '../page-types'
import { createReviewerRequestActions } from './request-actions'
import { ReviewerPicker } from './picker'
import { ReviewerRequestedList } from './requested-list'
export function PRReviewersPanel({
item,
loading,
repoPath,
sourceContext,
projectOrigin,
onReviewersRequested
}: {
item: GitHubWorkItem
loading: boolean
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin?: PullRequestPageProjectOrigin
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [reviewerInput, setReviewerInput] = useState('')
const [activeReviewerCursor, setActiveReviewerCursor] = useState({
resetKey: '',
index: 0
})
const [submitting, setSubmitting] = useState(false)
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
() => item.reviewRequests ?? []
)
const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({
itemId: item.id,
repoId: item.repoId,
reviewRequests: item.reviewRequests
}))
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const reviewerPanelMountedRef = useRef(true)
const cancelReviewerInputFocusFrame = useCallback((): void => {
if (reviewerInputFocusFrameRef.current !== null) {
cancelAnimationFrame(reviewerInputFocusFrameRef.current)
reviewerInputFocusFrameRef.current = null
}
}, [])
const scheduleReviewerInputFocus = useCallback((): void => {
if (!reviewerPanelMountedRef.current) {
return
}
cancelReviewerInputFocusFrame()
reviewerInputFocusFrameRef.current = requestAnimationFrame(() => {
reviewerInputFocusFrameRef.current = null
reviewerInputRef.current?.focus()
})
}, [cancelReviewerInputFocusFrame])
useEffect(() => {
reviewerPanelMountedRef.current = true
return () => {
reviewerPanelMountedRef.current = false
cancelReviewerInputFocusFrame()
}
}, [cancelReviewerInputFocusFrame])
// Why: clear stale optimistic review requests before paint on item switch/refetch (a passive Effect leaves one stale render).
if (
reviewRequestsSource.itemId !== item.id ||
reviewRequestsSource.repoId !== item.repoId ||
reviewRequestsSource.reviewRequests !== item.reviewRequests
) {
setReviewRequestsSource({
itemId: item.id,
repoId: item.repoId,
reviewRequests: item.reviewRequests
})
setLocalReviewRequests(item.reviewRequests ?? [])
}
const reviewerSeedUsers = useMemo<GitHubAssignableUser[]>(() => {
const byLogin = new Map<string, GitHubAssignableUser>()
const add = (user: GitHubAssignableUser): void => {
if (!user.login) {
return
}
byLogin.set(user.login.toLowerCase(), user)
}
for (const user of localReviewRequests) {
add(user)
}
for (const review of item.latestReviews ?? []) {
add({
login: review.login,
name: null,
avatarUrl: review.avatarUrl ?? ''
})
}
if (item.author) {
add({ login: item.author, name: null, avatarUrl: '' })
}
return Array.from(byLogin.values())
}, [item.author, item.latestReviews, localReviewRequests])
const reviewRepo = useMemo(
() => resolvePullRequestRepo(item, projectOrigin),
[item, projectOrigin]
)
const reviewerMetadataBySlug = useRepoAssigneesBySlug(
open && reviewRepo ? reviewRepo.owner : null,
open && reviewRepo ? reviewRepo.repo : null,
reviewerSeedUsers.map((user) => user.login),
sourceSettings,
reviewRepo?.host
)
const reviewerMetadataByPath = useRepoAssignees(
open && !reviewRepo ? repoPath : null,
open && !reviewRepo ? item.repoId : null,
sourceSettings
)
const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath
const displayItem = { ...item, reviewRequests: localReviewRequests }
const reviewers = getGitHubPRReviewerRows(displayItem)
const authorLogin = item.author?.toLowerCase() ?? null
const reviewerCandidates = useMemo(
() =>
mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter(
(user) => user.login.toLowerCase() !== authorLogin
),
[authorLogin, reviewerMetadata.data, reviewerSeedUsers]
)
const reviewerCandidatesByLogin = useMemo(
() => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])),
[reviewerCandidates]
)
const selectedReviewerLogins = useMemo(
() =>
new Set(
localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
),
[localReviewRequests]
)
const reviewerQueryState = useMemo(
() => getGitHubPRReviewerQueryState(reviewerInput),
[reviewerInput]
)
const reviewerQuery = reviewerQueryState.query
const filteredReviewerCandidates = useMemo(
() =>
filterGitHubPRReviewerCandidates({
candidates: reviewerCandidates,
queryState: reviewerQueryState
}),
[reviewerCandidates, reviewerQueryState]
)
const suggestedReviewerRows = useMemo(
() =>
reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge
? reviewerSeedUsers
.filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase()))
.filter((user) => user.login.toLowerCase() !== authorLogin)
.map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user)
.slice(0, 1)
: [],
[
authorLogin,
reviewerCandidatesByLogin,
reviewerQuery.length,
reviewerQueryState.isTooLarge,
reviewerSeedUsers,
selectedReviewerLogins
]
)
const everyoneElseReviewerRows = useMemo(() => {
const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase()))
return filteredReviewerCandidates.filter(
(user) => !suggestedLogins.has(user.login.toLowerCase())
)
}, [filteredReviewerCandidates, suggestedReviewerRows])
const actionableReviewerRows = useMemo(
() => [...suggestedReviewerRows, ...everyoneElseReviewerRows],
[everyoneElseReviewerRows, suggestedReviewerRows]
)
const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}`
if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) {
setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 })
}
const activeReviewerIndex =
activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0
const setActiveReviewerIndex = useCallback(
(nextIndex: number | ((current: number) => number)): void => {
setActiveReviewerCursor((current) => {
const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0
return {
resetKey: reviewerCursorResetKey,
index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex
}
})
},
[reviewerCursorResetKey]
)
const hasReviewerMetadata =
item.reviewDecision !== undefined ||
localReviewRequests.length > 0 ||
item.reviewRequests !== undefined ||
item.latestReviews !== undefined
const canRequestReview =
!!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment'
const { handleRequestReview, handleRemoveReviewers, requestReviewer } =
createReviewerRequestActions({
submitting,
setSubmitting,
reviewerInput,
setReviewerInput,
selectedReviewerLogins,
localReviewRequests,
setLocalReviewRequests,
sourceSettings,
repoPath,
sourceContext,
item,
reviewRepo,
reviewerPanelMountedRef,
reviewerCandidates,
patchWorkItem,
onReviewersRequested,
scheduleReviewerInputFocus
})
const handleReviewerPickerOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
scheduleReviewerInputFocus()
return
}
setReviewerInput('')
}
return (
<section>
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}</span>
<ReviewerPicker
open={open}
submitting={submitting}
canRequestReview={canRequestReview}
reviewerInput={reviewerInput}
reviewerInputRef={reviewerInputRef}
reviewerMetadataLoading={reviewerMetadata.loading}
reviewerMetadataError={reviewerMetadata.error}
hasReviewerMetadata={hasReviewerMetadata}
filteredReviewerCandidates={filteredReviewerCandidates}
suggestedReviewerRows={suggestedReviewerRows}
everyoneElseReviewerRows={everyoneElseReviewerRows}
actionableReviewerRows={actionableReviewerRows}
activeReviewerIndex={activeReviewerIndex}
selectedReviewerLogins={selectedReviewerLogins}
onOpenChange={handleReviewerPickerOpenChange}
onInputChange={setReviewerInput}
onActiveIndexChange={setActiveReviewerIndex}
onEnter={() => {
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
if (activeReviewer) {
void requestReviewer(activeReviewer)
return
}
void handleRequestReview()
}}
onRequestReviewer={(reviewer) => {
void requestReviewer(reviewer)
}}
/>
</div>
<ReviewerRequestedList
reviewers={reviewers}
loading={loading}
hasReviewerMetadata={hasReviewerMetadata}
selectedReviewerLogins={selectedReviewerLogins}
submitting={submitting}
canRequestReview={canRequestReview}
onRemove={(login) => {
void handleRemoveReviewers([login])
}}
/>
</section>
)
}
@@ -0,0 +1,79 @@
import React from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
export function ReviewerPickerRow({
reviewer,
suggested,
active,
selected,
activeIndex,
onHover,
onRequest
}: {
reviewer: GitHubAssignableUser
suggested: boolean
active: boolean
selected: boolean
activeIndex: number
onHover: (index: number) => void
onRequest: (reviewer: GitHubAssignableUser) => void
}): React.JSX.Element {
return (
<button
key={`${suggested ? 'suggested' : 'reviewer'}:${reviewer.login}`}
type="button"
aria-label={
selected
? translate(
'auto.components.PullRequestPage.36b514a457',
'Unrequest reviewer {{value0}}',
{ value0: reviewer.login }
)
: translate('auto.components.PullRequestPage.41d275d3ec', 'Request reviewer {{value0}}', {
value0: reviewer.login
})
}
aria-pressed={selected}
className={cn(
'flex min-h-10 w-full items-center gap-2 border-b border-border/70 px-3 py-2 text-left text-[13px] outline-none last:border-b-0 hover:bg-accent/70 focus-visible:bg-accent focus-visible:text-accent-foreground',
active && 'bg-accent text-accent-foreground',
selected && 'font-medium'
)}
onMouseEnter={() => onHover(activeIndex)}
onMouseDown={(event) => {
event.preventDefault()
}}
onFocus={() => onHover(activeIndex)}
onClick={() => {
onRequest(reviewer)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-foreground">
{selected ? <Check className="size-3.5" /> : null}
</span>
{reviewer.avatarUrl ? (
<img src={reviewer.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
{reviewer.login.slice(0, 1).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate">
<span className="font-semibold text-foreground">{reviewer.login}</span>
{reviewer.name ? (
<span className="ml-1 font-normal text-muted-foreground">{reviewer.name}</span>
) : null}
</span>
{suggested ? (
<span className="block truncate text-[12px] leading-4 text-muted-foreground">
{translate('auto.components.PullRequestPage.f4a4b3fd9f', 'Recently edited these files')}
</span>
) : null}
</span>
</button>
)
}
@@ -0,0 +1,184 @@
import React from 'react'
import { LoaderCircle, Pencil } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
import { ReviewerPickerRow } from './picker-row'
export function ReviewerPicker({
open,
submitting,
canRequestReview,
reviewerInput,
reviewerInputRef,
reviewerMetadataLoading,
reviewerMetadataError,
hasReviewerMetadata,
filteredReviewerCandidates,
suggestedReviewerRows,
everyoneElseReviewerRows,
actionableReviewerRows,
activeReviewerIndex,
selectedReviewerLogins,
onOpenChange,
onInputChange,
onActiveIndexChange,
onEnter,
onRequestReviewer
}: {
open: boolean
submitting: boolean
canRequestReview: boolean
reviewerInput: string
reviewerInputRef: React.RefObject<HTMLInputElement | null>
reviewerMetadataLoading: boolean
reviewerMetadataError?: string | null
hasReviewerMetadata: boolean
filteredReviewerCandidates: GitHubAssignableUser[]
suggestedReviewerRows: GitHubAssignableUser[]
everyoneElseReviewerRows: GitHubAssignableUser[]
actionableReviewerRows: GitHubAssignableUser[]
activeReviewerIndex: number
selectedReviewerLogins: Set<string>
onOpenChange: (nextOpen: boolean) => void
onInputChange: (value: string) => void
onActiveIndexChange: (nextIndex: number | ((current: number) => number)) => void
onEnter: () => void
onRequestReviewer: (reviewer: GitHubAssignableUser) => void
}): React.JSX.Element {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
disabled={submitting || !canRequestReview}
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{submitting ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<Pencil className="size-3" />
)}
</button>
</PopoverTrigger>
<PopoverContent
className="flex max-h-[420px] w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
align="end"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<div className="border-b border-border/70 p-2">
<Input
ref={reviewerInputRef}
value={reviewerInput}
onChange={(event) => onInputChange(event.target.value)}
disabled={submitting || !canRequestReview}
placeholder={translate(
'auto.components.PullRequestPage.3bde131f49',
'Type or choose a user'
)}
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
aria-expanded={open}
aria-haspopup="listbox"
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
onActiveIndexChange((current) => (current + 1) % actionableReviewerRows.length)
return
}
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
event.preventDefault()
onActiveIndexChange(
(current) =>
(current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length
)
return
}
if (event.key === 'Enter') {
event.preventDefault()
onEnter()
return
}
if (event.key === 'Escape') {
event.preventDefault()
onOpenChange(false)
}
}}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{reviewerMetadataLoading ? (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.PullRequestPage.57750f4a8c', 'Loading...')}
</div>
) : filteredReviewerCandidates.length > 0 ? (
<>
{suggestedReviewerRows.length > 0 ? (
<>
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
{translate('auto.components.PullRequestPage.828f045847', 'Suggestions')}
</div>
{suggestedReviewerRows.map((reviewer, index) => (
<ReviewerPickerRow
key={`suggested:${reviewer.login}`}
reviewer={reviewer}
suggested
active={actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login}
selected={selectedReviewerLogins.has(reviewer.login.toLowerCase())}
activeIndex={index}
onHover={onActiveIndexChange}
onRequest={onRequestReviewer}
/>
))}
</>
) : null}
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
{translate('auto.components.PullRequestPage.2760fa29a4', 'Everyone else')}
</div>
{everyoneElseReviewerRows.length > 0 ? (
everyoneElseReviewerRows.map((reviewer, index) => (
<ReviewerPickerRow
key={`reviewer:${reviewer.login}`}
reviewer={reviewer}
suggested={false}
active={actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login}
selected={selectedReviewerLogins.has(reviewer.login.toLowerCase())}
activeIndex={suggestedReviewerRows.length + index}
onHover={onActiveIndexChange}
onRequest={onRequestReviewer}
/>
))
) : (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{translate(
'auto.components.PullRequestPage.5ad00c7a0e',
'No matching reviewers.'
)}
</div>
)}
</>
) : (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{reviewerMetadataError ??
(hasReviewerMetadata
? translate(
'auto.components.PullRequestPage.5ad00c7a0e',
'No matching reviewers.'
)
: translate(
'auto.components.PullRequestPage.56ec6eafb7',
'Open the PR details to view current reviewers.'
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,260 @@
import { toast } from 'sonner'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
normalizeGitHubReviewerLogins,
parseGitHubReviewerInputLogins
} from '@/components/github-pr-reviewer-display'
import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations'
import { buildRequestedReviewUsers } from '@/components/github/work-item-state-presentation'
import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context'
import { translate } from '@/i18n/i18n'
import type {
GitHubAssignableUser,
GitHubOwnerRepo
} from '../../../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
type ReviewerRequestActionsArgs = {
submitting: boolean
setSubmitting: (value: boolean) => void
reviewerInput: string
setReviewerInput: (value: string) => void
selectedReviewerLogins: Set<string>
localReviewRequests: GitHubAssignableUser[]
setLocalReviewRequests: (value: GitHubAssignableUser[]) => void
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
repoPath: string | null
sourceContext?: TaskSourceContext | null
item: GitHubWorkItem
reviewRepo: GitHubOwnerRepo | null
reviewerPanelMountedRef: { current: boolean }
reviewerCandidates: GitHubAssignableUser[]
patchWorkItem: (
id: string,
patch: Partial<GitHubWorkItem>,
repoId?: string | null,
options?: { sourceContext?: TaskSourceContext | null }
) => void
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
scheduleReviewerInputFocus: () => void
}
export function createReviewerRequestActions(args: ReviewerRequestActionsArgs): {
handleRequestReview: (requestedLogins?: string[]) => Promise<void>
handleRemoveReviewers: (reviewersToRemove: string[]) => Promise<void>
requestReviewer: (reviewer: GitHubAssignableUser) => Promise<void>
} {
const handleRequestReview = async (requestedLogins?: string[]): Promise<void> => {
if (args.submitting) {
return
}
const logins = normalizeGitHubReviewerLogins(
requestedLogins ?? parseGitHubReviewerInputLogins(args.reviewerInput),
args.selectedReviewerLogins
)
if (logins.length === 0) {
toast.error(translate('auto.components.PullRequestPage.dace0d1a9f', 'Enter a reviewer'))
return
}
if (args.localReviewRequests.length + logins.length > 15) {
toast.error(
translate(
'auto.components.PullRequestPage.8f369a6b6b',
'You can request up to 15 reviewers'
)
)
return
}
const target = getActiveRuntimeTarget(args.sourceSettings)
if (target.kind !== 'environment' && !args.repoPath) {
toast.error(
translate(
'auto.components.PullRequestPage.1ae11c905c',
'No repo context available for this pull request.'
)
)
return
}
args.setSubmitting(true)
try {
const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)
const result =
target.kind === 'environment'
? await callRuntimeRpc<{ ok: boolean; error?: string }>(
target,
'github.requestPRReviewers',
{
repo: runtimeRepo,
prNumber: args.item.number,
reviewers: logins,
prRepo: args.reviewRepo
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.requestPRReviewers({
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
prNumber: args.item.number,
reviewers: logins,
prRepo: args.reviewRepo
})
if (!args.reviewerPanelMountedRef.current) {
return
}
if (!result.ok) {
toast.error(
result.error ??
translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer')
)
return
}
const nextReviewRequests = buildRequestedReviewUsers(
logins,
args.reviewerCandidates,
args.localReviewRequests
)
args.setLocalReviewRequests(nextReviewRequests)
args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.onReviewersRequested(nextReviewRequests)
if (target.kind === 'environment') {
notifyWorkItemDetailsMutation(
{
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
type: 'pr',
number: args.item.number
},
{ local: false }
)
}
args.setReviewerInput('')
toast.success(
logins.length === 1
? translate('auto.components.PullRequestPage.03282ff3b9', 'Reviewer requested')
: translate('auto.components.PullRequestPage.102d3d177f', 'Reviewers requested')
)
} catch {
if (args.reviewerPanelMountedRef.current) {
toast.error(
translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer')
)
}
} finally {
if (args.reviewerPanelMountedRef.current) {
args.setSubmitting(false)
}
}
}
const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise<void> => {
if (args.submitting) {
return
}
const selected = new Set(
args.localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())
)
const logins = reviewersToRemove
.map((reviewer) => reviewer.trim().replace(/^@/, ''))
.filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase()))
if (logins.length === 0) {
return
}
const target = getActiveRuntimeTarget(args.sourceSettings)
if (target.kind !== 'environment' && !args.repoPath) {
toast.error(
translate(
'auto.components.PullRequestPage.1ae11c905c',
'No repo context available for this pull request.'
)
)
return
}
args.setSubmitting(true)
try {
const runtimeRepo = getGitHubRuntimeRepoId(args.sourceContext, args.item.repoId)
const result =
target.kind === 'environment'
? await callRuntimeRpc<{ ok: boolean; error?: string }>(
target,
'github.removePRReviewers',
{
repo: runtimeRepo,
prNumber: args.item.number,
reviewers: logins,
prRepo: args.reviewRepo
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.removePRReviewers({
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
prNumber: args.item.number,
reviewers: logins,
prRepo: args.reviewRepo
})
if (!args.reviewerPanelMountedRef.current) {
return
}
if (!result.ok) {
toast.error(
result.error ??
translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer')
)
return
}
const removed = new Set(logins.map((login) => login.toLowerCase()))
const nextReviewRequests = args.localReviewRequests.filter(
(reviewer) => !removed.has(reviewer.login.toLowerCase())
)
args.setLocalReviewRequests(nextReviewRequests)
args.patchWorkItem(args.item.id, { reviewRequests: nextReviewRequests }, args.item.repoId, {
sourceContext: args.sourceContext
})
args.onReviewersRequested(nextReviewRequests)
if (target.kind === 'environment') {
notifyWorkItemDetailsMutation(
{
repoPath: args.repoPath ?? '',
repoId: args.item.repoId,
sourceContext: args.sourceContext,
type: 'pr',
number: args.item.number
},
{ local: false }
)
}
args.setReviewerInput('')
toast.success(
logins.length === 1
? translate('auto.components.PullRequestPage.2c1d93da43', 'Reviewer removed')
: translate('auto.components.PullRequestPage.1e6d089420', 'Reviewers removed')
)
} catch {
if (args.reviewerPanelMountedRef.current) {
toast.error(
translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer')
)
}
} finally {
if (args.reviewerPanelMountedRef.current) {
args.setSubmitting(false)
}
}
}
const requestReviewer = async (reviewer: GitHubAssignableUser): Promise<void> => {
await (args.selectedReviewerLogins.has(reviewer.login.toLowerCase())
? handleRemoveReviewers([reviewer.login])
: handleRequestReview([reviewer.login]))
args.scheduleReviewerInputFocus()
}
return { handleRequestReview, handleRemoveReviewers, requestReviewer }
}
@@ -0,0 +1,90 @@
import React from 'react'
import { LoaderCircle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ReviewerAvatar } from '@/components/github/work-item-state-presentation'
import { translate } from '@/i18n/i18n'
import type { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display'
export function ReviewerRequestedList({
reviewers,
loading,
hasReviewerMetadata,
selectedReviewerLogins,
submitting,
canRequestReview,
onRemove
}: {
reviewers: ReturnType<typeof getGitHubPRReviewerRows>
loading: boolean
hasReviewerMetadata: boolean
selectedReviewerLogins: Set<string>
submitting: boolean
canRequestReview: boolean
onRemove: (login: string) => void
}): React.JSX.Element {
if (loading && !hasReviewerMetadata) {
return (
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
</div>
)
}
if (reviewers.length === 0) {
return (
<div className="py-1 text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
</div>
)
}
return (
<div className="flex flex-col gap-2">
{reviewers.map((reviewer) => {
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
return (
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{reviewer.login}
</div>
{reviewer.name ? (
<div className="truncate text-[11px] text-muted-foreground">{reviewer.name}</div>
) : null}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{reviewer.stateLabel}
</span>
{canRemoveReviewer ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
disabled={submitting || !canRequestReview}
aria-label={translate(
'auto.components.PullRequestPage.ae9a38fd4a',
'Remove reviewer {{value0}}',
{ value0: reviewer.login }
)}
onClick={() => {
onRemove(reviewer.login)
}}
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
})}
</div>
)
}