Skip to content

Commit d8a485e

Browse files
Support gh pr checkout pull request references (pingdotgg#1457)
1 parent 02989fe commit d8a485e

File tree

8 files changed

+323
-16
lines changed

8 files changed

+323
-16
lines changed

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 {
@@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
433434
}
434435

435436
function 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,

0 commit comments

Comments
 (0)