Skip to content

Commit 7bc2a40

Browse files
TonybynMp4claudemaria-rcksjuliusmarminge
authored
feat(pull-requests): PR composers complete @user mentions (#30747)
Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: maria-rcks <[email protected]> Co-authored-by: Julius Marminge <[email protected]>
1 parent 03dbb2f commit 7bc2a40

35 files changed

Lines changed: 728 additions & 20 deletions

‎apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { PullRequestCommentFooter } from "./PullRequestReactions";
2626
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
2727
import { usePullRequestPermissions } from "./usePullRequestPermissions";
2828
import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
29+
import { PullRequestMentionSuggestions } from "./PullRequestMentionSuggestions";
30+
import { usePullRequestMentions } from "./usePullRequestMentions";
2931

3032
export function PullRequestDiscussionScreen({
3133
route,
@@ -55,6 +57,13 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss
5557
const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false });
5658
const thread = activity.data?.reviewThreads.find((thread) => thread.id === params.discussionId);
5759
const [body, setBody] = useState("");
60+
const mentions = usePullRequestMentions({
61+
environmentId: params.environmentId,
62+
projectId: params.projectId,
63+
value: body,
64+
onChangeValue: setBody,
65+
enabled: detail.data?.capabilities.mentions === true,
66+
});
5867
const [extra, setExtra] = useState<readonly PullRequestThreadComment[]>([]);
5968
const [cursor, setCursor] = useState<string | null | undefined>();
6069
const [busy, setBusy] = useState(false);
@@ -222,14 +231,21 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss
222231
</ScrollView>
223232
</ScrollViewMarker>
224233
{canReply ? (
225-
<View className="absolute inset-x-4 bottom-0" style={{ paddingBottom: bottomInset }}>
234+
<View
235+
className="absolute inset-x-4 bottom-0 gap-2"
236+
style={{ paddingBottom: bottomInset }}
237+
>
238+
<PullRequestMentionSuggestions mentions={mentions} />
226239
<GlassSurface
227240
chrome="none"
228241
onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)}
229242
className="flex-row items-end gap-1 p-1"
230243
>
231244
<TextInput
232245
accessibilityLabel="Discussion reply"
246+
selection={mentions.selection}
247+
onSelectionChange={mentions.onSelectionChange}
248+
onBlur={mentions.onBlur}
233249
multiline
234250
maxLength={65536}
235251
placeholder="Reply…"

‎apps/mobile/src/features/pull-requests/PullRequestEditScreen.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { useAtomCommand } from "../../state/use-atom-command";
1616
import { PullRequestSheetContent } from "./PullRequestSheetContent";
1717
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
1818
import { usePullRequestPermissions } from "./usePullRequestPermissions";
19+
import { PullRequestMentionSuggestions } from "./PullRequestMentionSuggestions";
20+
import { usePullRequestMentions } from "./usePullRequestMentions";
1921

2022
export type PullRequestEditParams = PullRequestParams &
2123
(
@@ -49,6 +51,13 @@ export function PullRequestEditScreen({ route }: StaticScreenProps<PullRequestEd
4951
: (pr?.body ?? null);
5052
const [draft, setDraft] = useState<string | null>(null);
5153
const value = draft ?? original ?? "";
54+
const mentions = usePullRequestMentions({
55+
environmentId: params.environmentId,
56+
projectId: params.projectId,
57+
value: value,
58+
onChangeValue: setDraft,
59+
enabled: params.field !== "title" && pr?.capabilities.mentions === true,
60+
});
5261
const [busy, setBusy] = useState(false);
5362
const [error, setError] = useState<string | null>(null);
5463
const inFlight = useRef(false);
@@ -131,8 +140,12 @@ export function PullRequestEditScreen({ route }: StaticScreenProps<PullRequestEd
131140
/>
132141
}
133142
>
143+
<PullRequestMentionSuggestions mentions={mentions} />
134144
<TextInput
135145
multiline={params.field !== "title"}
146+
selection={mentions.selection}
147+
onSelectionChange={mentions.onSelectionChange}
148+
onBlur={mentions.onBlur}
136149
editable={!busy && original !== null}
137150
autoFocus
138151
accessibilityLabel={

‎apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
1515
import { reviewPositionLabel } from "./pullRequestDiffComments";
1616
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
1717
import { usePullRequestPermissions } from "./usePullRequestPermissions";
18+
import { PullRequestMentionSuggestions } from "./PullRequestMentionSuggestions";
19+
import { usePullRequestMentions } from "./usePullRequestMentions";
1820

1921
export type InlineCommentParams = PullRequestParams & {
2022
readonly draftId?: string;
@@ -33,6 +35,13 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps<Inli
3335
const body = editedBody ?? saved?.body ?? "";
3436
const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params)));
3537
const { refresh: refreshDetail } = detail;
38+
const mentions = usePullRequestMentions({
39+
environmentId: params.environmentId,
40+
projectId: params.projectId,
41+
value: body,
42+
onChangeValue: setBody,
43+
enabled: detail.data?.capabilities.mentions === true,
44+
});
3645
useFocusEffect(refreshDetail);
3746
const permissions = usePullRequestPermissions(params, detail.data);
3847
const stale = detail.data && detail.data.headSha !== params.headSha;
@@ -101,8 +110,12 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps<Inli
101110
{params.code}
102111
</Text>
103112
</View>
113+
<PullRequestMentionSuggestions mentions={mentions} />
104114
<TextInput
105115
multiline
116+
selection={mentions.selection}
117+
onSelectionChange={mentions.onSelectionChange}
118+
onBlur={mentions.onBlur}
106119
editable={store.ready && !busy}
107120
autoFocus
108121
accessibilityLabel="Inline review comment"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
2+
3+
import { AppText as Text } from "../../components/ui/AppText";
4+
import { GlassSurface } from "../../components/ui/GlassSurface";
5+
import { PullRequestAvatar } from "./PullRequestPresentation";
6+
import type { usePullRequestMentions } from "./usePullRequestMentions";
7+
8+
/** The `@` suggestions for a pull request text input, rendered just above it. */
9+
export function PullRequestMentionSuggestions({
10+
mentions,
11+
}: {
12+
readonly mentions: ReturnType<typeof usePullRequestMentions>;
13+
}) {
14+
if (!mentions.open) return null;
15+
return (
16+
<GlassSurface
17+
glassEffectStyle="clear"
18+
tintColorClassName="accent-glass-surface"
19+
style={{ borderRadius: 16, overflow: "hidden" }}
20+
>
21+
{mentions.candidates.length > 0 ? (
22+
<ScrollView
23+
className="max-h-[180px]"
24+
keyboardShouldPersistTaps="always"
25+
showsVerticalScrollIndicator={false}
26+
accessibilityLabel="Mention a user"
27+
>
28+
{mentions.candidates.map((actor, index) => (
29+
<Pressable
30+
key={actor.login}
31+
accessibilityRole="button"
32+
accessibilityLabel={`Mention ${actor.login}`}
33+
onPress={() => mentions.select(actor)}
34+
className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
35+
style={{
36+
borderBottomWidth:
37+
index === mentions.candidates.length - 1 ? 0 : StyleSheet.hairlineWidth,
38+
}}
39+
>
40+
<PullRequestAvatar actor={actor} size={20} />
41+
<Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}>
42+
{actor.login}
43+
</Text>
44+
{actor.name && actor.name !== actor.login ? (
45+
<Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
46+
{actor.name}
47+
</Text>
48+
) : null}
49+
</Pressable>
50+
))}
51+
</ScrollView>
52+
) : (
53+
<View className="px-3.5 py-2.5">
54+
<Text className="text-xs text-foreground-tertiary">
55+
{mentions.isLoading
56+
? "Searching users..."
57+
: mentions.failed
58+
? "Couldn't search users on the host"
59+
: "No matching users"}
60+
</Text>
61+
</View>
62+
)}
63+
</GlassSurface>
64+
);
65+
}

‎apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { useAtomCommand } from "../../state/use-atom-command";
1919
import { PullRequestSheetContent as SheetContent } from "./PullRequestSheetContent";
2020
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
2121
import { usePullRequestPermissions } from "./usePullRequestPermissions";
22+
import { PullRequestMentionSuggestions } from "./PullRequestMentionSuggestions";
23+
import { usePullRequestMentions } from "./usePullRequestMentions";
2224

2325
function useActionSubmission(params: PullRequestParams) {
2426
const navigation = useNavigation();
@@ -81,6 +83,13 @@ export function PullRequestReviewSheet({
8183
...draft,
8284
...(params.mode === "review" ? { summary: body } : { comment: body }),
8385
}));
86+
const mentions = usePullRequestMentions({
87+
environmentId: params.environmentId,
88+
projectId: params.projectId,
89+
value: body,
90+
onChangeValue: setBody,
91+
enabled: result.data?.capabilities.mentions === true,
92+
});
8493
const stale = store.draft.comments.some((comment) => comment.headSha !== result.data?.headSha);
8594
const [requestedVerdict, setRequestedVerdict] = useState<PullRequestReviewVerdict>("comment");
8695
const offered = VERDICTS.filter((option) => permissions.verdicts.includes(option.value));
@@ -169,8 +178,12 @@ export function PullRequestReviewSheet({
169178
<Text className="px-4 text-xs text-foreground-muted">
170179
{reviewing ? "Summary" : "Comment"}
171180
</Text>
181+
<PullRequestMentionSuggestions mentions={mentions} />
172182
<TextInput
173183
multiline
184+
selection={mentions.selection}
185+
onSelectionChange={mentions.onSelectionChange}
186+
onBlur={mentions.onBlur}
174187
accessibilityLabel={reviewing ? "Review summary" : "Comment"}
175188
placeholder={
176189
reviewing
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { EnvironmentId, ProjectId, type PullRequestActor } from "@t3tools/contracts";
2+
import {
3+
detectPullRequestMention,
4+
insertPullRequestMention,
5+
narrowPullRequestMentions,
6+
} from "@t3tools/shared/pullRequestComposerTrigger";
7+
import { useState } from "react";
8+
import type { TextInputSelectionChangeEvent } from "react-native";
9+
10+
import { composerPullRequests } from "../../state/pull-requests";
11+
import { useEnvironmentQuery } from "../../state/query";
12+
import { useDebouncedValue } from "../../state/queries";
13+
14+
const MENTION_SUGGESTION_LIMIT = 6;
15+
16+
/** Completes `@login` in a pull request text input from the repository's users. */
17+
export function usePullRequestMentions(input: {
18+
readonly environmentId: string;
19+
readonly projectId: string;
20+
readonly value: string;
21+
readonly onChangeValue: (value: string) => void;
22+
/** The pull request's host completes mentions (`capabilities.mentions`), and the field takes them. */
23+
readonly enabled: boolean;
24+
}) {
25+
const [selection, setSelection] = useState<{ start: number; end: number } | null>(null);
26+
// Set only right after an insertion, so the native input keeps its own caret otherwise.
27+
const [placed, setPlaced] = useState<{ start: number; end: number } | null>(null);
28+
const trigger =
29+
!input.enabled || selection === null || selection.start !== selection.end
30+
? null
31+
: detectPullRequestMention(input.value, selection.start);
32+
const typed = trigger?.query ?? "";
33+
const search = typed === "" ? undefined : typed.slice(0, 100);
34+
const sent = useDebouncedValue(search, 250);
35+
const hosted = useEnvironmentQuery(
36+
trigger === null
37+
? null
38+
: composerPullRequests.filterOptions({
39+
environmentId: EnvironmentId.make(input.environmentId),
40+
input: {
41+
kind: "authors",
42+
projectId: ProjectId.make(input.projectId),
43+
...(sent ? { query: sent } : {}),
44+
},
45+
}),
46+
);
47+
// Older answers stay useful while the debounced search catches up, narrowed to what is typed now.
48+
const candidates = narrowPullRequestMentions(
49+
hosted.data?.authors ?? [],
50+
typed,
51+
MENTION_SUGGESTION_LIMIT,
52+
);
53+
54+
return {
55+
open: trigger !== null,
56+
candidates,
57+
isLoading: hosted.isPending || sent !== search,
58+
failed: hosted.error !== null,
59+
selection: placed ?? undefined,
60+
onSelectionChange: (event: TextInputSelectionChangeEvent) => {
61+
setSelection(event.nativeEvent.selection);
62+
setPlaced(null);
63+
},
64+
onBlur: () => setSelection(null),
65+
select: (actor: PullRequestActor) => {
66+
if (trigger === null) return;
67+
const next = insertPullRequestMention(input.value, trigger, actor.login);
68+
input.onChangeValue(next.text);
69+
setSelection({ start: next.cursor, end: next.cursor });
70+
setPlaced({ start: next.cursor, end: next.cursor });
71+
},
72+
};
73+
}

‎apps/web/src/components/DiffPanel.browser.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ vi.mock("~/hooks/useTheme", () => ({
7979
vi.mock("~/state/queries", () => ({
8080
useCheckpointDiff: () => ({ data: null, error: null, isPending: false }),
8181
useTurnItemDetail: () => ({ data: null, error: null, isPending: false }),
82+
useDebouncedValue: <A,>(value: A) => value,
8283
}));
8384
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} }));
8485
vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} }));

‎apps/web/src/components/diffs/DiffCommentAnnotation.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { MessageCircle, Trash2 } from "lucide-react";
33
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
44

55
import { Button } from "~/components/ui/button";
6-
import { Textarea } from "~/components/ui/textarea";
6+
import {
7+
PullRequestMarkdownTextarea,
8+
type PullRequestMentionScope,
9+
} from "~/components/pullRequest/PullRequestMarkdownTextarea";
710
import {
811
SpeechTextField,
912
TextFieldSpeechControls,
@@ -37,6 +40,8 @@ interface DiffCommentAnnotationProps {
3740
pending?: boolean;
3841
secondaryAction?: DiffCommentSecondaryAction;
3942
focusOnMount?: boolean;
43+
/** Set only for pull request comments, where `@` completes the repository's users. */
44+
mentionScope?: PullRequestMentionScope | null;
4045
}
4146

4247
export function DiffCommentAnnotation({
@@ -55,6 +60,7 @@ export function DiffCommentAnnotation({
5560
pending = false,
5661
secondaryAction,
5762
focusOnMount = true,
63+
mentionScope = null,
5864
}: DiffCommentAnnotationProps) {
5965
const [localDraftText, setLocalDraftText] = useState("");
6066
const displayedText = kind === "draft" && !onTextChange ? localDraftText : text;
@@ -121,16 +127,17 @@ export function DiffCommentAnnotation({
121127
contentEditable={false}
122128
onPointerDown={(event) => event.stopPropagation()}
123129
>
124-
<Textarea
130+
<PullRequestMarkdownTextarea
125131
ref={textareaRef}
132+
mentionScope={mentionScope}
126133
readOnly={speech?.freezesEditor}
127134
autoFocus={focusOnMount}
128135
size="sm"
129136
value={displayedText}
130137
dir={textDirection(displayedText)}
131138
placeholder={placeholder}
132139
aria-label={`Comment on lines ${rangeLabel}`}
133-
onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)}
140+
onValueChange={onTextChange ?? setLocalDraftText}
134141
onFocus={(event) => {
135142
const end = event.currentTarget.value.length;
136143
event.currentTarget.setSelectionRange(end, end);

‎apps/web/src/components/pullRequest/PullRequestCodeTab.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
ReviewThreadCard,
110110
toViewerSide,
111111
} from "./PullRequestReviewAnnotation";
112+
import { pullRequestMentionScope } from "./PullRequestMarkdownTextarea";
112113
import {
113114
getFileViewedRange,
114115
isFileDiffCollapsed,
@@ -550,6 +551,7 @@ function PullRequestCodeTab({
550551
commit,
551552
cacheKey: `pull-request:${referenceKey}:${detail.updatedAt}:${commit ?? "all"}`,
552553
});
554+
const mentionScope = pullRequestMentionScope(detail, environmentId, reference);
553555
const hostReview = detail.capabilities.review;
554556
const review = {
555557
inlineComment: hostReview.inlineComment && detail.viewerPermissions.comment,
@@ -960,6 +962,7 @@ function PullRequestCodeTab({
960962
canReact={detail.capabilities.reactions === true}
961963
environmentId={environmentId}
962964
reference={reference}
965+
mentionScope={mentionScope}
963966
pending={threadPending}
964967
fixPending={pendingFinding === pullRequestFindingKey({ kind: "thread", thread })}
965968
fixLabel={fixFindingLabel}
@@ -1031,6 +1034,7 @@ function PullRequestCodeTab({
10311034
kind="draft"
10321035
rangeLabel={`${draft.path}:${getReviewPositionAnchor(draft.position).line}`}
10331036
text=""
1037+
mentionScope={canCommentOnLines ? mentionScope : null}
10341038
submitLabel={canCommentOnLines ? "Add to review" : "Add to agent"}
10351039
{...(canCommentOnLines && onAddToAgentSelection
10361040
? {

0 commit comments

Comments
 (0)