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({
291291 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
292292 const branchListVirtualizer = useVirtualizer({
293293 count: filteredBranchPickerItems.length,
294 estimateSize: () => 28,
294 estimateSize: (index) =>
295 filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28,
295296 getScrollElement: () => branchListScrollElementRef.current,
296297 overscan: 12,
297298 enabled: isBranchMenuOpen && shouldVirtualizeBranchList,
......@@ -409,7 +410,7 @@ export function BranchToolbarBranchSelector({
409410 items={branchPickerItems}
410411 filteredItems={filteredBranchPickerItems}
411412 autoHighlight
412 virtualized
413 virtualized={shouldVirtualizeBranchList}
413414 onItemHighlighted={(_value, eventDetails) => {
414415 if (!isBranchMenuOpen || eventDetails.index < 0) return;
415416 branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
apps/web/src/components/ChatView.tsx+46-79
......@@ -119,7 +119,6 @@ import {
119119 type TurnDiffTreeNode,
120120} from "../lib/turnDiffTree";
121121import BranchToolbar from "./BranchToolbar";
122import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu";
123122import GitActionsControl from "./GitActionsControl";
124123import {
125124 isOpenFavoriteEditorShortcut,
......@@ -217,6 +216,7 @@ import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
217216import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
218217import { clamp } from "effect/Number";
219218import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
219import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
220220import { estimateTimelineMessageHeight } from "./timelineHeight";
221221
222222function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -408,7 +408,7 @@ type ComposerCommandItem =
408408
409409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
410410
411interface PullRequestComposerMenuState {
411interface PullRequestDialogState {
412412 initialReference: string | null;
413413 key: number;
414414}
......@@ -657,8 +657,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
657657 const [nowTick, setNowTick] = useState(() => Date.now());
658658 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
659659 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 );
662663 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
663664 Record<string, string[]>
664665 >({});
......@@ -764,19 +765,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
764765 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
765766 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
766767
767 const openPullRequestComposerMenu = useCallback((reference?: string) => {
768 const openPullRequestDialog = useCallback((reference?: string) => {
768769 if (!canCheckoutPullRequestIntoThread) {
769770 return;
770771 }
771 setPullRequestComposerMenuState({
772 setPullRequestDialogState({
772773 initialReference: reference ?? null,
773774 key: Date.now(),
774775 });
775776 setComposerHighlightedItemId(null);
776777 }, [canCheckoutPullRequestIntoThread]);
777778
778 const closePullRequestComposerMenu = useCallback(() => {
779 setPullRequestComposerMenuState(null);
779 const closePullRequestDialog = useCallback(() => {
780 setPullRequestDialogState(null);
780781 }, []);
781782
782783 const openOrReuseProjectDraftThread = useCallback(
......@@ -1254,7 +1255,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
12541255 timelineEntries,
12551256 ]);
12561257 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
1257 const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null;
12581258 const composerTriggerKind = composerTrigger?.kind ?? null;
12591259 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
12601260 const isPathTrigger = composerTriggerKind === "path";
......@@ -1311,22 +1311,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
13111311 label: "/default",
13121312 description: "Switch this thread back to normal chat mode",
13131313 },
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 },
13211314 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1322 const availableSlashCommandItems = canCheckoutPullRequestIntoThread
1323 ? slashCommandItems
1324 : slashCommandItems.filter((item) => item.command !== "checkout-pr");
13251315 const query = composerTrigger.query.trim().toLowerCase();
13261316 if (!query) {
1327 return [...availableSlashCommandItems];
1317 return [...slashCommandItems];
13281318 }
1329 return availableSlashCommandItems.filter(
1319 return slashCommandItems.filter(
13301320 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
13311321 );
13321322 }
......@@ -1347,16 +1337,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
13471337 label: name,
13481338 description: `${providerLabel} · ${slug}`,
13491339 }));
1350 }, [canCheckoutPullRequestIntoThread, composerTrigger, searchableModelOptions, workspaceEntries]);
1351 const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen;
1340 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1341 const composerMenuOpen = Boolean(composerTrigger);
13521342 const activeComposerMenuItem = useMemo(
13531343 () =>
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],
13601348 );
13611349 composerMenuOpenRef.current = composerMenuOpen;
13621350 composerMenuItemsRef.current = composerMenuItems;
......@@ -2055,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20552043
20562044 useEffect(() => {
20572045 setExpandedWorkGroups({});
2058 setPullRequestComposerMenuState(null);
2046 setPullRequestDialogState(null);
20592047 if (planSidebarOpenOnNextThreadRef.current) {
20602048 planSidebarOpenOnNextThreadRef.current = false;
20612049 setPlanSidebarOpen(true);
......@@ -2070,16 +2058,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
20702058 setComposerHighlightedItemId(null);
20712059 return;
20722060 }
2073 if (pullRequestComposerMenuOpen) {
2074 setComposerHighlightedItemId(null);
2075 return;
2076 }
20772061 setComposerHighlightedItemId((existing) =>
20782062 existing && composerMenuItems.some((item) => item.id === existing)
20792063 ? existing
20802064 : (composerMenuItems[0]?.id ?? null),
20812065 );
2082 }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]);
2066 }, [composerMenuItems, composerMenuOpen]);
20832067
20842068 useEffect(() => {
20852069 setIsRevertingCheckpoint(false);
......@@ -2589,15 +2573,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
25892573 }
25902574 const standaloneSlashCommand =
25912575 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);
26012578 promptRef.current = "";
26022579 clearComposerDraftContent(activeThread.id);
26032580 setComposerHighlightedItemId(null);
......@@ -3386,11 +3363,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
33863363 }
33873364 return;
33883365 }
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");
33943367 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
33953368 expectedText: expectedToken,
33963369 });
......@@ -3410,7 +3383,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
34103383 [
34113384 applyPromptReplacement,
34123385 handleInteractionModeChange,
3413 openPullRequestComposerMenu,
34143386 onProviderModelSelect,
34153387 resolveActiveComposerTrigger,
34163388 ],
......@@ -3485,12 +3457,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
34853457 const { trigger } = resolveActiveComposerTrigger();
34863458 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
34943460 if (menuIsActive) {
34953461 const currentItems = composerMenuItemsRef.current;
34963462 if (key === "ArrowDown" && currentItems.length > 0) {
......@@ -3712,28 +3678,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
37123678 >
37133679 {composerMenuOpen && !isComposerApprovalState && (
37143680 <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 />
37373690 </div>
37383691 )}
37393692
......@@ -4160,10 +4113,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
41604113 envLocked={envLocked}
41614114 onComposerFocusRequest={scheduleComposerFocus}
41624115 {...(canCheckoutPullRequestIntoThread
4163 ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu }
4116 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
41644117 : {})}
41654118 />
41664119 )}
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}
41674134 </div>{/* end chat column */}
41684135
41694136 {/* 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", () => {
134134 expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");
135135 });
136136
137 it("parses standalone /checkout-pr command", () => {
138 expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBe("checkout-pr");
139 });
140
141137 it("ignores slash commands with extra message text", () => {
142138 expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull();
143139 });
140
141 it("ignores removed /checkout-pr command", () => {
142 expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBeNull();
143 });
144144});
apps/web/src/composer-logic.ts+2-4
......@@ -1,7 +1,7 @@
11import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
33export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr";
4export type ComposerSlashCommand = "model" | "plan" | "default";
55
66export interface ComposerTrigger {
77 kind: ComposerTriggerKind;
......@@ -14,7 +14,6 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [
1414 "model",
1515 "plan",
1616 "default",
17 "checkout-pr",
1817];
1918
2019function clampCursor(text: string, cursor: number): number {
......@@ -169,13 +168,12 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude<
169168 ComposerSlashCommand,
170169 "model"
171170> | null {
172 const match = /^\/(plan|default|checkout-pr)\s*$/i.exec(text.trim());
171 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
173172 if (!match) {
174173 return null;
175174 }
176175 const command = match[1]?.toLowerCase();
177176 if (command === "plan") return "plan";
178 if (command === "checkout-pr") return "checkout-pr";
179177 return "default";
180178}
181179