Show GitHub PR reviewer names in task rows (#2309)
This commit is contained in:
parent
94f47c3e32
commit
a6ca940d0f
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue