Skip to content

Commit 9d2d206

Browse files
Support gh pr checkout PR references
- Accept `gh pr checkout` input in PR parsing and dialog copy - Keep checkout/create branch picker entries visible for matching queries - Auto-run project setup scripts after preparing a PR worktree thread
1 parent add5f34 commit 9d2d206

8 files changed

Lines changed: 323 additions & 16 deletions

apps/web/src/components/BranchToolbar.logic.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resolveBranchSelectionTarget,
77
resolveDraftEnvModeAfterBranchChange,
88
resolveBranchToolbarValue,
9+
shouldIncludeBranchPickerItem,
910
} from "./BranchToolbar.logic";
1011

1112
describe("resolveDraftEnvModeAfterBranchChange", () => {
@@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => {
267268
});
268269
});
269270
});
271+
272+
describe("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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: {
123123
reuseExistingWorktree: false,
124124
};
125125
}
126+
127+
export 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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
EnvMode,
2929
resolveBranchSelectionTarget,
3030
resolveBranchToolbarValue,
31+
shouldIncludeBranchPickerItem,
3132
} from "./BranchToolbar.logic";
3233
import { Button } from "./ui/button";
3334
import {
@@ -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

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ interface TestFixture {
5656

5757
let fixture: TestFixture;
5858
const wsRequests: WsRequestEnvelope["body"][] = [];
59+
let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
5960
const wsLink = ws.link(/ws(s)?:\/\/.*/);
6061

6162
interface ViewportSpec {
@@ -414,6 +415,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
414415
}
415416

416417
function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
418+
const customResult = customWsRpcResolver?.(body);
419+
if (customResult !== undefined) {
420+
return customResult;
421+
}
417422
const tag = body._tag;
418423
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
419424
return fixture.snapshot;
@@ -748,9 +753,11 @@ async function mountChatView(options: {
748753
viewport: ViewportSpec;
749754
snapshot: OrchestrationReadModel;
750755
configureFixture?: (fixture: TestFixture) => void;
756+
resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;
751757
}): Promise<MountedChatView> {
752758
fixture = buildFixture(options.snapshot);
753759
options.configureFixture?.(fixture);
760+
customWsRpcResolver = options.resolveRpc ?? null;
754761
await setViewport(options.viewport);
755762
await waitForProductionStyles();
756763

@@ -776,6 +783,7 @@ async function mountChatView(options: {
776783
await waitForLayout();
777784

778785
const cleanup = async () => {
786+
customWsRpcResolver = null;
779787
await screen.unmount();
780788
host.remove();
781789
};
@@ -835,6 +843,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
835843
localStorage.clear();
836844
document.body.innerHTML = "";
837845
wsRequests.length = 0;
846+
customWsRpcResolver = null;
838847
useComposerDraftStore.setState({
839848
draftsByThreadId: {},
840849
draftThreadsByThreadId: {},
@@ -850,6 +859,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
850859
});
851860

852861
afterEach(() => {
862+
customWsRpcResolver = null;
853863
document.body.innerHTML = "";
854864
});
855865

@@ -1206,6 +1216,154 @@ describe("ChatView timeline estimator parity (full app)", () => {
12061216
}
12071217
});
12081218

1219+
it("runs setup scripts after preparing a pull request worktree thread", async () => {
1220+
useComposerDraftStore.setState({
1221+
draftThreadsByThreadId: {
1222+
[THREAD_ID]: {
1223+
projectId: PROJECT_ID,
1224+
createdAt: NOW_ISO,
1225+
runtimeMode: "full-access",
1226+
interactionMode: "default",
1227+
branch: null,
1228+
worktreePath: null,
1229+
envMode: "local",
1230+
},
1231+
},
1232+
projectDraftThreadIdByProjectId: {
1233+
[PROJECT_ID]: THREAD_ID,
1234+
},
1235+
});
1236+
1237+
const mounted = await mountChatView({
1238+
viewport: DEFAULT_VIEWPORT,
1239+
snapshot: withProjectScripts(createDraftOnlySnapshot(), [
1240+
{
1241+
id: "setup",
1242+
name: "Setup",
1243+
command: "bun install",
1244+
icon: "configure",
1245+
runOnWorktreeCreate: true,
1246+
},
1247+
]),
1248+
resolveRpc: (body) => {
1249+
if (body._tag === WS_METHODS.gitResolvePullRequest) {
1250+
return {
1251+
pullRequest: {
1252+
number: 1359,
1253+
title: "Add thread archiving and settings navigation",
1254+
url: "https://github.com/pingdotgg/t3code/pull/1359",
1255+
baseBranch: "main",
1256+
headBranch: "archive-settings-overhaul",
1257+
state: "open",
1258+
},
1259+
};
1260+
}
1261+
if (body._tag === WS_METHODS.gitPreparePullRequestThread) {
1262+
return {
1263+
pullRequest: {
1264+
number: 1359,
1265+
title: "Add thread archiving and settings navigation",
1266+
url: "https://github.com/pingdotgg/t3code/pull/1359",
1267+
baseBranch: "main",
1268+
headBranch: "archive-settings-overhaul",
1269+
state: "open",
1270+
},
1271+
branch: "archive-settings-overhaul",
1272+
worktreePath: "/repo/worktrees/pr-1359",
1273+
};
1274+
}
1275+
return undefined;
1276+
},
1277+
});
1278+
1279+
try {
1280+
const branchButton = await waitForElement(
1281+
() =>
1282+
Array.from(document.querySelectorAll("button")).find(
1283+
(button) => button.textContent?.trim() === "main",
1284+
) as HTMLButtonElement | null,
1285+
"Unable to find branch selector button.",
1286+
);
1287+
branchButton.click();
1288+
1289+
const branchInput = await waitForElement(
1290+
() => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
1291+
"Unable to find branch search input.",
1292+
);
1293+
branchInput.focus();
1294+
await page.getByPlaceholder("Search branches...").fill("1359");
1295+
1296+
const checkoutItem = await waitForElement(
1297+
() =>
1298+
Array.from(document.querySelectorAll("span")).find(
1299+
(element) => element.textContent?.trim() === "Checkout Pull Request",
1300+
) as HTMLSpanElement | null,
1301+
"Unable to find checkout pull request option.",
1302+
);
1303+
checkoutItem.click();
1304+
1305+
const worktreeButton = await waitForElement(
1306+
() =>
1307+
Array.from(document.querySelectorAll("button")).find(
1308+
(button) => button.textContent?.trim() === "Worktree",
1309+
) as HTMLButtonElement | null,
1310+
"Unable to find Worktree button.",
1311+
);
1312+
worktreeButton.click();
1313+
1314+
await vi.waitFor(
1315+
() => {
1316+
const prepareRequest = wsRequests.find(
1317+
(request) => request._tag === WS_METHODS.gitPreparePullRequestThread,
1318+
);
1319+
expect(prepareRequest).toMatchObject({
1320+
_tag: WS_METHODS.gitPreparePullRequestThread,
1321+
cwd: "/repo/project",
1322+
reference: "1359",
1323+
mode: "worktree",
1324+
});
1325+
},
1326+
{ timeout: 8_000, interval: 16 },
1327+
);
1328+
1329+
await vi.waitFor(
1330+
() => {
1331+
const openRequest = wsRequests.find(
1332+
(request) =>
1333+
request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359",
1334+
);
1335+
expect(openRequest).toMatchObject({
1336+
_tag: WS_METHODS.terminalOpen,
1337+
threadId: expect.any(String),
1338+
cwd: "/repo/worktrees/pr-1359",
1339+
env: {
1340+
T3CODE_PROJECT_ROOT: "/repo/project",
1341+
T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359",
1342+
},
1343+
});
1344+
},
1345+
{ timeout: 8_000, interval: 16 },
1346+
);
1347+
1348+
await vi.waitFor(
1349+
() => {
1350+
const writeRequest = wsRequests.find(
1351+
(request) =>
1352+
request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",
1353+
);
1354+
expect(writeRequest).toMatchObject({
1355+
_tag: WS_METHODS.terminalWrite,
1356+
threadId: expect.any(String),
1357+
data: "bun install\r",
1358+
});
1359+
},
1360+
{ timeout: 8_000, interval: 16 },
1361+
);
1362+
} finally {
1363+
await mounted.cleanup();
1364+
}
1365+
});
1366+
12091367
it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
12101368
const mounted = await mountChatView({
12111369
viewport: DEFAULT_VIEWPORT,

0 commit comments

Comments
 (0)