From 131aa3a08fb8e0faab84e49732af37deca5bd318 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 21 May 2026 17:56:41 -0700 Subject: [PATCH] fix: address review findings (#2575) --- .../src/components/GitHubItemDialog.tsx | 598 +- .../src/components/PullRequestPage.tsx | 5634 +++++++++++++++++ src/renderer/src/components/TaskPage.tsx | 55 +- 3 files changed, 6187 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/PullRequestPage.tsx diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 03b3c2aa5..fc43d92cd 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -4253,7 +4253,8 @@ function GHEditSection({ onLabelsChange, onMutated, assignees, - onUse + onUse, + layout = 'horizontal' }: { item: GitHubWorkItem repoPath: string | null @@ -4269,6 +4270,10 @@ function GHEditSection({ onMutated: () => void assignees: string[] onUse: (item: GitHubWorkItem) => void + /** `'horizontal'` is the legacy strip rendered above the conversation; the + * `'sidebar'` layout matches the GitHub issue page's right rail with each + * metadata row stacked under a section heading. */ + layout?: 'horizontal' | 'sidebar' }): React.JSX.Element | null { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) @@ -4531,6 +4536,222 @@ function GHEditSection({ ) + if (layout === 'sidebar') { + return ( + + ) + } + return (
{/* State */} @@ -5253,118 +5474,242 @@ export default function GitHubItemDialog({ [details?.pullRequestId, detailsCacheKey, repoPath, workItem] ) + const isIssuePage = variant === 'page' && workItem?.type === 'issue' + const ownerRepo = workItem ? parseOwnerRepoFromItemUrl(workItem.url) : null + const issueStateBadgeTone = + localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' + const content = workItem ? (
-
-
- {variant === 'page' ? ( - - ) : null} -
- -
-
-
- - #{workItem.number} - {workItem.type === 'pr' ? 'Pull request' : 'Issue'} -
-

- {workItem.title} -

-
- {workItem.author ?? 'unknown'} - updated {formatRelativeTime(workItem.updatedAt)} - {workItem.branchName && ( - - {workItem.branchName} - - )} -
- {workItem.type === 'issue' && ( - - )} -
-
- {workItem.type === 'pr' && ( + {isIssuePage ? ( + <> + {/* Row 1: breadcrumb-style strip mirroring GitHub's canvas-subtle header */} +
+
- )} - - + · + {ownerRepo ? ( + <> + + {ownerRepo.owner} + / + {ownerRepo.repo} + + · + + ) : null} + #{workItem.number} +
+ + + + + + {linkCopied ? 'Copied' : 'Copy GitHub link'} + + + + + + + + Open on GitHub + + +
+
+
+ + {/* Row 2: large title block */} +
+
+

+ {workItem.title} + #{workItem.number} +

+
+ {/* Why: Orca's signature affordance — keep this primary so it + stands out against GitHub's familiar surface. */} - - - {linkCopied ? 'Copied' : 'Copy GitHub link'} - - - - +
+
+
+ + {localState === 'closed' ? ( + + ) : ( + + )} + {localState === 'closed' ? 'Closed' : 'Open'} + + + + {workItem.author ?? 'unknown'} + + opened this issue + + · updated {formatRelativeTime(workItem.updatedAt)} + + + +
+
+ + ) : ( +
+
+ {variant === 'page' ? ( + + ) : null} +
+ +
+
+
+ + #{workItem.number} + {workItem.type === 'pr' ? 'Pull request' : 'Issue'} +
+

+ {workItem.title} +

+
+ {workItem.author ?? 'unknown'} + updated {formatRelativeTime(workItem.updatedAt)} + {workItem.branchName && ( + + {workItem.branchName} + + )} +
+ {workItem.type === 'issue' && ( + + )} +
+
+ {workItem.type === 'pr' && ( - - - Open on GitHub - - - {variant === 'sheet' ? ( + )} + + + + + + {linkCopied ? 'Copied' : 'Copy GitHub link'} + + - Close · Esc + Open on GitHub - ) : null} + {variant === 'sheet' ? ( + + + + + + Close · Esc + + + ) : null} +
-
+ )} - {(repoPath || projectOrigin) && ( + {!isIssuePage && (repoPath || projectOrigin) && ( {error ? (
{error}
+ ) : isIssuePage ? ( +
+
+
+ { + if (repoPath) { + invalidateWorkItemDetailsCacheByMatch({ + repoPath, + repoId: effectiveRepoId ?? undefined, + type: workItem.type, + number: workItem.number + }) + } + }} + onChecksUpdated={(nextChecks) => { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} + onBodyUpdated={(nextBody) => { + if (detailsCacheKey) { + patchCachedWorkItemBody(detailsCacheKey, nextBody) + } + }} + onCommentAdded={appendOptimisticComment} + onReviewersRequested={(nextReviewRequests) => { + if (detailsCacheKey) { + patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) + } + onReviewRequestsChange?.( + { id: workItem.id, repoId: workItem.repoId }, + nextReviewRequests + ) + }} + /> +
+ {(repoPath || projectOrigin) && ( +
+
+ { + if (repoPath) { + invalidateWorkItemDetailsCacheByMatch({ + repoPath, + repoId: effectiveRepoId ?? undefined, + type: workItem.type, + number: workItem.number + }) + } + }} + assignees={details?.assignees ?? []} + onUse={onUse} + layout="sidebar" + /> +
+
+ )} +
+
) : ( import('@/components/editor/MonacoCodeExcerpt')) + +export type ItemDialogTab = 'conversation' | 'checks' | 'files' + +type MentionOption = { + login: string + name?: string | null + avatarUrl?: string + source: string +} + +type MentionQuery = { + atIndex: number + query: string +} + +const CODE_CONTEXT_EXPAND_STEP = 5 +const CODE_CONTEXT_FALLBACK_LINES = 20 +const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 + +const REACTION_EMOJI: Record = { + '+1': '👍', + '-1': '👎', + laugh: '😄', + confused: '😕', + heart: '❤️', + hooray: '🎉', + rocket: '🚀', + eyes: '👀' +} + +function normalizeItemDialogTab( + item: GitHubWorkItem | null, + tab: ItemDialogTab | undefined +): ItemDialogTab { + if (item?.type !== 'pr') { + return 'conversation' + } + return tab ?? 'conversation' +} + +/** Why: Project-origin rows don't always belong to the active local repo. + * When set, GHEditSection routes label/assignee/state mutations through + * slug-addressed IPCs against `owner`/`repo` instead of through `repoPath`, + * preventing edits from silently landing on the workspace's repo when the + * Project view is showing rows from a different repo. See + * docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + */ +export type PullRequestPageProjectOrigin = { + owner: string + repo: string + number: number + type: 'issue' | 'pr' + projectId: string + projectItemId: string + cacheKey: string +} + +type PullRequestPageProps = { + workItem: GitHubWorkItem | null + repoPath: string | null + repoId?: string | 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, edits in the dialog are + * routed via slug-addressed mutation IPCs against the row's actual repo + * instead of the active workspace's `repoPath`. Both can be set + * simultaneously (Project mode where the row also lives in the active + * workspace) — slug routing wins for writes. */ + projectOrigin?: PullRequestPageProjectOrigin +} + +function formatRelativeTime(input: string): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return 'recently' + } + const diffMs = date.getTime() - Date.now() + const diffMinutes = Math.round(diffMs / 60_000) + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour') + } + const diffDays = Math.round(diffHours / 24) + return formatter.format(diffDays, 'day') +} + +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 filterMentionOptions(options: MentionOption[], query: string): MentionOption[] { + const normalizedQuery = query.toLowerCase() + const filtered = normalizedQuery + ? options.filter( + (option) => + option.login.toLowerCase().includes(normalizedQuery) || + (option.name ?? '').toLowerCase().includes(normalizedQuery) + ) + : options + return filtered.slice(0, 8) +} + +function getStateLabel(item: GitHubWorkItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'draft') { + return 'Draft' + } + if (item.state === 'closed') { + return 'Closed' + } + return 'Open' + } + return item.state === 'closed' ? 'Closed' : 'Open' +} + +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 getPRMergeTooltip(item: GitHubWorkItem): string { + if (item.state === 'merged') { + return 'This pull request is already merged' + } + if (item.state === 'closed') { + return 'This pull request is closed' + } + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge status is unavailable for this PR' + } + if (item.mergeable === 'CONFLICTING') { + return 'GitHub reports merge conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Update the branch before merging' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'GitHub reports this pull request is blocked' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'GitHub says this PR can merge' + } + return 'GitHub has not reported a final merge status' +} + +function WorkItemStateBadge({ + item, + className +}: { + item: GitHubWorkItem + className?: string +}): React.JSX.Element { + return ( + + {getStateLabel(item)} + + ) +} + +function ReviewerAvatar({ + login, + avatarUrl +}: { + login: string + avatarUrl: string +}): React.JSX.Element { + if (avatarUrl) { + return ( + + ) + } + return ( + + {login.slice(0, 1).toUpperCase()} + + ) +} + +function mergeReviewerSuggestions( + users: GitHubAssignableUser[], + seedUsers: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...seedUsers, ...users]) { + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (!existing) { + byLogin.set(key, user) + continue + } + if (!existing.avatarUrl && user.avatarUrl) { + byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) + } + } + return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) +} + +function buildRequestedReviewUsers( + logins: string[], + candidates: GitHubAssignableUser[], + existingRequests: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of existingRequests) { + byLogin.set(user.login.toLowerCase(), user) + } + const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) + for (const login of logins) { + const key = login.toLowerCase() + if (byLogin.has(key)) { + continue + } + byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) +} + +function PRReviewersPanel({ + item, + loading, + repoPath, + onReviewersRequested +}: { + item: GitHubWorkItem + loading: boolean + repoPath: string | null + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [reviewerInput, setReviewerInput] = useState('') + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) + const [activeReviewerIndex, setActiveReviewerIndex] = useState(0) + const [submitting, setSubmitting] = useState(false) + const [localReviewRequests, setLocalReviewRequests] = useState( + () => item.reviewRequests ?? [] + ) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const settings = useAppStore((s) => s.settings) + const reviewerInputRef = useRef(null) + + useEffect(() => { + setLocalReviewRequests(item.reviewRequests ?? []) + }, [item.id, 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 reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewerMetadataBySlug = useRepoAssigneesBySlug( + open && reviewSlug ? reviewSlug.owner : null, + open && reviewSlug ? reviewSlug.repo : null, + reviewerSeedUsers.map((user) => user.login), + settings + ) + const reviewerMetadataByPath = useRepoAssignees( + open && !reviewSlug ? repoPath : null, + open && !reviewSlug ? item.repoId : null + ) + const reviewerMetadata = reviewSlug ? 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 reviewerQuery = reviewerInput.trim().replace(/^@/, '').toLowerCase() + const filteredReviewerCandidates = useMemo(() => { + const query = reviewerQuery + return reviewerCandidates + .filter((user) => { + const login = user.login.toLowerCase() + return ( + query.length === 0 || + login.includes(query) || + (user.name ?? '').toLowerCase().includes(query) + ) + }) + .sort((a, b) => { + const aLogin = a.login.toLowerCase() + const bLogin = b.login.toLowerCase() + const aStarts = aLogin.startsWith(query) + const bStarts = bLogin.startsWith(query) + if (aStarts !== bStarts) { + return aStarts ? -1 : 1 + } + return a.login.localeCompare(b.login) + }) + }, [reviewerCandidates, reviewerQuery]) + const suggestedReviewerRows = useMemo( + () => + reviewerQuery.length === 0 + ? 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, + 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] + ) + + useEffect(() => { + setActiveReviewerIndex(0) + }, [reviewerQuery, actionableReviewerRows.length]) + + const hasReviewerMetadata = + item.reviewDecision !== undefined || + localReviewRequests.length > 0 || + item.reviewRequests !== undefined || + item.latestReviews !== undefined + const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' + + const measureReviewerPickerPlacement = useCallback(() => { + const rect = reviewerInputRef.current?.getBoundingClientRect() + if (!rect) { + setReviewerPickerSide('bottom') + setReviewerPickerMaxHeight(null) + return + } + + const gap = 8 + const minUsefulHeight = 180 + const availableBelow = window.innerHeight - rect.bottom - gap + const availableAbove = rect.top - gap + const nextSide = + availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) + }, []) + + const handleRequestReview = async (requestedLogins?: string[]): Promise => { + if (submitting) { + return + } + const logins = normalizeGitHubReviewerLogins( + requestedLogins ?? reviewerInput.split(/[\s,]+/), + selectedReviewerLogins + ) + if (logins.length === 0) { + toast.error('Enter a reviewer') + return + } + if (localReviewRequests.length + logins.length > 15) { + toast.error('You can request up to 15 reviewers') + return + } + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment' && !repoPath) { + toast.error('No repo context available for this pull request.') + return + } + setSubmitting(true) + try { + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.requestPRReviewers', + { repo: item.repoId, prNumber: item.number, reviewers: logins }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.requestPRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + prNumber: item.number, + reviewers: logins + }) + if (!result.ok) { + toast.error(result.error ?? 'Failed to request reviewer') + return + } + const nextReviewRequests = buildRequestedReviewUsers( + logins, + reviewerCandidates, + localReviewRequests + ) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + onReviewersRequested(nextReviewRequests) + setReviewerInput('') + toast.success(logins.length === 1 ? 'Reviewer requested' : 'Reviewers requested') + } catch { + toast.error('Failed to request reviewer') + } finally { + 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(settings) + if (target.kind !== 'environment' && !repoPath) { + toast.error('No repo context available for this pull request.') + return + } + setSubmitting(true) + try { + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.removePRReviewers', + { repo: item.repoId, prNumber: item.number, reviewers: logins }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.removePRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + prNumber: item.number, + reviewers: logins + }) + if (!result.ok) { + toast.error(result.error ?? '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) + onReviewersRequested(nextReviewRequests) + setReviewerInput('') + toast.success(logins.length === 1 ? 'Reviewer removed' : 'Reviewers removed') + } catch { + toast.error('Failed to remove reviewer') + } finally { + setSubmitting(false) + } + } + + const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { + await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) + ? handleRemoveReviewers([reviewer.login]) + : handleRequestReview([reviewer.login])) + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + } + + const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + measureReviewerPickerPlacement() + } + setOpen(nextOpen) + if (nextOpen) { + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + 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 ( + + ) +} + +function isPRFileViewed(file: GitHubPRFile): boolean { + return file.viewerViewedState === 'VIEWED' +} + +function findNearestBraceBlock( + lines: string[], + targetLine: number +): { startLine: number; endLine: number } | null { + const stack: number[] = [] + const ranges: { startLine: number; endLine: number }[] = [] + const targetIndex = targetLine - 1 + + lines.forEach((line, lineIndex) => { + for (const character of line) { + if (character === '{') { + stack.push(lineIndex) + } else if (character === '}') { + const startLine = stack.pop() + if (startLine !== undefined && startLine <= lineIndex) { + ranges.push({ startLine: startLine + 1, endLine: lineIndex + 1 }) + } + } + } + }) + + const containingRange = ranges + .filter((range) => range.startLine - 1 <= targetIndex && targetIndex <= range.endLine - 1) + .sort((a, b) => a.endLine - a.startLine - (b.endLine - b.startLine))[0] + + if (containingRange) { + return containingRange + } + + return ( + ranges + .filter( + (range) => range.startLine - 1 >= targetIndex && range.startLine - 1 - targetIndex <= 8 + ) + .sort((a, b) => a.startLine - b.startLine)[0] ?? null + ) +} + +// Why: SWR cache for the work-item details fetch. Reopening the same drawer +// pays full IPC + `gh` process startup latency without this; with it, cached +// data paints immediately while a background refetch keeps the view honest. +// Cache is keyed by repoPath + issueSourcePreference + type + number so +// upstream/origin source toggles and issue#N vs pr#N never collide. Bounded +// to ~50 entries to cap memory; entries older than FRESH_MS trigger a +// background refetch on open. See docs/gh-work-item-drawer-cache.md. +const WORK_ITEM_DETAILS_CACHE_MAX = 50 +const WORK_ITEM_DETAILS_FRESH_MS = 30_000 +type WorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + fetchedAt: number + pending?: Promise + error?: string +} +const workItemDetailsCache = new Map() + +// Why: drawers subscribe via useSyncExternalStore so reopening a cached item +// paints synchronously on first render. Stability of the snapshot relies on +// every cache write replacing the entry object identity (delete+set), which +// touchWorkItemDetailsCache already does. +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 + type: 'issue' | 'pr' + number: number +}): string { + // Why: include all axes that change which (repo, item) the IPC resolves to. + // `\0` separator avoids ambiguity between fields that may contain `:` or `/`. + return [args.repoId, args.issueSourcePreference ?? 'auto', args.type, args.number].join('\0') +} + +function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { + // Why: re-insert to move to MRU position; Map preserves insertion order so + // the oldest key is always first when evicting. + workItemDetailsCache.delete(key) + workItemDetailsCache.set(key, entry) + while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { + const oldest = workItemDetailsCache.keys().next().value + if (oldest === undefined) { + break + } + workItemDetailsCache.delete(oldest) + } + notifyWorkItemDetailsCache() +} + +// Why: exposed so mutation handlers (in this file and elsewhere) can drop a +// stale entry after a successful local mutation. Cross-window invalidation +// arrives via the `gh:workItemMutated` event listener installed below. +export function invalidateWorkItemDetailsCacheForKey(key: string): void { + // Why: bump generation so an in-flight fetch launched before this exact-key + // invalidation will not write its stale result back into the cache. + workItemDetailsCacheGeneration += 1 + const existed = workItemDetailsCache.delete(key) + if (existed) { + notifyWorkItemDetailsCache() + } +} + +// Why: monotonically increases on every invalidation so an in-flight refetch +// that started before a mutation can detect that its result is stale and +// must not be written back. Without this, a mutation that lands while a +// refetch is in flight would have its invalidation silently undone when the +// stale promise resolves and re-populates the entry. +let workItemDetailsCacheGeneration = 0 + +// Why: when we don't have the exact cache key (e.g. an event from another +// window only carries repoPath + number + type), drop every entry that +// matches the (repoPath, type, number) tuple regardless of source preference. +function invalidateWorkItemDetailsCacheByMatch(args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +}): void { + workItemDetailsCacheGeneration += 1 + 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) { + 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 + }) +} + +// Why: install once at module load — every dialog instance shares the cache, +// so a single subscription is enough. The preload bridge re-emits the +// main-process broadcast for every window, so each renderer invalidates its +// own cache when any window's mutation lands. We track the unsubscribe so +// Vite HMR doesn't accumulate listeners across module reloads in dev. +let workItemMutatedUnsub: (() => 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 + }) + }) +} +if (typeof import.meta !== 'undefined' && import.meta.hot) { + import.meta.hot.dispose(() => { + workItemMutatedUnsub?.() + }) +} + +// Why: bounded LRU — opening many PRs with many files during a session +// would otherwise grow this module-level map without bound until reload. +const PR_FILE_CONTENT_CACHE_MAX = 64 +const prFileContentCache = new Map | GitHubPRFileContents>() + +function touchPRFileContentCache( + key: string, + value: Promise | GitHubPRFileContents +): void { + // Why: re-insert to move to the most-recently-used position; Map preserves + // insertion order so the oldest key is always first when evicting. + prFileContentCache.delete(key) + prFileContentCache.set(key, value) + while (prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX) { + const oldest = prFileContentCache.keys().next().value + if (oldest === undefined) { + break + } + prFileContentCache.delete(oldest) + } +} + +function getPRFileContentCacheKey(args: { + repoPath: string + repoId: string + prNumber: number + file: GitHubPRFile + headSha: string + baseSha: string +}): string { + return [ + args.repoId, + args.prNumber, + args.file.path, + args.file.oldPath ?? '', + args.file.status, + args.headSha, + args.baseSha + ].join('\0') +} + +function loadPRFileContents(args: { + repoPath: string + repoId: string + prNumber: number + file: GitHubPRFile + headSha: string + baseSha: string +}): Promise { + const cacheKey = getPRFileContentCacheKey(args) + const cached = prFileContentCache.get(cacheKey) + if (cached) { + touchPRFileContentCache(cacheKey, cached) + return Promise.resolve(cached) + } + const request = window.api.gh + .prFileContents({ + repoPath: args.repoPath, + repoId: args.repoId, + prNumber: args.prNumber, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + .then((contents) => { + touchPRFileContentCache(cacheKey, contents) + return contents + }) + .catch((err) => { + prFileContentCache.delete(cacheKey) + throw err + }) + touchPRFileContentCache(cacheKey, request) + return request +} + +function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + body: args.body, + type: args.type + }) +} + +function addPRReviewCommentForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commitId: string + path: string + line: number + startLine?: number + body: string +}): Promise>> { + return window.api.gh.addPRReviewComment({ + repoPath: args.repoPath, + repoId: args.repoId, + prNumber: args.prNumber, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }) +} + +function addPRReviewCommentReplyForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number +}): Promise>> { + return window.api.gh.addPRReviewCommentReply({ + repoPath: args.repoPath, + repoId: args.repoId, + prNumber: args.prNumber, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }) +} + +function setPRFileViewedForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + pullRequestId: string + path: string + viewed: boolean +}): Promise { + return window.api.gh.setPRFileViewed({ + repoPath: args.repoPath, + repoId: args.repoId, + prNumber: args.prNumber, + pullRequestId: args.pullRequestId, + path: args.path, + viewed: args.viewed + }) +} + +function getWorkItemDetailsForRepo(args: { + repoId?: string + repoPath: string + number: number + type: 'issue' | 'pr' +}): Promise { + return window.api.gh.workItemDetails({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + type: args.type + }) +} + +function PRViewedCheckbox({ + checked, + pending, + filePath, + onToggle +}: { + checked: boolean + pending: boolean + filePath: string + onToggle: () => void +}): React.JSX.Element { + return ( + + + + + + {checked ? 'Unmark viewed' : 'Mark viewed'} + + + ) +} + +const PR_DIFF_OVERSCAN = 5 + +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 mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['status'] { + switch (status) { + case 'added': + return 'added' + case 'removed': + return 'deleted' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + default: + return 'modified' + } +} + +function getPRFileSectionKey(path: string): string { + return `combined-commit:${path}` +} + +function gitHubPRFileToBranchEntry(file: GitHubPRFile): GitBranchChangeEntry { + return { + path: file.path, + oldPath: file.oldPath, + status: mapPRFileStatus(file.status), + added: file.additions, + removed: file.deletions + } +} + +function getPRFileDiffResult(contents: GitHubPRFileContents): GitDiffResult { + if (contents.originalIsBinary) { + return { + kind: 'binary', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: true, + modifiedIsBinary: contents.modifiedIsBinary + } + } + if (contents.modifiedIsBinary) { + return { + kind: 'binary', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: false, + modifiedIsBinary: true + } + } + + return { + kind: 'text', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: false, + modifiedIsBinary: false + } +} + +type PRFilesCombinedDiffViewerProps = { + files: GitHubPRFile[] + comments: PRComment[] + repoPath: string + repoId: string + prNumber: number + prUrl: string + headSha: string | undefined + baseSha: string | undefined + pendingViewedPaths: ReadonlySet + onCommentAdded: (comment: PRComment) => void + onViewedChange: (path: string, viewed: boolean) => Promise +} + +function PRFilesCombinedDiffViewer({ + files, + comments, + repoPath, + repoId, + prNumber, + 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 = 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: stale threads keep originalLine for the sidebar, but rendering + // that number 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, + headSha: headSha ?? null, + baseSha: baseSha ?? null, + files: diffEntrySignature + }), + [baseSha, diffEntrySignature, headSha, prNumber, repoId] + ) + const viewStateKey = useMemo( + () => [repoId || repoPath, prNumber].join('\0'), + [prNumber, 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: even cached restores represent a new PR/file generation; stale async + // diff loads from the previous view must not 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 + })) + ) + }, [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; 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: 'Diff unavailable because the PR commit SHAs are missing.' + } + } + const contents = await loadPRFileContents({ + repoPath, + repoId, + prNumber, + file, + headSha, + baseSha + }) + return { result: getPRFileDiffResult(contents) } + } + + load() + .catch((error) => ({ + result: { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } as GitDiffResult, + error: error instanceof Error ? error.message : 'Failed to load diff.' + })) + .then(({ result, error }) => { + loadingIndicesRef.current.delete(index) + if (generationRef.current !== generation) { + return + } + loadedIndicesRef.current.add(index) + setSections((prev) => + prev.map((current, currentIndex) => + currentIndex === index + ? { + ...current, + diffResult: result, + originalContent: result.kind === 'text' ? result.originalContent : '', + modifiedContent: result.kind === 'text' ? result.modifiedContent : '', + loading: false, + error + } + : current + ) + ) + }) + }, + [baseSha, fileByPath, headSha, prNumber, repoId, repoPath] + ) + + const retrySection = useCallback( + (index: number) => { + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + diffResult: null, + originalContent: '', + modifiedContent: '', + loading: true, + error: undefined + } + : 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) + }) + }, + 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('Unable to comment without the PR head SHA.') + return false + } + const result = await addPRReviewCommentForRepo({ + repoPath, + repoId, + prNumber, + commitId: headSha, + path: section.path, + line: lineNumber, + startLine, + body + }) + if (!result.ok) { + toast.error(result.error || 'Failed to add review comment.') + return false + } + onCommentAdded(result.comment) + toast.success('Review comment added.') + return true + }, + [headSha, onCommentAdded, prNumber, repoId, repoPath] + ) + + 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 && ( + + + + + + Show file tree + + + )} + + {files.filter(isPRFileViewed).length} / {files.length} 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 CommentCodeContext({ + comment, + repoPath, + repoId, + prNumber, + files, + headSha, + baseSha +}: { + comment: PRComment + repoPath: string | null + repoId: string + prNumber: number + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined +}): React.JSX.Element | null { + const [contents, setContents] = useState(null) + const [error, setError] = useState(false) + const [contextBefore, setContextBefore] = useState(0) + const [contextAfter, setContextAfter] = useState(0) + const file = useMemo( + () => files.find((candidate) => candidate.path === comment.path), + [comment.path, files] + ) + const line = comment.line + const startLine = comment.startLine ?? line + + useEffect(() => { + setContents(null) + setError(false) + if (!repoPath || !file || !headSha || !baseSha || !line || file.isBinary) { + return + } + let cancelled = false + loadPRFileContents({ repoPath, repoId, prNumber, file, headSha, baseSha }) + .then((result) => { + if (!cancelled) { + setContents(result) + } + }) + .catch(() => { + if (!cancelled) { + setError(true) + } + }) + return () => { + cancelled = true + } + }, [baseSha, file, headSha, line, prNumber, repoId, repoPath]) + + useEffect(() => { + setContextBefore(0) + setContextAfter(0) + }, [comment.id]) + + if (!comment.path || !line || !file || file.isBinary || error) { + return null + } + + if (!contents) { + return ( +
+ + Loading code context… +
+ ) + } + + const source = contents.modified || contents.original + const lines = source.split(/\r?\n/) + const language = detectLanguage(comment.path) + const commentFrom = Math.max(1, Math.min(startLine ?? line, line)) + const commentTo = Math.min(lines.length, Math.max(startLine ?? line, line)) + const from = Math.max(1, commentFrom - contextBefore) + const to = Math.min(lines.length, commentTo + contextAfter) + const selectedLines = lines.slice(from - 1, to) + const candidateBlockRange = findNearestBraceBlock(lines, commentFrom) + const candidateBlockLineCount = candidateBlockRange + ? candidateBlockRange.endLine - candidateBlockRange.startLine + 1 + : 0 + const isWholeFileBlock = + candidateBlockRange !== null && + candidateBlockRange.startLine <= 2 && + candidateBlockRange.endLine >= lines.length - 1 + const shouldUseBlockRange = + candidateBlockRange !== null && + !isWholeFileBlock && + candidateBlockLineCount <= CODE_CONTEXT_MAX_BLOCK_LINES + const blockRange = shouldUseBlockRange + ? candidateBlockRange + : { + startLine: Math.max(1, commentFrom - CODE_CONTEXT_FALLBACK_LINES), + endLine: Math.min(lines.length, commentTo + CODE_CONTEXT_FALLBACK_LINES) + } + const canExpandAbove = from > 1 + const canExpandBelow = to < lines.length + const canExpandBlock = blockRange.startLine < from || blockRange.endLine > to + const blockTooltip = shouldUseBlockRange + ? 'Show surrounding code block' + : 'Show nearby code context' + + if (selectedLines.length === 0) { + return null + } + + return ( +
+
+
+ {comment.path} + + L{from} + {to !== from ? `-L${to}` : ''} + + {(from !== commentFrom || to !== commentTo) && ( + + comment L{commentFrom} + {commentTo !== commentFrom ? `-L${commentTo}` : ''} + + )} +
+ + {(contextBefore > 0 || contextAfter > 0) && ( + + + + + Reset code context + + )} + + + + + Show more lines above + + + + + + Show more lines below + + + + + + {blockTooltip} + + +
+ + {selectedLines.map((codeLine, index) => { + const lineNumber = from + index + const isCommentedLine = lineNumber >= commentFrom && lineNumber <= commentTo + return ( +
+ + {lineNumber} + + {codeLine || ' '} +
+ ) + })} + + } + > + +
+
+ ) +} + +function ConversationTab({ + item, + repoPath, + 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 + 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 repoAssignees = useRepoAssignees(repoPath, item.repoId) + const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) + const visibleComments = useMemo( + () => filterPRCommentsByAudience(comments, commentFilter), + [commentFilter, comments] + ) + const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) + const mentionOptions = useMemo( + () => + buildMentionOptions({ + item, + comments, + participants: detailsParticipants, + assignableUsers: repoAssignees.data + }), + [comments, detailsParticipants, item, repoAssignees.data] + ) + + useEffect(() => { + if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { + setReplyingTo(null) + } + }, [replyingTo, visibleComments]) + + useEffect(() => { + if (!bodyEditing) { + setBodyDraft(body) + } + }, [body, bodyEditing, item.id]) + + useEffect(() => { + if (bodyEditing) { + requestAnimationFrame(() => bodyTextareaRef.current?.focus()) + } + }, [bodyEditing]) + + const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const markdownGitHubRepo = useMemo( + () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), + [bodySlug, projectOrigin] + ) + const canEditBody = + item.type === 'pr' ? Boolean(projectOrigin || bodySlug) : Boolean(projectOrigin || repoPath) + const bodyChanged = bodyDraft !== body + + const handleSaveBody = useCallback(async (): Promise => { + if (bodySaving || !bodyChanged) { + setBodyEditing(false) + return + } + setBodySaving(true) + try { + await runWorkItemBodyUpdate({ + item, + repoPath, + projectOrigin, + body: bodyDraft, + parsedSlug: bodySlug + }) + onBodyUpdated(bodyDraft) + setBodyEditing(false) + toast.success('Description updated.') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update description.') + } finally { + setBodySaving(false) + } + }, [bodyChanged, bodyDraft, bodySaving, bodySlug, item, onBodyUpdated, projectOrigin, repoPath]) + + const handleReply = useCallback( + async (comment: PRComment, replyBody: string): Promise => { + if (!repoPath) { + toast.error('Unable to reply without a repository path.') + return false + } + const result = + comment.path && item.type === 'pr' + ? await addPRReviewCommentReplyForRepo({ + repoPath, + repoId: item.repoId, + prNumber: item.number, + commentId: comment.id, + body: replyBody, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }) + : await addIssueCommentForRepo({ + repoPath, + repoId: item.repoId, + number: item.number, + body: `@${comment.author} ${replyBody}`, + type: item.type + }) + + if (!result.ok) { + toast.error(result.error || 'Failed to post reply.') + return false + } + onCommentAdded(result.comment) + setReplyingTo(null) + toast.success('Reply posted.') + return true + }, + [item.number, item.repoId, item.type, onCommentAdded, repoPath] + ) + + 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 ? `:L${comment.line}` : ''} + + )} + {comment.isResolved && ( + + resolved + + )} +
+ + + + + Reply to comment + + {comment.url && ( + + + + + Open comment on GitHub + + )} +
+
+
+ + + + {replyingTo === 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 ( + + + + + Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by {root.author} + {count > 1 ? ` (${count})` : ''} + + + + {cards} + + + + ) + } + + return ( +
+
+
+
+ {authorLabel} + updated {formatRelativeTime(item.updatedAt)} + {canEditBody && !loading && detailsLoaded ? ( + bodyEditing ? ( +
+ + +
+ ) : ( + + + + + Edit description + + ) + ) : null} +
+
+ {loading && !detailsLoaded ? ( +
+ +
+ ) : bodyEditing ? ( + { + if (event.key === 'Escape') { + event.preventDefault() + setBodyDraft(body) + setBodyEditing(false) + return + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + void handleSaveBody() + } + }} + placeholder="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() ? ( + + ) : ( + No description provided. + )} +
+
+ + {detailsLoaded ? ( + <> +
+ + Comments + {comments.length > 0 && ( + + {comments.length} + + )} +
+ + {item.type === 'pr' && comments.length > 0 && ( +
+ {PR_COMMENT_AUDIENCE_FILTERS.map((filter) => { + const isActive = commentFilter === filter.value + return ( + + ) + })} +
+ )} + + {comments.length === 0 ? ( +
+ No comments yet. +
+ ) : visibleComments.length === 0 ? ( +
+ {getPRCommentAudienceEmptyLabel(commentFilter)} +
+ ) : ( +
+ {visibleCommentGroups.map(renderCommentGroup)} +
+ )} + + ) : null} + + {detailsLoaded && repoPath && ( + + )} +
+ + {rightPanel} +
+ ) +} + +function PRActionsPanel({ + item, + repoPath, + repoId, + projectOrigin, + localState, + onStateChange, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | 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 canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) + const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' + const mergeDisabled = + !repoPath || + mergePending || + localState === 'closed' || + localState === 'merged' || + item.mergeable === 'CONFLICTING' + + 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) + patchProjectRowIfNeeded(state) + }, + [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem] + ) + + const handleStateChange = async (): Promise => { + if (!canMutateState || statePending) { + return + } + const label = nextState === 'closed' ? 'Close' : 'Reopen' + const confirmed = await confirm({ + title: `${label} PR #${item.number}?`, + description: + nextState === 'closed' + ? 'This will close the pull request on GitHub.' + : '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, + projectOrigin, + number: item.number, + updates: { state: nextState } + }) + toast.success(nextState === 'closed' ? 'Pull request closed' : 'Pull request reopened') + onMutated() + } catch (err) { + applyStatePatch(previousState) + toast.error(err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} PR`) + } finally { + setStatePending(false) + } + } + + const handleMerge = async (method: 'merge' | 'squash' | 'rebase'): Promise => { + if (!repoPath || mergeDisabled) { + return + } + const label = + method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge' + const confirmed = await confirm({ + title: `${label} PR #${item.number}?`, + description: 'This will update the pull request on GitHub.', + confirmLabel: label + }) + if (!confirmed) { + return + } + setMergePending(true) + try { + const result = await window.api.gh.mergePR({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + method + }) + if (!result.ok) { + toast.error(result.error) + return + } + applyStatePatch('merged') + toast.success('Pull request merged') + onMutated() + } catch { + toast.error('Failed to merge pull request') + } finally { + setMergePending(false) + } + } + + return ( + + ) +} + +function CommentReactions({ + reactions +}: { + reactions?: GitHubReaction[] +}): React.JSX.Element | null { + const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) + if (visibleReactions.length === 0) { + return null + } + + return ( +
+ {visibleReactions.map((reaction) => ( + + + {reaction.count} + + ))} +
+ ) +} + +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) + + useEffect(() => { + textareaRef.current?.focus() + }, []) + + const submit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed || submitting) { + return + } + setSubmitting(true) + try { + const ok = await onSubmit(trimmed) + if (ok) { + setBody('') + } + } finally { + setSubmitting(false) + } + }, [body, onSubmit, submitting]) + + return ( +
+ { + if (e.key === 'Escape') { + e.preventDefault() + onCancel() + return + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + 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" + /> +
+ + +
+
+ ) +} + +const CHECK_SORT_ORDER: Record = { + failure: 0, + timed_out: 0, + cancelled: 1, + pending: 2, + neutral: 3, + skipped: 4, + success: 5 +} + +function getCheckConclusion(check: PRCheckDetail): NonNullable { + return check.conclusion ?? 'pending' +} + +function getCheckStatusLabel(check: PRCheckDetail): string { + const conclusion = getCheckConclusion(check) + if (conclusion === 'success') { + return 'Successful' + } + if (conclusion === 'failure') { + return 'Failed' + } + if (conclusion === 'cancelled') { + return 'Cancelled' + } + if (conclusion === 'timed_out') { + return 'Timed out' + } + if (conclusion === 'neutral') { + return 'Neutral' + } + if (conclusion === 'skipped') { + return 'Skipped' + } + if (check.status === 'queued') { + return 'Queued' + } + if (check.status === 'in_progress') { + return 'In progress' + } + return 'Pending' +} + +function getCheckCounts(checks: PRCheckDetail[]): { + passing: number + failing: number + pending: number + skipped: number + neutral: number +} { + return checks.reduce( + (counts, check) => { + const conclusion = getCheckConclusion(check) + if (conclusion === 'success') { + counts.passing += 1 + } else if (['failure', 'cancelled', 'timed_out'].includes(conclusion)) { + counts.failing += 1 + } else if (conclusion === 'skipped') { + counts.skipped += 1 + } else if (conclusion === 'neutral') { + counts.neutral += 1 + } else { + counts.pending += 1 + } + return counts + }, + { passing: 0, failing: 0, pending: 0, skipped: 0, neutral: 0 } + ) +} + +function getChecksSummaryLabel(checks: PRCheckDetail[]): string { + const counts = getCheckCounts(checks) + if (checks.length === 0) { + return 'No checks found' + } + if (counts.failing > 0) { + return `${counts.failing} ${counts.failing === 1 ? 'check' : 'checks'} failing` + } + if (counts.pending > 0) { + return `${counts.pending} ${counts.pending === 1 ? 'check' : 'checks'} pending` + } + if (counts.passing === checks.length) { + return 'All checks passing' + } + return `${counts.passing} of ${checks.length} checks passing` +} + +function getBrokenChecks(checks: PRCheckDetail[]): PRCheckDetail[] { + return checks.filter((check) => + ['failure', 'cancelled', 'timed_out'].includes(getCheckConclusion(check)) + ) +} + +function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[]): string { + const brokenChecks = getBrokenChecks(checks) + const checkLines = + brokenChecks.length > 0 + ? brokenChecks.map((check) => { + const details = [ + getCheckStatusLabel(check), + check.checkRunId ? `check run ${check.checkRunId}` : null, + check.workflowRunId ? `workflow run ${check.workflowRunId}` : null, + check.url ? `details: ${check.url}` : null + ] + .filter(Boolean) + .join(', ') + return `- ${check.name}${details ? ` (${details})` : ''}` + }) + : ['- No failing check is currently listed; refresh PR checks first, then inspect CI.'] + + return [ + `Fix the broken checks for PR #${item.number}: ${item.title}`, + `PR: ${item.url}`, + '', + 'Broken checks:', + ...checkLines, + '', + 'Focus only on making the failing checks pass. Inspect the CI output first, make the smallest correct code or test changes, and do not work on unrelated cleanup.' + ].join('\n') +} + +function findWorkspaceAttachedToPR( + worktrees: Worktree[], + repoId: string, + prNumber: number +): Worktree | null { + return ( + worktrees.find( + (worktree) => + worktree.repoId === repoId && worktree.linkedPR === prNumber && !worktree.isArchived + ) ?? null + ) +} + +function pickDefaultAgent( + defaultAgent: TuiAgent | 'blank' | null | undefined, + detectedAgents: TuiAgent[] +): TuiAgent | null { + if (defaultAgent && defaultAgent !== 'blank' && detectedAgents.includes(defaultAgent)) { + return defaultAgent + } + return AGENT_CATALOG.find((entry) => detectedAgents.includes(entry.id))?.id ?? null +} + +type CheckDetailsLoadState = { + loading: boolean + details: PRCheckRunDetails | null + error: string | null +} + +function getCheckDetailsKey(check: PRCheckDetail): string { + return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) +} + +function formatCheckTimestamp(input: string | null | undefined): string | null { + if (!input) { + return null + } + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +function ChecksTab({ + item, + repoPath, + repoId, + headSha, + checks, + loading, + variant = 'compact', + onChecksUpdated +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + headSha: string | undefined + checks: GitHubWorkItemDetails['checks'] + loading: boolean + variant?: 'compact' | 'page' + onChecksUpdated: (checks: PRCheckDetail[]) => void +}): React.JSX.Element { + const [localChecks, setLocalChecks] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [rerunning, setRerunning] = useState(false) + const [fixingChecks, setFixingChecks] = useState(false) + const [expandedCheckKey, setExpandedCheckKey] = useState(null) + const [detailsByCheckKey, setDetailsByCheckKey] = useState>( + {} + ) + const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) + const prRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const sorted = [...list].sort( + (a, b) => + (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - + (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) + ) + const failedChecks = getBrokenChecks(list) + const counts = getCheckCounts(list) + const summaryLabel = getChecksSummaryLabel(list) + const SummaryIcon = + counts.failing > 0 + ? CHECK_ICON.failure + : counts.pending > 0 + ? CHECK_ICON.pending + : list.length > 0 + ? CHECK_ICON.success + : CircleDashed + const summaryColor = + counts.failing > 0 + ? CHECK_COLOR.failure + : counts.pending > 0 + ? CHECK_COLOR.pending + : list.length > 0 + ? CHECK_COLOR.success + : 'text-muted-foreground' + const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) + + useEffect(() => { + setLocalChecks(null) + setExpandedCheckKey(null) + setDetailsByCheckKey({}) + }, [checks]) + + const handleRefresh = useCallback(async (): Promise => { + if (!repoPath) { + toast.error('Unable to refresh checks without a repository path.') + return null + } + setRefreshing(true) + try { + const nextChecks = (await window.api.gh.prChecks({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + headSha, + noCache: true + })) as PRCheckDetail[] + setLocalChecks(nextChecks) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to refresh checks') + return null + } finally { + setRefreshing(false) + } + }, [headSha, item.number, onChecksUpdated, repoId, repoPath]) + + const handleRerun = useCallback( + async (failedOnly: boolean): Promise => { + if (!repoPath || rerunning) { + return + } + setRerunning(true) + try { + const result = await window.api.gh.rerunPRChecks({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + headSha, + failedOnly + }) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(result.count === 1 ? 'Check rerun requested' : 'Check reruns requested') + await handleRefresh() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setRerunning(false) + } + }, + [handleRefresh, headSha, item.number, rerunning, repoId, repoPath] + ) + + const handleFixBrokenChecks = useCallback(async (): Promise => { + const targetRepoId = repoId ?? item.repoId + if (!targetRepoId || fixingChecks) { + return + } + if (failedChecks.length === 0) { + toast.message('No broken checks to fix.') + return + } + + setFixingChecks(true) + try { + const prompt = buildFixBrokenChecksPrompt(item, list) + const store = useAppStore.getState() + const attachedWorkspace = findWorkspaceAttachedToPR( + store.allWorktrees(), + targetRepoId, + item.number + ) + + if (!attachedWorkspace) { + await launchWorkItemDirect({ + item: { ...item, pasteContent: prompt }, + repoId: targetRepoId, + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback: () => { + toast.error('Unable to create a fix workspace automatically.') + } + }) + return + } + + if (!activateAndRevealWorktree(attachedWorkspace.id)) { + toast.error('Unable to open the workspace attached to this pull request.') + return + } + + const connectionId = getConnectionId(attachedWorkspace.id) + if (connectionId === undefined) { + toast.error('Unable to resolve the workspace connection.') + return + } + + const activeStore = useAppStore.getState() + const detectedAgents = + typeof connectionId === 'string' + ? await activeStore.ensureRemoteDetectedAgents(connectionId) + : await activeStore.ensureDetectedAgents() + const agent = pickDefaultAgent(activeStore.settings?.defaultTuiAgent, detectedAgents) + if (!agent) { + toast.error('No AI agents detected. Configure a default agent in Settings.') + return + } + + const result = launchAgentInNewTab({ + agent, + worktreeId: attachedWorkspace.id, + prompt, + promptDelivery: 'draft', + launchSource: 'task_page' + }) + if (!result) { + toast.error('Could not build the agent launch command.') + return + } + focusTerminalTabSurface(result.tabId) + toast.success('Started an AI agent for the broken checks.') + } finally { + setFixingChecks(false) + } + }, [failedChecks.length, fixingChecks, item, list, repoId]) + + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setExpandedCheckKey((current) => (current === key ? null : key)) + if ( + !repoPath || + detailsByCheckKey[key] || + (!check.checkRunId && !check.workflowRunId && !check.url) + ) { + return + } + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { loading: true, details: null, error: null } + })) + void window.api.gh + .prCheckDetails({ + repoPath, + repoId: repoId ?? undefined, + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }) + .then((details) => { + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { + loading: false, + details, + error: details ? null : 'No inline details are available for this check.' + } + })) + }) + .catch((err) => { + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { + loading: false, + details: null, + error: err instanceof Error ? err.message : 'Failed to load check details.' + } + })) + }) + }, + [detailsByCheckKey, prRepo, repoId, repoPath] + ) + + const refreshAction = ( + + + + + + Refresh checks + + + ) + const fixBrokenChecksAction = + failedChecks.length > 0 || fixingChecks ? ( + + + + + + Start the default AI agent on these checks + + + ) : null + const rerunAction = + list.length > 0 || rerunning ? ( + + + + + + void handleRerun(true)} + > + + Rerun failed checks + + void handleRerun(false)}> + + 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' + )} + /> +
+
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 ? ( +
+ + Loading check details… +
+ ) : ( +
+
+ + Status:{' '} + {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} + + {startedAt && Started {startedAt}} + {completedAt && Completed {completedAt}} + {check.checkRunId && check #{check.checkRunId}} +
+ + {state?.error &&
{state.error}
} + + {hasOutput && ( +
+ {details?.title && ( +
+ {details.title} +
+ )} + {details?.summary && ( + + )} + {details?.text && ( + + )} +
+ )} + + {hasAnnotations && ( +
+
+ Annotations +
+
+ {details!.annotations.map((annotation, index) => ( +
0 && 'border-t border-border/30' + )} + > +
+ + {annotation.path ?? 'Annotation'} + {annotation.startLine ? `:${annotation.startLine}` : ''} + + {annotation.annotationLevel && ( + + {annotation.annotationLevel} + + )} +
+ {annotation.title && ( +
+ {annotation.title} +
+ )} +
+ {annotation.message} +
+ {annotation.rawDetails && ( +
+                          {annotation.rawDetails}
+                        
+ )} +
+ ))} +
+
+ )} + + {hasJobs && ( +
+
+ Jobs +
+
+ {details!.jobs.map((job, index) => ( +
0 && 'border-t border-border/30' + )} + > +
+ + {job.name} + + + {job.conclusion ?? job.status ?? 'unknown'} + +
+ {job.steps.length > 0 && ( +
+ {job.steps.map((step) => ( +
+ {step.name} + {step.conclusion ?? step.status} +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( +
+ No inline output is available for this check. +
+ )} + + {openUrl && ( +
+ +
+ )} +
+ )} +
+ ) + } + + if (loading && list.length === 0) { + return ( + <> + {variant === 'compact' ? compactHeader : null} +
+ +
+ + ) + } + if (list.length === 0) { + if (variant === 'page') { + return ( +
+
+ +
+ + No checks found + + + This pull request has no reported checks yet. + +
+ {actions} +
+
+ ) + } + return ( + <> + {compactHeader} +
+ +
No checks reported yet
+
+ + ) + } + if (variant === 'page') { + const countChips: { label: string; className: string }[] = [] + if (counts.passing > 0) { + countChips.push({ label: `${counts.passing} passing`, className: CHECK_COLOR.success }) + } + if (counts.failing > 0) { + countChips.push({ label: `${counts.failing} failing`, className: CHECK_COLOR.failure }) + } + if (counts.pending > 0) { + countChips.push({ label: `${counts.pending} pending`, className: CHECK_COLOR.pending }) + } + if (counts.skipped + counts.neutral > 0) { + countChips.push({ + label: `${counts.skipped + counts.neutral} skipped`, + className: 'text-muted-foreground' + }) + } + 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)} +
+ ))} +
+
+ ) + } + return ( + <> + {compactHeader} +
+ {sorted.map(renderCheckRow)} +
+ + ) +} + +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 ? filterMentionOptions(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) => ( + + ))} +
+ )} +