authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-27 00:08:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-27 00:08:45-07:00
logd8a485ea7e93f4e498be3314c0094a3af675938f
tree5ee4baa82cdc8a1f7a89c1c124bc7268c79d4509
parent02989fe600c2aff728b09c5600f75a3a073b3468
signature Signed by PGP key B5690EEEBB952194

Support `gh pr checkout` pull request references (#1457)


8 files changed, 323 insertions(+), 16 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+36
......@@ -6,6 +6,7 @@ import {
66 resolveBranchSelectionTarget,
77 resolveDraftEnvModeAfterBranchChange,
88 resolveBranchToolbarValue,
9 shouldIncludeBranchPickerItem,
910} from "./BranchToolbar.logic";
1011
1112describe("resolveDraftEnvModeAfterBranchChange", () => {
......@@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => {
267268 });
268269 });
269270});
271
272describe("shouldIncludeBranchPickerItem", () => {
273 it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => {
274 expect(
275 shouldIncludeBranchPickerItem({
276 itemValue: "__checkout_pull_request__:1359",
277 normalizedQuery: "gh pr checkout 1359",
278 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
279 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
280 }),
281 ).toBe(true);
282 });
283
284 it("keeps the synthetic create-branch item visible for arbitrary branch input", () => {
285 expect(
286 shouldIncludeBranchPickerItem({
287 itemValue: "__create_new_branch__:feature/demo",
288 normalizedQuery: "feature/demo",
289 createBranchItemValue: "__create_new_branch__:feature/demo",
290 checkoutPullRequestItemValue: null,
291 }),
292 ).toBe(true);
293 });
294
295 it("still filters ordinary branch items by query text", () => {
296 expect(
297 shouldIncludeBranchPickerItem({
298 itemValue: "main",
299 normalizedQuery: "gh pr checkout 1359",
300 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
301 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
302 }),
303 ).toBe(false);
304 });
305});
apps/web/src/components/BranchToolbar.logic.ts+23
......@@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: {
123123 reuseExistingWorktree: false,
124124 };
125125}
126
127export function shouldIncludeBranchPickerItem(input: {
128 itemValue: string;
129 normalizedQuery: string;
130 createBranchItemValue: string | null;
131 checkoutPullRequestItemValue: string | null;
132}): boolean {
133 const { itemValue, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
134
135 if (normalizedQuery.length === 0) {
136 return true;
137 }
138
139 if (createBranchItemValue && itemValue === createBranchItemValue) {
140 return true;
141 }
142
143 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
144 return true;
145 }
146
147 return itemValue.toLowerCase().includes(normalizedQuery);
148}
apps/web/src/components/BranchToolbarBranchSelector.tsx+15-5
......@@ -28,6 +28,7 @@ import {
2828 EnvMode,
2929 resolveBranchSelectionTarget,
3030 resolveBranchToolbarValue,
31 shouldIncludeBranchPickerItem,
3132} from "./BranchToolbar.logic";
3233import { Button } from "./ui/button";
3334import {
......@@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({
134135 () =>
135136 normalizedDeferredBranchQuery.length === 0
136137 ? branchPickerItems
137 : branchPickerItems.filter((itemValue) => {
138 if (createBranchItemValue && itemValue === createBranchItemValue) return true;
139 return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery);
140 }),
141 [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery],
138 : branchPickerItems.filter((itemValue) =>
139 shouldIncludeBranchPickerItem({
140 itemValue,
141 normalizedQuery: normalizedDeferredBranchQuery,
142 createBranchItemValue,
143 checkoutPullRequestItemValue,
144 }),
145 ),
146 [
147 branchPickerItems,
148 checkoutPullRequestItemValue,
149 createBranchItemValue,
150 normalizedDeferredBranchQuery,
151 ],
142152 );
143153 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
144154 canonicalActiveBranch,
apps/web/src/components/ChatView.browser.tsx+158
......@@ -56,6 +56,7 @@ interface TestFixture {
5656
5757let fixture: TestFixture;
5858const wsRequests: WsRequestEnvelope["body"][] = [];
59let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
5960const wsLink = ws.link(/ws(s)?:\/\/.*/);
6061
6162interface ViewportSpec {
......@@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
433434}
434435
435436function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
437 const customResult = customWsRpcResolver?.(body);
438 if (customResult !== undefined) {
439 return customResult;
440 }
436441 const tag = body._tag;
437442 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
438443 return fixture.snapshot;
......@@ -767,9 +772,11 @@ async function mountChatView(options: {
767772 viewport: ViewportSpec;
768773 snapshot: OrchestrationReadModel;
769774 configureFixture?: (fixture: TestFixture) => void;
775 resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;
770776}): Promise<MountedChatView> {
771777 fixture = buildFixture(options.snapshot);
772778 options.configureFixture?.(fixture);
779 customWsRpcResolver = options.resolveRpc ?? null;
773780 await setViewport(options.viewport);
774781 await waitForProductionStyles();
775782
......@@ -795,6 +802,7 @@ async function mountChatView(options: {
795802 await waitForLayout();
796803
797804 const cleanup = async () => {
805 customWsRpcResolver = null;
798806 await screen.unmount();
799807 host.remove();
800808 };
......@@ -854,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
854862 localStorage.clear();
855863 document.body.innerHTML = "";
856864 wsRequests.length = 0;
865 customWsRpcResolver = null;
857866 useComposerDraftStore.setState({
858867 draftsByThreadId: {},
859868 draftThreadsByThreadId: {},
......@@ -869,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
869878 });
870879
871880 afterEach(() => {
881 customWsRpcResolver = null;
872882 document.body.innerHTML = "";
873883 });
874884
......@@ -1357,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => {
13571367 }
13581368 });
13591369
1370 it("runs setup scripts after preparing a pull request worktree thread", async () => {
1371 useComposerDraftStore.setState({
1372 draftThreadsByThreadId: {
1373 [THREAD_ID]: {
1374 projectId: PROJECT_ID,
1375 createdAt: NOW_ISO,
1376 runtimeMode: "full-access",
1377 interactionMode: "default",
1378 branch: null,
1379 worktreePath: null,
1380 envMode: "local",
1381 },
1382 },
1383 projectDraftThreadIdByProjectId: {
1384 [PROJECT_ID]: THREAD_ID,
1385 },
1386 });
1387
1388 const mounted = await mountChatView({
1389 viewport: DEFAULT_VIEWPORT,
1390 snapshot: withProjectScripts(createDraftOnlySnapshot(), [
1391 {
1392 id: "setup",
1393 name: "Setup",
1394 command: "bun install",
1395 icon: "configure",
1396 runOnWorktreeCreate: true,
1397 },
1398 ]),
1399 resolveRpc: (body) => {
1400 if (body._tag === WS_METHODS.gitResolvePullRequest) {
1401 return {
1402 pullRequest: {
1403 number: 1359,
1404 title: "Add thread archiving and settings navigation",
1405 url: "https://github.com/pingdotgg/t3code/pull/1359",
1406 baseBranch: "main",
1407 headBranch: "archive-settings-overhaul",
1408 state: "open",
1409 },
1410 };
1411 }
1412 if (body._tag === WS_METHODS.gitPreparePullRequestThread) {
1413 return {
1414 pullRequest: {
1415 number: 1359,
1416 title: "Add thread archiving and settings navigation",
1417 url: "https://github.com/pingdotgg/t3code/pull/1359",
1418 baseBranch: "main",
1419 headBranch: "archive-settings-overhaul",
1420 state: "open",
1421 },
1422 branch: "archive-settings-overhaul",
1423 worktreePath: "/repo/worktrees/pr-1359",
1424 };
1425 }
1426 return undefined;
1427 },
1428 });
1429
1430 try {
1431 const branchButton = await waitForElement(
1432 () =>
1433 Array.from(document.querySelectorAll("button")).find(
1434 (button) => button.textContent?.trim() === "main",
1435 ) as HTMLButtonElement | null,
1436 "Unable to find branch selector button.",
1437 );
1438 branchButton.click();
1439
1440 const branchInput = await waitForElement(
1441 () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
1442 "Unable to find branch search input.",
1443 );
1444 branchInput.focus();
1445 await page.getByPlaceholder("Search branches...").fill("1359");
1446
1447 const checkoutItem = await waitForElement(
1448 () =>
1449 Array.from(document.querySelectorAll("span")).find(
1450 (element) => element.textContent?.trim() === "Checkout Pull Request",
1451 ) as HTMLSpanElement | null,
1452 "Unable to find checkout pull request option.",
1453 );
1454 checkoutItem.click();
1455
1456 const worktreeButton = await waitForElement(
1457 () =>
1458 Array.from(document.querySelectorAll("button")).find(
1459 (button) => button.textContent?.trim() === "Worktree",
1460 ) as HTMLButtonElement | null,
1461 "Unable to find Worktree button.",
1462 );
1463 worktreeButton.click();
1464
1465 await vi.waitFor(
1466 () => {
1467 const prepareRequest = wsRequests.find(
1468 (request) => request._tag === WS_METHODS.gitPreparePullRequestThread,
1469 );
1470 expect(prepareRequest).toMatchObject({
1471 _tag: WS_METHODS.gitPreparePullRequestThread,
1472 cwd: "/repo/project",
1473 reference: "1359",
1474 mode: "worktree",
1475 });
1476 },
1477 { timeout: 8_000, interval: 16 },
1478 );
1479
1480 await vi.waitFor(
1481 () => {
1482 const openRequest = wsRequests.find(
1483 (request) =>
1484 request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359",
1485 );
1486 expect(openRequest).toMatchObject({
1487 _tag: WS_METHODS.terminalOpen,
1488 threadId: expect.any(String),
1489 cwd: "/repo/worktrees/pr-1359",
1490 env: {
1491 T3CODE_PROJECT_ROOT: "/repo/project",
1492 T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359",
1493 },
1494 });
1495 },
1496 { timeout: 8_000, interval: 16 },
1497 );
1498
1499 await vi.waitFor(
1500 () => {
1501 const writeRequest = wsRequests.find(
1502 (request) =>
1503 request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",
1504 );
1505 expect(writeRequest).toMatchObject({
1506 _tag: WS_METHODS.terminalWrite,
1507 threadId: expect.any(String),
1508 data: "bun install\r",
1509 });
1510 },
1511 { timeout: 8_000, interval: 16 },
1512 );
1513 } finally {
1514 await mounted.cleanup();
1515 }
1516 });
1517
13601518 it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
13611519 const mounted = await mountChatView({
13621520 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+63-4
......@@ -239,6 +239,12 @@ interface ChatViewProps {
239239 threadId: ThreadId;
240240}
241241
242interface PendingPullRequestSetupRequest {
243 threadId: ThreadId;
244 worktreePath: string;
245 scriptId: string;
246}
247
242248export default function ChatView({ threadId }: ChatViewProps) {
243249 const threads = useStore((store) => store.threads);
244250 const projects = useStore((store) => store.projects);
......@@ -350,6 +356,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
350356 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
351357 const [pullRequestDialogState, setPullRequestDialogState] =
352358 useState<PullRequestDialogState | null>(null);
359 const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] =
360 useState<PendingPullRequestSetupRequest | null>(null);
353361 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
354362 Record<string, string[]>
355363 >({});
......@@ -527,14 +535,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
527535 params: { threadId: storedDraftThread.threadId },
528536 });
529537 }
530 return;
538 return storedDraftThread.threadId;
531539 }
532540
533541 const activeDraftThread = getDraftThread(threadId);
534542 if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {
535543 setDraftThreadContext(threadId, input);
536544 setProjectDraftThreadId(activeProject.id, threadId, input);
537 return;
545 return threadId;
538546 }
539547
540548 clearProjectDraftThreadId(activeProject.id);
......@@ -549,6 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
549557 to: "/$threadId",
550558 params: { threadId: nextThreadId },
551559 });
560 return nextThreadId;
552561 },
553562 [
554563 activeProject,
......@@ -565,13 +574,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
565574
566575 const handlePreparedPullRequestThread = useCallback(
567576 async (input: { branch: string; worktreePath: string | null }) => {
568 await openOrReuseProjectDraftThread({
577 const targetThreadId = await openOrReuseProjectDraftThread({
569578 branch: input.branch,
570579 worktreePath: input.worktreePath,
571580 envMode: input.worktreePath ? "worktree" : "local",
572581 });
582 const setupScript =
583 input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null;
584 if (targetThreadId && input.worktreePath && setupScript) {
585 setPendingPullRequestSetupRequest({
586 threadId: targetThreadId,
587 worktreePath: input.worktreePath,
588 scriptId: setupScript.id,
589 });
590 } else {
591 setPendingPullRequestSetupRequest(null);
592 }
573593 },
574 [openOrReuseProjectDraftThread],
594 [activeProject, openOrReuseProjectDraftThread],
575595 );
576596
577597 useEffect(() => {
......@@ -1418,6 +1438,45 @@ export default function ChatView({ threadId }: ChatViewProps) {
14181438 terminalState.terminalIds,
14191439 ],
14201440 );
1441
1442 useEffect(() => {
1443 if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) {
1444 return;
1445 }
1446 if (pendingPullRequestSetupRequest.threadId !== activeThreadId) {
1447 return;
1448 }
1449 if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) {
1450 return;
1451 }
1452
1453 const setupScript =
1454 activeProject.scripts.find(
1455 (script) => script.id === pendingPullRequestSetupRequest.scriptId,
1456 ) ?? null;
1457 setPendingPullRequestSetupRequest(null);
1458 if (!setupScript) {
1459 return;
1460 }
1461
1462 void runProjectScript(setupScript, {
1463 cwd: pendingPullRequestSetupRequest.worktreePath,
1464 worktreePath: pendingPullRequestSetupRequest.worktreePath,
1465 rememberAsLastInvoked: false,
1466 }).catch((error) => {
1467 toastManager.add({
1468 type: "error",
1469 title: "Failed to run setup script.",
1470 description: error instanceof Error ? error.message : "An error occurred.",
1471 });
1472 });
1473 }, [
1474 activeProject,
1475 activeThread,
1476 activeThreadId,
1477 pendingPullRequestSetupRequest,
1478 runProjectScript,
1479 ]);
14211480 const persistProjectScripts = useCallback(
14221481 async (input: {
14231482 projectId: ProjectId;
apps/web/src/components/PullRequestThreadDialog.tsx+3-3
......@@ -153,9 +153,9 @@ export function PullRequestThreadDialog({
153153 const validationMessage = !referenceDirty
154154 ? null
155155 : reference.trim().length === 0
156 ? "Paste a GitHub pull request URL or enter 123 / #123."
156 ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123."
157157 : parsedReference === null
158 ? "Use a GitHub pull request URL, 123, or #123."
158 ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123."
159159 : null;
160160 const errorMessage =
161161 validationMessage ??
......@@ -191,7 +191,7 @@ export function PullRequestThreadDialog({
191191 <span className="text-xs font-medium text-foreground">Pull request</span>
192192 <Input
193193 ref={referenceInputRef}
194 placeholder="https://github.com/owner/repo/pull/42 or #42"
194 placeholder="https://github.com/owner/repo/pull/42, gh pr checkout 42, or #42"
195195 value={reference}
196196 onChange={(event) => {
197197 setReferenceDirty(true);
apps/web/src/pullRequestReference.test.ts+14
......@@ -17,6 +17,20 @@ describe("parsePullRequestReference", () => {
1717 expect(parsePullRequestReference("#42")).toBe("#42");
1818 });
1919
20 it("accepts gh pr checkout commands with raw numbers", () => {
21 expect(parsePullRequestReference("gh pr checkout 42")).toBe("42");
22 });
23
24 it("accepts gh pr checkout commands with #number references", () => {
25 expect(parsePullRequestReference("gh pr checkout #42")).toBe("#42");
26 });
27
28 it("accepts gh pr checkout commands with GitHub pull request URLs", () => {
29 expect(
30 parsePullRequestReference("gh pr checkout https://github.com/pingdotgg/t3code/pull/42"),
31 ).toBe("https://github.com/pingdotgg/t3code/pull/42");
32 });
33
2034 it("rejects non-pull-request input", () => {
2135 expect(parsePullRequestReference("feature/my-branch")).toBeNull();
2236 });
apps/web/src/pullRequestReference.ts+11-4
......@@ -1,6 +1,7 @@
11const GITHUB_PULL_REQUEST_URL_PATTERN =
22 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
33const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
4const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
45
56export function parsePullRequestReference(input: string): string | null {
67 const trimmed = input.trim();
......@@ -8,14 +9,20 @@ export function parsePullRequestReference(input: string): string | null {
89 return null;
910 }
1011
11 const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed);
12 const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
13 const normalizedInput = ghCliCheckoutMatch?.[1]?.trim() ?? trimmed;
14 if (normalizedInput.length === 0) {
15 return null;
16 }
17
18 const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
1219 if (urlMatch?.[1]) {
13 return trimmed;
20 return normalizedInput;
1421 }
1522
16 const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed);
23 const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput);
1724 if (numberMatch?.[1]) {
18 return trimmed.startsWith("#") ? trimmed : numberMatch[1];
25 return normalizedInput.startsWith("#") ? normalizedInput : numberMatch[1];
1926 }
2027
2128 return null;