From 920bd38dbe7e6b318e47d923c2a0ee37a80e4358 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:09:11 -0700 Subject: [PATCH] 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. --- .orca-agent/split-pull-request-page-plan.md | 81 + config/max-lines-baseline.txt | 1 - .../src/components/PullRequestPage.tsx | 5892 +---------------- .../repro-8784-ghe-avatar-fallback.test.ts | 2 +- .../pull-request-page-host-boundary.test.ts | 299 +- .../actions/merge-actions.ts | 258 + .../pull-request-page/actions/panel.tsx | 244 + .../pull-request-page/cache/file-content.ts | 153 + .../cache/files-diff-view.ts | 15 + .../cache/work-item-details.ts | 194 + .../checks/details-request.ts | 98 + .../pull-request-page/checks/details.tsx | 229 + .../pull-request-page/checks/fix-dialog.tsx | 77 + .../pull-request-page/checks/fix-launch.ts | 138 + .../pull-request-page/checks/layouts.tsx | 135 + .../pull-request-page/checks/refresh.ts | 117 + .../pull-request-page/checks/rerun.ts | 86 + .../pull-request-page/checks/row.tsx | 59 + .../pull-request-page/checks/tab.tsx | 358 + .../pull-request-page/checks/toolbar.tsx | 203 + .../pull-request-page/comments/composer.tsx | 171 + .../pull-request-page/comments/reply-form.tsx | 102 + .../conversation/comment-card.tsx | 186 + .../conversation/comment-group.tsx | 110 + .../conversation/comments-list.tsx | 130 + .../conversation/description.tsx | 152 + .../pull-request-page/conversation/reply.ts | 77 + .../pull-request-page/conversation/tab.tsx | 367 + .../pull-request-page/edit/issue-updates.ts | 235 + .../pull-request-page/edit/section.tsx | 369 ++ .../files/combined-diff-viewer.tsx | 390 ++ .../files/inline-comments.ts | 36 + .../pull-request-page/files/line-comment.ts | 53 + .../pull-request-page/files/section-loader.ts | 220 + .../pull-request-page/files/toolbar.tsx | 77 + .../pull-request-page/files/view-restore.ts | 118 + .../mentions/options.test.ts | 55 + .../pull-request-page/mentions/options.ts | 53 + .../pull-request-page/mentions/query.test.ts | 22 + .../pull-request-page/mentions/query.ts | 14 + .../pull-request-page/mentions/textarea.tsx | 148 + .../pull-request-page/page-types.ts | 39 + .../pull-request-page/page/header.tsx | 274 + .../pull-request-page/page/surface.tsx | 384 ++ .../pull-request-page/page/tabs-shell.tsx | 226 + .../pull-request-page/page/use-details.ts | 244 + .../pull-request-page/page/viewed-sync.ts | 76 + .../presentation/state-badge.test.ts | 21 + .../presentation/state-badge.tsx | 43 + .../pull-request-page/reviewers/panel.tsx | 322 + .../reviewers/picker-row.tsx | 79 + .../pull-request-page/reviewers/picker.tsx | 184 + .../reviewers/request-actions.ts | 260 + .../reviewers/requested-list.tsx | 90 + 54 files changed, 7929 insertions(+), 6037 deletions(-) create mode 100644 .orca-agent/split-pull-request-page-plan.md create mode 100644 src/renderer/src/components/pull-request-page/actions/merge-actions.ts create mode 100644 src/renderer/src/components/pull-request-page/actions/panel.tsx create mode 100644 src/renderer/src/components/pull-request-page/cache/file-content.ts create mode 100644 src/renderer/src/components/pull-request-page/cache/files-diff-view.ts create mode 100644 src/renderer/src/components/pull-request-page/cache/work-item-details.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/details-request.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/details.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/fix-launch.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/layouts.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/refresh.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/rerun.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/row.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/tab.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/toolbar.tsx create mode 100644 src/renderer/src/components/pull-request-page/comments/composer.tsx create mode 100644 src/renderer/src/components/pull-request-page/comments/reply-form.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comment-card.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comment-group.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comments-list.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/description.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/reply.ts create mode 100644 src/renderer/src/components/pull-request-page/conversation/tab.tsx create mode 100644 src/renderer/src/components/pull-request-page/edit/issue-updates.ts create mode 100644 src/renderer/src/components/pull-request-page/edit/section.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/inline-comments.ts create mode 100644 src/renderer/src/components/pull-request-page/files/line-comment.ts create mode 100644 src/renderer/src/components/pull-request-page/files/section-loader.ts create mode 100644 src/renderer/src/components/pull-request-page/files/toolbar.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/view-restore.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/options.test.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/options.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/query.test.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/query.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/textarea.tsx create mode 100644 src/renderer/src/components/pull-request-page/page-types.ts create mode 100644 src/renderer/src/components/pull-request-page/page/header.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/surface.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/tabs-shell.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/use-details.ts create mode 100644 src/renderer/src/components/pull-request-page/page/viewed-sync.ts create mode 100644 src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts create mode 100644 src/renderer/src/components/pull-request-page/presentation/state-badge.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/panel.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/picker.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts create mode 100644 src/renderer/src/components/pull-request-page/reviewers/requested-list.tsx diff --git a/.orca-agent/split-pull-request-page-plan.md b/.orca-agent/split-pull-request-page-plan.md new file mode 100644 index 00000000000..3a901b47773 --- /dev/null +++ b/.orca-agent/split-pull-request-page-plan.md @@ -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} +``` diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 516bbeafcbb..d7af7e7e4d4 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 72f5d151eb2..12db9c71fd9 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,5888 +1,4 @@ -/* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useShallow } from 'zustand/react/shallow' -import type { editor as monacoEditor } from 'monaco-editor' -import { - ArrowLeft, - ArrowRight, - Check, - ChevronDown, - ChevronLeft, - CircleDashed, - CircleDot, - Copy, - ExternalLink, - FileText, - FolderKanban, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - GitPullRequestDraft, - ListChecks, - LoaderCircle, - MessageSquare, - MessageSquarePlus, - PanelLeftOpen, - Pencil, - Plus, - RefreshCw, - Send, - Wrench, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { Input } from '@/components/ui/input' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger -} from '@/components/ui/accordion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { setWithLRU } from '@/lib/scroll-cache' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -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 { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' -import { - getStoredTextDiffContent, - getStoredTextDiffResult -} from '@/components/editor/large-diff-section-content' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' -import { - beginGitHubChecksTabDetails, - createGitHubChecksTabState, - resetGitHubChecksTabForSource, - resolveGitHubChecksTabState, - settleGitHubChecksTabDetails, - toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabLocalChecks, - type CheckDetailsLoadState -} from '@/components/github-checks-tab-state' -import { - clearGitHubLinkCopied, - createGitHubLinkCopyState, - markGitHubLinkCopied, - resolveGitHubLinkCopyState -} from '@/components/github-link-copy-state' -import { - resolveGitHubBodyDraft, - shouldSyncGitHubBodyDraft -} from '@/components/github-body-draft-state' -import { - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - type PRCommentAudienceFilter -} from '../../../shared/pr-comment-audience' -import { - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters -} from '@/lib/pr-comment-audience-labels' -import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupCount, - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - isResolvedPRCommentGroup, - type PRCommentGroup -} from '../../../shared/pr-comment-groups' -import { - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS -} from '@/lib/pr-comment-resolution-classes' -import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' -import { - attachPRReviewReplyParent, - canPostPRReviewThreadReply -} from '@/components/right-sidebar/pr-comments-ai-launch-ack' -import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' -import { useAppStore } from '@/store' -import { useAllWorktrees } from '@/store/selectors' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' -import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { - getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins, - parseGitHubReviewerInputLogins -} from '@/components/github-pr-reviewer-display' -import { - filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState -} from '@/components/github/github-pr-reviewer-candidate-filter' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' -import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' -import { - canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, - getGitHubRuntimeRepoId, - getGitHubSourceRuntimeHost -} from '@/lib/github-source-runtime-context' -import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { - GITHUB_PR_MERGE_METHOD_LABELS, - resolveGitHubPRMergeMethods -} from '../../../shared/github/pull-request-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' -import { - findGithubPrWorkspaceAttachment, - getGithubPrWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' -import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' -import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' -import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../shared/source-control-ai-actions' -import { - saveSourceControlActionRecipe, - type SourceControlAiWriteTarget -} from '../../../shared/source-control-ai-recipe-save' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' -import type { PRCheckDetail } from '../../../shared/github/check-types' -import type { PRComment } from '../../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubPRMergeMethod -} from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' -import { - getCheckConclusion, - getCheckCountChips, - getCheckCounts, - getChecksSummaryLabel -} from '@/components/pr-check-counts' -import { - normalizeItemDialogTab, - parseOwnerRepoFromItemUrl, - resolvePullRequestRepo, - type GitHubWorkItemProjectOrigin, - type ItemDialogTab -} from '@/components/github/github-work-item-identity' -import { - addIssueCommentForRepo, - addPRReviewCommentForRepo, - addPRReviewCommentReplyForRepo, - notifyWorkItemDetailsMutation, - setPRFileViewedForRepo -} from '@/components/github/github-work-item-comment-mutations' -import { - runIssueUpdate, - runPullRequestStateUpdate, - runWorkItemBodyUpdate -} from '@/components/github/github-work-item-edit-mutations' -import { - PR_FILE_CONTENT_CACHE_MAX_BYTES, - getRetainedPRFileContentsByteCount, - isPRFileViewed -} from '@/components/github/pr-file-content-size' -import { - PR_DIFF_OVERSCAN, - getPRFileContentsRenderLimit, - getPRFileDiffResult, - getPRFileSectionKey, - gitHubPRFileToBranchEntry, - type PRFilesCombinedDiffViewerProps -} from '@/components/github/pr-file-diff-mapping' -import { - buildRequestedReviewUsers, - formatRelativeTime, - getStateLabel, - mergeReviewerSuggestions, - ReviewerAvatar -} from '@/components/github/work-item-state-presentation' -import { - formatCheckTimestamp, - getCheckDetailsKey, - getCheckStatusLabel -} from '@/components/github/pr-check-presentation' -import { CommentCodeContext } from '@/components/github/CommentCodeContext' -import { CommentReactions } from '@/components/github/CommentReactions' -import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' -import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' - -export type { ItemDialogTab } - -export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin - -type MentionOption = { - login: string - name?: string | null - avatarUrl?: string - source: string -} - -type MentionQuery = { - atIndex: number - query: string -} - -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 -} - -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 - } -} - -function buildMentionOptions({ - item, - comments, - participants, - assignableUsers -}: { - item: GitHubWorkItem - comments: PRComment[] - participants: GitHubAssignableUser[] - assignableUsers: GitHubAssignableUser[] -}): MentionOption[] { - const byLogin = new Map() - 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()) -} - -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' -} - -function WorkItemStateBadge({ - item, - className -}: { - item: GitHubWorkItem - className?: string -}): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) -} - -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( - () => 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(null) - const reviewerInputFocusFrameRef = useRef(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(() => { - const byLogin = new Map() - 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 = async (requestedLogins?: string[]): Promise => { - if (submitting) { - return - } - const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), - selectedReviewerLogins - ) - if (logins.length === 0) { - toast.error(translate('auto.components.PullRequestPage.dace0d1a9f', 'Enter a reviewer')) - return - } - if (localReviewRequests.length + logins.length > 15) { - toast.error( - translate( - 'auto.components.PullRequestPage.8f369a6b6b', - 'You can request up to 15 reviewers' - ) - ) - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.requestPRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.requestPRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - return - } - const nextReviewRequests = buildRequestedReviewUsers( - logins, - reviewerCandidates, - localReviewRequests - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.03282ff3b9', 'Reviewer requested') - : translate('auto.components.PullRequestPage.102d3d177f', 'Reviewers requested') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { - if (submitting) { - return - } - const selected = new Set(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(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.removePRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.removePRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!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 = localReviewRequests.filter( - (reviewer) => !removed.has(reviewer.login.toLowerCase()) - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.2c1d93da43', 'Reviewer removed') - : translate('auto.components.PullRequestPage.1e6d089420', 'Reviewers removed') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { - await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) - ? handleRemoveReviewers([reviewer.login]) - : handleRequestReview([reviewer.login])) - scheduleReviewerInputFocus() - } - - const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - scheduleReviewerInputFocus() - return - } - setReviewerInput('') - } - - const renderReviewerPickerRow = ( - reviewer: GitHubAssignableUser, - options: { suggested: boolean; activeIndex: number } - ): React.JSX.Element => { - const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login - return ( - - ) - } - - return ( -
-
- {translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')} - - - - - { - event.preventDefault() - }} - > -
- setReviewerInput(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() - setActiveReviewerIndex( - (current) => (current + 1) % actionableReviewerRows.length - ) - return - } - if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => - (current - 1 + actionableReviewerRows.length) % - actionableReviewerRows.length - ) - return - } - if (event.key === 'Enter') { - event.preventDefault() - const activeReviewer = actionableReviewerRows[activeReviewerIndex] - if (activeReviewer) { - void requestReviewer(activeReviewer) - return - } - void handleRequestReview() - return - } - if (event.key === 'Escape') { - event.preventDefault() - handleReviewerPickerOpenChange(false) - } - }} - /> -
-
- {reviewerMetadata.loading ? ( -
- {translate('auto.components.PullRequestPage.57750f4a8c', 'Loading...')} -
- ) : filteredReviewerCandidates.length > 0 ? ( - <> - {suggestedReviewerRows.length > 0 ? ( - <> -
- {translate('auto.components.PullRequestPage.828f045847', 'Suggestions')} -
- {suggestedReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: true, - activeIndex: index - }) - )} - - ) : null} -
- {translate('auto.components.PullRequestPage.2760fa29a4', 'Everyone else')} -
- {everyoneElseReviewerRows.length > 0 ? ( - everyoneElseReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: false, - activeIndex: suggestedReviewerRows.length + index - }) - ) - ) : ( -
- {translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - )} -
- )} - - ) : ( -
- {reviewerMetadata.error ?? - (hasReviewerMetadata - ? translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - ) - : translate( - 'auto.components.PullRequestPage.56ec6eafb7', - 'Open the PR details to view current reviewers.' - ))} -
- )} -
-
-
-
- {loading && !hasReviewerMetadata ? ( -
- - {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} -
- ) : reviewers.length > 0 ? ( -
- {reviewers.map((reviewer) => { - const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - return ( -
- -
-
- {reviewer.login} -
- {reviewer.name ? ( -
- {reviewer.name} -
- ) : null} -
- - {reviewer.stateLabel} - - {canRemoveReviewer ? ( - - - - - - {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} - - - ) : null} -
- ) - })} -
- ) : ( -
- {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} -
- )} -
- ) -} - -// 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 -const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' -type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} -const workItemDetailsCache = new Map() - -// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set). -const workItemDetailsCacheListeners = new Set<() => void>() -function subscribeWorkItemDetailsCache(listener: () => void): () => void { - workItemDetailsCacheListeners.add(listener) - return () => { - workItemDetailsCacheListeners.delete(listener) - } -} -function notifyWorkItemDetailsCache(): void { - for (const listener of workItemDetailsCacheListeners) { - listener() - } -} - -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') -} - -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() -} - -// 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 += 1 - const existed = workItemDetailsCache.delete(key) - if (existed) { - notifyWorkItemDetailsCache() - } -} - -// Why: monotonic counter so an in-flight refetch that started before a mutation detects its result is stale and skips the write-back. -let workItemDetailsCacheGeneration = 0 - -// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference. -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 += 1 - notifyWorkItemDetailsCache() - } -} - -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 -} - -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 - }) -} - -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 - }) -} - -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?.() - }) -} - -// 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 - byteCount: number -} -const prFileContentCache = new Map() -let prFileContentCacheBytes = 0 - -function touchPRFileContentCache( - key: string, - value: Promise | 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) - } -} - -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') -} - -function loadPRFileContents(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): Promise { - const cacheKey = getPRFileContentCacheKey(args) - const cached = prFileContentCache.get(cacheKey) - if (cached) { - touchPRFileContentCache(cacheKey, cached.value) - return Promise.resolve(cached.value) - } - let request: Promise - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - request = ( - runtimeHost - ? callRuntimeRpc( - { 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 -} - -type CachedPRFilesDiffViewState = { - entrySignature: string - sections: DiffSection[] - sectionHeights: Record - loadedIndices: number[] - scrollTop: number - sideBySide: boolean - fileTreeCollapsed: boolean - activeTreeSectionKey: string | null -} - -const prFilesDiffViewStateCache = new Map() -const prFilesDiffScrollTopCache = new Map() - -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( - () => - 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 - } - ] - }), - [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([]) - const [sideBySide, setSideBySide] = useState(false) - const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) - const [sectionHeights, setSectionHeights] = useState>({}) - const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) - const scrollContainerRef = useRef(null) - const pendingRestoreScrollTopRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) - const handleSectionSaveRef = useRef<(index: number) => Promise>(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 = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section || section.collapsed) { - return - } - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - const file = fileByPath.get(section.path) - if (!file) { - return - } - const generation = generationRef.current - 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 (!headSha || !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, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - 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 }) => { - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => - prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - ) - }) - }, - [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const retrySection = useCallback( - (index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section - ) - ) - loadSection(index) - }, - [loadSection] - ) - - const toggleSection = useCallback( - (index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section - ) - ) - if (shouldLoadAfterExpand) { - window.requestAnimationFrame(() => loadSection(index)) - } - }, - [loadSection] - ) - - const setAllSectionsCollapsed = useCallback( - (collapsed: boolean) => { - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - window.requestAnimationFrame(() => { - sectionsRef.current.forEach((_, index) => loadSection(index)) - }) - } - }, - [loadSection] - ) - - 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]) - - useEffect(() => { - if (sections.length === 0 && entries.length > 0) { - return - } - const preservedScrollTop = - prFilesDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - entrySignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide, - fileTreeCollapsed, - activeTreeSectionKey - }) - }, [ - activeTreeSectionKey, - entries.length, - entrySignature, - fileTreeCollapsed, - sectionHeights, - sections, - sideBySide, - viewStateKey - ]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - if (!container) { - return - } - - const updateCachedScrollPosition = (): void => { - const existing = prFilesDiffViewStateCache.get(viewStateKey) - setWithLRU(prFilesDiffScrollTopCache, viewStateKey, container.scrollTop) - if (!existing || existing.entrySignature !== entrySignature) { - return - } - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - ...existing, - scrollTop: container.scrollTop - }) - } - - container.addEventListener('scroll', updateCachedScrollPosition) - return () => { - updateCachedScrollPosition() - container.removeEventListener('scroll', updateCachedScrollPosition) - } - }, [entrySignature, viewStateKey]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - const targetScrollTop = pendingRestoreScrollTopRef.current - if (!container || targetScrollTop === null) { - return - } - - let frameId = 0 - let attempts = 0 - const restoreScrollPosition = (): void => { - const liveContainer = scrollContainerRef.current - const liveTarget = 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, viewStateKey, nextScrollTop) - - if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) { - pendingRestoreScrollTopRef.current = null - return - } - - attempts += 1 - if (attempts < 30) { - frameId = window.requestAnimationFrame(restoreScrollPosition) - } - } - - restoreScrollPosition() - return () => window.cancelAnimationFrame(frameId) - }, [sectionHeights, sections, viewStateKey]) - - 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 - } - ) => { - if (!headSha) { - toast.error( - translate( - 'auto.components.PullRequestPage.d8c3ba91c4', - 'Unable to comment without the PR head SHA.' - ) - ) - return false - } - const result = await addPRReviewCommentForRepo({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - commitId: headSha, - path: section.path, - line: lineNumber, - startLine, - body - }) - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.19628e058d', 'Failed to add review comment.') - ) - return false - } - onCommentAdded(result.comment) - toast.success( - translate('auto.components.PullRequestPage.eff839f438', 'Review comment added.') - ) - return true - }, - [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 ( - { - if (!pending) { - void onViewedChange(file.path, !viewed) - } - }} - /> - ) - }, - [fileByPath, onViewedChange, pendingViewedPaths] - ) - - return ( -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate('auto.components.PullRequestPage.319cf2d54b', 'Show file tree')} - - - )} - - {files.filter(isPRFileViewed).length} / {files.length}{' '} - {translate('auto.components.PullRequestPage.89e80af1c7', 'files viewed')} - -
-
- - -
-
-
- -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - return ( -
- - fileByPath.get(section.path)?.reviewCommentLineNumbers - } - setSectionHeights={setSectionHeights} - setSections={setSections} - modifiedEditorsRef={modifiedEditorsRef} - handleSectionSaveRef={handleSectionSaveRef} - /> -
- ) - })} -
-
-
-
- ) -} - -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(null) - const [commentFilter, setCommentFilter] = useState('all') - const [bodyDraft, setBodyDraft] = useState(body) - const [bodyEditing, setBodyEditing] = useState(false) - const [bodySaving, setBodySaving] = useState(false) - const bodyTextareaRef = useRef(null) - const bodyTextareaFocusFrameRef = useRef(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 => { - 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 => { - if (!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 = item.type === 'pr' && canPostPRReviewThreadReply(comment) - const result = isReviewThreadReply - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. - body: buildPRCommentConversationReplyBody(comment.author, replyBody), - type: item.type, - prRepo - }) - - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.5821aab360', 'Failed to post reply.') - ) - return false - } - onCommentAdded( - isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment - ) - setReplyingTo(null) - toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.')) - return true - }, - [ - canUseRepoMutationContext, - item.number, - item.repoId, - item.type, - onCommentAdded, - prRepo, - repoPath, - sourceContext - ] - ) - - const rightPanel = - item.type === 'pr' ? ( -
- - - - -
- ) : null - - const renderCommentCard = (comment: PRComment, isReply = false): React.JSX.Element => ( -
-
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - - · {formatRelativeTime(comment.createdAt)} - - {comment.path && ( - - {comment.path.split('/').pop()} - {comment.line - ? translate('auto.components.PullRequestPage.34b9f7c264', ':L{{value0}}', { - value0: comment.line - }) - : ''} - - )} - {comment.isResolved && ( - - {translate('auto.components.PullRequestPage.76b2a0ac5b', 'resolved')} - - )} -
- - - - - - {translate('auto.components.PullRequestPage.d6c6679de7', 'Reply to comment')} - - - {comment.url && ( - - - - - - {translate('auto.components.PullRequestPage.0ac19bb52e', 'Open comment on GitHub')} - - - )} -
-
-
- - - - {resolvedReplyingTo === comment.id && ( - setReplyingTo(null)} - onSubmit={(replyBody) => handleReply(comment, replyBody)} - /> - )} -
-
- ) - - const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => { - const cards = - group.kind === 'thread' - ? [ - renderCommentCard(group.root), - ...group.replies.map((reply) => renderCommentCard(reply, true)) - ] - : [renderCommentCard(group.comment)] - - if (!isResolvedPRCommentGroup(group)) { - return ( -
- {cards} -
- ) - } - - const root = getPRCommentGroupRoot(group) - const count = getPRCommentGroupCount(group) - return ( - - - - - {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})` : ''} - - - - {cards} - - - - ) - } - - return ( -
-
-
-
- {authorLabel} - - {translate('auto.components.PullRequestPage.169a93b29a', 'updated')}{' '} - {formatRelativeTime(item.updatedAt)} - - {canEditBody && !loading && detailsLoaded ? ( - bodyEditing ? ( -
- - -
- ) : ( - - - - - - {translate('auto.components.PullRequestPage.da9aaa8bcf', 'Edit description')} - - - ) - ) : null} -
-
- {loading && !detailsLoaded ? ( -
- -
- ) : bodyEditing ? ( - { - if (event.key === 'Escape') { - event.preventDefault() - setBodyDraft(body) - setBodyEditing(false) - return - } - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleSaveBody() - } - }} - 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() ? ( - - ) : ( - - {translate( - 'auto.components.PullRequestPage.c8ea6c7c4c', - 'No description provided.' - )} - - )} -
-
- - {detailsLoaded ? ( - <> -
- - - {translate('auto.components.PullRequestPage.3463d10a63', 'Comments')} - - {comments.length > 0 && ( - - {comments.length} - - )} -
- - {item.type === 'pr' && comments.length > 0 && ( -
- {getPrCommentAudienceFilters().map((filter) => { - const isActive = commentFilter === filter.value - return ( - - ) - })} -
- )} - - {comments.length === 0 ? ( -
- {translate('auto.components.PullRequestPage.d2d589556c', 'No comments yet.')} -
- ) : visibleComments.length === 0 ? ( -
- {getPRCommentAudienceEmptyLabel(commentFilter)} -
- ) : ( -
- {visibleCommentGroups.map(renderCommentGroup)} -
- )} - - ) : null} - - {detailsLoaded && canUseRepoMutationContext && ( - - )} -
- - {rightPanel} -
- ) -} - -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] - ) - - const handleStateChange = async (): Promise => { - if (!canMutateState || statePending) { - return - } - const label = nextState === 'closed' ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: - 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: nextState === 'closed' ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - const previousState = localState - setStatePending(true) - applyStatePatch(nextState) - try { - await runPullRequestStateUpdate({ - repoPath, - repoId, - sourceContext, - projectOrigin, - number: item.number, - prRepo, - updates: { state: nextState } - }) - toast.success( - nextState === 'closed' - ? translate('auto.components.PullRequestPage.7aa3b5f706', 'Pull request closed') - : translate('auto.components.PullRequestPage.710e47aa06', 'Pull request reopened') - ) - onMutated() - } catch (err) { - applyStatePatch(previousState) - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.b8c6cbb8c4', 'Failed to {{value0}} PR', { - value0: label.toLowerCase() - }) - ) - } finally { - setStatePending(false) - } - } - - const handleMerge = async (method: GitHubPRMergeMethod): Promise => { - if (mergeDisabled) { - return - } - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: translate( - 'auto.components.PullRequestPage.a63b3c159c', - 'This will update the pull request on GitHub.' - ), - confirmLabel: label - }) - if (!confirmed) { - return - } - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.mergePR', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - method, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.mergePR({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - method, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - applyStatePatch('merged') - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success(translate('auto.components.PullRequestPage.c57873d721', 'Pull request merged')) - onMutated() - } catch { - toast.error( - translate('auto.components.PullRequestPage.aae645d36d', 'Failed to merge pull request') - ) - } finally { - setMergePending(false) - } - } - - const handleAutoMerge = async (): Promise => { - if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.setPRAutoMerge', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRAutoMerge({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success( - enabled - ? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled') - : translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled') - ) - 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 { - setMergePending(false) - } - } - - return ( - - ) -} - -function CommentReplyForm({ - className, - placeholder, - mentionOptions, - onCancel, - onSubmit -}: { - className?: string - placeholder: string - mentionOptions: MentionOption[] - onCancel: () => void - onSubmit: (body: string) => Promise -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(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 ( -
- { - 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" - /> -
- - -
-
- ) -} - -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(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(null) - const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object - requestId: number - } | null>(null) - const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(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 saveFixChecksActionDefault = useCallback( - async ( - target: SourceControlAiWriteTarget, - actionId: SourceControlLaunchActionId, - recipe: SourceControlActionRecipe - ): Promise => { - 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) - }, - [updateRepo, updateSettings] - ) - const handleStartFixChecksFromDialog = useCallback( - async ({ - agent, - commandInput, - agentArgs - }: { - agent: Parameters[0]['agentOverride'] - commandInput: string - agentArgs: string - }): Promise => { - if (!targetRepoId) { - return false - } - return await launchWorkItemDirect({ - item: { ...item, repoId: targetRepoId, pasteContent: commandInput }, - repoId: targetRepoId, - launchSource: 'task_page', - telemetrySource: 'sidebar', - promptDelivery: 'submit-after-ready', - agentOverride: agent, - agentArgs, - openModalFallback: () => { - toast.error( - translate( - 'auto.components.PullRequestPage.c4c02ea23e', - 'Unable to create a fix workspace automatically.' - ) - ) - } - }) - }, - [item, targetRepoId] - ) - 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): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.c057f2fcb0', - 'Unable to refresh checks without a repository path.' - ) - ) - return null - } - const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current - if (committedChecksContextOwnerRef.current !== refreshContextOwner) { - return null - } - const refreshRequestId = ++nextChecksRefreshRequestIdRef.current - activeChecksRefreshRequestIdRef.current = refreshRequestId - setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - prRepo, - noCache: true - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - if ( - !mountedRef.current || - committedChecksContextOwnerRef.current !== refreshContextOwner || - activeChecksRefreshRequestIdRef.current !== refreshRequestId - ) { - return null - } - setChecksState((current) => - current.contextOwner === refreshContextOwner - ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) - : current - ) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - if ( - mountedRef.current && - committedChecksContextOwnerRef.current === refreshContextOwner && - activeChecksRefreshRequestIdRef.current === refreshRequestId - ) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') - ) - } - return null - } finally { - if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { - activeChecksRefreshRequestIdRef.current = null - } - if (mountedRef.current) { - setRefreshingOwner((current) => - current?.requestId === refreshRequestId ? null : current - ) - } - } - }, - [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - mountedRef, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleRerun = useCallback( - async (failedOnly: boolean): Promise => { - if (!canUseChecksRepoContext || rerunning) { - return - } - const rerunContextOwner = committedChecksContextOwnerRef.current - setRerunningOwner(rerunContextOwner) - try { - const result = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.rerunPRChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - failedOnly, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.rerunPRChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - failedOnly, - prRepo - }) - if (!mountedRef.current || 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 handleRefresh(rerunContextOwner) - } catch (err) { - if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') - ) - } - } finally { - if (mountedRef.current) { - setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) - } - } - }, - [ - canUseChecksRepoContext, - handleRefresh, - headSha, - item.number, - item.repoId, - mountedRef, - prRepo, - runtimeHost, - rerunning, - repoId, - repoPath, - sourceContext - ] - ) - - const handleFixBrokenChecks = useCallback(async (): Promise => { - if (!targetRepoId || fixingChecks) { - return - } - if (failedChecks.length === 0) { - toast.message( - translate('auto.components.PullRequestPage.51c65c0265', 'No broken checks to fix.') - ) - return - } - - const basePrompt = buildFixBrokenChecksPrompt({ - reviewKind: 'PR', - reviewNumber: item.number, - reviewTitle: item.title, - reviewUrl: item.url, - checks: list - }) - setFixingChecks(true) - try { - const started = await startFixChecksAgent({ - item, - repoId: targetRepoId, - basePrompt, - launchSource: 'task_page', - telemetrySource: 'sidebar', - openModalFallback: () => { - 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 { - setFixingChecks(false) - } - }, [failedChecks.length, fixingChecks, item, list, targetRepoId]) - - const requestCheckDetails = useCallback( - (check: PRCheckDetail, key: string): void => { - if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { - return - } - const requestId = ++nextCheckDetailsRequestIdRef.current - const commit = (next: Omit): void => { - if (!mountedRef.current) { - return - } - setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) - } - setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) - const detailsRequest = withGitHubCheckDetailsTimeout((signal) => - runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }, - { timeoutMs: 30_000, signal } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - 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.' - ) - }) - }) - }, - [ - canUseChecksRepoContext, - item.repoId, - 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 = ( - - - - - - {translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')} - - - ) - const fixBrokenChecksAction = - failedChecks.length > 0 || fixingChecks ? ( - - - - - - {translate( - 'auto.components.PullRequestPage.0fa8b8faec', - 'Start the default AI agent on these checks' - )} - - - ) : null - const rerunAction = - list.length > 0 || rerunning ? ( - - - - - - void handleRerun(true)} - > - - {translate('auto.components.PullRequestPage.68605516dd', 'Rerun failed checks')} - - void handleRerun(false)}> - - {translate('auto.components.PullRequestPage.54cddd1858', 'Rerun all checks')} - - - - ) : null - const secondaryActions = - variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || - rerunAction ? ( -
- {fixBrokenChecksAction} - {variant === 'page' ? rerunAction : null} -
- ) : null - const actions = ( -
- {refreshAction} - {fixBrokenChecksAction} - {rerunAction} -
- ) - const compactHeader = ( -
-
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
-
- {translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')} -
- {list.length > 0 && ( -
- {summaryLabel} -
- )} -
-
-
- {refreshAction} - {list.length > 0 && ( -
- {rerunAction} -
- )} -
-
- {secondaryActions ? ( -
{secondaryActions}
- ) : null} -
- ) - - const renderCheckRow = (check: PRCheckDetail): 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) - const expanded = expandedCheckKey === key - const detailsState = detailsByCheckKey[key] - return ( -
- - {expanded && renderCheckDetails(check, detailsState)} -
- ) - } - - const renderCheckDetails = ( - check: PRCheckDetail, - state: CheckDetailsLoadState | undefined - ): 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 ( -
- {state?.loading && !state.error ? ( -
- - {translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')} -
- ) : ( -
-
- - {translate('auto.components.PullRequestPage.662bc2998d', 'Status:')}{' '} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt} - - )} - {completedAt && ( - - {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '} - {completedAt} - - )} - {check.checkRunId && ( - - {translate('auto.components.PullRequestPage.f01bf79a79', 'check #')} - {check.checkRunId} - - )} -
- - {state?.error && ( -
-
- {state.error} -
- -
- )} - - {hasOutput && ( -
- {details?.title && ( -
- {details.title} -
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
- )} - - {hasAnnotations && ( -
-
- {translate('auto.components.PullRequestPage.8432d17901', 'Annotations')} -
-
- {details!.annotations.map((annotation, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {annotation.path ?? - translate('auto.components.PullRequestPage.35a0573f41', 'Annotation')} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                          {annotation.rawDetails}
-                        
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.PullRequestPage.7720c9c3f5', 'Jobs')} -
-
- {details!.jobs.map((job, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {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.' - )} -
- )} - - {openUrl && ( -
- -
- )} -
- )} -
- ) - } - - const fixChecksAgentDialog = ( - { - 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={repo?.connectionId ?? null} - repoId={targetRepoId} - promptDelivery="submit-after-ready" - launchPlatform={fixChecksLaunchPlatform} - launchSource="task_page" - savedAgentId={readSourceControlLaunchRecipeAgentId(fixChecksRecipe)} - savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null} - savedAgentArgs={fixChecksRecipe.agentArgs ?? null} - onSaveAgentDefault={saveFixChecksActionDefault} - onLaunched={() => { - toast.success( - translate( - 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' - ) - ) - }} - onStart={handleStartFixChecksFromDialog} - /> - ) - - if (loading && list.length === 0) { - return ( - <> - {variant === 'compact' ? compactHeader : null} -
- -
- - ) - } - if (list.length === 0) { - if (variant === 'page') { - return ( -
-
- -
- - {translate('auto.components.PullRequestPage.45877f5089', 'No checks found')} - - - {translate( - 'auto.components.PullRequestPage.3912daf310', - 'This pull request has no reported checks yet.' - )} - -
- {actions} -
-
- ) - } - return ( - <> - {compactHeader} -
- -
- {translate('auto.components.PullRequestPage.a18d01cda3', 'No checks reported yet')} -
-
- - ) - } - if (variant === 'page') { - const countChips = getCheckCountChips(counts) - return ( - <> -
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
- - {summaryLabel} - - {countChips.length > 1 && ( - - {countChips.map((chip, i) => ( - - {i > 0 && ·} - {chip.label} - - ))} - - )} -
- {actions} -
-
- {sorted.map((check, index) => ( -
0 && 'border-t border-border/40')} - > - {renderCheckRow(check)} -
- ))} -
-
- {fixChecksAgentDialog} - - ) - } - return ( - <> - {compactHeader} -
- {sorted.map(renderCheckRow)} -
- {fixChecksAgentDialog} - - ) -} - -function MentionTextarea({ - value, - onValueChange, - onKeyDown, - placeholder, - rows, - className, - wrapperClassName, - mentionOptions, - textareaRef -}: { - value: string - onValueChange: (value: string) => void - onKeyDown?: (event: React.KeyboardEvent) => void - placeholder: string - rows: number - className?: string - wrapperClassName?: string - mentionOptions: MentionOption[] - textareaRef: React.RefObject -}): React.JSX.Element { - const [mentionQuery, setMentionQuery] = useState(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 ( -
- {showSuggestions && ( -
- {suggestions.map((option, index) => ( - - ))} -
- )} -