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