Start new workspaces from pasted pull requests (#2290)

* fix(workspaces): start pasted pull requests from their branch

Recognized pull request links now select their branch directly instead of
waiting for a separate confirmation. Ensure single-branch clones also record
the fetched branch so the new worktree can track it.

* fix(workspaces): keep pasted PR checkout target-safe

* fix(workspaces): make pasted PR resolution deterministic

* fix(workspaces): preserve explicit checkout selection

A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments.

* fix(composer): ignore stale pull request lookups

A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it.

* fix(workspaces): make checkout choices authoritative

Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups.

* fix(workspaces): stabilize pull request auto-selection

Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout.

* fix(composer): release removed pull request lookups

Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
This commit is contained in:
Mohamed Boudra
2026-07-21 15:20:05 +02:00
committed by GitHub
parent fd4e13735c
commit 2cb1b041dd
20 changed files with 768 additions and 313 deletions
+35 -204
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactElement, RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -9,15 +9,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import {
Check,
ChevronDown,
Folder,
FolderPlus,
GitBranch,
GitPullRequest,
X,
} from "lucide-react-native";
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -80,7 +72,7 @@ import {
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
@@ -99,7 +91,8 @@ import {
} from "./new-workspace-picker-item";
import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
initialPickerSelectionState,
reducePickerSelection,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import {
@@ -186,10 +179,6 @@ interface PickerOptionData {
itemById: Map<string, PickerItem>;
}
interface PickerSelection {
item: PickerItem;
}
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
@@ -335,50 +324,6 @@ function ProjectPickerTrigger({
);
}
function CheckoutHintBadge({
label,
acceptLabel,
dismissLabel,
onAccept,
onDismiss,
iconColor,
iconSize,
}: {
label: string;
acceptLabel: string;
dismissLabel: string;
onAccept: () => void;
onDismiss: () => void;
iconColor: string;
iconSize: number;
}) {
return (
<View style={styles.checkoutHintBadge}>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
<Pressable
testID="new-workspace-checkout-hint-accept"
onPress={onAccept}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={acceptLabel}
>
<Check size={iconSize} color={iconColor} />
</Pressable>
<Pressable
testID="new-workspace-checkout-hint-dismiss"
onPress={onDismiss}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
>
<X size={iconSize} color={iconColor} />
</Pressable>
</View>
);
}
function PickerOptionItem({
testID,
label,
@@ -646,14 +591,6 @@ function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">
return `${presentation.numberPrefix}${item.number} ${item.title}`;
}
function getCheckoutHintPresentation(item: ForgeSearchItem) {
const presentation = getForgePresentation(item.forge ?? "github");
return {
noun: presentation.changeRequestAbbrev,
numberPrefix: presentation.numberPrefix,
};
}
function pickerItemLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
@@ -803,11 +740,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
return [styles.content, styles.contentCentered];
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
}
function normalizeBranchDetails(
data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
@@ -1060,47 +992,6 @@ function buildComposerConfig(input: {
};
}
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 usePendingWorkspaceDraftSetup(
draftId: string | undefined,
): PendingWorkspaceDraftSetup | null {
@@ -1647,7 +1538,6 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useOpenAddProject();
@@ -1718,10 +1608,22 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments);
const [pickerSelection, dispatchPickerSelection] = useReducer(
reducePickerSelection,
initialPickerSelectionState,
);
const selectedItem = pickerSelection.selectedItem;
const selectedItem = getSelectedPickerItem(manualPickerSelection);
const handleGithubPrDetected = useCallback(() => {
dispatchPickerSelection({ type: "pr-detected" });
}, []);
const handleGithubPrAutoAttach = useCallback((item: ForgeSearchItem) => {
dispatchPickerSelection({
type: "pr-added",
item: { kind: "github-pr", item },
});
}, []);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
@@ -1822,7 +1724,7 @@ export function NewWorkspaceScreen({
item,
});
setManualPickerSelection({ item });
dispatchPickerSelection({ type: "picker-selected", item });
chatDraft.setAttachments(nextAttachments);
setPickerOpen(false);
},
@@ -1838,7 +1740,7 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem],
);
const clearManualPickerSelectionForTargetChange = useCallback(
const clearPickerSelectionForTargetChange = useCallback(
(currentTargetId: string, nextTargetId: string) => {
const nextAttachments = clearPickerPrAttachmentForTargetChange({
attachments: chatDraft.attachments,
@@ -1847,7 +1749,7 @@ export function NewWorkspaceScreen({
});
if (nextAttachments === chatDraft.attachments) return;
chatDraft.setAttachments(nextAttachments);
setManualPickerSelection(null);
dispatchPickerSelection({ type: "target-changed" });
},
[chatDraft],
);
@@ -1859,17 +1761,17 @@ export function NewWorkspaceScreen({
// canCreateWorktree or non-git projects become unselectable.
selectProjectOption(id);
setProjectPickerOpen(false);
clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id);
clearPickerSelectionForTargetChange(selectedProjectOptionId, id);
},
[clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
[clearPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
);
const handleSelectWorkspaceHost = useCallback(
(id: string) => {
handleSelectHost(id);
clearManualPickerSelectionForTargetChange(selectedServerId, id);
clearPickerSelectionForTargetChange(selectedServerId, id);
},
[clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
[clearPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
);
const handleAddProject = useCallback(() => {
@@ -1877,32 +1779,6 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
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);
}, []);
@@ -2234,42 +2110,11 @@ export function NewWorkspaceScreen({
});
const composerFooter = useMemo(
() => (
<>
{agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null}
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
label={t("newWorkspace.refPicker.checkoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
acceptLabel={t("newWorkspace.refPicker.checkoutPr", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
dismissLabel={t("newWorkspace.refPicker.dismissCheckoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</>
),
[
acceptCheckoutHint,
agentControlsWithDisabled,
checkoutHintPrAttachment,
dismissCheckoutHint,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
() =>
agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null,
[agentControlsWithDisabled],
);
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
@@ -2294,6 +2139,7 @@ export function NewWorkspaceScreen({
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
@@ -2301,6 +2147,8 @@ export function NewWorkspaceScreen({
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
@@ -2387,23 +2235,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
checkoutHintBadge: {
flexDirection: "row",
alignItems: "center",
height: BADGE_HEIGHT,
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,
},