| author | |
| committer | |
| log | d8a485ea7e93f4e498be3314c0094a3af675938f |
| tree | 5ee4baa82cdc8a1f7a89c1c124bc7268c79d4509 |
| parent | 02989fe600c2aff728b09c5600f75a3a073b3468 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 323 insertions(+), 16 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+36| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | resolveBranchSelectionTarget, | 6 | resolveBranchSelectionTarget, |
| 7 | resolveDraftEnvModeAfterBranchChange, | 7 | resolveDraftEnvModeAfterBranchChange, |
| 8 | resolveBranchToolbarValue, | 8 | resolveBranchToolbarValue, |
| 9 | shouldIncludeBranchPickerItem, | ||
| 9 | } from "./BranchToolbar.logic"; | 10 | } from "./BranchToolbar.logic"; |
| 10 | 11 | ||
| 11 | describe("resolveDraftEnvModeAfterBranchChange", () => { | 12 | describe("resolveDraftEnvModeAfterBranchChange", () => { |
| ... | @@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => { | ... | @@ -267,3 +268,38 @@ describe("resolveBranchSelectionTarget", () => { |
| 267 | }); | 268 | }); |
| 268 | }); | 269 | }); |
| 269 | }); | 270 | }); |
| 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+23| ... | @@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: { | ... | @@ -123,3 +123,26 @@ export function resolveBranchSelectionTarget(input: { |
| 123 | reuseExistingWorktree: false, | 123 | reuseExistingWorktree: false, |
| 124 | }; | 124 | }; |
| 125 | } | 125 | } |
| 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+15-5| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | EnvMode, | 28 | EnvMode, |
| 29 | resolveBranchSelectionTarget, | 29 | resolveBranchSelectionTarget, |
| 30 | resolveBranchToolbarValue, | 30 | resolveBranchToolbarValue, |
| 31 | shouldIncludeBranchPickerItem, | ||
| 31 | } from "./BranchToolbar.logic"; | 32 | } from "./BranchToolbar.logic"; |
| 32 | import { Button } from "./ui/button"; | 33 | import { Button } from "./ui/button"; |
| 33 | import { | 34 | import { |
| ... | @@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({ | ... | @@ -134,11 +135,20 @@ export function BranchToolbarBranchSelector({ |
| 134 | () => | 135 | () => |
| 135 | normalizedDeferredBranchQuery.length === 0 | 136 | normalizedDeferredBranchQuery.length === 0 |
| 136 | ? branchPickerItems | 137 | ? branchPickerItems |
| 137 | : branchPickerItems.filter((itemValue) => { | 138 | : branchPickerItems.filter((itemValue) => |
| 138 | if (createBranchItemValue && itemValue === createBranchItemValue) return true; | 139 | shouldIncludeBranchPickerItem({ |
| 139 | return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery); | 140 | itemValue, |
| 140 | }), | 141 | normalizedQuery: normalizedDeferredBranchQuery, |
| 141 | [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery], | 142 | createBranchItemValue, |
| 143 | checkoutPullRequestItemValue, | ||
| 144 | }), | ||
| 145 | ), | ||
| 146 | [ | ||
| 147 | branchPickerItems, | ||
| 148 | checkoutPullRequestItemValue, | ||
| 149 | createBranchItemValue, | ||
| 150 | normalizedDeferredBranchQuery, | ||
| 151 | ], | ||
| 142 | ); | 152 | ); |
| 143 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( | 153 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( |
| 144 | canonicalActiveBranch, | 154 | canonicalActiveBranch, |
apps/web/src/components/ChatView.browser.tsx+158| ... | @@ -56,6 +56,7 @@ interface TestFixture { | ... | @@ -56,6 +56,7 @@ interface TestFixture { |
| 56 | 56 | ||
| 57 | let fixture: TestFixture; | 57 | let fixture: TestFixture; |
| 58 | const wsRequests: WsRequestEnvelope["body"][] = []; | 58 | const wsRequests: WsRequestEnvelope["body"][] = []; |
| 59 | let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; | ||
| 59 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | 60 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 60 | 61 | ||
| 61 | interface ViewportSpec { | 62 | interface ViewportSpec { |
| ... | @@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | ... | @@ -433,6 +434,10 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 433 | } | 434 | } |
| 434 | 435 | ||
| 435 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { | 436 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 437 | const customResult = customWsRpcResolver?.(body); | ||
| 438 | if (customResult !== undefined) { | ||
| 439 | return customResult; | ||
| 440 | } | ||
| 436 | const tag = body._tag; | 441 | const tag = body._tag; |
| 437 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | 442 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { |
| 438 | return fixture.snapshot; | 443 | return fixture.snapshot; |
| ... | @@ -767,9 +772,11 @@ async function mountChatView(options: { | ... | @@ -767,9 +772,11 @@ async function mountChatView(options: { |
| 767 | viewport: ViewportSpec; | 772 | viewport: ViewportSpec; |
| 768 | snapshot: OrchestrationReadModel; | 773 | snapshot: OrchestrationReadModel; |
| 769 | configureFixture?: (fixture: TestFixture) => void; | 774 | configureFixture?: (fixture: TestFixture) => void; |
| 775 | resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined; | ||
| 770 | }): Promise<MountedChatView> { | 776 | }): Promise<MountedChatView> { |
| 771 | fixture = buildFixture(options.snapshot); | 777 | fixture = buildFixture(options.snapshot); |
| 772 | options.configureFixture?.(fixture); | 778 | options.configureFixture?.(fixture); |
| 779 | customWsRpcResolver = options.resolveRpc ?? null; | ||
| 773 | await setViewport(options.viewport); | 780 | await setViewport(options.viewport); |
| 774 | await waitForProductionStyles(); | 781 | await waitForProductionStyles(); |
| 775 | 782 | ||
| ... | @@ -795,6 +802,7 @@ async function mountChatView(options: { | ... | @@ -795,6 +802,7 @@ async function mountChatView(options: { |
| 795 | await waitForLayout(); | 802 | await waitForLayout(); |
| 796 | 803 | ||
| 797 | const cleanup = async () => { | 804 | const cleanup = async () => { |
| 805 | customWsRpcResolver = null; | ||
| 798 | await screen.unmount(); | 806 | await screen.unmount(); |
| 799 | host.remove(); | 807 | host.remove(); |
| 800 | }; | 808 | }; |
| ... | @@ -854,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -854,6 +862,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 854 | localStorage.clear(); | 862 | localStorage.clear(); |
| 855 | document.body.innerHTML = ""; | 863 | document.body.innerHTML = ""; |
| 856 | wsRequests.length = 0; | 864 | wsRequests.length = 0; |
| 865 | customWsRpcResolver = null; | ||
| 857 | useComposerDraftStore.setState({ | 866 | useComposerDraftStore.setState({ |
| 858 | draftsByThreadId: {}, | 867 | draftsByThreadId: {}, |
| 859 | draftThreadsByThreadId: {}, | 868 | draftThreadsByThreadId: {}, |
| ... | @@ -869,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -869,6 +878,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 869 | }); | 878 | }); |
| 870 | 879 | ||
| 871 | afterEach(() => { | 880 | afterEach(() => { |
| 881 | customWsRpcResolver = null; | ||
| 872 | document.body.innerHTML = ""; | 882 | document.body.innerHTML = ""; |
| 873 | }); | 883 | }); |
| 874 | 884 | ||
| ... | @@ -1357,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1357,6 +1367,154 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1357 | } | 1367 | } |
| 1358 | }); | 1368 | }); |
| 1359 | 1369 | ||
| 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 | |||
| 1360 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { | 1518 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { |
| 1361 | const mounted = await mountChatView({ | 1519 | const mounted = await mountChatView({ |
| 1362 | viewport: DEFAULT_VIEWPORT, | 1520 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx+63-4| ... | @@ -239,6 +239,12 @@ interface ChatViewProps { | ... | @@ -239,6 +239,12 @@ interface ChatViewProps { |
| 239 | threadId: ThreadId; | 239 | threadId: ThreadId; |
| 240 | } | 240 | } |
| 241 | 241 | ||
| 242 | interface PendingPullRequestSetupRequest { | ||
| 243 | threadId: ThreadId; | ||
| 244 | worktreePath: string; | ||
| 245 | scriptId: string; | ||
| 246 | } | ||
| 247 | |||
| 242 | export default function ChatView({ threadId }: ChatViewProps) { | 248 | export default function ChatView({ threadId }: ChatViewProps) { |
| 243 | const threads = useStore((store) => store.threads); | 249 | const threads = useStore((store) => store.threads); |
| 244 | const projects = useStore((store) => store.projects); | 250 | const projects = useStore((store) => store.projects); |
| ... | @@ -350,6 +356,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -350,6 +356,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 350 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 356 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 351 | const [pullRequestDialogState, setPullRequestDialogState] = | 357 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 352 | useState<PullRequestDialogState | null>(null); | 358 | useState<PullRequestDialogState | null>(null); |
| 359 | const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = | ||
| 360 | useState<PendingPullRequestSetupRequest | null>(null); | ||
| 353 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< | 361 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 354 | Record<string, string[]> | 362 | Record<string, string[]> |
| 355 | >({}); | 363 | >({}); |
| ... | @@ -527,14 +535,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -527,14 +535,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 527 | params: { threadId: storedDraftThread.threadId }, | 535 | params: { threadId: storedDraftThread.threadId }, |
| 528 | }); | 536 | }); |
| 529 | } | 537 | } |
| 530 | return; | 538 | return storedDraftThread.threadId; |
| 531 | } | 539 | } |
| 532 | 540 | ||
| 533 | const activeDraftThread = getDraftThread(threadId); | 541 | const activeDraftThread = getDraftThread(threadId); |
| 534 | if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { | 542 | if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { |
| 535 | setDraftThreadContext(threadId, input); | 543 | setDraftThreadContext(threadId, input); |
| 536 | setProjectDraftThreadId(activeProject.id, threadId, input); | 544 | setProjectDraftThreadId(activeProject.id, threadId, input); |
| 537 | return; | 545 | return threadId; |
| 538 | } | 546 | } |
| 539 | 547 | ||
| 540 | clearProjectDraftThreadId(activeProject.id); | 548 | clearProjectDraftThreadId(activeProject.id); |
| ... | @@ -549,6 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -549,6 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 549 | to: "/$threadId", | 557 | to: "/$threadId", |
| 550 | params: { threadId: nextThreadId }, | 558 | params: { threadId: nextThreadId }, |
| 551 | }); | 559 | }); |
| 560 | return nextThreadId; | ||
| 552 | }, | 561 | }, |
| 553 | [ | 562 | [ |
| 554 | activeProject, | 563 | activeProject, |
| ... | @@ -565,13 +574,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -565,13 +574,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 565 | 574 | ||
| 566 | const handlePreparedPullRequestThread = useCallback( | 575 | const handlePreparedPullRequestThread = useCallback( |
| 567 | async (input: { branch: string; worktreePath: string | null }) => { | 576 | async (input: { branch: string; worktreePath: string | null }) => { |
| 568 | await openOrReuseProjectDraftThread({ | 577 | const targetThreadId = await openOrReuseProjectDraftThread({ |
| 569 | branch: input.branch, | 578 | branch: input.branch, |
| 570 | worktreePath: input.worktreePath, | 579 | worktreePath: input.worktreePath, |
| 571 | envMode: input.worktreePath ? "worktree" : "local", | 580 | envMode: input.worktreePath ? "worktree" : "local", |
| 572 | }); | 581 | }); |
| 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 | } | ||
| 573 | }, | 593 | }, |
| 574 | [openOrReuseProjectDraftThread], | 594 | [activeProject, openOrReuseProjectDraftThread], |
| 575 | ); | 595 | ); |
| 576 | 596 | ||
| 577 | useEffect(() => { | 597 | useEffect(() => { |
| ... | @@ -1418,6 +1438,45 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1418,6 +1438,45 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1418 | terminalState.terminalIds, | 1438 | terminalState.terminalIds, |
| 1419 | ], | 1439 | ], |
| 1420 | ); | 1440 | ); |
| 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 | ]); | ||
| 1421 | const persistProjectScripts = useCallback( | 1480 | const persistProjectScripts = useCallback( |
| 1422 | async (input: { | 1481 | async (input: { |
| 1423 | projectId: ProjectId; | 1482 | projectId: ProjectId; |
apps/web/src/components/PullRequestThreadDialog.tsx+3-3| ... | @@ -153,9 +153,9 @@ export function PullRequestThreadDialog({ | ... | @@ -153,9 +153,9 @@ export function PullRequestThreadDialog({ |
| 153 | const validationMessage = !referenceDirty | 153 | const validationMessage = !referenceDirty |
| 154 | ? null | 154 | ? null |
| 155 | : reference.trim().length === 0 | 155 | : 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." |
| 157 | : parsedReference === null | 157 | : 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." |
| 159 | : null; | 159 | : null; |
| 160 | const errorMessage = | 160 | const errorMessage = |
| 161 | validationMessage ?? | 161 | validationMessage ?? |
| ... | @@ -191,7 +191,7 @@ export function PullRequestThreadDialog({ | ... | @@ -191,7 +191,7 @@ export function PullRequestThreadDialog({ |
| 191 | <span className="text-xs font-medium text-foreground">Pull request</span> | 191 | <span className="text-xs font-medium text-foreground">Pull request</span> |
| 192 | <Input | 192 | <Input |
| 193 | ref={referenceInputRef} | 193 | 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" |
| 195 | value={reference} | 195 | value={reference} |
| 196 | onChange={(event) => { | 196 | onChange={(event) => { |
| 197 | setReferenceDirty(true); | 197 | setReferenceDirty(true); |
apps/web/src/pullRequestReference.test.ts+14| ... | @@ -17,6 +17,20 @@ describe("parsePullRequestReference", () => { | ... | @@ -17,6 +17,20 @@ describe("parsePullRequestReference", () => { |
| 17 | expect(parsePullRequestReference("#42")).toBe("#42"); | 17 | expect(parsePullRequestReference("#42")).toBe("#42"); |
| 18 | }); | 18 | }); |
| 19 | 19 | ||
| 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 | |||
| 20 | it("rejects non-pull-request input", () => { | 34 | it("rejects non-pull-request input", () => { |
| 21 | expect(parsePullRequestReference("feature/my-branch")).toBeNull(); | 35 | expect(parsePullRequestReference("feature/my-branch")).toBeNull(); |
| 22 | }); | 36 | }); |
apps/web/src/pullRequestReference.ts+11-4| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = | 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = |
| 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; |
| 3 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; | 3 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; |
| 4 | const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; | ||
| 4 | 5 | ||
| 5 | export function parsePullRequestReference(input: string): string | null { | 6 | export function parsePullRequestReference(input: string): string | null { |
| 6 | const trimmed = input.trim(); | 7 | const trimmed = input.trim(); |
| ... | @@ -8,14 +9,20 @@ export function parsePullRequestReference(input: string): string | null { | ... | @@ -8,14 +9,20 @@ export function parsePullRequestReference(input: string): string | null { |
| 8 | return null; | 9 | return null; |
| 9 | } | 10 | } |
| 10 | 11 | ||
| 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); | ||
| 12 | if (urlMatch?.[1]) { | 19 | if (urlMatch?.[1]) { |
| 13 | return trimmed; | 20 | return normalizedInput; |
| 14 | } | 21 | } |
| 15 | 22 | ||
| 16 | const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed); | 23 | const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput); |
| 17 | if (numberMatch?.[1]) { | 24 | if (numberMatch?.[1]) { |
| 18 | return trimmed.startsWith("#") ? trimmed : numberMatch[1]; | 25 | return normalizedInput.startsWith("#") ? normalizedInput : numberMatch[1]; |
| 19 | } | 26 | } |
| 20 | 27 | ||
| 21 | return null; | 28 | return null; |