Show GitHub PR reviewer names in task rows (#2309)

This commit is contained in:
Jinjing 2026-05-18 23:06:54 -07:00 committed by GitHub
parent 94f47c3e32
commit a6ca940d0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1257 additions and 369 deletions

View File

@ -114,7 +114,16 @@ describe('listWorkItems', () => {
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/add-feature',
baseRefName: 'main'
baseRefName: 'main',
reviewRequests: [
{
requestedReviewer: {
login: 'AmethystLiang',
name: 'Amethyst Liang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
}
]
}
])
})
@ -147,7 +156,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--assignee',
@ -157,7 +166,7 @@ describe('listWorkItems', () => {
)
const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',')
expect(prListFields).not.toContain('statusCheckRollup')
expect(prListFields).not.toContain('reviewRequests')
expect(prListFields).toContain('reviewRequests')
expect(prListFields).not.toContain('mergeStateStatus')
expect(items).toEqual([
{
@ -182,7 +191,14 @@ describe('listWorkItems', () => {
updatedAt: '2026-03-28T00:00:00Z',
author: 'octocat',
branchName: 'feature/add-feature',
baseRefName: 'main'
baseRefName: 'main',
reviewRequests: [
{
login: 'AmethystLiang',
name: 'Amethyst Liang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
}
])
})
@ -215,7 +231,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
@ -315,7 +331,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
@ -354,26 +370,24 @@ describe('listWorkItems', () => {
it('marks fork PRs as cross-repository when REST payload only includes head.label', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 1849,
title: 'Fork PR with missing head repo',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/1849',
updated_at: '2026-04-01T00:00:00Z',
user: { login: 'contributor' },
head: {
ref: 'feat/onboarding-model-choice-782',
repo: null,
label: 'contributor:feat/onboarding-model-choice-782'
},
base: { ref: 'main' }
}
])
})
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 1849,
title: 'Fork PR with missing head repo',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/1849',
updated_at: '2026-04-01T00:00:00Z',
user: { login: 'contributor' },
head: {
ref: 'feat/onboarding-model-choice-782',
repo: null,
label: 'contributor:feat/onboarding-model-choice-782'
},
base: { ref: 'main' }
}
])
})
const { items } = await listWorkItems('/repo-root', 10)
expect(items).toEqual([

View File

@ -84,6 +84,7 @@ vi.mock('./rate-limit', () => ({
import {
getPRComments,
getPRForBranch,
getWorkItem,
getPullRequestPushTarget,
mergePR,
resolveReviewThread,
@ -824,17 +825,46 @@ describe('getPRForBranch', () => {
remoteName: 'origin',
branchName: 'feature/test'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['api', 'repos/fork/orca/pulls/1849'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/fork/orca/pulls/1849'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/stablyai/orca/pulls/1849'],
{ cwd: '/repo-root' }
)
})
it('normalizes reviewer avatars from REST pull request payloads', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 42,
title: 'Review me',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updated_at: '2026-03-28T00:00:00Z',
user: { login: 'author' },
draft: false,
requested_reviewers: [
{
login: 'AmethystLiang',
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
})
await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({
reviewRequests: [
{
login: 'AmethystLiang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
})
})
describe('GitHub GraphQL rate-limit guard', () => {

View File

@ -249,11 +249,12 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner'
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests'
// Why: these fields are intentionally excluded from `gh pr list` because
// statusCheckRollup/review/merge metadata fan out into expensive GraphQL work
// across every row. Fetch them only for single-PR detail surfaces.
// statusCheckRollup/review decision/merge metadata fan out into expensive
// GraphQL work across every row. Requested reviewers are kept in the list
// payload because the Tasks table renders that column on first paint.
const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify'
@ -342,7 +343,12 @@ function userFromUnknown(
return {
login,
name: typeof raw.name === 'string' ? raw.name : null,
avatarUrl: typeof raw.avatarUrl === 'string' ? raw.avatarUrl : ''
avatarUrl:
typeof raw.avatarUrl === 'string'
? raw.avatarUrl
: typeof raw.avatar_url === 'string'
? raw.avatar_url
: ''
}
}
@ -2504,7 +2510,7 @@ export async function requestPRReviewers(
): Promise<{ ok: true } | { ok: false; error: string }> {
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
if (logins.length === 0) {
return { ok: false, error: 'Enter at least one reviewer login' }
return { ok: false, error: 'Enter at least one reviewer' }
}
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = await getOwnerRepo(repoPath, connectionId)

View File

@ -34,11 +34,13 @@ import {
RefreshCw,
Send,
UndoDot,
Users,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { VisuallyHidden } from 'radix-ui'
import {
@ -84,6 +86,11 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import {
appendGitHubPRRequestedReviewers,
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
} from '@/components/github-pr-reviewer-display'
import type {
GitHubOwnerRepo,
GitHubPRFile,
@ -177,6 +184,10 @@ type GitHubItemDialogProps = {
repoId?: string | null
/** 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
@ -359,6 +370,193 @@ function WorkItemStateBadge({
)
}
function ReviewerAvatar({
login,
avatarUrl
}: {
login: string
avatarUrl: string
}): React.JSX.Element {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
loading="lazy"
decoding="async"
title={login}
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
/>
)
}
return (
<span
title={login}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted text-[10px] font-medium text-muted-foreground"
>
{login.slice(0, 1).toUpperCase()}
</span>
)
}
function PRReviewersPanel({
item,
loading,
repoPath,
onReviewersRequested
}: {
item: GitHubWorkItem
loading: boolean
repoPath: string | null
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const [reviewerInput, setReviewerInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
() => item.reviewRequests ?? []
)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
useEffect(() => {
setLocalReviewRequests(item.reviewRequests ?? [])
}, [item.id, item.reviewRequests])
const displayItem = { ...item, reviewRequests: localReviewRequests }
const reviewers = getGitHubPRReviewerRows(displayItem)
const selectedReviewerLogins = useMemo(
() =>
new Set(
localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
),
[localReviewRequests]
)
const hasReviewerMetadata =
item.reviewDecision !== undefined ||
localReviewRequests.length > 0 ||
item.reviewRequests !== undefined ||
item.latestReviews !== undefined
const canRequestReview =
!!repoPath || getActiveRuntimeTarget(useAppStore.getState().settings).kind === 'environment'
const handleRequestReview = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (submitting) {
return
}
const logins = normalizeGitHubReviewerLogins(
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(useAppStore.getState().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 = appendGitHubPRRequestedReviewers(localReviewRequests, logins)
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)
}
}
return (
<aside className="rounded-lg border border-border/50 bg-card/50 shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<Users className="size-3.5 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">Reviewers</span>
{reviewers.length > 0 ? (
<span className="ml-auto rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{reviewers.length}
</span>
) : null}
</div>
<div className="px-3 py-2.5">
{loading && !hasReviewerMetadata ? (
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
Loading reviewers
</div>
) : reviewers.length > 0 ? (
<div className="flex flex-col gap-2">
{reviewers.map((reviewer) => (
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{reviewer.login}
</div>
{reviewer.name ? (
<div className="truncate text-[11px] text-muted-foreground">
{reviewer.name}
</div>
) : null}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{reviewer.stateLabel}
</span>
</div>
))}
</div>
) : (
<div className="py-1 text-[12px] text-muted-foreground">No reviewers requested.</div>
)}
<form className="mt-3 flex items-center gap-2" onSubmit={handleRequestReview}>
<Input
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
disabled={submitting || !canRequestReview}
placeholder="Reviewer"
aria-label="Reviewer"
className="h-8 min-w-0 flex-1 rounded-md border-border/50 bg-background text-xs"
/>
<Button
type="submit"
size="sm"
disabled={submitting || !canRequestReview || reviewerInput.trim().length === 0}
className="h-8 shrink-0 px-2.5 text-xs"
>
{submitting ? <LoaderCircle className="size-3.5 animate-spin" /> : 'Request'}
</Button>
</form>
</div>
</aside>
)
}
function fileStatusTone(status: GitHubPRFile['status']): string {
switch (status) {
case 'added':
@ -743,6 +941,25 @@ function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void {
})
}
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
})
}
// 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
@ -1484,7 +1701,8 @@ function ConversationTab({
onUse,
onMutated,
onChecksUpdated,
onCommentAdded
onCommentAdded,
onReviewersRequested
}: {
item: GitHubWorkItem
repoPath: string | null
@ -1504,6 +1722,7 @@ function ConversationTab({
onMutated: () => void
onChecksUpdated: (checks: PRCheckDetail[]) => void
onCommentAdded: (comment: PRComment) => void
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const authorLabel = item.author ?? 'unknown'
const [replyingTo, setReplyingTo] = useState<number | null>(null)
@ -1573,7 +1792,7 @@ function ConversationTab({
const startWorkspaceButton = (
<Button
onClick={() => onUse(item)}
className="w-full justify-center gap-2"
className="self-start justify-center gap-2 xl:self-stretch"
aria-label={`Start workspace from ${item.type === 'pr' ? 'PR' : 'issue'}`}
>
{`Start workspace from ${item.type === 'pr' ? 'PR' : 'issue'}`}
@ -1594,6 +1813,12 @@ function ConversationTab({
onStateChange={onStateChange}
onMutated={onMutated}
/>
<PRReviewersPanel
item={item}
loading={loading}
repoPath={repoPath}
onReviewersRequested={onReviewersRequested}
/>
<aside className="rounded-lg border border-border/50 bg-card/50 shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<CircleDashed className="size-3.5 text-muted-foreground" />
@ -1899,10 +2124,10 @@ function PRActionsPanel({
const applyStatePatch = useCallback(
(state: GitHubWorkItem['state']) => {
onStateChange(state)
patchWorkItem(item.id, { state })
patchWorkItem(item.id, { state }, item.repoId)
patchProjectRowIfNeeded(state)
},
[item.id, onStateChange, patchProjectRowIfNeeded, patchWorkItem]
[item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem]
)
const handleStateChange = async (): Promise<void> => {
@ -1980,7 +2205,7 @@ function PRActionsPanel({
type="button"
variant={nextState === 'closed' ? 'destructive' : 'secondary'}
size="sm"
className="w-full justify-center gap-2"
className="w-fit justify-center gap-2 xl:w-full"
disabled={!canMutateState || statePending}
onClick={() => void handleStateChange()}
>
@ -2002,7 +2227,7 @@ function PRActionsPanel({
type="button"
variant="outline"
size="sm"
className="w-full justify-center gap-2"
className="w-fit justify-center gap-2 xl:w-full"
disabled={mergePending || localState === 'closed' || localState === 'merged'}
>
{mergePending ? (
@ -2668,16 +2893,16 @@ function GHEditSection({
}),
onOptimistic: () => {
onStateChange(newState)
patchWorkItem(item.id, { state: newState })
patchWorkItem(item.id, { state: newState }, item.repoId)
patchProjectRowIfNeeded({ state: newState })
},
onRevert: () => {
onStateChange(prevState)
patchWorkItem(item.id, { state: prevState })
patchWorkItem(item.id, { state: prevState }, item.repoId)
patchProjectRowIfNeeded({ state: prevState })
},
onSuccess: () => {
patchWorkItem(item.id, { state: newState })
patchWorkItem(item.id, { state: newState }, item.repoId)
patchProjectRowIfNeeded({ state: newState })
onMutated()
},
@ -2717,7 +2942,7 @@ function GHEditSection({
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
patchWorkItem(item.id, { labels: newLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: newLabels })
},
onSuccess: () => {
@ -2725,7 +2950,7 @@ function GHEditSection({
},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
patchWorkItem(item.id, { labels: prevLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: prevLabels })
},
onError: (err) => toast.error(err)
@ -2742,12 +2967,12 @@ function GHEditSection({
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
patchWorkItem(item.id, { labels: newLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: newLabels })
},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
patchWorkItem(item.id, { labels: prevLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: prevLabels })
},
onSuccess: () => {
@ -3202,6 +3427,7 @@ export default function GitHubItemDialog({
repoId,
projectOrigin,
onUse,
onReviewRequestsChange,
onClose
}: GitHubItemDialogProps): React.JSX.Element {
const [tab, setTab] = useState<ItemDialogTab>('conversation')
@ -3445,6 +3671,28 @@ export default function GitHubItemDialog({
}, [repoPath, effectiveRepoId, workItem, detailsCacheKey, refetchTick])
const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot
const displayWorkItem = useMemo<GitHubWorkItem | null>(() => {
if (!workItem) {
return null
}
if (!details?.item) {
return workItem
}
return { ...workItem, ...details.item, repoId: workItem.repoId }
}, [details?.item, workItem])
useEffect(() => {
if (!workItem || details?.item.reviewRequests === undefined) {
return
}
// Why: PR details can carry fresher reviewer metadata than the list row;
// push it back so the Tasks review chip doesn't keep a stale snapshot.
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
details.item.reviewRequests
)
}, [details?.item.reviewRequests, onReviewRequestsChange, workItem])
const body = details?.body ?? ''
const comments = details?.comments ?? []
const files = details?.files ?? []
@ -3725,7 +3973,7 @@ export default function GitHubItemDialog({
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<TabsContent value="conversation" className="mt-0">
<ConversationTab
item={workItem}
item={displayWorkItem ?? workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
body={body}
@ -3756,6 +4004,15 @@ export default function GitHubItemDialog({
}
}}
onCommentAdded={appendOptimisticComment}
onReviewersRequested={(nextReviewRequests) => {
if (detailsCacheKey) {
patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests)
}
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
nextReviewRequests
)
}}
/>
</TabsContent>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import {
appendGitHubPRRequestedReviewers,
getGitHubPRPrimaryReviewer,
getGitHubPRReviewerRows,
getGitHubPRReviewLabel,
normalizeGitHubReviewerLogins
} from './github-pr-reviewer-display'
function item(patch: Partial<GitHubWorkItem>): GitHubWorkItem {
return patch as GitHubWorkItem
}
describe('GitHub PR reviewer display', () => {
it('shows the requested reviewer instead of a request count', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }]
})
)
).toBe('ExampleReviewer')
})
it('keeps multiple reviewers compact while still naming the first reviewer', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewRequests: [
{ login: 'ExampleReviewer', name: null, avatarUrl: '' },
{ login: 'agent-slack', name: null, avatarUrl: '' },
{ login: 'stably', name: null, avatarUrl: '' }
]
})
)
).toBe('ExampleReviewer +2')
})
it('preserves stronger review decision labels', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewDecision: 'APPROVED',
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }]
})
)
).toBe('Approved')
})
it('falls back to reviewed users and empty metadata labels', () => {
expect(getGitHubPRReviewLabel(item({ latestReviews: [{ login: 'reviewer' }] }))).toBe(
'reviewer'
)
expect(getGitHubPRReviewLabel(item({ reviewRequests: [] }))).toBe('No reviewers')
expect(getGitHubPRReviewLabel(item({}))).toBe('Reviewers')
})
it('returns the primary reviewer avatar without requiring another lookup', () => {
expect(
getGitHubPRPrimaryReviewer(
item({
reviewRequests: [
{
login: 'ExampleReviewer',
name: null,
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
)
).toEqual({
login: 'ExampleReviewer',
name: null,
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
})
})
it('builds reviewer rows for requested and reviewed users', () => {
expect(
getGitHubPRReviewerRows(
item({
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }],
latestReviews: [
{ login: 'reviewer', state: 'APPROVED', avatarUrl: 'avatar-2' },
{ login: 'ExampleReviewer', state: 'COMMENTED', avatarUrl: 'avatar-1b' }
]
})
)
).toEqual([
{
login: 'ExampleReviewer',
name: null,
avatarUrl: 'avatar-1',
stateLabel: 'Requested'
},
{
login: 'reviewer',
name: null,
avatarUrl: 'avatar-2',
stateLabel: 'Approved'
}
])
})
it('appends requested reviewers without duplicating existing logins', () => {
expect(
appendGitHubPRRequestedReviewers(
[{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }],
['examplereviewer', '@new-reviewer']
)
).toEqual([
{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' },
{ login: 'new-reviewer', name: null, avatarUrl: '' }
])
})
it('normalizes reviewer input before sending it to GitHub', () => {
expect(
normalizeGitHubReviewerLogins(
[' @ExampleReviewer ', 'examplereviewer', '@new-reviewer'],
new Set(['existing'])
)
).toEqual(['ExampleReviewer', 'new-reviewer'])
})
})

View File

@ -0,0 +1,164 @@
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
type ReviewDisplayItem = Pick<GitHubWorkItem, 'reviewDecision' | 'reviewRequests' | 'latestReviews'>
export type GitHubPRPrimaryReviewer = Pick<GitHubAssignableUser, 'login' | 'avatarUrl'> & {
name?: string | null
}
export type GitHubPRReviewerRow = GitHubPRPrimaryReviewer & {
stateLabel: string
}
function uniqueLogins(logins: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const login of logins) {
const trimmed = login?.trim()
if (!trimmed) {
continue
}
const key = trimmed.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(trimmed)
}
return result
}
export function normalizeGitHubReviewerLogins(
logins: readonly string[],
excludedLogins: ReadonlySet<string> = new Set()
): string[] {
return uniqueLogins(logins.map((login) => login.trim().replace(/^@/, ''))).filter(
(login) => !excludedLogins.has(login.toLowerCase())
)
}
function formatReviewerLogins(logins: readonly string[]): string | null {
if (logins.length === 0) {
return null
}
if (logins.length === 1) {
return logins[0]
}
return `${logins[0]} +${logins.length - 1}`
}
function formatReviewState(state: string | null | undefined): string {
switch (state) {
case 'APPROVED':
return 'Approved'
case 'CHANGES_REQUESTED':
return 'Changes requested'
case 'COMMENTED':
return 'Commented'
case 'DISMISSED':
return 'Dismissed'
case 'PENDING':
return 'Pending'
default:
return 'Reviewed'
}
}
export function getGitHubPRReviewLabel(item: ReviewDisplayItem): string {
if (
item.reviewDecision === undefined &&
item.reviewRequests === undefined &&
item.latestReviews === undefined
) {
return 'Reviewers'
}
if (item.reviewDecision === 'APPROVED') {
return 'Approved'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'Changes requested'
}
const requestedLabel = formatReviewerLogins(
uniqueLogins((item.reviewRequests ?? []).map((user) => user.login))
)
if (requestedLabel) {
return requestedLabel
}
const reviewedLabel = formatReviewerLogins(
uniqueLogins((item.latestReviews ?? []).map((review) => review.login))
)
if (reviewedLabel) {
return reviewedLabel
}
return 'No reviewers'
}
export function getGitHubPRPrimaryReviewer(
item: ReviewDisplayItem
): GitHubPRPrimaryReviewer | null {
const requested = (item.reviewRequests ?? []).find((user) => user.login.trim())
if (requested) {
return requested
}
const reviewed = (item.latestReviews ?? []).find((review) => review.login.trim())
if (reviewed) {
return {
login: reviewed.login,
avatarUrl: reviewed.avatarUrl ?? '',
name: null
}
}
return null
}
export function getGitHubPRReviewerRows(item: ReviewDisplayItem): GitHubPRReviewerRow[] {
const byLogin = new Map<string, GitHubPRReviewerRow>()
for (const user of item.reviewRequests ?? []) {
const login = user.login.trim()
if (!login) {
continue
}
byLogin.set(login.toLowerCase(), {
login,
name: user.name,
avatarUrl: user.avatarUrl,
stateLabel: 'Requested'
})
}
for (const review of item.latestReviews ?? []) {
const login = review.login.trim()
const key = login.toLowerCase()
if (!login || byLogin.has(key)) {
continue
}
byLogin.set(key, {
login,
name: null,
avatarUrl: review.avatarUrl ?? '',
stateLabel: formatReviewState(review.state)
})
}
return Array.from(byLogin.values())
}
export function appendGitHubPRRequestedReviewers(
current: readonly GitHubAssignableUser[],
logins: readonly string[]
): GitHubAssignableUser[] {
const byLogin = new Map<string, GitHubAssignableUser>()
for (const user of current) {
const login = user.login.trim()
if (login) {
byLogin.set(login.toLowerCase(), user)
}
}
for (const rawLogin of logins) {
const login = rawLogin.trim().replace(/^@/, '')
if (!login) {
continue
}
const key = login.toLowerCase()
if (!byLogin.has(key)) {
byLogin.set(key, { login, name: null, avatarUrl: '' })
}
}
return Array.from(byLogin.values())
}

View File

@ -13,6 +13,11 @@ import type {
GitHubProjectRow as GitHubProjectRowType
} from '../../../../shared/github-project-types'
const PROJECT_FROZEN_COLUMN_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]'
const PROJECT_FROZEN_COLUMN_HOVER_SURFACE_CLASS =
'group-hover/project-row:[background:color-mix(in_srgb,var(--accent)_60%,var(--background))]'
type Props = {
row: GitHubProjectRowType
fields: GitHubProjectField[]
@ -55,15 +60,32 @@ export default function ProjectRow({
const rowInner = (
<div
className={cn(
'group grid min-h-10 items-stretch gap-3 border-b border-border/30 px-3 hover:bg-accent/60',
'group group/project-row grid min-h-10 items-stretch gap-3 border-b border-border/30 px-3 hover:bg-accent/60',
disabled && 'opacity-60'
)}
style={{ gridTemplateColumns: gridTemplate }}
>
{fields.map((f, idx) => {
const next = fields[idx + 1]
const frozen = idx < 2
return (
<div key={f.id} className="relative flex min-w-0 items-stretch overflow-hidden">
<div
key={f.id}
className={cn(
'flex min-w-0 items-stretch overflow-hidden',
!frozen && 'relative',
frozen &&
cn(
'relative z-10 before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit',
PROJECT_FROZEN_COLUMN_SURFACE_CLASS,
PROJECT_FROZEN_COLUMN_HOVER_SURFACE_CLASS
),
idx === 1 && 'border-r border-border/40'
)}
style={
frozen ? { transform: 'translateX(var(--project-scroll-left, 0px))' } : undefined
}
>
<div className="flex min-w-0 flex-1 items-stretch overflow-hidden">
<ProjectCell
row={row}

View File

@ -8,7 +8,7 @@ import ProjectRow from './ProjectRow'
import { groupRows, sortRows } from './group-sort'
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
import {
buildGridTemplate,
ACTION_COLUMN_WIDTH,
loadColumnWidths,
MIN_COLUMN_WIDTH,
resolveWidth,
@ -25,6 +25,24 @@ import type {
type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection }
const PROJECT_FROZEN_COLUMN_HEADER_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--background)_95%,var(--muted))]'
function buildProjectGridTemplate(
fields: GitHubProjectField[],
widths: Readonly<Record<string, number>>
): string {
// Why: the first two columns are frozen during horizontal scroll, so their
// actual widths must be deterministic for the second sticky offset.
const cols = fields.map((field, index) =>
index < 2
? `${resolveWidth(field, widths)}px`
: `minmax(${MIN_COLUMN_WIDTH}px, ${resolveWidth(field, widths)}fr)`
)
cols.push(`${ACTION_COLUMN_WIDTH}px`)
return cols.join(' ')
}
type Props = {
table: GitHubProjectTable
onOpenDialog?: (row: GitHubProjectRow) => void
@ -94,7 +112,16 @@ export default function ProjectViewList({
[scopeKey]
)
const gridTemplate = useMemo(() => buildGridTemplate(fields, widths), [fields, widths])
const gridTemplate = useMemo(() => buildProjectGridTemplate(fields, widths), [fields, widths])
const handleListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
// Why: frozen columns need the horizontal offset, but piping every scroll
// tick through React state rerenders the entire project row set.
event.currentTarget.style.setProperty(
'--project-scroll-left',
`${event.currentTarget.scrollLeft}px`
)
}, [])
const toggleColumn = (fieldId: string): void => {
setHidden((prev) => {
@ -167,7 +194,11 @@ export default function ProjectViewList({
: null
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto">
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto scrollbar-sleek"
style={{ '--project-scroll-left': '0px' } as React.CSSProperties}
onScroll={handleListScroll}
>
<ProjectHeaderRow
fields={fields}
availableFields={availableFields}
@ -265,8 +296,24 @@ function ProjectHeaderRow({
// user-resizable pair set), so omit its handle to keep the total
// table width invariant.
const next = fields[idx + 1]
const frozen = idx < 2
return (
<div key={f.id} className="relative flex min-w-0 items-center">
<div
key={f.id}
className={cn(
'flex min-w-0 items-center',
!frozen && 'relative',
frozen &&
cn(
'relative z-20 backdrop-blur before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit',
PROJECT_FROZEN_COLUMN_HEADER_SURFACE_CLASS
),
idx === 1 && 'border-r border-border/50'
)}
style={
frozen ? { transform: 'translateX(var(--project-scroll-left, 0px))' } : undefined
}
>
<button
type="button"
onClick={() => onSortClick(f.id)}

View File

@ -84,6 +84,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
)
const [loading, setLoading] = useState(false)
const fetchRunIdRef = useRef(0)
const [error, setError] = useState<{
error: GitHubProjectViewError
totalCount?: number
@ -111,6 +112,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const doFetch = useCallback(
async (selection: ResolvedProjectSelection, force = false, queryOverride?: string) => {
const runId = fetchRunIdRef.current + 1
fetchRunIdRef.current = runId
setLoading(true)
setError(null)
try {
@ -128,7 +131,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
setError({ error: res.error, totalCount: res.totalCount })
}
} finally {
setLoading(false)
// Why: a manual refresh can overlap with a tab/search fetch; an older
// request finishing first must not clear the newer refresh indicator.
if (fetchRunIdRef.current === runId) {
setLoading(false)
}
}
},
[fetchProjectViewTable]
@ -582,7 +589,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex flex-none items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2">
<div className="flex min-w-0 flex-none flex-wrap items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2">
<ProjectPicker
activeProject={
activeProject && table
@ -665,7 +672,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
<Button
variant="outline"
size="icon"
className="h-7 w-7"
className="h-7 w-7 cursor-pointer disabled:pointer-events-auto disabled:cursor-wait"
onClick={() => {
if (!activeProject || !currentCacheKey) {
return
@ -686,7 +693,10 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
currentAppliedOverride
)
}}
aria-label="Refresh"
disabled={loading}
aria-busy={loading}
aria-label={loading ? 'Refreshing' : 'Refresh'}
title={loading ? 'Refreshing' : 'Refresh'}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
@ -899,7 +909,7 @@ function ProjectSearchInput({
}, [])
return (
<div className="relative min-w-[280px] flex-1 max-w-xl">
<div className="relative min-w-0 max-w-xl flex-1 basis-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}

View File

@ -0,0 +1,25 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import CommentMarkdown from './CommentMarkdown'
describe('CommentMarkdown', () => {
it('contains long PR body markdown inside its available width', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
content={[
'`src/main/hooks.ts:289 getEffectiveHookScript with policy=shared-only returns yamlScript?.trim() only; localScript is ignored`',
'',
'```',
'const veryLongLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";',
'```'
].join('\n')}
/>
)
expect(markup).toContain('min-w-0')
expect(markup).toContain('max-w-full')
expect(markup).toContain('[overflow-wrap:anywhere]')
expect(markup).toContain('overflow-x-auto')
})
})

View File

@ -38,11 +38,13 @@ const compactComponents: Components = {
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono">{children}</code>
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
{children}
</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments
pre: ({ children }) => (
<pre className="my-1 rounded bg-accent p-1.5 text-[10px] font-mono overflow-x-auto max-h-32">
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
{children}
</pre>
),
@ -90,7 +92,7 @@ const compactComponents: Components = {
// overflow container keeps the card layout stable while still letting the
// user scroll to see the full table.
table: ({ children }) => (
<div className="my-1 overflow-x-auto">
<div className="my-1 max-w-full overflow-x-auto">
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
{children}
</table>
@ -112,10 +114,12 @@ const documentComponents: Components = {
</a>
),
code: ({ children }) => (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em]">{children}</code>
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="my-3 max-h-80 overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
@ -207,6 +211,7 @@ const CommentMarkdown = React.memo(
// The descendant selector (pre code) has higher specificity than the
// direct utility classes on <code>, so these overrides win reliably.
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',
'min-w-0 max-w-full [overflow-wrap:anywhere]',
className
)}
{...rest}

View File

@ -7,6 +7,7 @@ import {
buildTaskPageRepoSourceState,
findTaskPageDialogWorkItem,
findTaskPageLinearDrawerIssue,
reconcileTaskPagePagesWithWorkItemsCache,
selectTaskPageWorkItemsCacheEntries
} from './task-page-cache-selectors'
@ -73,6 +74,26 @@ describe('task page cache selectors', () => {
expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2' })).toBeNull()
})
it('reconciles paged table rows with patched work-item cache entries', () => {
const stale = {
...workItem('pr-1', 'repo-1'),
reviewRequests: []
}
const patched = {
...stale,
reviewRequests: [{ login: 'AmethystLiang', name: null, avatarUrl: '' }]
}
const otherRepoSameId = workItem('pr-1', 'repo-2')
const pages = [[stale, otherRepoSameId]]
const nextPages = reconcileTaskPagePagesWithWorkItemsCache(pages, [
entry<GitHubWorkItem[]>([patched])
])
expect(nextPages[0][0]).toBe(patched)
expect(nextPages[0][1]).toBe(otherRepoSameId)
})
it('returns null while the Linear drawer is closed and finds open issues by stable reference', () => {
const issue = linearIssue('LIN-1')
const searchIssue = linearIssue('LIN-2')

View File

@ -51,6 +51,39 @@ export function buildTaskPageRepoSourceState(
})
}
function taskPageWorkItemCacheKey(item: GitHubWorkItem): string {
return `${item.repoId}\u0000${item.id}`
}
export function reconcileTaskPagePagesWithWorkItemsCache(
pages: readonly GitHubWorkItem[][],
entries: readonly (CacheEntry<GitHubWorkItem[]> | undefined)[]
): GitHubWorkItem[][] {
const cachedItems = new Map<string, GitHubWorkItem>()
for (const entry of entries) {
for (const item of entry?.data ?? []) {
cachedItems.set(taskPageWorkItemCacheKey(item), item)
}
}
let changed = false
const nextPages = pages.map((page) => {
let pageChanged = false
const nextPage = page.map((item) => {
const cached = cachedItems.get(taskPageWorkItemCacheKey(item))
if (!cached || cached === item) {
return item
}
pageChanged = true
changed = true
return cached
})
return pageChanged ? nextPage : page
})
return changed ? nextPages : (pages as GitHubWorkItem[][])
}
export function findTaskPageDialogWorkItem(
workItemsCache: WorkItemsCache,
dialogWorkItemKey: TaskPageDialogWorkItemKey

View File

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { createGitHubSlice, workItemsCacheKey } from './github'
import type { AppState } from '../types'
import type { PRInfo } from '../../../../shared/types'
import type { GitHubWorkItem, PRInfo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
@ -163,6 +163,49 @@ describe('createGitHubSlice.evictGitHubRepoCaches', () => {
})
})
describe('createGitHubSlice.patchWorkItem', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
it('can scope patches to one repo when different repos have the same work-item id', () => {
const store = createTestStore()
const repoOneItem = {
id: 'pr:42',
repoId: 'repo-1',
type: 'pr',
number: 42,
title: 'Repo one PR'
} as GitHubWorkItem
const repoTwoItem = {
id: 'pr:42',
repoId: 'repo-2',
type: 'pr',
number: 42,
title: 'Repo two PR'
} as GitHubWorkItem
store.setState({
workItemsCache: {
[workItemsCacheKey('repo-1', 20, '')]: { data: [repoOneItem], fetchedAt: 1 },
[workItemsCacheKey('repo-2', 20, '')]: { data: [repoTwoItem], fetchedAt: 1 }
}
})
store.getState().patchWorkItem('pr:42', { reviewRequests: [] }, 'repo-1')
const state = store.getState()
const repoOnePatched = state.workItemsCache[workItemsCacheKey('repo-1', 20, '')]?.data?.[0]
const repoTwoPatched = state.workItemsCache[workItemsCacheKey('repo-2', 20, '')]?.data?.[0]
expect(repoOnePatched).toMatchObject({
repoId: 'repo-1',
reviewRequests: []
})
expect(repoTwoPatched).toBe(repoTwoItem)
})
})
describe('createGitHubSlice.fetchPRChecks', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -585,7 +585,7 @@ export type GitHubSlice = {
* "new workspace" buttons) to warm the cache before the page mounts.
*/
prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void
patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>) => void
patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>, repoId?: string | null) => void
/**
* Monotonic counter bumped whenever a repo's issue-source preference is
* flipped. Subscribers (TaskPage's fetch effect) include this in their
@ -1663,7 +1663,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
},
patchWorkItem: (itemId, patch) => {
patchWorkItem: (itemId, patch, repoId) => {
set((s) => {
const nextCache = { ...s.workItemsCache }
let changed = false
@ -1672,7 +1672,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (!entry?.data) {
continue
}
const idx = entry.data.findIndex((item) => item.id === itemId)
// Why: GitHub issue/PR ids are only unique within a repo. Cross-repo
// task views can contain the same `pr:42` id from multiple repos.
const idx = entry.data.findIndex(
(item) => item.id === itemId && (!repoId || item.repoId === repoId)
)
if (idx === -1) {
continue
}