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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user