authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 22:36:22-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
logd28ced8fd7471302d42e2e272fcbd62f9b1404c8
treea0b24c78ef5b0f2940929454255219f07e15b7f1
parent076608021c89afb294f4bdc75d99018936540c95

Move PR checkout flow to dialog and drop /checkout-pr command

- 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

6 files changed, 336 insertions(+), 348 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+3-2
...@@ -291,7 +291,8 @@ export function BranchToolbarBranchSelector({...@@ -291,7 +291,8 @@ export function BranchToolbarBranchSelector({
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({...@@ -409,7 +410,7 @@ export function BranchToolbarBranchSelector({
409 items={branchPickerItems}410 items={branchPickerItems}
410 filteredItems={filteredBranchPickerItems}411 filteredItems={filteredBranchPickerItems}
411 autoHighlight412 autoHighlight
412 virtualized413 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" });
apps/web/src/components/ChatView.tsx+46-79
...@@ -119,7 +119,6 @@ import {...@@ -119,7 +119,6 @@ import {
119 type TurnDiffTreeNode,119 type TurnDiffTreeNode,
120} from "../lib/turnDiffTree";120} from "../lib/turnDiffTree";
121import BranchToolbar from "./BranchToolbar";121import BranchToolbar from "./BranchToolbar";
122import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu";
123import GitActionsControl from "./GitActionsControl";122import GitActionsControl from "./GitActionsControl";
124import {123import {
125 isOpenFavoriteEditorShortcut,124 isOpenFavoriteEditorShortcut,
...@@ -217,6 +216,7 @@ import { shouldUseCompactComposerFooter } from "./composerFooterLayout";...@@ -217,6 +216,7 @@ import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
217import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";216import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
218import { clamp } from "effect/Number";217import { clamp } from "effect/Number";
219import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";218import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
219import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
220import { estimateTimelineMessageHeight } from "./timelineHeight";220import { estimateTimelineMessageHeight } from "./timelineHeight";
221221
222function formatMessageMeta(createdAt: string, duration: string | null): string {222function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -408,7 +408,7 @@ type ComposerCommandItem =...@@ -408,7 +408,7 @@ type ComposerCommandItem =
408408
409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
410410
411interface PullRequestComposerMenuState {411interface 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) {...@@ -657,8 +657,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -764,19 +765,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
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);
766767
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]);
777778
778 const closePullRequestComposerMenu = useCallback(() => {779 const closePullRequestDialog = useCallback(() => {
779 setPullRequestComposerMenuState(null);780 setPullRequestDialogState(null);
780 }, []);781 }, []);
781782
782 const openOrReuseProjectDraftThread = useCallback(783 const openOrReuseProjectDraftThread = useCallback(
...@@ -1254,7 +1255,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1254,7 +1255,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1311,22 +1311,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1347,16 +1337,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 pullRequestComposerMenuOpen1344 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
1355 ? null1345 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) {...@@ -2055,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20552043
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) {...@@ -2070,16 +2058,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 ? existing2063 ? existing
2080 : (composerMenuItems[0]?.id ?? null),2064 : (composerMenuItems[0]?.id ?? null),
2081 );2065 );
2082 }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]);2066 }, [composerMenuItems, composerMenuOpen]);
20832067
2084 useEffect(() => {2068 useEffect(() => {
2085 setIsRevertingCheckpoint(false);2069 setIsRevertingCheckpoint(false);
...@@ -2589,15 +2573,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2589,15 +2573,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3386,11 +3363,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3410,7 +3383,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3485,12 +3457,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
3485 const { trigger } = resolveActiveComposerTrigger();3457 const { trigger } = resolveActiveComposerTrigger();
3486 const menuIsActive = composerMenuOpenRef.current || trigger !== null;3458 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
34873459
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) {...@@ -3712,28 +3678,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 <ComposerPullRequestCommandMenu3682 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 )}
37393692
...@@ -4160,10 +4113,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4160,10 +4113,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
4160 envLocked={envLocked}4113 envLocked={envLocked}
4161 onComposerFocusRequest={scheduleComposerFocus}4114 onComposerFocusRequest={scheduleComposerFocus}
4162 {...(canCheckoutPullRequestIntoThread4115 {...(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 */}
41684135
4169 {/* Plan sidebar */}4136 {/* Plan sidebar */}
apps/web/src/components/ComposerPullRequestCommandMenu.tsx deleted-259
...@@ -1,259 +0,0 @@
1import type { GitResolvePullRequestResult } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6import {
7 gitPreparePullRequestThreadMutationOptions,
8 gitResolvePullRequestQueryOptions,
9} from "~/lib/gitReactQuery";
10import { cn } from "~/lib/utils";
11import { parsePullRequestReference } from "~/pullRequestReference";
12import { Button } from "./ui/button";
13import { Input } from "./ui/input";
14import { Spinner } from "./ui/spinner";
15
16interface 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
23export 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 @@
1import type { GitResolvePullRequestResult } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6import {
7 gitPreparePullRequestThreadMutationOptions,
8 gitResolvePullRequestQueryOptions,
9} from "~/lib/gitReactQuery";
10import { cn } from "~/lib/utils";
11import { parsePullRequestReference } from "~/pullRequestReference";
12import { Button } from "./ui/button";
13import {
14 Dialog,
15 DialogDescription,
16 DialogFooter,
17 DialogHeader,
18 DialogPanel,
19 DialogPopup,
20 DialogTitle,
21} from "./ui/dialog";
22import { Input } from "./ui/input";
23import { Spinner } from "./ui/spinner";
24
25interface 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
33export 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,11 +134,11 @@ describe("parseStandaloneComposerSlashCommand", () => {
134 expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");134 expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");
135 });135 });
136136
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});
apps/web/src/composer-logic.ts+2-4
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr";4export type ComposerSlashCommand = "model" | "plan" | "default";
55
6export interface ComposerTrigger {6export interface ComposerTrigger {
7 kind: ComposerTriggerKind;7 kind: ComposerTriggerKind;
...@@ -14,7 +14,6 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [...@@ -14,7 +14,6 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [
14 "model",14 "model",
15 "plan",15 "plan",
16 "default",16 "default",
17 "checkout-pr",
18];17];
1918
20function clampCursor(text: string, cursor: number): number {19function clampCursor(text: string, cursor: number): number {
...@@ -169,13 +168,12 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude<...@@ -169,13 +168,12 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude<
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}
181179