Skip to content

Commit 226bbe5

Browse files
sheepbox8646claude
andcommitted
feat(client): anchor review composer to selected diff lines with pending review flow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7cb6fa2 commit 226bbe5

3 files changed

Lines changed: 238 additions & 11 deletions

File tree

packages/client/src/renderer/i18n/locales/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,6 +2311,24 @@
23112311
"reply": "replied",
23122312
"diff": {
23132313
"viewThread": "View review thread"
2314+
},
2315+
"anchor": {
2316+
"commentingOn": "Commenting on {path} · {range}",
2317+
"oldSide": "(old code)",
2318+
"clear": "Clear selected lines"
2319+
},
2320+
"addSingleComment": "Add single comment",
2321+
"addToReview": "Add to review",
2322+
"singleDisabledPending": "You have a pending review; new comments join it.",
2323+
"threadError": "The comment could not be added. Reselect the lines and try again.",
2324+
"pending": {
2325+
"count": "{count} pending review comments",
2326+
"discard": "Discard review",
2327+
"discardTitle": "Discard pending review?",
2328+
"discardDescription": "All pending review comments will be deleted. This cannot be undone.",
2329+
"discardConfirm": "Discard",
2330+
"discardCancel": "Cancel",
2331+
"discardError": "The pending review could not be discarded."
23142332
}
23152333
},
23162334
"reviewDecision": {

packages/client/src/renderer/i18n/locales/zh.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,6 +2311,24 @@
23112311
"reply": "回复了",
23122312
"diff": {
23132313
"viewThread": "查看评论线程"
2314+
},
2315+
"anchor": {
2316+
"commentingOn": "评论 {path} · {range}",
2317+
"oldSide": "(旧代码)",
2318+
"clear": "清除所选行"
2319+
},
2320+
"addSingleComment": "单条评论",
2321+
"addToReview": "加入 Review",
2322+
"singleDisabledPending": "存在待提交的 review,新评论将加入其中。",
2323+
"threadError": "评论添加失败,请重新选择行后重试。",
2324+
"pending": {
2325+
"count": "{count} 条待提交评论",
2326+
"discard": "丢弃 Review",
2327+
"discardTitle": "丢弃待提交的 review?",
2328+
"discardDescription": "所有待提交的 review 评论都会被删除,且无法恢复。",
2329+
"discardConfirm": "丢弃",
2330+
"discardCancel": "取消",
2331+
"discardError": "待提交的 review 丢弃失败。"
23142332
}
23152333
},
23162334
"reviewDecision": {

packages/client/src/renderer/pages/pull-request/components/pull-request-review-tab.vue

Lines changed: 202 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,30 @@ import { computed, onMounted, ref } from 'vue'
44
import { useI18n } from 'vue-i18n'
55
import {
66
Button,
7+
Dialog,
8+
DialogContent,
9+
DialogDescription,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
713
Empty,
814
EmptyDescription,
915
EmptyHeader,
1016
EmptyMedia,
1117
EmptyTitle,
1218
Skeleton,
1319
} from '@oh-my-github/ui'
14-
import { AlertCircle, FileDiff } from 'lucide-vue-next'
20+
import { AlertCircle, FileDiff, MessageSquarePlus, X } from 'lucide-vue-next'
1521
import { ChangedFilesTree, ConversationCommentComposer } from '@/components'
22+
import { useReviewSelection } from '@/composables/use-review-selection'
1623
import {
24+
addPullRequestReviewThread,
25+
discardPendingPullRequestReview,
26+
submitPendingPullRequestReview,
1727
submitPullRequestReview,
1828
usePullRequestFilesQuery,
29+
usePullRequestReviewThreadsQuery,
30+
useReviewThreadsInvalidation,
1931
} from '@/composables/github/use-pull-requests'
2032
2133
const props = defineProps<{
@@ -45,10 +57,38 @@ const showFilesEmpty = computed(() =>
4557
&& files.value.length === 0
4658
)
4759
60+
const threadsQuery = usePullRequestReviewThreadsQuery(
61+
() => props.owner,
62+
() => props.repo,
63+
() => props.number,
64+
() => props.active,
65+
)
66+
const pendingReview = computed(() => threadsQuery.data.value?.pendingReview ?? null)
67+
const { invalidateReviewThreads } = useReviewThreadsInvalidation()
68+
const { selection, clearSelection } = useReviewSelection()
69+
4870
const reviewBody = ref('')
4971
const reviewError = ref<string | null>(null)
5072
const submittingEvent = ref<GitHubPullRequestReviewEvent | null>(null)
5173
const viewerLogin = ref<string | null>(null)
74+
const isSubmittingThread = ref<'single' | 'review' | null>(null)
75+
const isDiscardDialogOpen = ref(false)
76+
const isDiscardingPending = ref(false)
77+
78+
const activeSelection = computed(() => {
79+
const value = selection.value
80+
if (!value) return null
81+
if (value.owner !== props.owner || value.repo !== props.repo || value.number !== props.number) return null
82+
return value
83+
})
84+
85+
const anchorLabel = computed(() => {
86+
const anchor = activeSelection.value
87+
if (!anchor) return ''
88+
const range = anchor.startLine === null ? `L${anchor.line}` : `L${anchor.startLine}–L${anchor.line}`
89+
const label = t('pullRequest.review.anchor.commentingOn', { path: anchor.path, range })
90+
return anchor.side === 'LEFT' ? `${label} ${t('pullRequest.review.anchor.oldSide')}` : label
91+
})
5292
5393
const isOwnPullRequest = computed(() =>
5494
viewerLogin.value !== null
@@ -77,27 +117,87 @@ function retryFiles(): void {
77117
}
78118
79119
async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void> {
80-
if (submittingEvent.value) return
120+
if (submittingEvent.value || isSubmittingThread.value) return
81121
122+
const pending = pendingReview.value
82123
const body = reviewBody.value.trim()
83-
if (!body && event !== 'APPROVE') return
124+
if (!body && event !== 'APPROVE' && !pending) return
84125
85126
submittingEvent.value = event
86127
reviewError.value = null
87128
88129
try {
89-
await submitPullRequestReview(props.owner, props.repo, props.number, {
90-
event,
91-
...(body ? { body } : {}),
92-
})
130+
if (pending) {
131+
await submitPendingPullRequestReview(props.owner, props.repo, props.number, {
132+
reviewId: pending.id,
133+
event,
134+
...(body ? { body } : {}),
135+
})
136+
} else {
137+
await submitPullRequestReview(props.owner, props.repo, props.number, {
138+
event,
139+
...(body ? { body } : {}),
140+
})
141+
}
93142
reviewBody.value = ''
143+
invalidateReviewThreads(props.owner, props.repo, props.number)
94144
emit('refetch')
95145
} catch {
96146
reviewError.value = t('pullRequest.review.error')
97147
} finally {
98148
submittingEvent.value = null
99149
}
100150
}
151+
152+
async function submitLineComment(mode: 'single' | 'review'): Promise<void> {
153+
const anchor = activeSelection.value
154+
const body = reviewBody.value.trim()
155+
if (!anchor || !body || isSubmittingThread.value || submittingEvent.value) return
156+
157+
isSubmittingThread.value = mode
158+
reviewError.value = null
159+
160+
try {
161+
await addPullRequestReviewThread(props.owner, props.repo, props.number, {
162+
pullRequestId: props.pullRequest.nodeId,
163+
pendingReviewId: pendingReview.value?.id ?? null,
164+
mode,
165+
path: anchor.path,
166+
side: anchor.side,
167+
line: anchor.line,
168+
startLine: anchor.startLine,
169+
startSide: anchor.startLine === null ? null : anchor.side,
170+
body,
171+
})
172+
reviewBody.value = ''
173+
clearSelection()
174+
invalidateReviewThreads(props.owner, props.repo, props.number)
175+
emit('refetch')
176+
} catch {
177+
reviewError.value = t('pullRequest.review.threadError')
178+
} finally {
179+
isSubmittingThread.value = null
180+
}
181+
}
182+
183+
async function discardPendingReview(): Promise<void> {
184+
const pending = pendingReview.value
185+
if (!pending || isDiscardingPending.value) return
186+
187+
isDiscardingPending.value = true
188+
reviewError.value = null
189+
190+
try {
191+
await discardPendingPullRequestReview(props.owner, props.repo, pending.id)
192+
isDiscardDialogOpen.value = false
193+
invalidateReviewThreads(props.owner, props.repo, props.number)
194+
emit('refetch')
195+
} catch {
196+
reviewError.value = t('pullRequest.review.pending.discardError')
197+
} finally {
198+
isDiscardingPending.value = false
199+
}
200+
}
101201
</script>
102202

103203
<template>
@@ -108,13 +208,36 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
108208
{{ t('pullRequest.review.title') }}
109209
</h2>
110210
<span
111-
v-if="isOwnPullRequest"
211+
v-if="pendingReview"
212+
class="select-none text-body text-muted-foreground"
213+
>
214+
{{ t('pullRequest.review.pending.count', { count: pendingReview.commentCount }) }}
215+
</span>
216+
<span
217+
v-else-if="isOwnPullRequest"
112218
class="text-body text-muted-foreground"
113219
>
114220
{{ t('pullRequest.review.ownPullRequest') }}
115221
</span>
116222
</div>
117223

224+
<div
225+
v-if="activeSelection"
226+
class="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2"
227+
>
228+
<MessageSquarePlus class="size-4 shrink-0 text-muted-foreground" />
229+
<span class="min-w-0 flex-1 truncate text-body text-foreground">{{ anchorLabel }}</span>
230+
<Button
231+
:aria-label="t('pullRequest.review.anchor.clear')"
232+
size="icon-sm"
233+
type="button"
234+
variant="ghost"
235+
@click="clearSelection"
236+
>
237+
<X class="size-3.5" />
238+
</Button>
239+
</div>
240+
118241
<ConversationCommentComposer
119242
v-model="reviewBody"
120243
:error="reviewError"
@@ -124,9 +247,49 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
124247
:repo="repo"
125248
>
126249
<template #actions>
127-
<div class="flex shrink-0 items-center gap-2">
250+
<div
251+
v-if="activeSelection"
252+
class="flex shrink-0 items-center gap-2"
253+
>
254+
<Button
255+
:disabled="!hasBody || Boolean(pendingReview) || Boolean(isSubmittingThread)"
256+
:loading="isSubmittingThread === 'single'"
257+
loading-mode="leading"
258+
size="sm"
259+
:title="pendingReview ? t('pullRequest.review.singleDisabledPending') : undefined"
260+
type="button"
261+
variant="outline"
262+
@click="submitLineComment('single')"
263+
>
264+
{{ t('pullRequest.review.addSingleComment') }}
265+
</Button>
266+
<Button
267+
:disabled="!hasBody || Boolean(isSubmittingThread)"
268+
:loading="isSubmittingThread === 'review'"
269+
loading-mode="leading"
270+
size="sm"
271+
type="button"
272+
@click="submitLineComment('review')"
273+
>
274+
{{ t('pullRequest.review.addToReview') }}
275+
</Button>
276+
</div>
277+
<div
278+
v-else
279+
class="flex shrink-0 items-center gap-2"
280+
>
281+
<Button
282+
v-if="pendingReview"
283+
:disabled="Boolean(submittingEvent) || isDiscardingPending"
284+
size="sm"
285+
type="button"
286+
variant="ghost"
287+
@click="isDiscardDialogOpen = true"
288+
>
289+
{{ t('pullRequest.review.pending.discard') }}
290+
</Button>
128291
<Button
129-
:disabled="!hasBody || Boolean(submittingEvent)"
292+
:disabled="(!hasBody && !pendingReview) || Boolean(submittingEvent)"
130293
:loading="submittingEvent === 'COMMENT'"
131294
loading-mode="leading"
132295
size="sm"
@@ -137,7 +300,7 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
137300
{{ t('pullRequest.review.comment') }}
138301
</Button>
139302
<Button
140-
:disabled="isOwnPullRequest || !hasBody || Boolean(submittingEvent)"
303+
:disabled="isOwnPullRequest || (!hasBody && !pendingReview) || Boolean(submittingEvent)"
141304
:loading="submittingEvent === 'REQUEST_CHANGES'"
142305
loading-mode="leading"
143306
size="sm"
@@ -236,5 +399,33 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
236399
/>
237400
</div>
238401
</section>
402+
403+
<Dialog v-model:open="isDiscardDialogOpen">
404+
<DialogContent>
405+
<DialogHeader>
406+
<DialogTitle>{{ t('pullRequest.review.pending.discardTitle') }}</DialogTitle>
407+
<DialogDescription>{{ t('pullRequest.review.pending.discardDescription') }}</DialogDescription>
408+
</DialogHeader>
409+
<DialogFooter>
410+
<Button
411+
:disabled="isDiscardingPending"
412+
type="button"
413+
variant="ghost"
414+
@click="isDiscardDialogOpen = false"
415+
>
416+
{{ t('pullRequest.review.pending.discardCancel') }}
417+
</Button>
418+
<Button
419+
:loading="isDiscardingPending"
420+
loading-mode="leading"
421+
type="button"
422+
variant="destructive"
423+
@click="discardPendingReview"
424+
>
425+
{{ t('pullRequest.review.pending.discardConfirm') }}
426+
</Button>
427+
</DialogFooter>
428+
</DialogContent>
429+
</Dialog>
239430
</div>
240431
</template>

0 commit comments

Comments
 (0)