History

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

Julius Marminge committed 8 months ago

Commit d28ced81 parent 07660805 files · +154 −166Browse files

Unified · Split

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