Auto-attach pasted GitHub PR/issue URLs in the composer (#1006)

* Auto-attach pasted GitHub PR/issue URLs in the composer

Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.

Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.

* Fix oxfmt formatting in new-workspace-screen import
This commit is contained in:
Mohamed Boudra
2026-05-14 17:52:46 +08:00
committed by GitHub
parent 8296c797ff
commit 2e8bced707
12 changed files with 1147 additions and 130 deletions
@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import {
deriveAutoPickerItemFromAttachments,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
@@ -20,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
};
}
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
function prAttachment(
item: GitHubSearchItem,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item };
}
@@ -98,34 +97,53 @@ describe("syncPickerPrAttachment", () => {
});
});
describe("deriveAutoPickerItemFromAttachments", () => {
it("returns null when there are no attachments", () => {
expect(deriveAutoPickerItemFromAttachments([])).toBeNull();
describe("findCheckoutHintPrAttachment", () => {
it("returns the first attached PR that is not selected or dismissed", () => {
const first = prAttachment(makePrItem(101, "A"));
const second = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), first, second],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBe(first);
});
it("returns the PR when exactly one is attached", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("skips the selected PR and offers the next attached PR", () => {
const selected = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [selected, next],
selectedItem: { kind: "github-pr", item: selected.item },
dismissedPrNumbers: new Set(),
}),
).toBe(next);
});
it("returns null when multiple PRs are attached", () => {
const a = makePrItem(101, "A");
const b = makePrItem(202, "B");
expect(deriveAutoPickerItemFromAttachments([prAttachment(a), prAttachment(b)])).toBeNull();
it("skips dismissed PRs and ignores issues", () => {
const dismissed = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), dismissed, next],
selectedItem: null,
dismissedPrNumbers: new Set([101]),
}),
).toBe(next);
});
it("ignores non-PR attachments", () => {
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44)])).toBeNull();
});
it("returns the lone PR even when other non-PR attachments are present", () => {
const pr = makePrItem(923, "Nix overridable npm deps hash");
expect(deriveAutoPickerItemFromAttachments([issueAttachment(44), prAttachment(pr)])).toEqual({
kind: "github-pr",
item: pr,
});
it("returns null when only issues qualify", () => {
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44)],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBeNull();
});
});
@@ -34,18 +34,21 @@ export function syncPickerPrAttachment(input: {
return { attachments: nextAttachments, attachedPrNumber };
}
// Suggest a ref picker target derived from composer attachments alone. When the
// user attaches exactly one PR they almost always want the worktree based on
// that PR — surface it as the picker's default so the worktree isn't silently
// branched off main. The caller is responsible for letting manual picks win.
export function deriveAutoPickerItemFromAttachments(
attachments: ReadonlyArray<UserComposerAttachment>,
): PickerItem | null {
let onlyPr: Extract<UserComposerAttachment, { kind: "github_pr" }> | null = null;
for (const attachment of attachments) {
export function findCheckoutHintPrAttachment(input: {
attachments: ReadonlyArray<UserComposerAttachment>;
selectedItem: PickerItem | null;
dismissedPrNumbers: ReadonlySet<number>;
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | null {
const selectedPrNumber =
input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null;
for (const attachment of input.attachments) {
if (attachment.kind !== "github_pr") continue;
if (onlyPr) return null;
onlyPr = attachment;
const prNumber = attachment.item.number;
if (prNumber === selectedPrNumber) continue;
if (input.dismissedPrNumbers.has(prNumber)) continue;
return attachment;
}
return onlyPr ? { kind: "github-pr", item: onlyPr.item } : null;
return null;
}
+174 -49
View File
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
@@ -38,10 +39,7 @@ import {
type PickerCheckoutRequest,
type PickerItem,
} from "./new-workspace-picker-item";
import {
deriveAutoPickerItemFromAttachments,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
@@ -72,17 +70,6 @@ interface PickerSelection {
attachedPrNumber: number | null;
}
// Manual picks always win; the auto-promoted item is a fallback so the user
// doesn't silently get "main" when they meant the PR they just attached.
function combinePickerSelection(
manual: PickerSelection | null,
autoItem: PickerItem | null,
): PickerSelection | null {
if (manual) return manual;
if (autoItem) return { item: autoItem, attachedPrNumber: null };
return null;
}
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
@@ -160,6 +147,46 @@ function RefPickerTrigger({
);
}
function CheckoutHintBadge({
prNumber,
onAccept,
onDismiss,
iconColor,
iconSize,
}: {
prNumber: number;
onAccept: () => void;
onDismiss: () => void;
iconColor: string;
iconSize: number;
}) {
return (
<View style={styles.checkoutHintBadge}>
<Text style={styles.badgeText} numberOfLines={1}>
Check out PR #{prNumber}?
</Text>
<Pressable
testID="new-workspace-checkout-hint-accept"
onPress={onAccept}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Check out PR #${prNumber}`}
>
<Check size={iconSize} color={iconColor} />
</Pressable>
<Pressable
testID="new-workspace-checkout-hint-dismiss"
onPress={onDismiss}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`}
>
<X size={iconSize} color={iconColor} />
</Pressable>
</View>
);
}
function PickerOptionItem({
testID,
label,
@@ -262,6 +289,17 @@ function computePickerOptionData(
return { options: timedOptions.map((t) => t.option), itemById: idMap };
}
function normalizeBranchDetails(
data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
| undefined,
): Array<{ name: string; committerDate: number }> {
const details = data?.branchDetails;
if (details && details.length > 0) return details;
const names = data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}
interface SubmitDraftInput {
serverId: string;
draftKey: string;
@@ -363,6 +401,47 @@ function computeWorkspaceTitle(
);
}
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
if (attachment.kind === "github_pr") {
numbers.add(attachment.item.number);
}
}
return numbers;
}
function pruneDismissedCheckoutHintPrNumbers(
dismissed: ReadonlySet<number>,
attached: ReadonlySet<number>,
): ReadonlySet<number> {
let changed = false;
const next = new Set<number>();
for (const prNumber of dismissed) {
if (attached.has(prNumber)) {
next.add(prNumber);
} else {
changed = true;
}
}
return changed ? next : dismissed;
}
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
() => new Set(),
);
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
useEffect(() => {
setDismissedPrNumbers((current) =>
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
);
}, [attachedPrNumbers]);
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
@@ -460,13 +539,10 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments);
const autoPickerItem = useMemo(
() => deriveAutoPickerItemFromAttachments(chatDraft.attachments),
[chatDraft.attachments],
);
const pickerSelection = combinePickerSelection(manualPickerSelection, autoPickerItem);
const selectedItem = pickerSelection?.item ?? null;
const selectedItem = manualPickerSelection?.item ?? null;
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
@@ -507,32 +583,23 @@ export function NewWorkspaceScreen({
staleTime: 15_000,
});
const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
limit: 20,
kinds: ["github-pr"],
});
},
const githubPrSearchQuery = useGithubSearchQuery({
client,
serverId,
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
kinds: ["github-pr"],
enabled: pickerQueryEnabled,
staleTime: 30_000,
});
const branchDetails = useMemo(() => {
const details = branchSuggestionsQuery.data?.branchDetails;
if (details && details.length > 0) return details;
const names = branchSuggestionsQuery.data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}, [branchSuggestionsQuery.data?.branchDetails, branchSuggestionsQuery.data?.branches]);
const branchDetails = useMemo(
() => normalizeBranchDetails(branchSuggestionsQuery.data),
[branchSuggestionsQuery.data],
);
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return [];
const items = githubPrSearchQuery.data?.items ?? [];
return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
return githubPrSearchQuery.data?.items ?? [];
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById }: PickerOptionData = useMemo(
@@ -552,14 +619,11 @@ export function NewWorkspaceScreen({
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
const selectPickerItem = useCallback(
(item: PickerItem) => {
const next = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
item,
});
@@ -572,9 +636,44 @@ export function NewWorkspaceScreen({
}
setPickerOpen(false);
},
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
[chatDraft, manualPickerSelection?.attachedPrNumber],
);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
selectPickerItem(item);
},
[itemById, selectPickerItem],
);
const checkoutHintPrAttachment = useMemo(
() =>
findCheckoutHintPrAttachment({
attachments: chatDraft.attachments,
selectedItem,
dismissedPrNumbers: dismissedCheckoutHintPrNumbers,
}),
[chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem],
);
const acceptCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item });
}, [checkoutHintPrAttachment, selectPickerItem]);
const dismissCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
const prNumber = checkoutHintPrAttachment.item.number;
setDismissedCheckoutHintPrNumbers((current) => {
if (current.has(prNumber)) return current;
const next = new Set(current);
next.add(prNumber);
return next;
});
}, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]);
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
@@ -831,6 +930,15 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption}
/>
</View>
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
prNumber={checkoutHintPrAttachment.item.number}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
@@ -908,6 +1016,23 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
checkoutHintBadge: {
flexDirection: "row",
alignItems: "center",
height: 28,
maxWidth: 240,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
checkoutHintAction: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
},
badgeHovered: {
backgroundColor: theme.colors.surface2,
},