- Replace the inline composer PR menu with a dedicated `PullRequestThreadDialog` - Remove `/checkout-pr` from composer slash-command parsing and tests - Fix branch selector virtualization sizing for the checkout-PR row
Unified · Split
| @@ -291,7 +291,8 @@ export function BranchToolbarBranchSelector({ · 290 unmodified lines | |||
| 291 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | 291 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| 292 | const branchListVirtualizer = useVirtualizer({ | 292 | const branchListVirtualizer = useVirtualizer({ |
| 293 | count: filteredBranchPickerItems.length, | 293 | count: filteredBranchPickerItems.length, |
| 294 | estimateSize: () => 28, | 294 | estimateSize: (index) => |
| 295 | filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, | ||
| 295 | getScrollElement: () => branchListScrollElementRef.current, | 296 | getScrollElement: () => branchListScrollElementRef.current, |
| 296 | overscan: 12, | 297 | overscan: 12, |
| 297 | enabled: isBranchMenuOpen && shouldVirtualizeBranchList, | 298 | enabled: isBranchMenuOpen && shouldVirtualizeBranchList, |
| @@ -409,7 +410,7 @@ export function BranchToolbarBranchSelector({ · 111 unmodified lines | |||
| 409 | items={branchPickerItems} | 410 | items={branchPickerItems} |
| 410 | filteredItems={filteredBranchPickerItems} | 411 | filteredItems={filteredBranchPickerItems} |
| 411 | autoHighlight | 412 | autoHighlight |
| 412 | virtualized | 413 | virtualized={shouldVirtualizeBranchList} |
| 413 | onItemHighlighted={(_value, eventDetails) => { | 414 | onItemHighlighted={(_value, eventDetails) => { |
| 414 | if (!isBranchMenuOpen || eventDetails.index < 0) return; | 415 | if (!isBranchMenuOpen || eventDetails.index < 0) return; |
| 415 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); | 416 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); |
| @@ -119,7 +119,6 @@ import { · 118 unmodified lines | |||
| 119 | type TurnDiffTreeNode, | 119 | type TurnDiffTreeNode, |
| 120 | } from "../lib/turnDiffTree"; | 120 | } from "../lib/turnDiffTree"; |
| 121 | import BranchToolbar from "./BranchToolbar"; | 121 | import BranchToolbar from "./BranchToolbar"; |
| 122 | import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu"; | ||
| 123 | import GitActionsControl from "./GitActionsControl"; | 122 | import GitActionsControl from "./GitActionsControl"; |
| 124 | import { | 123 | import { |
| 125 | isOpenFavoriteEditorShortcut, | 124 | isOpenFavoriteEditorShortcut, |
| @@ -217,6 +216,7 @@ import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; · 91 unmodified lines | |||
| 217 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 216 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 218 | import { clamp } from "effect/Number"; | 217 | import { clamp } from "effect/Number"; |
| 219 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 218 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 219 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | ||
| 220 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 220 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 221 | 221 | ||
| 222 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 222 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| @@ -408,7 +408,7 @@ type ComposerCommandItem = · 185 unmodified lines | |||
| 408 | 408 | ||
| 409 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | 409 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| 410 | 410 | ||
| 411 | interface PullRequestComposerMenuState { | 411 | interface PullRequestDialogState { |
| 412 | initialReference: string | null; | 412 | initialReference: string | null; |
| 413 | key: number; | 413 | key: number; |
| 414 | } | 414 | } |
| @@ -657,8 +657,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 242 unmodified lines | |||
| 657 | const [nowTick, setNowTick] = useState(() => Date.now()); | 657 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 658 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 658 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 659 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 659 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 660 | const [pullRequestComposerMenuState, setPullRequestComposerMenuState] = | 660 | const [pullRequestDialogState, setPullRequestDialogState] = useState<PullRequestDialogState | null>( |
| 661 | useState<PullRequestComposerMenuState | null>(null); | 661 | null, |
| 662 | ); | ||
| 662 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< | 663 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 663 | Record<string, string[]> | 664 | Record<string, string[]> |
| 664 | >({}); | 665 | >({}); |
| @@ -764,19 +765,19 @@ export default function ChatView({ threadId }: ChatViewProps) { · 99 unmodified lines | |||
| 764 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 765 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 765 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); | 766 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); |
| 766 | 767 | ||
| 767 | const openPullRequestComposerMenu = useCallback((reference?: string) => { | 768 | const openPullRequestDialog = useCallback((reference?: string) => { |
| 768 | if (!canCheckoutPullRequestIntoThread) { | 769 | if (!canCheckoutPullRequestIntoThread) { |
| 769 | return; | 770 | return; |
| 770 | } | 771 | } |
| 771 | setPullRequestComposerMenuState({ | 772 | setPullRequestDialogState({ |
| 772 | initialReference: reference ?? null, | 773 | initialReference: reference ?? null, |
| 773 | key: Date.now(), | 774 | key: Date.now(), |
| 774 | }); | 775 | }); |
| 775 | setComposerHighlightedItemId(null); | 776 | setComposerHighlightedItemId(null); |
| 776 | }, [canCheckoutPullRequestIntoThread]); | 777 | }, [canCheckoutPullRequestIntoThread]); |
| 777 | 778 | ||
| 778 | const closePullRequestComposerMenu = useCallback(() => { | 779 | const closePullRequestDialog = useCallback(() => { |
| 779 | setPullRequestComposerMenuState(null); | 780 | setPullRequestDialogState(null); |
| 780 | }, []); | 781 | }, []); |
| 781 | 782 | ||
| 782 | const openOrReuseProjectDraftThread = useCallback( | 783 | const openOrReuseProjectDraftThread = useCallback( |
| @@ -1254,7 +1255,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 471 unmodified lines | |||
| 1254 | timelineEntries, | 1255 | timelineEntries, |
| 1255 | ]); | 1256 | ]); |
| 1256 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 1257 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 1257 | const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null; | ||
| 1258 | const composerTriggerKind = composerTrigger?.kind ?? null; | 1258 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 1259 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 1259 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 1260 | const isPathTrigger = composerTriggerKind === "path"; | 1260 | const isPathTrigger = composerTriggerKind === "path"; |
| @@ -1311,22 +1311,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 50 unmodified lines | |||
| 1311 | label: "/default", | 1311 | label: "/default", |
| 1312 | description: "Switch this thread back to normal chat mode", | 1312 | description: "Switch this thread back to normal chat mode", |
| 1313 | }, | 1313 | }, |
| 1314 | { | ||
| 1315 | id: "slash:checkout-pr", | ||
| 1316 | type: "slash-command", | ||
| 1317 | command: "checkout-pr", | ||
| 1318 | label: "/checkout-pr", | ||
| 1319 | description: "Create a new thread from a GitHub pull request", | ||
| 1320 | }, | ||
| 1321 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | 1314 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1322 | const availableSlashCommandItems = canCheckoutPullRequestIntoThread | ||
| 1323 | ? slashCommandItems | ||
| 1324 | : slashCommandItems.filter((item) => item.command !== "checkout-pr"); | ||
| 1325 | const query = composerTrigger.query.trim().toLowerCase(); | 1315 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1326 | if (!query) { | 1316 | if (!query) { |
| 1327 | return [...availableSlashCommandItems]; | 1317 | return [...slashCommandItems]; |
| 1328 | } | 1318 | } |
| 1329 | return availableSlashCommandItems.filter( | 1319 | return slashCommandItems.filter( |
| 1330 | (item) => item.command.includes(query) || item.label.slice(1).includes(query), | 1320 | (item) => item.command.includes(query) || item.label.slice(1).includes(query), |
| 1331 | ); | 1321 | ); |
| 1332 | } | 1322 | } |
| @@ -1347,16 +1337,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines | |||
| 1347 | label: name, | 1337 | label: name, |
| 1348 | description: `${providerLabel} · ${slug}`, | 1338 | description: `${providerLabel} · ${slug}`, |
| 1349 | })); | 1339 | })); |
| 1350 | }, [canCheckoutPullRequestIntoThread, composerTrigger, searchableModelOptions, workspaceEntries]); | 1340 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); |
| 1351 | const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen; | 1341 | const composerMenuOpen = Boolean(composerTrigger); |
| 1352 | const activeComposerMenuItem = useMemo( | 1342 | const activeComposerMenuItem = useMemo( |
| 1353 | () => | 1343 | () => |
| 1354 | pullRequestComposerMenuOpen | 1344 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? |
| 1355 | ? null | 1345 | composerMenuItems[0] ?? |
| 1356 | : (composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | 1346 | null, |
| 1357 | composerMenuItems[0] ?? | 1347 | [composerHighlightedItemId, composerMenuItems], |
| 1358 | null), | ||
| 1359 | [composerHighlightedItemId, composerMenuItems, pullRequestComposerMenuOpen], | ||
| 1360 | ); | 1348 | ); |
| 1361 | composerMenuOpenRef.current = composerMenuOpen; | 1349 | composerMenuOpenRef.current = composerMenuOpen; |
| 1362 | composerMenuItemsRef.current = composerMenuItems; | 1350 | composerMenuItemsRef.current = composerMenuItems; |
| @@ -2055,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 692 unmodified lines | |||
| 2055 | 2043 | ||
| 2056 | useEffect(() => { | 2044 | useEffect(() => { |
| 2057 | setExpandedWorkGroups({}); | 2045 | setExpandedWorkGroups({}); |
| 2058 | setPullRequestComposerMenuState(null); | 2046 | setPullRequestDialogState(null); |
| 2059 | if (planSidebarOpenOnNextThreadRef.current) { | 2047 | if (planSidebarOpenOnNextThreadRef.current) { |
| 2060 | planSidebarOpenOnNextThreadRef.current = false; | 2048 | planSidebarOpenOnNextThreadRef.current = false; |
| 2061 | setPlanSidebarOpen(true); | 2049 | setPlanSidebarOpen(true); |
| @@ -2070,16 +2058,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines | |||
| 2070 | setComposerHighlightedItemId(null); | 2058 | setComposerHighlightedItemId(null); |
| 2071 | return; | 2059 | return; |
| 2072 | } | 2060 | } |
| 2073 | if (pullRequestComposerMenuOpen) { | ||
| 2074 | setComposerHighlightedItemId(null); | ||
| 2075 | return; | ||
| 2076 | } | ||
| 2077 | setComposerHighlightedItemId((existing) => | 2061 | setComposerHighlightedItemId((existing) => |
| 2078 | existing && composerMenuItems.some((item) => item.id === existing) | 2062 | existing && composerMenuItems.some((item) => item.id === existing) |
| 2079 | ? existing | 2063 | ? existing |
| 2080 | : (composerMenuItems[0]?.id ?? null), | 2064 | : (composerMenuItems[0]?.id ?? null), |
| 2081 | ); | 2065 | ); |
| 2082 | }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]); | 2066 | }, [composerMenuItems, composerMenuOpen]); |
| 2083 | 2067 | ||
| 2084 | useEffect(() => { | 2068 | useEffect(() => { |
| 2085 | setIsRevertingCheckpoint(false); | 2069 | setIsRevertingCheckpoint(false); |
| @@ -2589,15 +2573,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 503 unmodified lines | |||
| 2589 | } | 2573 | } |
| 2590 | const standaloneSlashCommand = | 2574 | const standaloneSlashCommand = |
| 2591 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; | 2575 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; |
| 2592 | const shouldHandleStandaloneSlashCommand = | 2576 | if (standaloneSlashCommand) { |
| 2593 | standaloneSlashCommand !== null && | 2577 | await handleInteractionModeChange(standaloneSlashCommand); |
| 2594 | (standaloneSlashCommand !== "checkout-pr" || canCheckoutPullRequestIntoThread); | ||
| 2595 | if (shouldHandleStandaloneSlashCommand && standaloneSlashCommand) { | ||
| 2596 | if (standaloneSlashCommand === "checkout-pr") { | ||
| 2597 | openPullRequestComposerMenu(); | ||
| 2598 | } else { | ||
| 2599 | await handleInteractionModeChange(standaloneSlashCommand); | ||
| 2600 | } | ||
| 2601 | promptRef.current = ""; | 2578 | promptRef.current = ""; |
| 2602 | clearComposerDraftContent(activeThread.id); | 2579 | clearComposerDraftContent(activeThread.id); |
| 2603 | setComposerHighlightedItemId(null); | 2580 | setComposerHighlightedItemId(null); |
| @@ -3386,11 +3363,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 782 unmodified lines | |||
| 3386 | } | 3363 | } |
| 3387 | return; | 3364 | return; |
| 3388 | } | 3365 | } |
| 3389 | if (item.command === "checkout-pr") { | 3366 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); |
| 3390 | openPullRequestComposerMenu(); | ||
| 3391 | } else { | ||
| 3392 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | ||
| 3393 | } | ||
| 3394 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 3367 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 3395 | expectedText: expectedToken, | 3368 | expectedText: expectedToken, |
| 3396 | }); | 3369 | }); |
| @@ -3410,7 +3383,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines | |||
| 3410 | [ | 3383 | [ |
| 3411 | applyPromptReplacement, | 3384 | applyPromptReplacement, |
| 3412 | handleInteractionModeChange, | 3385 | handleInteractionModeChange, |
| 3413 | openPullRequestComposerMenu, | ||
| 3414 | onProviderModelSelect, | 3386 | onProviderModelSelect, |
| 3415 | resolveActiveComposerTrigger, | 3387 | resolveActiveComposerTrigger, |
| 3416 | ], | 3388 | ], |
| @@ -3485,12 +3457,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 68 unmodified lines | |||
| 3485 | const { trigger } = resolveActiveComposerTrigger(); | 3457 | const { trigger } = resolveActiveComposerTrigger(); |
| 3486 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | 3458 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; |
| 3487 | 3459 | ||
| 3488 | if (pullRequestComposerMenuOpen) { | ||
| 3489 | if (key === "ArrowDown" || key === "ArrowUp" || key === "Tab" || key === "Enter") { | ||
| 3490 | return true; | ||
| 3491 | } | ||
| 3492 | } | ||
| 3493 | |||
| 3494 | if (menuIsActive) { | 3460 | if (menuIsActive) { |
| 3495 | const currentItems = composerMenuItemsRef.current; | 3461 | const currentItems = composerMenuItemsRef.current; |
| 3496 | if (key === "ArrowDown" && currentItems.length > 0) { | 3462 | if (key === "ArrowDown" && currentItems.length > 0) { |
| @@ -3712,28 +3678,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 215 unmodified lines | |||
| 3712 | > | 3678 | > |
| 3713 | {composerMenuOpen && !isComposerApprovalState && ( | 3679 | {composerMenuOpen && !isComposerApprovalState && ( |
| 3714 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 3680 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 3715 | {pullRequestComposerMenuState ? ( | 3681 | <ComposerCommandMenu |
| 3716 | <ComposerPullRequestCommandMenu | 3682 | items={composerMenuItems} |
| 3717 | key={pullRequestComposerMenuState.key} | 3683 | resolvedTheme={resolvedTheme} |
| 3718 | cwd={activeProject?.cwd ?? null} | 3684 | isLoading={isComposerMenuLoading} |
| 3719 | initialReference={pullRequestComposerMenuState.initialReference} | 3685 | triggerKind={composerTriggerKind} |
| 3720 | onCancel={closePullRequestComposerMenu} | 3686 | activeItemId={activeComposerMenuItem?.id ?? null} |
| 3721 | onPrepared={async (input) => { | 3687 | onHighlightedItemChange={onComposerMenuItemHighlighted} |
| 3722 | await handlePreparedPullRequestThread(input); | 3688 | onSelect={onSelectComposerItem} |
| 3723 | closePullRequestComposerMenu(); | 3689 | /> |
| 3724 | }} | ||
| 3725 | /> | ||
| 3726 | ) : ( | ||
| 3727 | <ComposerCommandMenu | ||
| 3728 | items={composerMenuItems} | ||
| 3729 | resolvedTheme={resolvedTheme} | ||
| 3730 | isLoading={isComposerMenuLoading} | ||
| 3731 | triggerKind={composerTriggerKind} | ||
| 3732 | activeItemId={activeComposerMenuItem?.id ?? null} | ||
| 3733 | onHighlightedItemChange={onComposerMenuItemHighlighted} | ||
| 3734 | onSelect={onSelectComposerItem} | ||
| 3735 | /> | ||
| 3736 | )} | ||
| 3737 | </div> | 3690 | </div> |
| 3738 | )} | 3691 | )} |
| 3739 | 3692 | ||
| @@ -4160,10 +4113,24 @@ export default function ChatView({ threadId }: ChatViewProps) { · 420 unmodified lines | |||
| 4160 | envLocked={envLocked} | 4113 | envLocked={envLocked} |
| 4161 | onComposerFocusRequest={scheduleComposerFocus} | 4114 | onComposerFocusRequest={scheduleComposerFocus} |
| 4162 | {...(canCheckoutPullRequestIntoThread | 4115 | {...(canCheckoutPullRequestIntoThread |
| 4163 | ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu } | 4116 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 4164 | : {})} | 4117 | : {})} |
| 4165 | /> | 4118 | /> |
| 4166 | )} | 4119 | )} |
| 4120 | {pullRequestDialogState ? ( | ||
| 4121 | <PullRequestThreadDialog | ||
| 4122 | key={pullRequestDialogState.key} | ||
| 4123 | open | ||
| 4124 | cwd={activeProject?.cwd ?? null} | ||
| 4125 | initialReference={pullRequestDialogState.initialReference} | ||
| 4126 | onOpenChange={(open) => { | ||
| 4127 | if (!open) { | ||
| 4128 | closePullRequestDialog(); | ||
| 4129 | } | ||
| 4130 | }} | ||
| 4131 | onPrepared={handlePreparedPullRequestThread} | ||
| 4132 | /> | ||
| 4133 | ) : null} | ||
| 4167 | </div>{/* end chat column */} | 4134 | </div>{/* end chat column */} |
| 4168 | 4135 | ||
| 4169 | {/* Plan sidebar */} | 4136 | {/* Plan sidebar */} |
| @@ -10,22 +10,33 @@ import { · 9 unmodified lines | |||
| 10 | import { cn } from "~/lib/utils"; | 10 | import { cn } from "~/lib/utils"; |
| 11 | import { parsePullRequestReference } from "~/pullRequestReference"; | 11 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 12 | import { Button } from "./ui/button"; | 12 | import { Button } from "./ui/button"; |
| 13 | import { | ||
| 14 | Dialog, | ||
| 15 | DialogDescription, | ||
| 16 | DialogFooter, | ||
| 17 | DialogHeader, | ||
| 18 | DialogPanel, | ||
| 19 | DialogPopup, | ||
| 20 | DialogTitle, | ||
| 21 | } from "./ui/dialog"; | ||
| 13 | import { Input } from "./ui/input"; | 22 | import { Input } from "./ui/input"; |
| 14 | import { Spinner } from "./ui/spinner"; | 23 | import { Spinner } from "./ui/spinner"; |
| 15 | 24 | ||
| 16 | interface ComposerPullRequestCommandMenuProps { | 25 | interface PullRequestThreadDialogProps { |
| 26 | open: boolean; | ||
| 17 | cwd: string | null; | 27 | cwd: string | null; |
| 18 | initialReference: string | null; | 28 | initialReference: string | null; |
| 19 | onCancel: () => void; | 29 | onOpenChange: (open: boolean) => void; |
| 20 | onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void; | 30 | onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void; |
| 21 | } | 31 | } |
| 22 | 32 | ||
| 23 | export function ComposerPullRequestCommandMenu({ | 33 | export function PullRequestThreadDialog({ |
| 34 | open, | ||
| 24 | cwd, | 35 | cwd, |
| 25 | initialReference, | 36 | initialReference, |
| 26 | onCancel, | 37 | onOpenChange, |
| 27 | onPrepared, | 38 | onPrepared, |
| 28 | }: ComposerPullRequestCommandMenuProps) { | 39 | }: PullRequestThreadDialogProps) { |
| 29 | const queryClient = useQueryClient(); | 40 | const queryClient = useQueryClient(); |
| 30 | const referenceInputRef = useRef<HTMLInputElement>(null); | 41 | const referenceInputRef = useRef<HTMLInputElement>(null); |
| 31 | const [reference, setReference] = useState(initialReference ?? ""); | 42 | const [reference, setReference] = useState(initialReference ?? ""); |
| @@ -38,12 +49,14 @@ export function ComposerPullRequestCommandMenu({ · 6 unmodified lines | |||
| 38 | ); | 49 | ); |
| 39 | 50 | ||
| 40 | useEffect(() => { | 51 | useEffect(() => { |
| 52 | if (!open) return; | ||
| 41 | setReference(initialReference ?? ""); | 53 | setReference(initialReference ?? ""); |
| 42 | setReferenceDirty(false); | 54 | setReferenceDirty(false); |
| 43 | setPreparingMode(null); | 55 | setPreparingMode(null); |
| 44 | }, [initialReference]); | 56 | }, [initialReference, open]); |
| 45 | 57 | ||
| 46 | useEffect(() => { | 58 | useEffect(() => { |
| 59 | if (!open) return; | ||
| 47 | const frame = window.requestAnimationFrame(() => { | 60 | const frame = window.requestAnimationFrame(() => { |
| 48 | referenceInputRef.current?.focus(); | 61 | referenceInputRef.current?.focus(); |
| 49 | referenceInputRef.current?.select(); | 62 | referenceInputRef.current?.select(); |
| @@ -51,14 +64,14 @@ export function ComposerPullRequestCommandMenu({ · 1 unmodified line | |||
| 51 | return () => { | 64 | return () => { |
| 52 | window.cancelAnimationFrame(frame); | 65 | window.cancelAnimationFrame(frame); |
| 53 | }; | 66 | }; |
| 54 | }, []); | 67 | }, [open]); |
| 55 | 68 | ||
| 56 | const parsedReference = parsePullRequestReference(reference); | 69 | const parsedReference = parsePullRequestReference(reference); |
| 57 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); | 70 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 58 | const resolvePullRequestQuery = useQuery( | 71 | const resolvePullRequestQuery = useQuery( |
| 59 | gitResolvePullRequestQueryOptions({ | 72 | gitResolvePullRequestQueryOptions({ |
| 60 | cwd, | 73 | cwd, |
| 61 | reference: parsedDebouncedReference, | 74 | reference: open ? parsedDebouncedReference : null, |
| 62 | }), | 75 | }), |
| 63 | ); | 76 | ); |
| 64 | const cachedPullRequest = useMemo(() => { | 77 | const cachedPullRequest = useMemo(() => { |
| @@ -83,6 +96,7 @@ export function ComposerPullRequestCommandMenu({ · 18 unmodified lines | |||
| 83 | : null; | 96 | : null; |
| 84 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; | 97 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; |
| 85 | const isResolving = | 98 | const isResolving = |
| 99 | open && | ||
| 86 | parsedReference !== null && | 100 | parsedReference !== null && |
| 87 | resolvedPullRequest === null && | 101 | resolvedPullRequest === null && |
| 88 | (referenceDebouncer.state.isPending || | 102 | (referenceDebouncer.state.isPending || |
| @@ -120,11 +134,13 @@ export function ComposerPullRequestCommandMenu({ · 31 unmodified lines | |||
| 120 | branch: result.branch, | 134 | branch: result.branch, |
| 121 | worktreePath: result.worktreePath, | 135 | worktreePath: result.worktreePath, |
| 122 | }); | 136 | }); |
| 137 | onOpenChange(false); | ||
| 123 | } finally { | 138 | } finally { |
| 124 | setPreparingMode(null); | 139 | setPreparingMode(null); |
| 125 | } | 140 | } |
| 126 | }, [ | 141 | }, [ |
| 127 | cwd, | 142 | cwd, |
| 143 | onOpenChange, | ||
| 128 | onPrepared, | 144 | onPrepared, |
| 129 | parsedReference, | 145 | parsedReference, |
| 130 | preparePullRequestThreadMutation, | 146 | preparePullRequestThreadMutation, |
| @@ -135,10 +151,10 @@ export function ComposerPullRequestCommandMenu({ · 4 unmodified lines | |||
| 135 | !referenceDirty | 151 | !referenceDirty |
| 136 | ? null | 152 | ? null |
| 137 | : reference.trim().length === 0 | 153 | : reference.trim().length === 0 |
| 138 | ? "Paste a GitHub pull request URL or enter 123 / #123." | 154 | ? "Paste a GitHub pull request URL or enter 123 / #123." |
| 139 | : parsedReference === null | 155 | : parsedReference === null |
| 140 | ? "Use a GitHub pull request URL, 123, or #123." | 156 | ? "Use a GitHub pull request URL, 123, or #123." |
| 141 | : null; | 157 | : null; |
| 142 | const errorMessage = | 158 | const errorMessage = |
| 143 | validationMessage ?? | 159 | validationMessage ?? |
| 144 | (resolvedPullRequest === null && resolvePullRequestQuery.isError | 160 | (resolvedPullRequest === null && resolvePullRequestQuery.isError |
| @@ -152,73 +168,79 @@ export function ComposerPullRequestCommandMenu({ · 7 unmodified lines | |||
| 152 | : null)); | 168 | : null)); |
| 153 | 169 | ||
| 154 | return ( | 170 | return ( |
| 155 | <div className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | 171 | <Dialog |
| 156 | <div className="space-y-4 p-3"> | 172 | open={open} |
| 157 | <div className="space-y-2"> | 173 | onOpenChange={(nextOpen) => { |
| 158 | <div className="flex items-center justify-between gap-3"> | 174 | if (!preparePullRequestThreadMutation.isPending) { |
| 159 | <div> | 175 | onOpenChange(nextOpen); |
| 160 | <p className="font-medium text-sm">Checkout Pull Request</p> | 176 | } |
| 161 | <p className="text-muted-foreground text-xs"> | 177 | }} |
| 162 | Resolve a PR, then choose Local or Worktree for the new draft thread. | 178 | > |
| 163 | </p> | 179 | <DialogPopup className="max-w-xl"> |
| 164 | </div> | 180 | <DialogHeader> |
| 165 | </div> | 181 | <DialogTitle>Checkout Pull Request</DialogTitle> |
| 166 | <Input | 182 | <DialogDescription> |
| 167 | autoFocus | 183 | Resolve a GitHub pull request, then create the draft thread in the main repo or in a |
| 168 | ref={referenceInputRef} | 184 | dedicated worktree. |
| 169 | placeholder="https://github.com/owner/repo/pull/42 or #42" | 185 | </DialogDescription> |
| 170 | value={reference} | 186 | </DialogHeader> |
| 171 | onChange={(event) => { | 187 | <DialogPanel className="space-y-4"> |
| 172 | setReferenceDirty(true); | 188 | <label className="grid gap-1.5"> |
| 173 | setReference(event.target.value); | 189 | <span className="text-xs font-medium text-foreground">Pull request</span> |
| 174 | }} | 190 | <Input |
| 175 | onKeyDown={(event) => { | 191 | ref={referenceInputRef} |
| 176 | if (event.key !== "Enter") { | 192 | placeholder="https://github.com/owner/repo/pull/42 or #42" |
| 177 | return; | 193 | value={reference} |
| 178 | } | 194 | onChange={(event) => { |
| 179 | event.preventDefault(); | 195 | setReferenceDirty(true); |
| 180 | if (!isResolving && !preparePullRequestThreadMutation.isPending) { | 196 | setReference(event.target.value); |
| 181 | void handleConfirm("local"); | 197 | }} |
| 182 | } | 198 | onKeyDown={(event) => { |
| 183 | }} | 199 | if (event.key !== "Enter") { |
| 184 | /> | 200 | return; |
| 185 | </div> | 201 | } |
| 202 | event.preventDefault(); | ||
| 203 | if (!isResolving && !preparePullRequestThreadMutation.isPending) { | ||
| 204 | void handleConfirm("local"); | ||
| 205 | } | ||
| 206 | }} | ||
| 207 | /> | ||
| 208 | </label> | ||
| 186 | 209 | ||
| 187 | {resolvedPullRequest ? ( | 210 | {resolvedPullRequest ? ( |
| 188 | <div className="rounded-xl border border-border/70 bg-muted/24 p-3"> | 211 | <div className="rounded-xl border border-border/70 bg-muted/24 p-3"> |
| 189 | <div className="flex items-start justify-between gap-3"> | 212 | <div className="flex items-start justify-between gap-3"> |
| 190 | <div className="min-w-0"> | 213 | <div className="min-w-0"> |
| 191 | <p className="truncate font-medium text-sm">{resolvedPullRequest.title}</p> | 214 | <p className="truncate font-medium text-sm">{resolvedPullRequest.title}</p> |
| 192 | <p className="truncate text-muted-foreground text-xs"> | 215 | <p className="truncate text-muted-foreground text-xs"> |
| 193 | #{resolvedPullRequest.number} · {resolvedPullRequest.headBranch} to{" "} | 216 | #{resolvedPullRequest.number} · {resolvedPullRequest.headBranch} to{" "} |
| 194 | {resolvedPullRequest.baseBranch} | 217 | {resolvedPullRequest.baseBranch} |
| 195 | </p> | 218 | </p> |
| 219 | </div> | ||
| 220 | <span className={cn("shrink-0 text-xs capitalize", statusTone)}> | ||
| 221 | {resolvedPullRequest.state} | ||
| 222 | </span> | ||
| 196 | </div> | 223 | </div> |
| 197 | <span className={cn("shrink-0 text-xs capitalize", statusTone)}> | ||
| 198 | {resolvedPullRequest.state} | ||
| 199 | </span> | ||
| 200 | </div> | 224 | </div> |
| 201 | </div> | 225 | ) : null} |
| 202 | ) : null} | ||
| 203 | |||
| 204 | {isResolving ? ( | ||
| 205 | <div className="flex items-center gap-2 text-muted-foreground text-xs"> | ||
| 206 | <Spinner className="size-3.5" /> | ||
| 207 | Resolving pull request... | ||
| 208 | </div> | ||
| 209 | ) : null} | ||
| 210 | 226 | ||
| 211 | {errorMessage ? <p className="text-destructive text-xs">{errorMessage}</p> : null} | 227 | {isResolving ? ( |
| 212 | </div> | 228 | <div className="flex items-center gap-2 text-muted-foreground text-xs"> |
| 229 | <Spinner className="size-3.5" /> | ||
| 230 | Resolving pull request... | ||
| 231 | </div> | ||
| 232 | ) : null} | ||
| 213 | 233 | ||
| 214 | <div className="flex items-center justify-between gap-2 border-t px-3 py-2"> | 234 | {errorMessage ? <p className="text-destructive text-xs">{errorMessage}</p> : null} |
| 215 | <p className="text-muted-foreground text-xs"> | 235 | </DialogPanel> |
| 216 | {resolvedPullRequest | 236 | <DialogFooter> |
| 217 | ? "Create the draft thread in the main repo or in a dedicated worktree." | 237 | <Button |
| 218 | : "Resolve the pull request first."} | 238 | type="button" |
| 219 | </p> | 239 | variant="outline" |
| 220 | <div className="flex items-center gap-2"> | 240 | size="sm" |
| 221 | <Button type="button" variant="ghost" size="sm" onClick={onCancel}> | 241 | onClick={() => onOpenChange(false)} |
| 242 | disabled={preparePullRequestThreadMutation.isPending} | ||
| 243 | > | ||
| 222 | Cancel | 244 | Cancel |
| 223 | </Button> | 245 | </Button> |
| 224 | <Button | 246 | <Button |
| @@ -252,8 +274,8 @@ export function ComposerPullRequestCommandMenu({ · 27 unmodified lines | |||
| 252 | > | 274 | > |
| 253 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} | 275 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} |
| 254 | </Button> | 276 | </Button> |
| 255 | </div> | 277 | </DialogFooter> |
| 256 | </div> | 278 | </DialogPopup> |
| 257 | </div> | 279 | </Dialog> |
| 258 | ); | 280 | ); |
| 259 | } | 281 | } |
| @@ -134,11 +134,11 @@ describe("parseStandaloneComposerSlashCommand", () => { · 133 unmodified lines | |||
| 134 | expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); | 134 | expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); |
| 135 | }); | 135 | }); |
| 136 | 136 | ||
| 137 | it("parses standalone /checkout-pr command", () => { | ||
| 138 | expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBe("checkout-pr"); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("ignores slash commands with extra message text", () => { | 137 | it("ignores slash commands with extra message text", () => { |
| 142 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); | 138 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); |
| 143 | }); | 139 | }); |
| 140 | |||
| 141 | it("ignores removed /checkout-pr command", () => { | ||
| 142 | expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBeNull(); | ||
| 143 | }); | ||
| 144 | }); | 144 | }); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 2 | ||
| 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; | 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; |
| 4 | export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr"; | 4 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 5 | 5 | ||
| 6 | export interface ComposerTrigger { | 6 | export interface ComposerTrigger { |
| 7 | kind: ComposerTriggerKind; | 7 | kind: ComposerTriggerKind; |
| @@ -14,7 +14,6 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ · 6 unmodified lines | |||
| 14 | "model", | 14 | "model", |
| 15 | "plan", | 15 | "plan", |
| 16 | "default", | 16 | "default", |
| 17 | "checkout-pr", | ||
| 18 | ]; | 17 | ]; |
| 19 | 18 | ||
| 20 | function clampCursor(text: string, cursor: number): number { | 19 | function clampCursor(text: string, cursor: number): number { |
| @@ -169,13 +168,12 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude< · 148 unmodified lines | |||
| 169 | ComposerSlashCommand, | 168 | ComposerSlashCommand, |
| 170 | "model" | 169 | "model" |
| 171 | > | null { | 170 | > | null { |
| 172 | const match = /^\/(plan|default|checkout-pr)\s*$/i.exec(text.trim()); | 171 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); |
| 173 | if (!match) { | 172 | if (!match) { |
| 174 | return null; | 173 | return null; |
| 175 | } | 174 | } |
| 176 | const command = match[1]?.toLowerCase(); | 175 | const command = match[1]?.toLowerCase(); |
| 177 | if (command === "plan") return "plan"; | 176 | if (command === "plan") return "plan"; |
| 178 | if (command === "checkout-pr") return "checkout-pr"; | ||
| 179 | return "default"; | 177 | return "default"; |
| 180 | } | 178 | } |
| 181 | 179 | ||