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