| author | |
| committer | |
| log | 223ff4490f764a74ff911589e97b9bbcd595fee8 |
| tree | 7c6ca413a547093cb6b00100119000bd95cae49b |
| parent | 5fa35d211682ee02e34fba0711838ca431ed003b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>50 files changed, 2309 insertions(+), 1486 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+1-21| ... | ... | @@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 416 | 416 | [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], |
| 417 | 417 | ); |
| 418 | 418 | |
| 419 | const projectCwdByKey = useMemo(() => { | |
| 420 | const map = new Map<string, string>(); | |
| 421 | for (const project of props.projects) { | |
| 422 | map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 423 | } | |
| 424 | return map; | |
| 425 | }, [props.projects]); | |
| 426 | ||
| 427 | 419 | const projectByKey = useMemo(() => { |
| 428 | 420 | const map = new Map<string, EnvironmentProject>(); |
| 429 | 421 | for (const project of props.projects) { |
| ... | ... | @@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 850 | 842 | onPinThread={handlePinThread} |
| 851 | 843 | onUnpinThread={handleUnpinThread} |
| 852 | 844 | onMovePinnedThread={handleMovePinnedThread} |
| 853 | projectCwd={ | |
| 854 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | |
| 855 | } | |
| 856 | 845 | onSwipeableClose={handleSwipeableClose} |
| 857 | 846 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 858 | 847 | /> |
| ... | ... | @@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 875 | 864 | machineByEnvironmentId, |
| 876 | 865 | pinReorderEnvironmentIds, |
| 877 | 866 | projectByKey, |
| 878 | projectCwdByKey, | |
| 879 | 867 | props.onArchiveThread, |
| 880 | 868 | props.onDeletePendingTask, |
| 881 | 869 | props.onSelectPendingTask, |
| ... | ... | @@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 903 | 891 | const v2ExtraData = useMemo( |
| 904 | 892 | () => ({ |
| 905 | 893 | projectByKey, |
| 906 | projectCwdByKey, | |
| 907 | 894 | projectTitleByProjectKey: v2ProjectTitleByProjectKey, |
| 908 | 895 | serverConfigs, |
| 909 | 896 | savedConnectionsById: props.savedConnectionsById, |
| ... | ... | @@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 913 | 900 | }), |
| 914 | 901 | [ |
| 915 | 902 | projectByKey, |
| 916 | projectCwdByKey, | |
| 917 | 903 | props.searchQuery, |
| 918 | 904 | props.savedConnectionsById, |
| 919 | 905 | serverConfigs, |
| ... | ... | @@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 925 | 911 | |
| 926 | 912 | const extraData = useMemo( |
| 927 | 913 | () => ({ |
| 928 | projectCwdByKey, | |
| 929 | 914 | savedConnectionsById: props.savedConnectionsById, |
| 930 | 915 | searchQuery: props.searchQuery, |
| 931 | 916 | threadSearchMatchByKey, |
| 932 | 917 | }), |
| 933 | [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], | |
| 918 | [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], | |
| 934 | 919 | ); |
| 935 | 920 | |
| 936 | 921 | const renderItem = useCallback( |
| ... | ... | @@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 982 | 967 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 983 | 968 | } |
| 984 | 969 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} |
| 985 | projectCwd={ | |
| 986 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 987 | null | |
| 988 | } | |
| 989 | 970 | isLast={item.isLast} |
| 990 | 971 | searchMatch={threadSearchMatchByKey.get( |
| 991 | 972 | threadSearchMatchKey({ |
| ... | ... | @@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1021 | 1002 | handleSwipeableWillOpen, |
| 1022 | 1003 | handleRegenerateThreadTitle, |
| 1023 | 1004 | machineByEnvironmentId, |
| 1024 | projectCwdByKey, | |
| 1025 | 1005 | props.onArchiveThread, |
| 1026 | 1006 | props.onDeletePendingTask, |
| 1027 | 1007 | props.onDeleteThread, |
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+4-33| ... | ... | @@ -12,10 +12,8 @@ import { |
| 12 | 12 | |
| 13 | 13 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 14 | 14 | import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; |
| 15 | import { useProject, useThreadShell } from "../../state/entities"; | |
| 16 | import { useEnvironmentQuery } from "../../state/query"; | |
| 15 | import { useThreadShell } from "../../state/entities"; | |
| 17 | 16 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; |
| 18 | import { vcsEnvironment } from "../../state/vcs"; | |
| 19 | 17 | import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; |
| 20 | 18 | import { |
| 21 | 19 | dispatchHardwareKeyboardCommand, |
| ... | ... | @@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({ |
| 40 | 38 | const navigation = useNavigation(); |
| 41 | 39 | const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); |
| 42 | 40 | const activeThread = useThreadShell(activeThreadRef); |
| 43 | const activeProjectRef = useMemo( | |
| 44 | () => | |
| 45 | activeThread === null | |
| 46 | ? null | |
| 47 | : { | |
| 48 | environmentId: activeThread.environmentId, | |
| 49 | projectId: activeThread.projectId, | |
| 50 | }, | |
| 51 | [activeThread], | |
| 52 | ); | |
| 53 | const activeProject = useProject(activeProjectRef); | |
| 54 | const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; | |
| 55 | const gitStatus = useEnvironmentQuery( | |
| 56 | activeThread !== null && | |
| 57 | activeThread.linkedPullRequest == null && | |
| 58 | activeThread.branch !== null && | |
| 59 | activeThreadCwd !== null | |
| 60 | ? vcsEnvironment.status({ | |
| 61 | environmentId: activeThread.environmentId, | |
| 62 | input: { cwd: activeThreadCwd }, | |
| 63 | }) | |
| 64 | : null, | |
| 65 | ).data; | |
| 66 | const detectedPullRequestUrl = | |
| 67 | activeThread?.branch != null && gitStatus?.refName === activeThread.branch | |
| 68 | ? (gitStatus.pr?.url ?? null) | |
| 69 | : null; | |
| 70 | 41 | const copyTarget = useMemo( |
| 71 | 42 | () => |
| 72 | 43 | activeThreadRef === null |
| 73 | 44 | ? null |
| 74 | 45 | : resolveThreadReferenceCopyTarget({ |
| 75 | 46 | threadId: activeThread?.id ?? activeThreadRef.threadId, |
| 76 | linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, | |
| 77 | detectedPullRequestUrl, | |
| 47 | linkedPullRequestUrl: | |
| 48 | (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, | |
| 78 | 49 | }), |
| 79 | [activeThread, activeThreadRef, detectedPullRequestUrl], | |
| 50 | [activeThread, activeThreadRef], | |
| 80 | 51 | ); |
| 81 | 52 | const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK); |
| 82 | 53 | const copyRequestIdRef = useRef(0); |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx-15| ... | ... | @@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane( |
| 353 | 353 | }), |
| 354 | 354 | [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], |
| 355 | 355 | ); |
| 356 | const projectCwdByKey = useMemo(() => { | |
| 357 | const map = new Map<string, string>(); | |
| 358 | for (const project of projects) { | |
| 359 | map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 360 | } | |
| 361 | return map; | |
| 362 | }, [projects]); | |
| 363 | 356 | const projectByKey = useMemo(() => { |
| 364 | 357 | const map = new Map<string, EnvironmentProject>(); |
| 365 | 358 | for (const project of projects) { |
| ... | ... | @@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane( |
| 742 | 735 | () => ({ |
| 743 | 736 | selectedThreadKey: props.selectedThreadKey ?? "", |
| 744 | 737 | projectByKey, |
| 745 | projectCwdByKey, | |
| 746 | 738 | projectTitleByProjectKey, |
| 747 | 739 | savedConnectionsById, |
| 748 | 740 | serverConfigs, |
| ... | ... | @@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane( |
| 752 | 744 | [ |
| 753 | 745 | props.selectedThreadKey, |
| 754 | 746 | projectByKey, |
| 755 | projectCwdByKey, | |
| 756 | 747 | projectTitleByProjectKey, |
| 757 | 748 | savedConnectionsById, |
| 758 | 749 | serverConfigs, |
| ... | ... | @@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane( |
| 914 | 905 | onPinThread={pinThread} |
| 915 | 906 | onUnpinThread={unpinThread} |
| 916 | 907 | onMovePinnedThread={movePinnedThread} |
| 917 | projectCwd={projectCwdByKey.get(scopeKey) ?? null} | |
| 918 | 908 | onSwipeableClose={handleSwipeableClose} |
| 919 | 909 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 920 | 910 | simultaneousSwipeGesture={sidebarScrollGesture} |
| ... | ... | @@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane( |
| 1000 | 990 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 1001 | 991 | } |
| 1002 | 992 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} |
| 1003 | projectCwd={ | |
| 1004 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 1005 | null | |
| 1006 | } | |
| 1007 | 993 | isLast={item.isLast} |
| 1008 | 994 | searchMatch={threadSearchMatchByKey.get( |
| 1009 | 995 | threadSearchMatchKey({ |
| ... | ... | @@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane( |
| 1054 | 1040 | pinThread, |
| 1055 | 1041 | pinningEnvironmentIds, |
| 1056 | 1042 | projectByKey, |
| 1057 | projectCwdByKey, | |
| 1058 | 1043 | projectTitleByProjectKey, |
| 1059 | 1044 | regenerateThreadTitle, |
| 1060 | 1045 | props.onNewThreadInProject, |
apps/mobile/src/features/threads/thread-list-items.tsx+1-2| ... | ... | @@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 416 | 416 | readonly thread: EnvironmentThreadShell; |
| 417 | 417 | readonly environmentLabel: string | null; |
| 418 | 418 | readonly environmentMachine?: EnvironmentMachineKind; |
| 419 | readonly projectCwd: string | null; | |
| 420 | 419 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 421 | 420 | readonly searchQuery?: string; |
| 422 | 421 | readonly isLast: boolean; |
| ... | ... | @@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 453 | 452 | const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = |
| 454 | 453 | props; |
| 455 | 454 | const status = resolveThreadStatus(thread); |
| 456 | const pr = useThreadPr(thread, props.projectCwd); | |
| 455 | const pr = useThreadPr(thread); | |
| 457 | 456 | const timestamp = relativeTime( |
| 458 | 457 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, |
| 459 | 458 | ); |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+1-2| ... | ... | @@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 377 | 377 | readonly canMovePinnedDown?: boolean; |
| 378 | 378 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 379 | 379 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 380 | readonly projectCwd?: string | null; | |
| 381 | 380 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 382 | 381 | readonly searchQuery?: string; |
| 383 | 382 | readonly simultaneousSwipeGesture?: ComponentProps< |
| ... | ... | @@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 403 | 402 | const snoozedRow = props.snoozed === true; |
| 404 | 403 | const pinnedRow = props.pinned === true; |
| 405 | 404 | |
| 406 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); | |
| 405 | const pr = useThreadPr(thread); | |
| 407 | 406 | |
| 408 | 407 | const theme = useUniwindTheme(); |
| 409 | 408 | const screenColor = theme["--color-screen"]; |
apps/mobile/src/state/use-thread-pr.ts+23-46| ... | ... | @@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; |
| 12 | 12 | import { appAtomRegistry } from "./atom-registry"; |
| 13 | 13 | import { useEnvironmentQuery } from "./query"; |
| 14 | 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; |
| 15 | import { vcsEnvironment } from "./vcs"; | |
| 16 | 15 | |
| 17 | const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 16 | const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 18 | 17 | const MAX_THREAD_PR_SNAPSHOTS = 500; |
| 19 | 18 | |
| 20 | 19 | interface ThreadPrSnapshot { |
| ... | ... | @@ -23,7 +22,7 @@ interface ThreadPrSnapshot { |
| 23 | 22 | } |
| 24 | 23 | |
| 25 | 24 | // One bounded cache survives row virtualization without retaining one live |
| 26 | // atom for every thread, branch, directory, or linked pull request ever seen. | |
| 25 | // atom for every thread or pull request ever seen. | |
| 27 | 26 | const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe( |
| 28 | 27 | Atom.keepAlive, |
| 29 | 28 | Atom.withLabel("mobile:thread-pr-snapshots"), |
| ... | ... | @@ -36,20 +35,13 @@ export { |
| 36 | 35 | } from "./thread-pr-presentation"; |
| 37 | 36 | |
| 38 | 37 | /** |
| 39 | * Live PR status for a thread's branch. Subscriptions are deduplicated per | |
| 40 | * (environmentId, cwd) by the atom family, so many rows on the same worktree | |
| 41 | * or project root share one stream — and virtualization means only visible | |
| 42 | * rows subscribe at all. | |
| 38 | * Live status for a thread's server-provided PR. Visible rows share a summary | |
| 39 | * request for the same PR in the same environment. | |
| 43 | 40 | */ |
| 44 | export function useThreadPr( | |
| 45 | thread: EnvironmentThreadShell, | |
| 46 | projectCwd: string | null, | |
| 47 | ): ThreadPrPresentation | null { | |
| 48 | const cwd = thread.worktreePath ?? projectCwd; | |
| 41 | export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { | |
| 42 | const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; | |
| 49 | 43 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 50 | const snapshotIdentity = JSON.stringify( | |
| 51 | thread.linkedPullRequest ?? { branch: thread.branch, cwd }, | |
| 52 | ); | |
| 44 | const snapshotIdentity = JSON.stringify(pullRequestRef); | |
| 53 | 45 | // Select this row's entry so writes for other rows do not re-render it. |
| 54 | 46 | const snapshotEntry = useAtomValue( |
| 55 | 47 | threadPrSnapshotsAtom, |
| ... | ... | @@ -59,45 +51,30 @@ export function useThreadPr( |
| 59 | 51 | ), |
| 60 | 52 | ); |
| 61 | 53 | const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; |
| 62 | const gitStatus = useEnvironmentQuery( | |
| 63 | thread.linkedPullRequest == null && thread.branch !== null && cwd !== null | |
| 64 | ? vcsEnvironment.status({ | |
| 65 | environmentId: thread.environmentId, | |
| 66 | input: { cwd }, | |
| 67 | }) | |
| 68 | : null, | |
| 69 | ); | |
| 70 | const linkedPullRequest = useEnvironmentQuery( | |
| 71 | thread.linkedPullRequest == null | |
| 54 | const pullRequestSummary = useEnvironmentQuery( | |
| 55 | pullRequestRef === null | |
| 72 | 56 | ? null |
| 73 | : linkedPullRequestDetailAtom({ | |
| 57 | : pullRequestSummaryAtom({ | |
| 74 | 58 | environmentId: thread.environmentId, |
| 75 | 59 | input: { |
| 76 | projectId: thread.linkedPullRequest.projectId, | |
| 77 | repository: thread.linkedPullRequest.repository, | |
| 78 | number: thread.linkedPullRequest.number, | |
| 60 | projectId: pullRequestRef.projectId, | |
| 61 | repository: pullRequestRef.repository, | |
| 62 | number: pullRequestRef.number, | |
| 79 | 63 | }, |
| 80 | 64 | }), |
| 81 | 65 | ); |
| 82 | 66 | |
| 83 | 67 | const live = useMemo<ThreadPrPresentation | null | undefined>(() => { |
| 84 | if (thread.linkedPullRequest != null) { | |
| 85 | const detail = linkedPullRequest.data; | |
| 86 | return detail === null | |
| 87 | ? undefined | |
| 88 | : presentThreadPr(pullRequestDetailToVcsStatus(detail), { | |
| 89 | kind: detail.provider, | |
| 90 | name: detail.provider, | |
| 91 | baseUrl: "", | |
| 92 | }); | |
| 93 | } | |
| 94 | ||
| 95 | const status = gitStatus.data; | |
| 96 | if (thread.branch === null) return null; | |
| 97 | if (status === null) return undefined; | |
| 98 | if (status.refName !== thread.branch || !status.pr) return null; | |
| 99 | return presentThreadPr(status.pr, status.sourceControlProvider); | |
| 100 | }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); | |
| 68 | if (pullRequestRef === null) return null; | |
| 69 | const summary = pullRequestSummary.data; | |
| 70 | return summary === null | |
| 71 | ? undefined | |
| 72 | : presentThreadPr(pullRequestDetailToVcsStatus(summary), { | |
| 73 | kind: summary.provider, | |
| 74 | name: summary.provider, | |
| 75 | baseUrl: "", | |
| 76 | }); | |
| 77 | }, [pullRequestRef, pullRequestSummary.data]); | |
| 101 | 78 | |
| 102 | 79 | useEffect(() => { |
| 103 | 80 | if (live === undefined) return; |
apps/mobile/src/state/use-thread-selection.ts+1| ... | ... | @@ -55,6 +55,7 @@ function threadDetailToShell( |
| 55 | 55 | branch: thread.branch, |
| 56 | 56 | worktreePath: thread.worktreePath, |
| 57 | 57 | linkedPullRequest: thread.linkedPullRequest ?? null, |
| 58 | branchPullRequest: thread.branchPullRequest ?? null, | |
| 58 | 59 | latestTurn: thread.latestTurn, |
| 59 | 60 | createdAt: thread.createdAt, |
| 60 | 61 | updatedAt: thread.updatedAt, |
apps/server/integration/OrchestrationEngineHarness.integration.ts+7| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 68 | 68 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; |
| 69 | 69 | import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; |
| 70 | import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; | |
| 70 | 71 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 71 | 72 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 72 | 73 | import { |
| ... | ... | @@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 394 | 395 | drainThrough: () => Effect.void, |
| 395 | 396 | }), |
| 396 | 397 | ), |
| 398 | Layer.provideMerge( | |
| 399 | Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { | |
| 400 | start: () => Effect.void, | |
| 401 | drain: Effect.void, | |
| 402 | }), | |
| 403 | ), | |
| 397 | 404 | Layer.provideMerge( |
| 398 | 405 | Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { |
| 399 | 406 | start: () => Effect.void, |
apps/server/src/git/GitManager.test.ts+228-7| ... | ... | @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; |
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | 14 | import * as PlatformError from "effect/PlatformError"; |
| 15 | 15 | import * as References from "effect/References"; |
| 16 | import * as Schema from "effect/Schema"; | |
| 16 | 17 | import * as Scope from "effect/Scope"; |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | import { expect } from "vite-plus/test"; |
| ... | ... | @@ -30,10 +31,13 @@ import { |
| 30 | 31 | TextGenerationError, |
| 31 | 32 | } from "@t3tools/contracts"; |
| 32 | 33 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 34 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | |
| 33 | 35 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 34 | 36 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 35 | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 36 | 38 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 39 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; | |
| 40 | import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; | |
| 37 | 41 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 38 | 42 | import * as ServerConfig from "../config.ts"; |
| 39 | 43 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| ... | ... | @@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; |
| 41 | 45 | import * as ServerSettings from "../serverSettings.ts"; |
| 42 | 46 | import * as GitManager from "./GitManager.ts"; |
| 43 | 47 | |
| 48 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 49 | ||
| 44 | 50 | interface FakeGhScenario { |
| 45 | 51 | prListSequence?: string[]; |
| 46 | 52 | prListByHeadSelector?: Record<string, string>; |
| ... | ... | @@ -620,6 +626,7 @@ function preparePullRequestThread( |
| 620 | 626 | |
| 621 | 627 | function makeManager(input?: { |
| 622 | 628 | ghScenario?: FakeGhScenario; |
| 629 | sourceControlProvider?: SourceControlProvider["Service"]; | |
| 623 | 630 | textGeneration?: Partial<FakeGitTextGeneration>; |
| 624 | 631 | serverSettings?: Parameters<typeof ServerSettings.layerTest>[0]; |
| 625 | 632 | setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; |
| ... | ... | @@ -659,7 +666,10 @@ function makeManager(input?: { |
| 659 | 666 | ); |
| 660 | 667 | const sourceControlRegistryLayer = Layer.effect( |
| 661 | 668 | SourceControlProviderRegistry.SourceControlProviderRegistry, |
| 662 | GitHubSourceControlProvider.make.pipe( | |
| 669 | (input?.sourceControlProvider === undefined | |
| 670 | ? GitHubSourceControlProvider.make | |
| 671 | : Effect.succeed(input.sourceControlProvider) | |
| 672 | ).pipe( | |
| 663 | 673 | Effect.map((provider) => |
| 664 | 674 | SourceControlProviderRegistry.SourceControlProviderRegistry.of({ |
| 665 | 675 | get: () => Effect.succeed(provider), |
| ... | ... | @@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1146 | 1156 | branch: "feature/saved-branch", |
| 1147 | 1157 | }); |
| 1148 | 1158 | |
| 1149 | expect(pullRequest).toEqual({ | |
| 1159 | expect(pullRequest).toMatchObject({ | |
| 1160 | number: 216, | |
| 1161 | title: "Saved branch PR", | |
| 1162 | url: "https://github.com/pingdotgg/t3code/pull/216", | |
| 1163 | baseRef: "main", | |
| 1164 | headRef: "feature/saved-branch", | |
| 1150 | 1165 | state: "open", |
| 1151 | 1166 | closedAt: null, |
| 1152 | 1167 | mergedAt: null, |
| ... | ... | @@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1191 | 1206 | |
| 1192 | 1207 | const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); |
| 1193 | 1208 | |
| 1194 | expect(pullRequest).toEqual({ | |
| 1209 | expect(pullRequest).toMatchObject({ | |
| 1195 | 1210 | state: "merged", |
| 1196 | 1211 | closedAt: null, |
| 1197 | 1212 | mergedAt: "2026-04-07T15:00:00Z", |
| ... | ... | @@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1244 | 1259 | branch: "feature/deleted-local-branch", |
| 1245 | 1260 | }); |
| 1246 | 1261 | |
| 1247 | expect(pullRequest).toEqual({ | |
| 1262 | expect(pullRequest).toMatchObject({ | |
| 1248 | 1263 | state: "merged", |
| 1249 | 1264 | closedAt: null, |
| 1250 | 1265 | mergedAt: null, |
| ... | ... | @@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1310 | 1325 | branch: "feature/deleted-fork-branch", |
| 1311 | 1326 | }); |
| 1312 | 1327 | |
| 1313 | expect(pullRequest).toEqual({ | |
| 1328 | expect(pullRequest).toMatchObject({ | |
| 1314 | 1329 | state: "merged", |
| 1315 | 1330 | closedAt: null, |
| 1316 | 1331 | mergedAt: null, |
| ... | ... | @@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1432 | 1447 | updatedAt: "2026-04-07T15:00:00Z", |
| 1433 | 1448 | }, |
| 1434 | 1449 | ]), |
| 1450 | encodeCliJson([ | |
| 1451 | { | |
| 1452 | number: 221, | |
| 1453 | title: "New PR on the same branch", | |
| 1454 | url: "https://github.com/pingdotgg/codething-mvp/pull/221", | |
| 1455 | baseRefName: "main", | |
| 1456 | headRefName: "feature/shared-pr-cache", | |
| 1457 | state: "OPEN", | |
| 1458 | updatedAt: "2026-04-08T15:00:00Z", | |
| 1459 | }, | |
| 1460 | ]), | |
| 1435 | 1461 | ], |
| 1436 | 1462 | }, |
| 1437 | 1463 | }); |
| ... | ... | @@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1445 | 1471 | expect(status.pr?.state).toBe("merged"); |
| 1446 | 1472 | expect(pullRequest?.state).toBe("merged"); |
| 1447 | 1473 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); |
| 1474 | const refreshed = yield* manager.branchPullRequest( | |
| 1475 | { cwd: repoDir, branch: "feature/shared-pr-cache" }, | |
| 1476 | { refresh: true }, | |
| 1477 | ); | |
| 1478 | expect(refreshed).toMatchObject({ | |
| 1479 | number: 221, | |
| 1480 | state: "open", | |
| 1481 | repositoryKey: "github.com/pingdotgg/codething-mvp", | |
| 1482 | }); | |
| 1483 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); | |
| 1448 | 1484 | }), |
| 1449 | 1485 | ); |
| 1450 | 1486 | |
| ... | ... | @@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1459 | 1495 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); |
| 1460 | 1496 | yield* runGit(repoDir, ["checkout", "main"]); |
| 1461 | 1497 | |
| 1462 | const { manager } = yield* makeManager({ | |
| 1498 | const { manager, ghCalls } = yield* makeManager({ | |
| 1463 | 1499 | ghScenario: { |
| 1464 | 1500 | failWith: new GitHubCli.GitHubCliUnavailableError({ |
| 1465 | 1501 | command: "gh", |
| ... | ... | @@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1474 | 1510 | .pipe(Effect.flip); |
| 1475 | 1511 | |
| 1476 | 1512 | expect(error._tag).toBe("SourceControlProviderError"); |
| 1513 | const refreshError = yield* manager | |
| 1514 | .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) | |
| 1515 | .pipe(Effect.flip); | |
| 1516 | expect(refreshError._tag).toBe("SourceControlProviderError"); | |
| 1517 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); | |
| 1477 | 1518 | }), |
| 1478 | 1519 | ); |
| 1479 | 1520 | |
| ... | ... | @@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1594 | 1635 | expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); |
| 1595 | 1636 | }); |
| 1596 | 1637 | |
| 1638 | it.each([ | |
| 1639 | [ | |
| 1640 | "https://github.example.com/team/repository/pull/42?tab=files", | |
| 1641 | "github.example.com/team/repository", | |
| 1642 | ], | |
| 1643 | [ | |
| 1644 | "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42", | |
| 1645 | "gitlab.example.com/group/subgroup/repository", | |
| 1646 | ], | |
| 1647 | ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], | |
| 1648 | [ | |
| 1649 | "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 1650 | "dev.azure.com/org/project/_git/repository", | |
| 1651 | ], | |
| 1652 | [ | |
| 1653 | "https://org.visualstudio.com/project/_git/repository/pullrequest/42", | |
| 1654 | "org.visualstudio.com/project/_git/repository", | |
| 1655 | ], | |
| 1656 | [ | |
| 1657 | "https://gitlab.example/group/pull/123/repository/-/merge_requests/42", | |
| 1658 | "gitlab.example/group/pull/123/repository", | |
| 1659 | ], | |
| 1660 | ["https://github.example.com/team/repository/issues/42", null], | |
| 1661 | ] as const)("reads the repository from the returned PR URL %s", (url, expected) => { | |
| 1662 | expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected); | |
| 1663 | }); | |
| 1664 | ||
| 1665 | it.effect("distinguishes Enterprise forks with the same head branch", () => | |
| 1666 | Effect.gen(function* () { | |
| 1667 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1668 | yield* initRepo(repoDir); | |
| 1669 | const originDir = yield* createBareRemote(); | |
| 1670 | const forkDir = yield* createBareRemote(); | |
| 1671 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | |
| 1672 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1673 | yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); | |
| 1674 | yield* runGit(repoDir, ["checkout", "-b", "feature"]); | |
| 1675 | yield* runGit(repoDir, ["push", "-u", "fork", "feature"]); | |
| 1676 | yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1677 | repoDir, | |
| 1678 | "origin", | |
| 1679 | "git@github.example.com:team/repository.git", | |
| 1680 | originDir, | |
| 1681 | ); | |
| 1682 | yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1683 | repoDir, | |
| 1684 | "fork", | |
| 1685 | "git@github.example.com:alice/repository.git", | |
| 1686 | forkDir, | |
| 1687 | ); | |
| 1688 | const output = encodeCliJson([ | |
| 1689 | { | |
| 1690 | number: 2, | |
| 1691 | title: "Another fork", | |
| 1692 | url: "https://github.example.com/team/repository/pull/2", | |
| 1693 | baseRefName: "main", | |
| 1694 | headRefName: "feature", | |
| 1695 | state: "OPEN", | |
| 1696 | updatedAt: "2026-04-08T15:00:00Z", | |
| 1697 | isCrossRepository: true, | |
| 1698 | headRepository: { nameWithOwner: "bob/repository" }, | |
| 1699 | headRepositoryOwner: { login: "bob" }, | |
| 1700 | }, | |
| 1701 | { | |
| 1702 | number: 1, | |
| 1703 | title: "This fork", | |
| 1704 | url: "https://github.example.com/team/repository/pull/1", | |
| 1705 | baseRefName: "main", | |
| 1706 | headRefName: "feature", | |
| 1707 | state: "OPEN", | |
| 1708 | updatedAt: "2026-04-07T15:00:00Z", | |
| 1709 | isCrossRepository: true, | |
| 1710 | headRepository: { nameWithOwner: "alice/repository" }, | |
| 1711 | headRepositoryOwner: { login: "alice" }, | |
| 1712 | }, | |
| 1713 | ]); | |
| 1714 | const { manager } = yield* makeManager({ | |
| 1715 | ghScenario: { | |
| 1716 | prListByHeadSelector: { | |
| 1717 | "alice:feature": output, | |
| 1718 | "fork:feature": output, | |
| 1719 | feature: output, | |
| 1720 | }, | |
| 1721 | }, | |
| 1722 | }); | |
| 1723 | expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({ | |
| 1724 | number: 1, | |
| 1725 | repositoryKey: "github.example.com/team/repository", | |
| 1726 | }); | |
| 1727 | }), | |
| 1728 | ); | |
| 1729 | ||
| 1730 | it.effect.each([ | |
| 1731 | "git@gitlab.com:Group/Subgroup/Fork.git", | |
| 1732 | "https://gitlab.com/Group/Subgroup/Fork.git", | |
| 1733 | ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => | |
| 1734 | Effect.gen(function* () { | |
| 1735 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1736 | yield* initRepo(repoDir); | |
| 1737 | const originDir = yield* createBareRemote(); | |
| 1738 | const forkDir = yield* createBareRemote(); | |
| 1739 | const branch = "feature/NestedGroups"; | |
| 1740 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | |
| 1741 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1742 | yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); | |
| 1743 | yield* runGit(repoDir, ["checkout", "-b", branch]); | |
| 1744 | yield* runGit(repoDir, ["push", "-u", "fork", branch]); | |
| 1745 | yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1746 | repoDir, | |
| 1747 | "origin", | |
| 1748 | "git@gitlab.com:Group/Upstream/Repository.git", | |
| 1749 | originDir, | |
| 1750 | ); | |
| 1751 | yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); | |
| 1752 | const output = encodeCliJson([ | |
| 1753 | { | |
| 1754 | iid: 2, | |
| 1755 | title: "Another subgroup's fork", | |
| 1756 | web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2", | |
| 1757 | target_branch: "main", | |
| 1758 | source_branch: branch, | |
| 1759 | state: "opened", | |
| 1760 | updated_at: "2026-04-08T15:00:00Z", | |
| 1761 | source_project_id: 102, | |
| 1762 | target_project_id: 100, | |
| 1763 | source_project: { path_with_namespace: "Group/Other/Fork" }, | |
| 1764 | }, | |
| 1765 | { | |
| 1766 | iid: 1, | |
| 1767 | title: "This subgroup's fork", | |
| 1768 | web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1", | |
| 1769 | target_branch: "main", | |
| 1770 | source_branch: branch, | |
| 1771 | state: "opened", | |
| 1772 | updated_at: "2026-04-07T15:00:00Z", | |
| 1773 | source_project_id: 101, | |
| 1774 | target_project_id: 100, | |
| 1775 | source_project: { path_with_namespace: "Group/Subgroup/Fork" }, | |
| 1776 | }, | |
| 1777 | ]); | |
| 1778 | const calls: VcsProcess.VcsProcessInput[] = []; | |
| 1779 | const provider = yield* GitLabSourceControlProvider.make.pipe( | |
| 1780 | Effect.provide( | |
| 1781 | GitLabCli.layer.pipe( | |
| 1782 | Layer.provide( | |
| 1783 | Layer.mock(VcsProcess.VcsProcess)({ | |
| 1784 | run: (input) => | |
| 1785 | Effect.sync(() => { | |
| 1786 | calls.push(input); | |
| 1787 | return fakeGhOutput(output); | |
| 1788 | }), | |
| 1789 | }), | |
| 1790 | ), | |
| 1791 | ), | |
| 1792 | ), | |
| 1793 | ); | |
| 1794 | const { manager } = yield* makeManager({ sourceControlProvider: provider }); | |
| 1795 | ||
| 1796 | expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({ | |
| 1797 | number: 1, | |
| 1798 | repositoryKey: "gitlab.com/group/upstream/repository", | |
| 1799 | }); | |
| 1800 | expect(calls.length).toBeGreaterThan(0); | |
| 1801 | for (const call of calls) { | |
| 1802 | expect(call.command).toBe("glab"); | |
| 1803 | expect(call.args).toEqual([ | |
| 1804 | "mr", | |
| 1805 | "list", | |
| 1806 | "--source-branch", | |
| 1807 | branch, | |
| 1808 | "--all", | |
| 1809 | "--per-page", | |
| 1810 | "20", | |
| 1811 | "--output", | |
| 1812 | "json", | |
| 1813 | ]); | |
| 1814 | } | |
| 1815 | }), | |
| 1816 | ); | |
| 1817 | ||
| 1597 | 1818 | it.effect( |
| 1598 | 1819 | "status ignores unrelated fork PRs when the current branch tracks the same repository", |
| 1599 | 1820 | () => |
| ... | ... | @@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2149 | 2370 | branch: "feature/fork-settle", |
| 2150 | 2371 | }); |
| 2151 | 2372 | |
| 2152 | expect(pullRequest).toEqual({ | |
| 2373 | expect(pullRequest).toMatchObject({ | |
| 2153 | 2374 | state: "merged", |
| 2154 | 2375 | closedAt: null, |
| 2155 | 2376 | mergedAt: null, |
apps/server/src/git/GitManager.ts+46-20| ... | ... | @@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti |
| 77 | 77 | readonly refreshMissingPullRequest?: boolean; |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & { | |
| 81 | readonly repositoryKey: string | null; | |
| 82 | readonly updatedAt: string | null; | |
| 83 | readonly closedAt?: string | null; | |
| 84 | readonly mergedAt?: string | null; | |
| 85 | }; | |
| 86 | ||
| 80 | 87 | interface SourceControlTextGenerationSettings { |
| 81 | 88 | readonly modelSelection: ModelSelection; |
| 82 | 89 | readonly style: SourceControlWritingStyleSettings; |
| ... | ... | @@ -96,18 +103,10 @@ export class GitManager extends Context.Service< |
| 96 | 103 | options?: GitRemoteStatusOptions, |
| 97 | 104 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 98 | 105 | /** Resolve the PR for a saved branch without changing the current checkout. */ |
| 99 | readonly branchPullRequest: (input: { | |
| 100 | readonly cwd: string; | |
| 101 | readonly branch: string; | |
| 102 | }) => Effect.Effect< | |
| 103 | { | |
| 104 | readonly state: "open" | "closed" | "merged"; | |
| 105 | readonly updatedAt: string | null; | |
| 106 | readonly closedAt?: string | null; | |
| 107 | readonly mergedAt?: string | null; | |
| 108 | } | null, | |
| 109 | GitManagerServiceError | |
| 110 | >; | |
| 106 | readonly branchPullRequest: ( | |
| 107 | input: { readonly cwd: string; readonly branch: string }, | |
| 108 | options?: { readonly refresh?: boolean }, | |
| 109 | ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>; | |
| 111 | 110 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 112 | 111 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; |
| 113 | 112 | readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; |
| ... | ... | @@ -214,9 +213,26 @@ interface BranchHeadContext { |
| 214 | 213 | isCrossRepository: boolean; |
| 215 | 214 | } |
| 216 | 215 | |
| 216 | export function pullRequestRepositoryKey(value: string): string | null { | |
| 217 | try { | |
| 218 | const url = new URL(value); | |
| 219 | const match = | |
| 220 | /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( | |
| 221 | url.pathname, | |
| 222 | ); | |
| 223 | if (match?.[1] === undefined) return null; | |
| 224 | url.pathname = match[1]; | |
| 225 | url.search = ""; | |
| 226 | url.hash = ""; | |
| 227 | return normalizeGitRemoteUrl(url.toString()); | |
| 228 | } catch { | |
| 229 | return null; | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 217 | 233 | function parseRepositoryNameFromPullRequestUrl(url: string): string | null { |
| 218 | 234 | const trimmed = url.trim(); |
| 219 | const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | |
| 235 | const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | |
| 220 | 236 | const repositoryName = match?.[1]?.trim() ?? ""; |
| 221 | 237 | return repositoryName.length > 0 ? repositoryName : null; |
| 222 | 238 | } |
| ... | ... | @@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName( |
| 254 | 270 | return `t3code/pr-${pullRequest.number}/${suffix}`; |
| 255 | 271 | } |
| 256 | 272 | |
| 257 | function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | |
| 273 | function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | |
| 258 | 274 | const trimmed = url?.trim() ?? ""; |
| 259 | 275 | if (trimmed.length === 0) { |
| 260 | 276 | return null; |
| 261 | 277 | } |
| 262 | 278 | |
| 263 | 279 | const match = |
| 264 | /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | |
| 280 | /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( | |
| 265 | 281 | trimmed, |
| 266 | 282 | ); |
| 267 | 283 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; |
| ... | ... | @@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null |
| 273 | 289 | if (trimmed.length === 0) { |
| 274 | 290 | return null; |
| 275 | 291 | } |
| 292 | // GitLab reports the top-level group as owner. The full path distinguishes subgroups. | |
| 276 | 293 | const [ownerLogin] = trimmed.split("/"); |
| 277 | 294 | const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; |
| 278 | 295 | return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; |
| ... | ... | @@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () { |
| 1261 | 1278 | } |
| 1262 | 1279 | |
| 1263 | 1280 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); |
| 1264 | const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | |
| 1281 | const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | |
| 1265 | 1282 | return { |
| 1266 | 1283 | remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, |
| 1267 | 1284 | repositoryNameWithOwner, |
| ... | ... | @@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () { |
| 2031 | 2048 | }); |
| 2032 | 2049 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( |
| 2033 | 2050 | "branchPullRequest", |
| 2034 | )(function* ({ cwd, branch }) { | |
| 2051 | )(function* ({ cwd, branch }, options) { | |
| 2035 | 2052 | const cacheCwd = yield* normalizeStatusCacheKey(cwd); |
| 2036 | 2053 | const remotes = yield* gitCore.execute({ |
| 2037 | 2054 | operation: "GitManager.branchPullRequest.remotes", |
| ... | ... | @@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () { |
| 2111 | 2128 | localBranchExists, |
| 2112 | 2129 | ...(localBranchExists ? {} : { remoteName }), |
| 2113 | 2130 | }); |
| 2131 | if (options?.refresh) { | |
| 2132 | // A completed turn can create a PR or reuse a merged PR's branch. | |
| 2133 | // Refresh successful answers, but keep failed lookups' retry backoff. | |
| 2134 | const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( | |
| 2135 | Effect.orElseSucceed(() => Option.none()), | |
| 2136 | ); | |
| 2137 | if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); | |
| 2138 | } | |
| 2114 | 2139 | let cached = yield* Cache.get(prLookupCache, cacheKey); |
| 2115 | 2140 | // The cached head context may have resolved on a different remote than |
| 2116 | 2141 | // the saved upstream: a branch tracking origin/main but pushed to a fork |
| ... | ... | @@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () { |
| 2165 | 2190 | ) { |
| 2166 | 2191 | return null; |
| 2167 | 2192 | } |
| 2168 | const statusPr = toStatusPr(latest); | |
| 2169 | 2193 | return { |
| 2170 | state: statusPr.state, | |
| 2171 | updatedAt: statusPr.updatedAt, | |
| 2194 | ...toStatusPr(latest), | |
| 2172 | 2195 | closedAt: latest.closedAt ?? null, |
| 2173 | 2196 | mergedAt: latest.mergedAt ?? null, |
| 2197 | // Hosting CLIs can select an upstream repository instead of origin. | |
| 2198 | // The returned PR URL names the repository that actually owns it. | |
| 2199 | repositoryKey: pullRequestRepositoryKey(latest.url), | |
| 2174 | 2200 | }; |
| 2175 | 2201 | }); |
| 2176 | 2202 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | ProjectId, |
| 14 | 14 | ThreadId, |
| 15 | 15 | TurnId, |
| 16 | type OrchestrationCommand, | |
| 16 | 17 | type OrchestrationEvent, |
| 17 | 18 | ProviderInstanceId, |
| 18 | 19 | } from "@t3tools/contracts"; |
| ... | ... | @@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => { |
| 993 | 994 | await system.dispose(); |
| 994 | 995 | }); |
| 995 | 996 | |
| 997 | it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( | |
| 998 | "rejects PR discovery completed after a newer %s command", | |
| 999 | async (change) => { | |
| 1000 | const system = await createOrchestrationSystem(); | |
| 1001 | try { | |
| 1002 | const projectId = ProjectId.make("pr-race-project"); | |
| 1003 | const threadId = ThreadId.make("pr-race-thread"); | |
| 1004 | const previous = { | |
| 1005 | projectId, | |
| 1006 | repository: "owner/repository", | |
| 1007 | number: 1, | |
| 1008 | url: "https://example.test/owner/repository/pull/1", | |
| 1009 | }; | |
| 1010 | const replacement = { | |
| 1011 | ...previous, | |
| 1012 | number: 2, | |
| 1013 | url: "https://example.test/owner/repository/pull/2", | |
| 1014 | }; | |
| 1015 | await system.run( | |
| 1016 | system.engine.dispatch({ | |
| 1017 | type: "project.create", | |
| 1018 | commandId: CommandId.make("pr-race-project-create"), | |
| 1019 | projectId, | |
| 1020 | title: "PR race project", | |
| 1021 | workspaceRoot: "/tmp/pr-race-project", | |
| 1022 | defaultModelSelection: null, | |
| 1023 | createdAt: now(), | |
| 1024 | }), | |
| 1025 | ); | |
| 1026 | await system.run( | |
| 1027 | system.engine.dispatch({ | |
| 1028 | type: "thread.create", | |
| 1029 | commandId: CommandId.make("pr-race-thread-create"), | |
| 1030 | threadId, | |
| 1031 | projectId, | |
| 1032 | title: "PR race thread", | |
| 1033 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 1034 | runtimeMode: "full-access", | |
| 1035 | interactionMode: "default", | |
| 1036 | branch: "feature", | |
| 1037 | worktreePath: null, | |
| 1038 | createdAt: now(), | |
| 1039 | }), | |
| 1040 | ); | |
| 1041 | const observed = await system.run( | |
| 1042 | system.engine.dispatch({ | |
| 1043 | type: "thread.meta.update", | |
| 1044 | commandId: CommandId.make("pr-race-link"), | |
| 1045 | threadId, | |
| 1046 | linkedPullRequest: previous, | |
| 1047 | }), | |
| 1048 | ); | |
| 1049 | const metadataChanges = { | |
| 1050 | unlink: { linkedPullRequest: null }, | |
| 1051 | relink: { | |
| 1052 | linkedPullRequest: { | |
| 1053 | ...previous, | |
| 1054 | number: 3, | |
| 1055 | url: "https://example.test/owner/repository/pull/3", | |
| 1056 | }, | |
| 1057 | }, | |
| 1058 | branch: { branch: "another-feature" }, | |
| 1059 | worktree: { worktreePath: "/tmp/another-worktree" }, | |
| 1060 | project: {}, | |
| 1061 | }; | |
| 1062 | await system.run( | |
| 1063 | system.engine.dispatch( | |
| 1064 | change === "project" | |
| 1065 | ? { | |
| 1066 | type: "project.meta.update", | |
| 1067 | commandId: CommandId.make("pr-race-project-move"), | |
| 1068 | projectId, | |
| 1069 | workspaceRoot: "/tmp/another-project-root", | |
| 1070 | } | |
| 1071 | : change === "delete" | |
| 1072 | ? { type: "thread.delete", commandId: CommandId.make("pr-race-delete"), threadId } | |
| 1073 | : { | |
| 1074 | type: "thread.meta.update", | |
| 1075 | commandId: CommandId.make(`pr-race-${change}`), | |
| 1076 | threadId, | |
| 1077 | ...metadataChanges[change], | |
| 1078 | }, | |
| 1079 | ), | |
| 1080 | ); | |
| 1081 | const command = { | |
| 1082 | type: "thread.pull-request.sync", | |
| 1083 | commandId: CommandId.make("pr-race-stale-sync"), | |
| 1084 | threadId, | |
| 1085 | projectId, | |
| 1086 | snapshotSequence: observed.sequence, | |
| 1087 | expected: { | |
| 1088 | workspaceRoot: "/tmp/pr-race-project", | |
| 1089 | branch: "feature", | |
| 1090 | worktreePath: null, | |
| 1091 | linkedPullRequest: previous, | |
| 1092 | branchPullRequest: null, | |
| 1093 | }, | |
| 1094 | branchPullRequest: replacement, | |
| 1095 | linkedPullRequest: replacement, | |
| 1096 | } satisfies OrchestrationCommand; | |
| 1097 | const error = await system.run(system.engine.dispatch(command).pipe(Effect.flip)); | |
| 1098 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 1099 | if (change === "delete") return; | |
| 1100 | const current = (await system.readModel()).threads[0]; | |
| 1101 | expect(current?.branchPullRequest ?? null).toBeNull(); | |
| 1102 | expect(current?.linkedPullRequest ?? null).toEqual( | |
| 1103 | change === "unlink" | |
| 1104 | ? null | |
| 1105 | : change === "relink" | |
| 1106 | ? metadataChanges.relink.linkedPullRequest | |
| 1107 | : previous, | |
| 1108 | ); | |
| 1109 | } finally { | |
| 1110 | await system.dispose(); | |
| 1111 | } | |
| 1112 | }, | |
| 1113 | ); | |
| 1114 | ||
| 1115 | it("saves PR associations through streaming and unrelated metadata edits", async () => { | |
| 1116 | const system = await createOrchestrationSystem(); | |
| 1117 | try { | |
| 1118 | const projectId = ProjectId.make("pr-sync-project"); | |
| 1119 | const threadId = ThreadId.make("pr-sync-thread"); | |
| 1120 | await system.run( | |
| 1121 | system.engine.dispatch({ | |
| 1122 | type: "project.create", | |
| 1123 | commandId: CommandId.make("pr-sync-project-create"), | |
| 1124 | projectId, | |
| 1125 | title: "PR sync project", | |
| 1126 | workspaceRoot: "/tmp/pr-sync-project", | |
| 1127 | defaultModelSelection: null, | |
| 1128 | createdAt: now(), | |
| 1129 | }), | |
| 1130 | ); | |
| 1131 | const created = await system.run( | |
| 1132 | system.engine.dispatch({ | |
| 1133 | type: "thread.create", | |
| 1134 | commandId: CommandId.make("pr-sync-thread-create"), | |
| 1135 | threadId, | |
| 1136 | projectId, | |
| 1137 | title: "PR sync thread", | |
| 1138 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 1139 | runtimeMode: "full-access", | |
| 1140 | interactionMode: "default", | |
| 1141 | branch: "feature", | |
| 1142 | worktreePath: null, | |
| 1143 | createdAt: now(), | |
| 1144 | }), | |
| 1145 | ); | |
| 1146 | const reference = { | |
| 1147 | projectId, | |
| 1148 | repository: "owner/repository", | |
| 1149 | number: 42, | |
| 1150 | url: "https://example.test/owner/repository/pull/42", | |
| 1151 | }; | |
| 1152 | const activityAt = "2026-01-01T01:00:00.000Z"; | |
| 1153 | await system.run( | |
| 1154 | system.engine.dispatch({ | |
| 1155 | type: "thread.message.assistant.delta", | |
| 1156 | commandId: CommandId.make("pr-sync-streaming-message"), | |
| 1157 | threadId, | |
| 1158 | messageId: MessageId.make("pr-sync-message"), | |
| 1159 | delta: "The PR is ready.", | |
| 1160 | createdAt: activityAt, | |
| 1161 | }), | |
| 1162 | ); | |
| 1163 | await system.run( | |
| 1164 | system.engine.dispatch({ | |
| 1165 | type: "thread.meta.update", | |
| 1166 | commandId: CommandId.make("pr-sync-title-and-model"), | |
| 1167 | threadId, | |
| 1168 | title: "Renamed thread", | |
| 1169 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 1170 | }), | |
| 1171 | ); | |
| 1172 | await system.run( | |
| 1173 | system.engine.dispatch({ | |
| 1174 | type: "project.meta.update", | |
| 1175 | commandId: CommandId.make("pr-sync-project-title"), | |
| 1176 | projectId, | |
| 1177 | title: "Renamed project", | |
| 1178 | }), | |
| 1179 | ); | |
| 1180 | const beforeSync = (await system.readModel()).threads[0]; | |
| 1181 | await system.run( | |
| 1182 | system.engine.dispatch({ | |
| 1183 | type: "thread.pull-request.sync", | |
| 1184 | commandId: CommandId.make("pr-sync-discovery"), | |
| 1185 | projectId, | |
| 1186 | threadId, | |
| 1187 | snapshotSequence: created.sequence, | |
| 1188 | expected: { | |
| 1189 | workspaceRoot: "/tmp/pr-sync-project", | |
| 1190 | branch: "feature", | |
| 1191 | worktreePath: null, | |
| 1192 | linkedPullRequest: null, | |
| 1193 | branchPullRequest: null, | |
| 1194 | }, | |
| 1195 | branchPullRequest: reference, | |
| 1196 | }), | |
| 1197 | ); | |
| 1198 | const current = (await system.readModel()).threads[0]; | |
| 1199 | expect(current?.branchPullRequest).toEqual(reference); | |
| 1200 | expect(current?.linkedPullRequest ?? null).toBeNull(); | |
| 1201 | expect(current?.updatedAt).toBe(beforeSync?.updatedAt); | |
| 1202 | } finally { | |
| 1203 | await system.dispose(); | |
| 1204 | } | |
| 1205 | }); | |
| 1206 | ||
| 996 | 1207 | it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { |
| 997 | 1208 | const system = await createOrchestrationSystem(); |
| 998 | 1209 | const { engine } = system; |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+17| ... | ... | @@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 185 | 185 | }); |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | // The decider compares the lookup inputs. Only recreation needs an | |
| 189 | // event check, since it can reset a thread to the same field values. | |
| 190 | if ( | |
| 191 | envelope.command.type === "thread.pull-request.sync" && | |
| 192 | (yield* eventStore.hasEventAfter({ | |
| 193 | aggregateKind: "thread", | |
| 194 | aggregateId: envelope.command.threadId, | |
| 195 | sequenceExclusive: envelope.command.snapshotSequence, | |
| 196 | type: "thread.created", | |
| 197 | })) | |
| 198 | ) { | |
| 199 | return yield* new OrchestrationCommandInvariantError({ | |
| 200 | commandType: envelope.command.type, | |
| 201 | detail: `thread ${envelope.command.threadId} was recreated before pull request discovery`, | |
| 202 | }); | |
| 203 | } | |
| 204 | ||
| 188 | 205 | if ( |
| 189 | 206 | envelope.command.type === "thread.auto-settle" && |
| 190 | 207 | threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11| ... | ... | @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; | |
| 13 | 14 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 14 | 15 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 15 | 16 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| ... | ... | @@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { |
| 65 | 66 | drainThrough: () => Effect.void, |
| 66 | 67 | }), |
| 67 | 68 | ), |
| 69 | Layer.provideMerge( | |
| 70 | Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { | |
| 71 | start: () => { | |
| 72 | started.push("thread-pull-request-reactor"); | |
| 73 | return Effect.void; | |
| 74 | }, | |
| 75 | drain: Effect.void, | |
| 76 | }), | |
| 77 | ), | |
| 68 | 78 | Layer.provideMerge( |
| 69 | 79 | Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { |
| 70 | 80 | start: () => { |
| ... | ... | @@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { |
| 95 | 105 | "provider-command-reactor", |
| 96 | 106 | "checkpoint-reactor", |
| 97 | 107 | "thread-deletion-reactor", |
| 108 | "thread-pull-request-reactor", | |
| 98 | 109 | "thread-settlement-reactor", |
| 99 | 110 | "agent-awareness-relay", |
| 100 | 111 | ]); |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3| ... | ... | @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; | |
| 13 | 14 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 14 | 15 | |
| 15 | 16 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| ... | ... | @@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { |
| 18 | 19 | const checkpointReactor = yield* CheckpointReactor; |
| 19 | 20 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 20 | 21 | const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; |
| 22 | const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; | |
| 21 | 23 | const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; |
| 22 | 24 | |
| 23 | 25 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| ... | ... | @@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { |
| 25 | 27 | yield* providerCommandReactor.start(); |
| 26 | 28 | yield* checkpointReactor.start(); |
| 27 | 29 | yield* threadDeletionReactor.start(); |
| 30 | yield* threadPullRequestReactor.start(); | |
| 28 | 31 | yield* threadSettlementReactor.start(); |
| 29 | 32 | yield* agentAwarenessRelay.start(); |
| 30 | 33 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+92| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | MessageId, |
| 9 | 9 | ProjectId, |
| 10 | 10 | ThreadId, |
| 11 | ThreadLinkedPullRequest, | |
| 11 | 12 | TurnId, |
| 12 | 13 | ProviderInstanceId, |
| 13 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -18,6 +19,7 @@ import * as Effect from "effect/Effect"; |
| 18 | 19 | import * as FileSystem from "effect/FileSystem"; |
| 19 | 20 | import * as Layer from "effect/Layer"; |
| 20 | 21 | import * as Path from "effect/Path"; |
| 22 | import * as Schema from "effect/Schema"; | |
| 21 | 23 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 22 | 24 | |
| 23 | 25 | import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; |
| ... | ... | @@ -59,6 +61,9 @@ const exists = (filePath: string) => |
| 59 | 61 | }); |
| 60 | 62 | |
| 61 | 63 | const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); |
| 64 | const encodeThreadLinkedPullRequest = Schema.encodeSync( | |
| 65 | Schema.fromJsonString(ThreadLinkedPullRequest), | |
| 66 | ); | |
| 62 | 67 | |
| 63 | 68 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( |
| 64 | 69 | "OrchestrationProjectionPipeline cursor batches", |
| ... | ... | @@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-") |
| 204 | 209 | }, |
| 205 | 210 | ); |
| 206 | 211 | |
| 212 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-projection-")))( | |
| 213 | "branch pull request projection", | |
| 214 | (it) => { | |
| 215 | it.effect("persists branch pull request updates without changing manual links", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 218 | const eventStore = yield* OrchestrationEventStore; | |
| 219 | const sql = yield* SqlClient.SqlClient; | |
| 220 | const now = "2026-01-01T00:00:00.000Z"; | |
| 221 | const threadId = ThreadId.make("thread-pull-request"); | |
| 222 | const projectId = ProjectId.make("project-pull-request"); | |
| 223 | const eventFields = { | |
| 224 | aggregateKind: "thread" as const, | |
| 225 | aggregateId: threadId, | |
| 226 | occurredAt: now, | |
| 227 | commandId: null, | |
| 228 | causationEventId: null, | |
| 229 | correlationId: null, | |
| 230 | metadata: {}, | |
| 231 | }; | |
| 232 | const created = yield* eventStore.append({ | |
| 233 | ...eventFields, | |
| 234 | type: "thread.created", | |
| 235 | eventId: EventId.make("evt-pull-request-created"), | |
| 236 | payload: { | |
| 237 | threadId, | |
| 238 | projectId, | |
| 239 | title: "Pull request thread", | |
| 240 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 241 | runtimeMode: "full-access", | |
| 242 | interactionMode: "default", | |
| 243 | branch: "feature", | |
| 244 | worktreePath: null, | |
| 245 | createdAt: now, | |
| 246 | updatedAt: now, | |
| 247 | }, | |
| 248 | }); | |
| 249 | yield* projectionPipeline.projectEvent(created); | |
| 250 | const linkedPullRequest = { | |
| 251 | projectId, | |
| 252 | repository: "pingdotgg/t3code", | |
| 253 | number: 42, | |
| 254 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 255 | }; | |
| 256 | const branchPullRequest = { | |
| 257 | ...linkedPullRequest, | |
| 258 | number: 43, | |
| 259 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 260 | }; | |
| 261 | const updates = [ | |
| 262 | { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, | |
| 263 | { payload: { title: "Renamed thread" }, expected: branchPullRequest }, | |
| 264 | { payload: { branchPullRequest: null }, expected: null }, | |
| 265 | ]; | |
| 266 | ||
| 267 | for (const [index, update] of updates.entries()) { | |
| 268 | const event = yield* eventStore.append({ | |
| 269 | ...eventFields, | |
| 270 | type: "thread.meta-updated", | |
| 271 | eventId: EventId.make(`evt-pull-request-update-${index}`), | |
| 272 | payload: { threadId, updatedAt: now, ...update.payload }, | |
| 273 | }); | |
| 274 | yield* projectionPipeline.projectEvent(event); | |
| 275 | ||
| 276 | const rows = yield* sql<{ | |
| 277 | readonly linkedPullRequest: string | null; | |
| 278 | readonly branchPullRequest: string | null; | |
| 279 | }>` | |
| 280 | SELECT | |
| 281 | linked_pull_request_json AS "linkedPullRequest", | |
| 282 | branch_pull_request_json AS "branchPullRequest" | |
| 283 | FROM projection_threads | |
| 284 | WHERE thread_id = ${threadId} | |
| 285 | `; | |
| 286 | assert.deepEqual(rows, [ | |
| 287 | { | |
| 288 | linkedPullRequest: encodeThreadLinkedPullRequest(linkedPullRequest), | |
| 289 | branchPullRequest: | |
| 290 | update.expected === null ? null : encodeThreadLinkedPullRequest(update.expected), | |
| 291 | }, | |
| 292 | ]); | |
| 293 | } | |
| 294 | }), | |
| 295 | ); | |
| 296 | }, | |
| 297 | ); | |
| 298 | ||
| 207 | 299 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 208 | 300 | it.effect("bootstraps all projection states and writes projection rows", () => |
| 209 | 301 | Effect.gen(function* () { |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4| ... | ... | @@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 605 | 605 | branch: event.payload.branch, |
| 606 | 606 | worktreePath: event.payload.worktreePath, |
| 607 | 607 | linkedPullRequest: null, |
| 608 | branchPullRequest: null, | |
| 608 | 609 | latestTurnId: null, |
| 609 | 610 | createdAt: event.payload.createdAt, |
| 610 | 611 | updatedAt: event.payload.updatedAt, |
| ... | ... | @@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 805 | 806 | ...(event.payload.linkedPullRequest !== undefined |
| 806 | 807 | ? { linkedPullRequest: event.payload.linkedPullRequest } |
| 807 | 808 | : {}), |
| 809 | ...(event.payload.branchPullRequest !== undefined | |
| 810 | ? { branchPullRequest: event.payload.branchPullRequest } | |
| 811 | : {}), | |
| 808 | 812 | updatedAt: event.payload.updatedAt, |
| 809 | 813 | }); |
| 810 | 814 | return; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+36| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | MessageId, |
| 7 | 7 | ProjectId, |
| 8 | 8 | ThreadId, |
| 9 | ThreadLinkedPullRequest, | |
| 9 | 10 | TurnId, |
| 10 | 11 | ProviderInstanceId, |
| 11 | 12 | } from "@t3tools/contracts"; |
| ... | ... | @@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val |
| 36 | 37 | const encodeChatAttachments = Schema.encodeEffect( |
| 37 | 38 | Schema.fromJsonString(Schema.Array(ChatAttachment)), |
| 38 | 39 | ); |
| 40 | const encodeThreadLinkedPullRequest = Schema.encodeSync( | |
| 41 | Schema.fromJsonString(ThreadLinkedPullRequest), | |
| 42 | ); | |
| 39 | 43 | |
| 40 | 44 | const projectionSnapshotLayer = it.layer( |
| 41 | 45 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| ... | ... | @@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 52 | 56 | Effect.gen(function* () { |
| 53 | 57 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
| 54 | 58 | const sql = yield* SqlClient.SqlClient; |
| 59 | const branchPullRequest = { | |
| 60 | projectId: asProjectId("project-1"), | |
| 61 | repository: "pingdotgg/t3code", | |
| 62 | number: 43, | |
| 63 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 64 | }; | |
| 55 | 65 | |
| 56 | 66 | yield* sql`DELETE FROM projection_projects`; |
| 57 | 67 | yield* sql`DELETE FROM projection_state`; |
| ... | ... | @@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 92 | 102 | branch, |
| 93 | 103 | worktree_path, |
| 94 | 104 | linked_pull_request_json, |
| 105 | branch_pull_request_json, | |
| 95 | 106 | latest_turn_id, |
| 96 | 107 | latest_user_message_at, |
| 97 | 108 | pending_approval_count, |
| ... | ... | @@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 113 | 124 | NULL, |
| 114 | 125 | NULL, |
| 115 | 126 | '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', |
| 127 | ${encodeThreadLinkedPullRequest(branchPullRequest)}, | |
| 116 | 128 | 'turn-1', |
| 117 | 129 | '2026-02-24T00:00:04.000Z', |
| 118 | 130 | 1, |
| ... | ... | @@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 323 | 335 | number: 42, |
| 324 | 336 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 325 | 337 | }, |
| 338 | branchPullRequest, | |
| 326 | 339 | latestTurn: { |
| 327 | 340 | turnId: asTurnId("turn-1"), |
| 328 | 341 | state: "completed", |
| ... | ... | @@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 451 | 464 | number: 42, |
| 452 | 465 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 453 | 466 | }, |
| 467 | branchPullRequest, | |
| 454 | 468 | latestTurn: { |
| 455 | 469 | turnId: asTurnId("turn-1"), |
| 456 | 470 | state: "completed", |
| ... | ... | @@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 498 | 512 | assert.deepEqual(threadDetail.value, snapshot.threads[0]); |
| 499 | 513 | } |
| 500 | 514 | |
| 515 | const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); | |
| 516 | assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); | |
| 517 | const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); | |
| 518 | assert.equal(threadShell._tag, "Some"); | |
| 519 | if (threadShell._tag === "Some") { | |
| 520 | assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); | |
| 521 | } | |
| 522 | ||
| 501 | 523 | yield* sql` |
| 502 | 524 | INSERT INTO projection_thread_activities ( |
| 503 | 525 | activity_id, |
| ... | ... | @@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 743 | 765 | Effect.gen(function* () { |
| 744 | 766 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
| 745 | 767 | const sql = yield* SqlClient.SqlClient; |
| 768 | const branchPullRequest = { | |
| 769 | projectId: asProjectId("project-archive-test"), | |
| 770 | repository: "pingdotgg/t3code", | |
| 771 | number: 43, | |
| 772 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 773 | }; | |
| 746 | 774 | |
| 747 | 775 | yield* sql`DELETE FROM projection_projects`; |
| 748 | 776 | yield* sql`DELETE FROM projection_threads`; |
| ... | ... | @@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 849 | 877 | shellSnapshot.threads.map((thread) => thread.id), |
| 850 | 878 | [ThreadId.make("thread-active")], |
| 851 | 879 | ); |
| 880 | assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null); | |
| 881 | ||
| 882 | yield* sql` | |
| 883 | UPDATE projection_threads | |
| 884 | SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)} | |
| 885 | WHERE thread_id = 'thread-archived' | |
| 886 | `; | |
| 852 | 887 | |
| 853 | 888 | const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); |
| 854 | 889 | assert.deepEqual( |
| ... | ... | @@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 856 | 891 | [ThreadId.make("thread-archived")], |
| 857 | 892 | ); |
| 858 | 893 | assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); |
| 894 | assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest); | |
| 859 | 895 | const activeContext = yield* snapshotQuery.getThreadRuntimeContext( |
| 860 | 896 | ThreadId.make("thread-active"), |
| 861 | 897 | ); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11| ... | ... | @@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( |
| 116 | 116 | Struct.assign({ |
| 117 | 117 | modelSelection: Schema.fromJsonString(ModelSelection), |
| 118 | 118 | linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), |
| 119 | branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), | |
| 119 | 120 | }), |
| 120 | 121 | ); |
| 121 | 122 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| ... | ... | @@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 500 | 501 | branch, |
| 501 | 502 | worktree_path AS "worktreePath", |
| 502 | 503 | linked_pull_request_json AS "linkedPullRequest", |
| 504 | branch_pull_request_json AS "branchPullRequest", | |
| 503 | 505 | latest_turn_id AS "latestTurnId", |
| 504 | 506 | created_at AS "createdAt", |
| 505 | 507 | updated_at AS "updatedAt", |
| ... | ... | @@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 538 | 540 | branch, |
| 539 | 541 | worktree_path AS "worktreePath", |
| 540 | 542 | linked_pull_request_json AS "linkedPullRequest", |
| 543 | branch_pull_request_json AS "branchPullRequest", | |
| 541 | 544 | latest_turn_id AS "latestTurnId", |
| 542 | 545 | created_at AS "createdAt", |
| 543 | 546 | updated_at AS "updatedAt", |
| ... | ... | @@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 578 | 581 | branch, |
| 579 | 582 | worktree_path AS "worktreePath", |
| 580 | 583 | linked_pull_request_json AS "linkedPullRequest", |
| 584 | branch_pull_request_json AS "branchPullRequest", | |
| 581 | 585 | latest_turn_id AS "latestTurnId", |
| 582 | 586 | created_at AS "createdAt", |
| 583 | 587 | updated_at AS "updatedAt", |
| ... | ... | @@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1067 | 1071 | branch, |
| 1068 | 1072 | worktree_path AS "worktreePath", |
| 1069 | 1073 | linked_pull_request_json AS "linkedPullRequest", |
| 1074 | branch_pull_request_json AS "branchPullRequest", | |
| 1070 | 1075 | latest_turn_id AS "latestTurnId", |
| 1071 | 1076 | created_at AS "createdAt", |
| 1072 | 1077 | updated_at AS "updatedAt", |
| ... | ... | @@ -2062,6 +2067,7 @@ pending_approval_requests AS ( |
| 2062 | 2067 | interactionMode: row.interactionMode, |
| 2063 | 2068 | branch: row.branch, |
| 2064 | 2069 | worktreePath: row.worktreePath, |
| 2070 | branchPullRequest: row.branchPullRequest, | |
| 2065 | 2071 | ...(row.linkedPullRequest === null |
| 2066 | 2072 | ? {} |
| 2067 | 2073 | : { linkedPullRequest: row.linkedPullRequest }), |
| ... | ... | @@ -2275,6 +2281,7 @@ pending_approval_requests AS ( |
| 2275 | 2281 | interactionMode: row.interactionMode, |
| 2276 | 2282 | branch: row.branch, |
| 2277 | 2283 | worktreePath: row.worktreePath, |
| 2284 | branchPullRequest: row.branchPullRequest, | |
| 2278 | 2285 | ...(row.linkedPullRequest === null |
| 2279 | 2286 | ? {} |
| 2280 | 2287 | : { linkedPullRequest: row.linkedPullRequest }), |
| ... | ... | @@ -2415,6 +2422,7 @@ pending_approval_requests AS ( |
| 2415 | 2422 | interactionMode: row.interactionMode, |
| 2416 | 2423 | branch: row.branch, |
| 2417 | 2424 | worktreePath: row.worktreePath, |
| 2425 | branchPullRequest: row.branchPullRequest, | |
| 2418 | 2426 | ...(row.linkedPullRequest === null |
| 2419 | 2427 | ? {} |
| 2420 | 2428 | : { linkedPullRequest: row.linkedPullRequest }), |
| ... | ... | @@ -2563,6 +2571,7 @@ pending_approval_requests AS ( |
| 2563 | 2571 | interactionMode: row.interactionMode, |
| 2564 | 2572 | branch: row.branch, |
| 2565 | 2573 | worktreePath: row.worktreePath, |
| 2574 | branchPullRequest: row.branchPullRequest, | |
| 2566 | 2575 | ...(row.linkedPullRequest === null |
| 2567 | 2576 | ? {} |
| 2568 | 2577 | : { linkedPullRequest: row.linkedPullRequest }), |
| ... | ... | @@ -2884,6 +2893,7 @@ pending_approval_requests AS ( |
| 2884 | 2893 | interactionMode: threadRow.value.interactionMode, |
| 2885 | 2894 | branch: threadRow.value.branch, |
| 2886 | 2895 | worktreePath: threadRow.value.worktreePath, |
| 2896 | branchPullRequest: threadRow.value.branchPullRequest, | |
| 2887 | 2897 | ...(threadRow.value.linkedPullRequest === null |
| 2888 | 2898 | ? {} |
| 2889 | 2899 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), |
| ... | ... | @@ -3165,6 +3175,7 @@ pending_approval_requests AS ( |
| 3165 | 3175 | interactionMode: threadRow.value.interactionMode, |
| 3166 | 3176 | branch: threadRow.value.branch, |
| 3167 | 3177 | worktreePath: threadRow.value.worktreePath, |
| 3178 | branchPullRequest: threadRow.value.branchPullRequest, | |
| 3168 | 3179 | ...(threadRow.value.linkedPullRequest === null |
| 3169 | 3180 | ? {} |
| 3170 | 3181 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), |
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts created+613| ... | ... | @@ -0,0 +1,613 @@ |
| 1 | import { | |
| 2 | EventId, | |
| 3 | GitManagerError, | |
| 4 | ProjectId, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | type OrchestrationCommand, | |
| 8 | type OrchestrationEvent, | |
| 9 | type OrchestrationProjectShell, | |
| 10 | type OrchestrationShellSnapshot, | |
| 11 | type OrchestrationThreadShell, | |
| 12 | type PullRequestRef, | |
| 13 | type PullRequestSummary, | |
| 14 | type ThreadLinkedPullRequest, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { describe, expect, it } from "@effect/vitest"; | |
| 17 | import * as Crypto from "effect/Crypto"; | |
| 18 | import * as Deferred from "effect/Deferred"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as FileSystem from "effect/FileSystem"; | |
| 21 | import * as Layer from "effect/Layer"; | |
| 22 | import * as PubSub from "effect/PubSub"; | |
| 23 | import * as Queue from "effect/Queue"; | |
| 24 | import * as Ref from "effect/Ref"; | |
| 25 | import * as Stream from "effect/Stream"; | |
| 26 | import { TestClock } from "effect/testing"; | |
| 27 | ||
| 28 | import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; | |
| 29 | import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | |
| 30 | import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; | |
| 31 | import { ServerActivation } from "../serverActivation.ts"; | |
| 32 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | |
| 33 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | |
| 34 | import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; | |
| 35 | ||
| 36 | const NOW = "2026-09-01T12:00:00.000Z"; | |
| 37 | const PROJECT_ID = ProjectId.make("project"); | |
| 38 | const REPOSITORY = "owner/repository"; | |
| 39 | const REPOSITORY_KEY = `github.com/${REPOSITORY}`; | |
| 40 | type SyncCommand = Extract<OrchestrationCommand, { type: "thread.pull-request.sync" }>; | |
| 41 | ||
| 42 | function reference(number: number): ThreadLinkedPullRequest { | |
| 43 | return { | |
| 44 | projectId: PROJECT_ID, | |
| 45 | repository: REPOSITORY, | |
| 46 | number, | |
| 47 | url: `https://github.com/${REPOSITORY}/pull/${number}`, | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | function branchPullRequest( | |
| 52 | number = 42, | |
| 53 | state: GitBranchPullRequest["state"] = "open", | |
| 54 | ): GitBranchPullRequest { | |
| 55 | return { | |
| 56 | ...reference(number), | |
| 57 | title: "Branch pull request", | |
| 58 | baseRef: "main", | |
| 59 | headRef: "feature", | |
| 60 | state, | |
| 61 | updatedAt: NOW, | |
| 62 | repositoryKey: REPOSITORY_KEY, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | function summary(input: PullRequestRef, state: PullRequestSummary["state"]): PullRequestSummary { | |
| 67 | return { | |
| 68 | ...input, | |
| 69 | provider: "github", | |
| 70 | title: "Pull request", | |
| 71 | url: reference(input.number).url, | |
| 72 | state, | |
| 73 | headBranch: "feature", | |
| 74 | baseBranch: "main", | |
| 75 | updatedAt: NOW, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | function thread( | |
| 80 | id: string, | |
| 81 | overrides: Partial<OrchestrationThreadShell> = {}, | |
| 82 | ): OrchestrationThreadShell { | |
| 83 | return { | |
| 84 | id: ThreadId.make(id), | |
| 85 | projectId: PROJECT_ID, | |
| 86 | title: id, | |
| 87 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 88 | runtimeMode: "full-access", | |
| 89 | interactionMode: "default", | |
| 90 | branch: "feature", | |
| 91 | worktreePath: null, | |
| 92 | latestTurn: null, | |
| 93 | createdAt: NOW, | |
| 94 | updatedAt: NOW, | |
| 95 | archivedAt: null, | |
| 96 | settledOverride: null, | |
| 97 | settledAt: null, | |
| 98 | session: null, | |
| 99 | latestUserMessageAt: NOW, | |
| 100 | hasPendingApprovals: false, | |
| 101 | hasPendingUserInput: false, | |
| 102 | hasActionableProposedPlan: false, | |
| 103 | ...overrides, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | const project = { | |
| 108 | id: PROJECT_ID, | |
| 109 | title: "Project", | |
| 110 | workspaceRoot: "/workspace/project", | |
| 111 | repositoryIdentity: { | |
| 112 | canonicalKey: REPOSITORY_KEY, | |
| 113 | displayName: REPOSITORY, | |
| 114 | rootPath: "/workspace/project", | |
| 115 | locator: { | |
| 116 | source: "git-remote", | |
| 117 | remoteName: "origin", | |
| 118 | remoteUrl: `git@github.com:${REPOSITORY}.git`, | |
| 119 | }, | |
| 120 | }, | |
| 121 | defaultModelSelection: null, | |
| 122 | scripts: [], | |
| 123 | createdAt: NOW, | |
| 124 | updatedAt: NOW, | |
| 125 | } satisfies OrchestrationProjectShell; | |
| 126 | ||
| 127 | const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options: { | |
| 128 | readonly threads: ReadonlyArray<OrchestrationThreadShell>; | |
| 129 | readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; | |
| 130 | readonly summary?: PullRequestService["Service"]["summary"]; | |
| 131 | readonly existingWorktrees?: ReadonlyArray<string>; | |
| 132 | readonly project?: OrchestrationProjectShell; | |
| 133 | readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; | |
| 134 | }) { | |
| 135 | const activation = yield* Deferred.make<void>(); | |
| 136 | const snapshots = yield* Ref.make<OrchestrationShellSnapshot>({ | |
| 137 | snapshotSequence: 1, | |
| 138 | projects: [options.project ?? project], | |
| 139 | threads: options.threads, | |
| 140 | updatedAt: NOW, | |
| 141 | }); | |
| 142 | const reads = yield* Queue.unbounded<void>(); | |
| 143 | const events = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 144 | const commands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]); | |
| 145 | const branchCalls = yield* Ref.make< | |
| 146 | ReadonlyArray<{ readonly cwd: string; readonly branch: string; readonly refresh: boolean }> | |
| 147 | >([]); | |
| 148 | const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); | |
| 149 | let uuid = 0; | |
| 150 | const dependencies = Layer.mergeAll( | |
| 151 | Layer.mock(ProjectionSnapshotQuery)({ | |
| 152 | getShellSnapshot: () => | |
| 153 | Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), | |
| 154 | }), | |
| 155 | Layer.mock(GitManager)({ | |
| 156 | branchPullRequest: (input, readOptions) => | |
| 157 | Ref.update(branchCalls, (calls) => [ | |
| 158 | ...calls, | |
| 159 | { ...input, refresh: readOptions?.refresh === true }, | |
| 160 | ]).pipe( | |
| 161 | Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), | |
| 162 | ), | |
| 163 | }), | |
| 164 | Layer.mock(PullRequestService)({ | |
| 165 | summary: (input, readOptions) => | |
| 166 | Ref.update(summaryCalls, (calls) => [...calls, input]).pipe( | |
| 167 | Effect.andThen( | |
| 168 | options.summary?.(input, readOptions) ?? Effect.succeed(summary(input, "open")), | |
| 169 | ), | |
| 170 | ), | |
| 171 | }), | |
| 172 | Layer.mock(RepositoryIdentityResolver)({ | |
| 173 | resolve: | |
| 174 | options.resolveRepositoryIdentity ?? | |
| 175 | (() => Effect.succeed(options.project?.repositoryIdentity ?? project.repositoryIdentity)), | |
| 176 | }), | |
| 177 | Layer.mock(OrchestrationEngineService)({ | |
| 178 | subscribeDomainEvents: PubSub.subscribe(events).pipe( | |
| 179 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 180 | ), | |
| 181 | dispatch: (command) => { | |
| 182 | if (command.type !== "thread.pull-request.sync") { | |
| 183 | return Effect.die(`Unexpected command: ${command.type}`); | |
| 184 | } | |
| 185 | return Ref.update(commands, (current) => [...current, command]).pipe( | |
| 186 | Effect.andThen( | |
| 187 | Ref.updateAndGet(snapshots, (snapshot) => ({ | |
| 188 | ...snapshot, | |
| 189 | snapshotSequence: snapshot.snapshotSequence + 1, | |
| 190 | threads: snapshot.threads.map((current) => | |
| 191 | current.id === command.threadId | |
| 192 | ? { | |
| 193 | ...current, | |
| 194 | branchPullRequest: command.branchPullRequest, | |
| 195 | ...(command.linkedPullRequest !== undefined | |
| 196 | ? { linkedPullRequest: command.linkedPullRequest } | |
| 197 | : {}), | |
| 198 | } | |
| 199 | : current, | |
| 200 | ), | |
| 201 | })), | |
| 202 | ), | |
| 203 | Effect.map((snapshot) => ({ sequence: snapshot.snapshotSequence })), | |
| 204 | ); | |
| 205 | }, | |
| 206 | }), | |
| 207 | Layer.succeed(ServerActivation, Deferred.await(activation)), | |
| 208 | Layer.succeed( | |
| 209 | Crypto.Crypto, | |
| 210 | Crypto.make({ | |
| 211 | randomBytes: (size) => new Uint8Array(size).fill(++uuid), | |
| 212 | digest: (_algorithm, data) => Effect.succeed(data), | |
| 213 | }), | |
| 214 | ), | |
| 215 | FileSystem.layerNoop({ | |
| 216 | exists: (path) => Effect.succeed(options.existingWorktrees?.includes(path) ?? false), | |
| 217 | }), | |
| 218 | ); | |
| 219 | ||
| 220 | const start = Effect.fn("startThreadPullRequestHarness")(function* () { | |
| 221 | const reactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; | |
| 222 | yield* reactor.start(); | |
| 223 | yield* Deferred.succeed(activation, undefined); | |
| 224 | yield* Queue.take(reads); | |
| 225 | yield* reactor.drain; | |
| 226 | return reactor; | |
| 227 | }); | |
| 228 | ||
| 229 | return { | |
| 230 | start, | |
| 231 | reads, | |
| 232 | snapshots, | |
| 233 | commands, | |
| 234 | branchCalls, | |
| 235 | summaryCalls, | |
| 236 | publish: (event: OrchestrationEvent) => PubSub.publish(events, event), | |
| 237 | layer: ThreadPullRequestReactor.layer.pipe(Layer.provide(dependencies)), | |
| 238 | }; | |
| 239 | }); | |
| 240 | ||
| 241 | describe("ThreadPullRequestReactor", () => { | |
| 242 | it.effect("discovers saved branch PRs without a client and shares branch lookups", () => | |
| 243 | Effect.scoped( | |
| 244 | Effect.gen(function* () { | |
| 245 | const fixture = yield* makeHarness({ | |
| 246 | threads: [ | |
| 247 | thread("first"), | |
| 248 | thread("second"), | |
| 249 | thread("archived", { archivedAt: NOW }), | |
| 250 | thread("no-branch", { branch: null }), | |
| 251 | ], | |
| 252 | branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 253 | }); | |
| 254 | yield* Effect.gen(function* () { | |
| 255 | const reactor = yield* fixture.start(); | |
| 256 | expect(yield* Ref.get(fixture.branchCalls)).toEqual([ | |
| 257 | { cwd: project.workspaceRoot, branch: "feature", refresh: false }, | |
| 258 | { cwd: project.workspaceRoot, branch: "feature", refresh: false }, | |
| 259 | ]); | |
| 260 | expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ | |
| 261 | "first", | |
| 262 | "second", | |
| 263 | ]); | |
| 264 | expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual( | |
| 265 | reference(42), | |
| 266 | ); | |
| 267 | ||
| 268 | yield* TestClock.adjust("1 minute"); | |
| 269 | yield* Queue.take(fixture.reads); | |
| 270 | yield* reactor.drain; | |
| 271 | expect(yield* Ref.get(fixture.commands)).toHaveLength(2); | |
| 272 | }).pipe(Effect.provide(fixture.layer)); | |
| 273 | }), | |
| 274 | ), | |
| 275 | ); | |
| 276 | ||
| 277 | it.effect("replaces terminal manual links but preserves open links and explicit unlink", () => | |
| 278 | Effect.scoped( | |
| 279 | Effect.gen(function* () { | |
| 280 | const fixture = yield* makeHarness({ | |
| 281 | threads: [ | |
| 282 | thread("merged", { linkedPullRequest: reference(1) }), | |
| 283 | thread("closed", { linkedPullRequest: reference(2) }), | |
| 284 | thread("open", { linkedPullRequest: reference(3) }), | |
| 285 | thread("unlinked", { linkedPullRequest: null }), | |
| 286 | ], | |
| 287 | branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 288 | summary: (input) => | |
| 289 | Effect.succeed( | |
| 290 | summary( | |
| 291 | input, | |
| 292 | input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open", | |
| 293 | ), | |
| 294 | ), | |
| 295 | }); | |
| 296 | yield* Effect.gen(function* () { | |
| 297 | yield* fixture.start(); | |
| 298 | const snapshot = yield* Ref.get(fixture.snapshots); | |
| 299 | expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([ | |
| 300 | reference(42), | |
| 301 | reference(42), | |
| 302 | reference(3), | |
| 303 | null, | |
| 304 | ]); | |
| 305 | expect( | |
| 306 | snapshot.threads.every((current) => current.branchPullRequest?.number === 42), | |
| 307 | ).toBe(true); | |
| 308 | }).pipe(Effect.provide(fixture.layer)); | |
| 309 | }), | |
| 310 | ), | |
| 311 | ); | |
| 312 | ||
| 313 | it.effect("refreshes discovery after a turn ends without client demand", () => | |
| 314 | Effect.scoped( | |
| 315 | Effect.gen(function* () { | |
| 316 | const current = thread("turn-thread"); | |
| 317 | const detected = yield* Ref.make<GitBranchPullRequest | null>(null); | |
| 318 | const fixture = yield* makeHarness({ | |
| 319 | threads: [current], | |
| 320 | branchPullRequest: (_input, options) => | |
| 321 | options?.refresh | |
| 322 | ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) | |
| 323 | : Ref.get(detected), | |
| 324 | }); | |
| 325 | yield* Effect.gen(function* () { | |
| 326 | const reactor = yield* fixture.start(); | |
| 327 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 328 | yield* fixture.publish({ | |
| 329 | type: "thread.session-set", | |
| 330 | sequence: 2, | |
| 331 | eventId: EventId.make("turn-finished"), | |
| 332 | aggregateKind: "thread", | |
| 333 | aggregateId: current.id, | |
| 334 | occurredAt: NOW, | |
| 335 | commandId: null, | |
| 336 | causationEventId: null, | |
| 337 | correlationId: null, | |
| 338 | metadata: {}, | |
| 339 | payload: { | |
| 340 | threadId: current.id, | |
| 341 | session: { | |
| 342 | threadId: current.id, | |
| 343 | status: "ready", | |
| 344 | providerName: "Codex", | |
| 345 | runtimeMode: "full-access", | |
| 346 | activeTurnId: null, | |
| 347 | lastError: null, | |
| 348 | updatedAt: NOW, | |
| 349 | }, | |
| 350 | }, | |
| 351 | }); | |
| 352 | yield* Queue.take(fixture.reads); | |
| 353 | yield* reactor.drain; | |
| 354 | expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); | |
| 355 | expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( | |
| 356 | 1, | |
| 357 | ); | |
| 358 | }).pipe(Effect.provide(fixture.layer)); | |
| 359 | }), | |
| 360 | ), | |
| 361 | ); | |
| 362 | ||
| 363 | it.effect("uses live worktrees and falls back to the project for removed worktrees", () => | |
| 364 | Effect.scoped( | |
| 365 | Effect.gen(function* () { | |
| 366 | const fixture = yield* makeHarness({ | |
| 367 | threads: [ | |
| 368 | thread("live", { worktreePath: "/workspace/worktree" }), | |
| 369 | thread("removed", { worktreePath: "/workspace/removed" }), | |
| 370 | ], | |
| 371 | existingWorktrees: ["/workspace/worktree"], | |
| 372 | branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 373 | }); | |
| 374 | yield* Effect.gen(function* () { | |
| 375 | yield* fixture.start(); | |
| 376 | expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual( | |
| 377 | new Set(["/workspace/project", "/workspace/worktree"]), | |
| 378 | ); | |
| 379 | }).pipe(Effect.provide(fixture.layer)); | |
| 380 | }), | |
| 381 | ), | |
| 382 | ); | |
| 383 | ||
| 384 | it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () => | |
| 385 | Effect.scoped( | |
| 386 | Effect.gen(function* () { | |
| 387 | const fixture = yield* makeHarness({ | |
| 388 | threads: [ | |
| 389 | thread("terminal", { branch: "main", branchPullRequest: reference(1) }), | |
| 390 | thread("open", { branchPullRequest: reference(2) }), | |
| 391 | thread("worktree", { | |
| 392 | worktreePath: "/workspace/worktree", | |
| 393 | branchPullRequest: reference(1), | |
| 394 | }), | |
| 395 | thread("cleared", { | |
| 396 | branch: null, | |
| 397 | branchPullRequest: reference(1), | |
| 398 | linkedPullRequest: reference(3), | |
| 399 | }), | |
| 400 | ], | |
| 401 | summary: (input) => | |
| 402 | Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")), | |
| 403 | }); | |
| 404 | yield* Effect.gen(function* () { | |
| 405 | yield* fixture.start(); | |
| 406 | const snapshot = yield* Ref.get(fixture.snapshots); | |
| 407 | expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([ | |
| 408 | reference(1), | |
| 409 | null, | |
| 410 | null, | |
| 411 | null, | |
| 412 | ]); | |
| 413 | expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3)); | |
| 414 | }).pipe(Effect.provide(fixture.layer)); | |
| 415 | }), | |
| 416 | ), | |
| 417 | ); | |
| 418 | ||
| 419 | it.effect("keeps saved links on lookup failures and rejects a different repository", () => | |
| 420 | Effect.scoped( | |
| 421 | Effect.gen(function* () { | |
| 422 | const fixture = yield* makeHarness({ | |
| 423 | threads: [ | |
| 424 | thread("failed", { branch: "failed", branchPullRequest: reference(1) }), | |
| 425 | thread("wrong-repository", { | |
| 426 | branch: "wrong-repository", | |
| 427 | branchPullRequest: reference(2), | |
| 428 | }), | |
| 429 | thread("healthy"), | |
| 430 | ], | |
| 431 | branchPullRequest: ({ cwd, branch }) => | |
| 432 | branch === "failed" | |
| 433 | ? Effect.fail( | |
| 434 | new GitManagerError({ | |
| 435 | operation: "branchPullRequest", | |
| 436 | cwd, | |
| 437 | detail: "Lookup failed", | |
| 438 | }), | |
| 439 | ) | |
| 440 | : Effect.succeed({ | |
| 441 | ...branchPullRequest(), | |
| 442 | repositoryKey: | |
| 443 | branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY, | |
| 444 | }), | |
| 445 | }); | |
| 446 | yield* Effect.gen(function* () { | |
| 447 | yield* fixture.start(); | |
| 448 | expect( | |
| 449 | (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest), | |
| 450 | ).toEqual([reference(1), reference(2), reference(42)]); | |
| 451 | expect(yield* Ref.get(fixture.commands)).toHaveLength(1); | |
| 452 | }).pipe(Effect.provide(fixture.layer)); | |
| 453 | }), | |
| 454 | ), | |
| 455 | ); | |
| 456 | ||
| 457 | it.effect("retries failed settled backfills and stops querying them after success", () => | |
| 458 | Effect.scoped( | |
| 459 | Effect.gen(function* () { | |
| 460 | const online = yield* Ref.make(false); | |
| 461 | const fixture = yield* makeHarness({ | |
| 462 | threads: [ | |
| 463 | thread("backfill", { settledOverride: "settled", settledAt: NOW }), | |
| 464 | thread("known", { | |
| 465 | branch: "known", | |
| 466 | settledOverride: "settled", | |
| 467 | settledAt: NOW, | |
| 468 | branchPullRequest: reference(1), | |
| 469 | }), | |
| 470 | ], | |
| 471 | branchPullRequest: ({ cwd }) => | |
| 472 | Ref.get(online).pipe( | |
| 473 | Effect.flatMap((connected) => | |
| 474 | connected | |
| 475 | ? Effect.succeed(branchPullRequest(42, "merged")) | |
| 476 | : Effect.fail( | |
| 477 | new GitManagerError({ | |
| 478 | operation: "branchPullRequest", | |
| 479 | cwd, | |
| 480 | detail: "Offline", | |
| 481 | }), | |
| 482 | ), | |
| 483 | ), | |
| 484 | ), | |
| 485 | }); | |
| 486 | yield* Effect.gen(function* () { | |
| 487 | const reactor = yield* fixture.start(); | |
| 488 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 489 | yield* Ref.set(online, true); | |
| 490 | yield* TestClock.adjust("1 minute"); | |
| 491 | yield* Queue.take(fixture.reads); | |
| 492 | yield* reactor.drain; | |
| 493 | expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); | |
| 494 | yield* TestClock.adjust("1 minute"); | |
| 495 | yield* Queue.take(fixture.reads); | |
| 496 | yield* reactor.drain; | |
| 497 | expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ | |
| 498 | "feature", | |
| 499 | "feature", | |
| 500 | "feature", | |
| 501 | ]); | |
| 502 | }).pipe(Effect.provide(fixture.layer)); | |
| 503 | }), | |
| 504 | ), | |
| 505 | ); | |
| 506 | ||
| 507 | it.effect("stops retrying a settled backfill after repeated lookup failures", () => | |
| 508 | Effect.scoped( | |
| 509 | Effect.gen(function* () { | |
| 510 | const fixture = yield* makeHarness({ | |
| 511 | threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })], | |
| 512 | branchPullRequest: ({ cwd }) => | |
| 513 | Effect.fail( | |
| 514 | new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }), | |
| 515 | ), | |
| 516 | }); | |
| 517 | yield* Effect.gen(function* () { | |
| 518 | const reactor = yield* fixture.start(); | |
| 519 | for ( | |
| 520 | let attempt = 1; | |
| 521 | attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2; | |
| 522 | attempt++ | |
| 523 | ) { | |
| 524 | yield* TestClock.adjust("1 minute"); | |
| 525 | yield* Queue.take(fixture.reads); | |
| 526 | yield* reactor.drain; | |
| 527 | } | |
| 528 | expect(yield* Ref.get(fixture.branchCalls)).toHaveLength( | |
| 529 | ThreadPullRequestReactor.BACKFILL_ATTEMPTS, | |
| 530 | ); | |
| 531 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 532 | }).pipe(Effect.provide(fixture.layer)); | |
| 533 | }), | |
| 534 | ), | |
| 535 | ); | |
| 536 | ||
| 537 | it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => | |
| 538 | Effect.scoped( | |
| 539 | Effect.gen(function* () { | |
| 540 | const fixture = yield* makeHarness({ | |
| 541 | threads: [thread("azure")], | |
| 542 | project: { | |
| 543 | ...project, | |
| 544 | repositoryIdentity: { | |
| 545 | canonicalKey: "ssh.dev.azure.com/v3/org/project/repository", | |
| 546 | displayName: "v3/org/project/repository", | |
| 547 | name: "repository", | |
| 548 | provider: "azure-devops", | |
| 549 | rootPath: project.workspaceRoot, | |
| 550 | locator: { | |
| 551 | source: "git-remote", | |
| 552 | remoteName: "origin", | |
| 553 | remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository", | |
| 554 | }, | |
| 555 | }, | |
| 556 | }, | |
| 557 | branchPullRequest: () => | |
| 558 | Effect.succeed({ | |
| 559 | ...branchPullRequest(), | |
| 560 | repositoryKey: "dev.azure.com/org/project/_git/repository", | |
| 561 | url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 562 | }), | |
| 563 | }); | |
| 564 | yield* Effect.gen(function* () { | |
| 565 | yield* fixture.start(); | |
| 566 | expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({ | |
| 567 | projectId: PROJECT_ID, | |
| 568 | repository: "repository", | |
| 569 | number: 42, | |
| 570 | url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 571 | }); | |
| 572 | }).pipe(Effect.provide(fixture.layer)); | |
| 573 | }), | |
| 574 | ), | |
| 575 | ); | |
| 576 | ||
| 577 | it.effect.each(["primary", "branch"] as const)( | |
| 578 | "rejects the group's links if the %s remote changes during a summary read", | |
| 579 | (changedRemote) => | |
| 580 | Effect.scoped( | |
| 581 | Effect.gen(function* () { | |
| 582 | const identity = yield* Ref.make(project.repositoryIdentity); | |
| 583 | const detected = yield* Ref.make(branchPullRequest()); | |
| 584 | const fixture = yield* makeHarness({ | |
| 585 | threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")], | |
| 586 | branchPullRequest: () => Ref.get(detected), | |
| 587 | resolveRepositoryIdentity: (_cwd, options) => | |
| 588 | options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity), | |
| 589 | summary: (input) => | |
| 590 | (changedRemote === "primary" | |
| 591 | ? Ref.set(identity, { | |
| 592 | ...project.repositoryIdentity, | |
| 593 | canonicalKey: "github.com/other/repository", | |
| 594 | displayName: "other/repository", | |
| 595 | }) | |
| 596 | : Ref.set(detected, { | |
| 597 | ...branchPullRequest(99), | |
| 598 | repositoryKey: "github.com/other/repository", | |
| 599 | url: "https://github.com/other/repository/pull/99", | |
| 600 | }) | |
| 601 | ).pipe(Effect.as(summary(input, "merged"))), | |
| 602 | }); | |
| 603 | yield* Effect.gen(function* () { | |
| 604 | yield* fixture.start(); | |
| 605 | expect(yield* Ref.get(fixture.commands)).toEqual([]); | |
| 606 | expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual( | |
| 607 | reference(1), | |
| 608 | ); | |
| 609 | }).pipe(Effect.provide(fixture.layer)); | |
| 610 | }), | |
| 611 | ), | |
| 612 | ); | |
| 613 | }); |
apps/server/src/orchestration/ThreadPullRequestReactor.ts created+371| ... | ... | @@ -0,0 +1,371 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | type OrchestrationEvent, | |
| 4 | type OrchestrationProjectShell, | |
| 5 | type ThreadId, | |
| 6 | type ThreadLinkedPullRequest, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | |
| 9 | import * as Cause from "effect/Cause"; | |
| 10 | import * as Context from "effect/Context"; | |
| 11 | import * as Crypto from "effect/Crypto"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Schedule from "effect/Schedule"; | |
| 16 | import type * as Scope from "effect/Scope"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | ||
| 19 | import * as GitManager from "../git/GitManager.ts"; | |
| 20 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | |
| 21 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | |
| 22 | import { forkParked } from "../serverActivation.ts"; | |
| 23 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | |
| 24 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; | |
| 25 | ||
| 26 | export class ThreadPullRequestReactor extends Context.Service< | |
| 27 | ThreadPullRequestReactor, | |
| 28 | { | |
| 29 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | |
| 30 | readonly drain: Effect.Effect<void>; | |
| 31 | } | |
| 32 | >()("t3/orchestration/ThreadPullRequestReactor") {} | |
| 33 | ||
| 34 | function samePullRequest( | |
| 35 | left: ThreadLinkedPullRequest | null | undefined, | |
| 36 | right: ThreadLinkedPullRequest | null, | |
| 37 | ): boolean { | |
| 38 | if (left == null || right === null) return left == null && right === null; | |
| 39 | return ( | |
| 40 | left.projectId === right.projectId && | |
| 41 | left.repository.toLowerCase() === right.repository.toLowerCase() && | |
| 42 | left.number === right.number && | |
| 43 | left.url === right.url | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | /** Startup lookups per settled thread before discovery gives up on it. */ | |
| 48 | export const BACKFILL_ATTEMPTS = 5; | |
| 49 | ||
| 50 | interface RefreshRequest { | |
| 51 | readonly threadId: ThreadId | null; | |
| 52 | readonly refresh: boolean; | |
| 53 | readonly backfill?: boolean; | |
| 54 | } | |
| 55 | ||
| 56 | function canonicalRepositoryKey(key: string): string { | |
| 57 | return key | |
| 58 | .replace( | |
| 59 | /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, | |
| 60 | "dev.azure.com/$1/$2/_git/$3", | |
| 61 | ) | |
| 62 | .replace( | |
| 63 | /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, | |
| 64 | "dev.azure.com/$1/$2/_git/$3", | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | export function pullRequestMatchesProject( | |
| 69 | pullRequest: GitManager.GitBranchPullRequest, | |
| 70 | project: OrchestrationProjectShell, | |
| 71 | ): boolean { | |
| 72 | return ( | |
| 73 | pullRequest.repositoryKey !== null && | |
| 74 | project.repositoryIdentity != null && | |
| 75 | canonicalRepositoryKey(pullRequest.repositoryKey) === | |
| 76 | canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | export const make = Effect.gen(function* () { | |
| 81 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | |
| 82 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 83 | const git = yield* GitManager.GitManager; | |
| 84 | const pullRequests = yield* PullRequestService.PullRequestService; | |
| 85 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 86 | const crypto = yield* Crypto.Crypto; | |
| 87 | const fileSystem = yield* FileSystem.FileSystem; | |
| 88 | // Settled threads get one link discovery at startup. Failed lookups retry on | |
| 89 | // the periodic pass a few times, then stop until the thread changes or the | |
| 90 | // server restarts, so a missing or logged-out CLI cannot loop forever. | |
| 91 | const pendingBackfill = new Map<ThreadId, number>(); | |
| 92 | const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { | |
| 93 | for (const thread of threads) pendingBackfill.delete(thread.id); | |
| 94 | }; | |
| 95 | const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { | |
| 96 | for (const thread of threads) { | |
| 97 | const remaining = pendingBackfill.get(thread.id); | |
| 98 | if (remaining === undefined) continue; | |
| 99 | if (remaining <= 1) pendingBackfill.delete(thread.id); | |
| 100 | else pendingBackfill.set(thread.id, remaining - 1); | |
| 101 | } | |
| 102 | }; | |
| 103 | ||
| 104 | const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( | |
| 105 | request: RefreshRequest, | |
| 106 | ) { | |
| 107 | const snapshot = yield* snapshots.getShellSnapshot(); | |
| 108 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); | |
| 109 | if (request.backfill) { | |
| 110 | for (const thread of snapshot.threads) { | |
| 111 | if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { | |
| 112 | pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); | |
| 113 | } | |
| 114 | } | |
| 115 | } | |
| 116 | const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); | |
| 117 | for (const threadId of pendingBackfill.keys()) { | |
| 118 | if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); | |
| 119 | } | |
| 120 | const threads = snapshot.threads.filter( | |
| 121 | (thread) => | |
| 122 | thread.archivedAt === null && | |
| 123 | (request.threadId === null || thread.id === request.threadId) && | |
| 124 | (thread.settledOverride !== "settled" || | |
| 125 | request.threadId !== null || | |
| 126 | pendingBackfill.has(thread.id)) && | |
| 127 | (thread.branch !== null || thread.branchPullRequest != null), | |
| 128 | ); | |
| 129 | const groups = Map.groupBy(threads, (thread) => | |
| 130 | JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), | |
| 131 | ); | |
| 132 | ||
| 133 | yield* Effect.forEach( | |
| 134 | groups.values(), | |
| 135 | (group) => | |
| 136 | Effect.gen(function* () { | |
| 137 | const first = group[0]!; | |
| 138 | const project = projects.get(first.projectId); | |
| 139 | if (project === undefined) return finishBackfill(group); | |
| 140 | const repository = PullRequestService.repositoryIdentityOf(project); | |
| 141 | if (first.branch !== null && repository === null) return finishBackfill(group); | |
| 142 | const worktreeExists = | |
| 143 | first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); | |
| 144 | const cwd = | |
| 145 | worktreeExists && first.worktreePath !== null | |
| 146 | ? first.worktreePath | |
| 147 | : project.workspaceRoot; | |
| 148 | const detected = | |
| 149 | first.branch === null | |
| 150 | ? null | |
| 151 | : yield* git.branchPullRequest( | |
| 152 | { cwd, branch: first.branch }, | |
| 153 | { refresh: request.refresh }, | |
| 154 | ); | |
| 155 | // A worktree can have different remotes, and the project identity | |
| 156 | // can lag a remote edit. Do not attach its PR to the wrong repository. | |
| 157 | if (detected !== null && !pullRequestMatchesProject(detected, project)) { | |
| 158 | return finishBackfill(group); | |
| 159 | } | |
| 160 | const detectedReference = | |
| 161 | detected !== null && repository !== null | |
| 162 | ? { | |
| 163 | projectId: project.id, | |
| 164 | repository, | |
| 165 | number: detected.number, | |
| 166 | url: detected.url, | |
| 167 | } | |
| 168 | : null; | |
| 169 | ||
| 170 | const plans = yield* Effect.forEach(group, (thread) => | |
| 171 | Effect.gen(function* () { | |
| 172 | let branchPullRequest = detectedReference; | |
| 173 | // Shared checkouts often return to the default branch after | |
| 174 | // a merge. Keep that thread's terminal PR across the change. | |
| 175 | if ( | |
| 176 | branchPullRequest === null && | |
| 177 | thread.branch !== null && | |
| 178 | thread.worktreePath === null && | |
| 179 | thread.branchPullRequest != null | |
| 180 | ) { | |
| 181 | const previous = yield* pullRequests.summary(thread.branchPullRequest, { | |
| 182 | recoverTransientFailure: false, | |
| 183 | }); | |
| 184 | if (previous.state === "merged" || previous.state === "closed") { | |
| 185 | branchPullRequest = thread.branchPullRequest; | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | let replacement: ThreadLinkedPullRequest | undefined; | |
| 190 | if ( | |
| 191 | thread.linkedPullRequest != null && | |
| 192 | detected?.state === "open" && | |
| 193 | detectedReference !== null && | |
| 194 | !samePullRequest(thread.linkedPullRequest, detectedReference) | |
| 195 | ) { | |
| 196 | const linked = yield* pullRequests.summary(thread.linkedPullRequest, { | |
| 197 | recoverTransientFailure: false, | |
| 198 | }); | |
| 199 | if (linked.state === "merged" || linked.state === "closed") { | |
| 200 | replacement = detectedReference; | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | if ( | |
| 205 | samePullRequest(thread.branchPullRequest, branchPullRequest) && | |
| 206 | replacement === undefined | |
| 207 | ) { | |
| 208 | pendingBackfill.delete(thread.id); | |
| 209 | return null; | |
| 210 | } | |
| 211 | return { thread, branchPullRequest, replacement }; | |
| 212 | }).pipe( | |
| 213 | Effect.catchCause((cause) => | |
| 214 | Cause.hasInterruptsOnly(cause) | |
| 215 | ? Effect.failCause(cause) | |
| 216 | : Effect.logWarning("thread pull request discovery failed", { | |
| 217 | threadId: thread.id, | |
| 218 | cause: Cause.pretty(cause), | |
| 219 | }).pipe( | |
| 220 | Effect.tap(() => Effect.sync(() => failBackfill([thread]))), | |
| 221 | Effect.as(null), | |
| 222 | ), | |
| 223 | ), | |
| 224 | ), | |
| 225 | ); | |
| 226 | const updates = plans.filter((plan) => plan !== null); | |
| 227 | if (updates.length === 0) return; | |
| 228 | ||
| 229 | if (detected !== null && first.branch !== null) { | |
| 230 | // Summary reads can outlast a remote edit. Recheck the branch and | |
| 231 | // the project's primary remote before saving the group's links. | |
| 232 | const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); | |
| 233 | const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { | |
| 234 | refresh: true, | |
| 235 | }); | |
| 236 | if ( | |
| 237 | current === null || | |
| 238 | current.number !== detected.number || | |
| 239 | current.url !== detected.url || | |
| 240 | current.state !== detected.state || | |
| 241 | current.repositoryKey !== detected.repositoryKey || | |
| 242 | !pullRequestMatchesProject(current, { | |
| 243 | ...project, | |
| 244 | repositoryIdentity: currentIdentity, | |
| 245 | }) | |
| 246 | ) { | |
| 247 | return failBackfill(updates.map((update) => update.thread)); | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | yield* Effect.forEach( | |
| 252 | updates, | |
| 253 | ({ thread, branchPullRequest, replacement }) => | |
| 254 | Effect.gen(function* () { | |
| 255 | const uuid = yield* crypto.randomUUIDv4; | |
| 256 | yield* engine.dispatch({ | |
| 257 | type: "thread.pull-request.sync", | |
| 258 | commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), | |
| 259 | threadId: thread.id, | |
| 260 | projectId: project.id, | |
| 261 | snapshotSequence: snapshot.snapshotSequence, | |
| 262 | expected: { | |
| 263 | workspaceRoot: project.workspaceRoot, | |
| 264 | branch: thread.branch, | |
| 265 | worktreePath: thread.worktreePath, | |
| 266 | linkedPullRequest: thread.linkedPullRequest ?? null, | |
| 267 | branchPullRequest: thread.branchPullRequest ?? null, | |
| 268 | }, | |
| 269 | branchPullRequest, | |
| 270 | ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}), | |
| 271 | }); | |
| 272 | pendingBackfill.delete(thread.id); | |
| 273 | }).pipe( | |
| 274 | // The thread changed since the lookup. Its own events requeue it. | |
| 275 | Effect.catchTags({ | |
| 276 | OrchestrationCommandInvariantError: () => | |
| 277 | Effect.sync(() => finishBackfill([thread])), | |
| 278 | }), | |
| 279 | Effect.catchCause((cause) => | |
| 280 | Cause.hasInterruptsOnly(cause) | |
| 281 | ? Effect.failCause(cause) | |
| 282 | : Effect.logWarning("thread pull request update failed", { | |
| 283 | threadId: thread.id, | |
| 284 | cause: Cause.pretty(cause), | |
| 285 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), | |
| 286 | ), | |
| 287 | ), | |
| 288 | { discard: true }, | |
| 289 | ); | |
| 290 | }).pipe( | |
| 291 | Effect.catchCause((cause) => | |
| 292 | Cause.hasInterruptsOnly(cause) | |
| 293 | ? Effect.failCause(cause) | |
| 294 | : Effect.logWarning("thread branch pull request lookup failed", { | |
| 295 | threadIds: group.map((thread) => thread.id), | |
| 296 | cause: Cause.pretty(cause), | |
| 297 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), | |
| 298 | ), | |
| 299 | ), | |
| 300 | { concurrency: 8, discard: true }, | |
| 301 | ); | |
| 302 | }); | |
| 303 | ||
| 304 | const worker = yield* makeDrainableWorker((request: RefreshRequest) => | |
| 305 | synchronize(request).pipe( | |
| 306 | Effect.catchCause((cause) => | |
| 307 | Cause.hasInterruptsOnly(cause) | |
| 308 | ? Effect.failCause(cause) | |
| 309 | : Effect.logWarning("thread pull request refresh failed", { | |
| 310 | cause: Cause.pretty(cause), | |
| 311 | }), | |
| 312 | ), | |
| 313 | ), | |
| 314 | ); | |
| 315 | ||
| 316 | const processEvent = (event: OrchestrationEvent) => { | |
| 317 | switch (event.type) { | |
| 318 | case "thread.created": | |
| 319 | case "thread.unarchived": | |
| 320 | return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); | |
| 321 | case "thread.meta-updated": | |
| 322 | if ( | |
| 323 | event.payload.branchPullRequest === undefined && | |
| 324 | (event.payload.branch !== undefined || | |
| 325 | event.payload.worktreePath !== undefined || | |
| 326 | event.payload.linkedPullRequest !== undefined) | |
| 327 | ) { | |
| 328 | return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); | |
| 329 | } | |
| 330 | break; | |
| 331 | case "thread.session-set": | |
| 332 | if ( | |
| 333 | event.payload.session.status !== "running" && | |
| 334 | event.payload.session.status !== "starting" | |
| 335 | ) { | |
| 336 | return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); | |
| 337 | } | |
| 338 | break; | |
| 339 | case "thread.turn-diff-completed": | |
| 340 | case "thread.unsettled": | |
| 341 | return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); | |
| 342 | case "project.meta-updated": | |
| 343 | if (event.payload.workspaceRoot !== undefined) { | |
| 344 | return worker.enqueue({ threadId: null, refresh: false }); | |
| 345 | } | |
| 346 | break; | |
| 347 | } | |
| 348 | return Effect.void; | |
| 349 | }; | |
| 350 | ||
| 351 | const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { | |
| 352 | const events = yield* engine.subscribeDomainEvents; | |
| 353 | yield* forkParked(Stream.runForEach(events, processEvent)); | |
| 354 | // Run without client demand. Saved branch lookups share GitManager's | |
| 355 | // provider cache and retry backoff with status and automatic settlement. | |
| 356 | yield* forkParked( | |
| 357 | Effect.gen(function* () { | |
| 358 | yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); | |
| 359 | yield* worker.drain; | |
| 360 | yield* Effect.gen(function* () { | |
| 361 | yield* worker.enqueue({ threadId: null, refresh: false }); | |
| 362 | yield* worker.drain; | |
| 363 | }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); | |
| 364 | }).pipe(Effect.asVoid), | |
| 365 | ); | |
| 366 | }); | |
| 367 | ||
| 368 | return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"]; | |
| 369 | }); | |
| 370 | ||
| 371 | export const layer = Layer.effect(ThreadPullRequestReactor, make); |
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+104-22| ... | ... | @@ -25,7 +25,7 @@ import * as Ref from "effect/Ref"; |
| 25 | 25 | import * as Stream from "effect/Stream"; |
| 26 | 26 | import { TestClock } from "effect/testing"; |
| 27 | 27 | |
| 28 | import { GitManager } from "../git/GitManager.ts"; | |
| 28 | import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; | |
| 29 | 29 | import { |
| 30 | 30 | PullRequestService, |
| 31 | 31 | type PullRequestMergeEvent, |
| ... | ... | @@ -132,6 +132,24 @@ function makePullRequestSummary(input: { |
| 132 | 132 | }; |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | function makeBranchPullRequest( | |
| 136 | state: GitBranchPullRequest["state"], | |
| 137 | updatedAt: string | null = NOW, | |
| 138 | ): GitBranchPullRequest { | |
| 139 | return { | |
| 140 | number: 42, | |
| 141 | title: "Branch pull request", | |
| 142 | url: "https://example.test/owner/repository/pull/42", | |
| 143 | baseRef: "main", | |
| 144 | headRef: "saved-feature", | |
| 145 | repositoryKey: "example.test/owner/repository", | |
| 146 | state, | |
| 147 | updatedAt, | |
| 148 | closedAt: state === "closed" ? updatedAt : null, | |
| 149 | mergedAt: state === "merged" ? updatedAt : null, | |
| 150 | }; | |
| 151 | } | |
| 152 | ||
| 135 | 153 | interface HarnessOptions { |
| 136 | 154 | readonly snapshot: OrchestrationShellSnapshot; |
| 137 | 155 | readonly settings?: ServerSettings; |
| ... | ... | @@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 173 | 191 | return next; |
| 174 | 192 | }); |
| 175 | 193 | |
| 176 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => | |
| 194 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) => | |
| 177 | 195 | Ref.update(branchCalls, (calls) => [...calls, input]).pipe( |
| 178 | Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), | |
| 196 | Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), | |
| 179 | 197 | ); |
| 180 | 198 | const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => |
| 181 | 199 | Effect.gen(function* () { |
| ... | ... | @@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( |
| 281 | 299 | }); |
| 282 | 300 | |
| 283 | 301 | describe("ThreadSettlementReactor", () => { |
| 302 | it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => | |
| 303 | Effect.scoped( | |
| 304 | Effect.gen(function* () { | |
| 305 | yield* TestClock.setTime(Date.parse(NOW)); | |
| 306 | const previous = { | |
| 307 | projectId: PROJECT_ID, | |
| 308 | repository: "owner/repository", | |
| 309 | number: 1, | |
| 310 | url: "https://example.test/owner/repository/pull/1", | |
| 311 | }; | |
| 312 | const project = { | |
| 313 | ...makeProject(), | |
| 314 | repositoryIdentity: { | |
| 315 | canonicalKey: "example.test/owner/repository", | |
| 316 | rootPath: "/workspace/project", | |
| 317 | displayName: "owner/repository", | |
| 318 | locator: { | |
| 319 | source: "git-remote" as const, | |
| 320 | remoteName: "origin", | |
| 321 | remoteUrl: "https://example.test/owner/repository.git", | |
| 322 | }, | |
| 323 | }, | |
| 324 | }; | |
| 325 | const fixture = yield* makeHarness({ | |
| 326 | snapshot: makeSnapshot( | |
| 327 | [ | |
| 328 | makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }), | |
| 329 | makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }), | |
| 330 | makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), | |
| 331 | makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), | |
| 332 | makeThread("resumed-manual", { | |
| 333 | branch: "main", | |
| 334 | linkedPullRequest: previous, | |
| 335 | latestUserMessageAt: "2026-08-28T00:00:00.000Z", | |
| 336 | }), | |
| 337 | makeThread("resumed-detected", { | |
| 338 | branch: "main", | |
| 339 | branchPullRequest: previous, | |
| 340 | latestUserMessageAt: "2026-08-28T00:00:00.000Z", | |
| 341 | }), | |
| 342 | ], | |
| 343 | [project], | |
| 344 | ), | |
| 345 | settings: { | |
| 346 | ...DEFAULT_SERVER_SETTINGS, | |
| 347 | sidebarAutoSettleAfterDays: null, | |
| 348 | sidebarAutoSettleOnMerge: true, | |
| 349 | }, | |
| 350 | branchPullRequest: ({ branch }, options) => | |
| 351 | Effect.succeed( | |
| 352 | branch === "reused" | |
| 353 | ? makeBranchPullRequest(options?.refresh ? "open" : "merged") | |
| 354 | : branch === "foreign" | |
| 355 | ? { | |
| 356 | ...makeBranchPullRequest("open"), | |
| 357 | repositoryKey: "example.test/another/repository", | |
| 358 | } | |
| 359 | : null, | |
| 360 | ), | |
| 361 | pullRequestSummary: (input) => | |
| 362 | Effect.succeed({ | |
| 363 | ...makePullRequestSummary({ ...input, state: "merged" }), | |
| 364 | mergedAt: "2026-08-27T00:00:00.000Z", | |
| 365 | }), | |
| 366 | }); | |
| 367 | yield* Effect.gen(function* () { | |
| 368 | const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 369 | yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 370 | assert.deepStrictEqual( | |
| 371 | new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), | |
| 372 | new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), | |
| 373 | ); | |
| 374 | }).pipe(Effect.provide(fixture.layer)); | |
| 375 | }), | |
| 376 | ), | |
| 377 | ); | |
| 378 | ||
| 284 | 379 | it.effect("starts without clients and skips protected threads before pull request lookup", () => |
| 285 | 380 | Effect.scoped( |
| 286 | 381 | Effect.gen(function* () { |
| ... | ... | @@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => { |
| 375 | 470 | }), |
| 376 | 471 | ]), |
| 377 | 472 | branchPullRequest: () => |
| 378 | Ref.get(pullRequest).pipe( | |
| 379 | Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), | |
| 380 | ), | |
| 473 | Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))), | |
| 381 | 474 | }); |
| 382 | 475 | |
| 383 | 476 | yield* Effect.gen(function* () { |
| ... | ... | @@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => { |
| 430 | 523 | Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( |
| 431 | 524 | Effect.flatMap((count) => |
| 432 | 525 | count === 1 |
| 433 | ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) | |
| 526 | ? Effect.succeed(makeBranchPullRequest("open")) | |
| 434 | 527 | : Deferred.succeed(periodicLookupStarted, undefined).pipe( |
| 435 | 528 | Effect.andThen(Deferred.await(releasePeriodicLookup)), |
| 436 | Effect.as({ state: "open" as const, updatedAt: NOW }), | |
| 529 | Effect.as(makeBranchPullRequest("open")), | |
| 437 | 530 | ), |
| 438 | 531 | ), |
| 439 | 532 | ), |
| ... | ... | @@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => { |
| 477 | 570 | ]), |
| 478 | 571 | branchPullRequest: () => |
| 479 | 572 | Ref.get(state).pipe( |
| 480 | Effect.map((pullRequestState) => ({ | |
| 481 | state: pullRequestState, | |
| 482 | updatedAt: NOW, | |
| 483 | closedAt: NOW, | |
| 484 | mergedAt: NOW, | |
| 485 | })), | |
| 573 | Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), | |
| 486 | 574 | ), |
| 487 | 575 | onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), |
| 488 | 576 | }); |
| ... | ... | @@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => { |
| 618 | 706 | : Effect.void, |
| 619 | 707 | ), |
| 620 | 708 | Effect.andThen(Ref.get(state)), |
| 621 | Effect.map((pullRequestState) => ({ | |
| 622 | state: pullRequestState, | |
| 623 | updatedAt: NOW, | |
| 624 | closedAt: NOW, | |
| 625 | mergedAt: NOW, | |
| 626 | })), | |
| 709 | Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), | |
| 627 | 710 | ), |
| 628 | 711 | }); |
| 629 | 712 | |
| ... | ... | @@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => { |
| 896 | 979 | makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), |
| 897 | 980 | ], |
| 898 | 981 | ), |
| 899 | branchPullRequest: () => | |
| 900 | Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), | |
| 982 | branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")), | |
| 901 | 983 | pullRequestSummary: (input) => |
| 902 | 984 | Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), |
| 903 | 985 | }); |
apps/server/src/orchestration/ThreadSettlementReactor.ts+52-42| ... | ... | @@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 17 | 17 | import { forkParked } from "../serverActivation.ts"; |
| 18 | 18 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; |
| 19 | 19 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; |
| 20 | import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | isAutoSettlementCandidate, |
| 22 | 23 | resolveAutoSettlementAt, |
| ... | ... | @@ -46,10 +47,8 @@ export const make = Effect.gen(function* () { |
| 46 | 47 | const snapshot = yield* snapshots.getShellSnapshot(); |
| 47 | 48 | const now = DateTime.formatIso(yield* DateTime.now); |
| 48 | 49 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); |
| 49 | // A merge event re-sweeps every candidate, not just the threads linked to | |
| 50 | // the merged pull request: most threads carry no link and settle from | |
| 51 | // their branch lookup, which would otherwise wait for the next minute's | |
| 52 | // sweep on a possibly stale cached answer. | |
| 50 | // A merge rechecks all candidates, including branches that discovery has | |
| 51 | // not linked yet. Those lookups can still have cached the PR as open. | |
| 53 | 52 | const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); |
| 54 | 53 | |
| 55 | 54 | // Return the thread when it still needs a pull request decision. A rejected |
| ... | ... | @@ -101,14 +100,14 @@ export const make = Effect.gen(function* () { |
| 101 | 100 | }, |
| 102 | 101 | )).filter((thread) => thread !== null); |
| 103 | 102 | |
| 104 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. | |
| 103 | // Use the same cwd as PR discovery so both paths share GitManager's cache. | |
| 105 | 104 | const lookupCwdByThreadId = new Map<string, string>(); |
| 106 | 105 | yield* Effect.forEach( |
| 107 | 106 | lookupCandidates, |
| 108 | 107 | (thread) => |
| 109 | 108 | Effect.gen(function* () { |
| 110 | 109 | const project = projects.get(thread.projectId); |
| 111 | if (project === undefined || thread.linkedPullRequest != null) return; | |
| 110 | if (project === undefined || thread.branch === null) return; | |
| 112 | 111 | const worktreeExists = |
| 113 | 112 | thread.worktreePath !== null && |
| 114 | 113 | (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); |
| ... | ... | @@ -122,12 +121,8 @@ export const make = Effect.gen(function* () { |
| 122 | 121 | { concurrency: 8, discard: true }, |
| 123 | 122 | ); |
| 124 | 123 | if (mergedPullRequest !== null) { |
| 125 | // The merge just confirmed a terminal state the lookup caches can still | |
| 126 | // call open (branch answers live two minutes, the sweep runs every | |
| 127 | // minute). Drop the swept checkouts' cached answers so the merge settles | |
| 128 | // its branch threads now instead of on a later sweep. Threads linked to | |
| 129 | // the merged pull request settle from the event itself below and need no | |
| 130 | // lookup, so they are absent from this map by construction. | |
| 124 | // The merge confirmed a state the branch cache can still call open. | |
| 125 | // Recheck those branches now instead of waiting for cache expiry. | |
| 131 | 126 | const cwds = [...new Set(lookupCwdByThreadId.values())]; |
| 132 | 127 | yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { |
| 133 | 128 | concurrency: 8, |
| ... | ... | @@ -135,12 +130,15 @@ export const make = Effect.gen(function* () { |
| 135 | 130 | }); |
| 136 | 131 | } |
| 137 | 132 | const lookupKey = (thread: (typeof candidates)[number]) => { |
| 138 | if (thread.linkedPullRequest != null) { | |
| 133 | const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | |
| 134 | if (reference != null) { | |
| 139 | 135 | return JSON.stringify([ |
| 140 | 136 | "linked", |
| 141 | thread.linkedPullRequest.projectId, | |
| 142 | thread.linkedPullRequest.repository, | |
| 143 | thread.linkedPullRequest.number, | |
| 137 | reference.projectId, | |
| 138 | reference.repository, | |
| 139 | reference.number, | |
| 140 | lookupCwdByThreadId.get(thread.id), | |
| 141 | thread.branch, | |
| 144 | 142 | ]); |
| 145 | 143 | } |
| 146 | 144 | if (thread.branch === null) return JSON.stringify(["none", thread.id]); |
| ... | ... | @@ -154,35 +152,47 @@ export const make = Effect.gen(function* () { |
| 154 | 152 | const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( |
| 155 | 153 | thread: (typeof candidates)[number], |
| 156 | 154 | ) { |
| 157 | if (thread.linkedPullRequest != null) { | |
| 158 | // The event carries the merged state, so only the threads linked to | |
| 159 | // that exact pull request settle from it. Every other linked thread | |
| 160 | // falls through to a fresh summary lookup below: the merge sweep | |
| 161 | // covers all candidates, and an unrelated merge must never settle | |
| 162 | // them. | |
| 163 | if ( | |
| 155 | const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | |
| 156 | if (reference != null) { | |
| 157 | const matchesMerge = | |
| 164 | 158 | mergedPullRequest !== null && |
| 165 | thread.linkedPullRequest.projectId === mergedPullRequest.projectId && | |
| 166 | thread.linkedPullRequest.repository.toLowerCase() === | |
| 167 | mergedPullRequest.repository.toLowerCase() && | |
| 168 | thread.linkedPullRequest.number === mergedPullRequest.number | |
| 169 | ) { | |
| 170 | return { | |
| 171 | state: "merged", | |
| 172 | mergedAt: mergedPullRequest.mergedAt, | |
| 173 | } satisfies SettlementPullRequest; | |
| 174 | } | |
| 175 | if (!projects.has(thread.linkedPullRequest.projectId)) { | |
| 159 | reference.projectId === mergedPullRequest.projectId && | |
| 160 | reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && | |
| 161 | reference.number === mergedPullRequest.number; | |
| 162 | if (!matchesMerge && !projects.has(reference.projectId)) { | |
| 176 | 163 | return yield* Effect.die(new Error("linked pull request project not found")); |
| 177 | 164 | } |
| 178 | const summary = yield* pullRequests.summary( | |
| 179 | { | |
| 180 | projectId: thread.linkedPullRequest.projectId, | |
| 181 | repository: thread.linkedPullRequest.repository, | |
| 182 | number: thread.linkedPullRequest.number, | |
| 183 | }, | |
| 184 | { recoverTransientFailure: false }, | |
| 185 | ); | |
| 165 | const summary = matchesMerge | |
| 166 | ? ({ | |
| 167 | state: "merged", | |
| 168 | closedAt: null, | |
| 169 | mergedAt: mergedPullRequest.mergedAt, | |
| 170 | } satisfies SettlementPullRequest) | |
| 171 | : yield* pullRequests.summary( | |
| 172 | { | |
| 173 | projectId: reference.projectId, | |
| 174 | repository: reference.repository, | |
| 175 | number: reference.number, | |
| 176 | }, | |
| 177 | { recoverTransientFailure: false }, | |
| 178 | ); | |
| 179 | const cwd = lookupCwdByThreadId.get(thread.id); | |
| 180 | if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { | |
| 181 | // A reused branch can already have a new open PR while discovery | |
| 182 | // is replacing its old link. Do not let settlement win that race. | |
| 183 | const current = yield* git.branchPullRequest( | |
| 184 | { cwd, branch: thread.branch }, | |
| 185 | { refresh: true }, | |
| 186 | ); | |
| 187 | const project = projects.get(thread.projectId); | |
| 188 | if ( | |
| 189 | current?.state === "open" && | |
| 190 | project !== undefined && | |
| 191 | pullRequestMatchesProject(current, project) | |
| 192 | ) { | |
| 193 | return current; | |
| 194 | } | |
| 195 | } | |
| 186 | 196 | return { |
| 187 | 197 | state: summary.state, |
| 188 | 198 | closedAt: summary.closedAt ?? null, |
apps/server/src/orchestration/decider.ts+59| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | EventId, |
| 3 | 3 | MessageId, |
| 4 | ThreadLinkedPullRequest, | |
| 4 | 5 | UserInputRequestedPayload, |
| 5 | 6 | isImportedAgentSessionMessageId, |
| 6 | 7 | type OrchestrationCommand, |
| ... | ... | @@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; |
| 38 | 39 | |
| 39 | 40 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 40 | 41 | const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); |
| 42 | const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); | |
| 41 | 43 | |
| 42 | 44 | /** |
| 43 | 45 | * Blocked-on-you work derived from the thread's retained activities: an |
| ... | ... | @@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 836 | 838 | }; |
| 837 | 839 | } |
| 838 | 840 | |
| 841 | case "thread.pull-request.sync": { | |
| 842 | const thread = yield* requireThreadNotArchived({ | |
| 843 | readModel, | |
| 844 | command, | |
| 845 | threadId: command.threadId, | |
| 846 | }); | |
| 847 | if (thread.deletedAt !== null) { | |
| 848 | return yield* new OrchestrationCommandInvariantError({ | |
| 849 | commandType: command.type, | |
| 850 | detail: `thread ${command.threadId} was deleted before pull request discovery`, | |
| 851 | }); | |
| 852 | } | |
| 853 | if ( | |
| 854 | thread.projectId !== command.projectId || | |
| 855 | thread.branch !== command.expected.branch || | |
| 856 | thread.worktreePath !== command.expected.worktreePath || | |
| 857 | !threadPullRequestLinksEqual( | |
| 858 | thread.linkedPullRequest ?? null, | |
| 859 | command.expected.linkedPullRequest, | |
| 860 | ) || | |
| 861 | !threadPullRequestLinksEqual( | |
| 862 | thread.branchPullRequest ?? null, | |
| 863 | command.expected.branchPullRequest, | |
| 864 | ) | |
| 865 | ) { | |
| 866 | return yield* new OrchestrationCommandInvariantError({ | |
| 867 | commandType: command.type, | |
| 868 | detail: `thread ${command.threadId} changed before pull request discovery`, | |
| 869 | }); | |
| 870 | } | |
| 871 | const project = yield* requireProject({ readModel, command, projectId: command.projectId }); | |
| 872 | if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) { | |
| 873 | return yield* new OrchestrationCommandInvariantError({ | |
| 874 | commandType: command.type, | |
| 875 | detail: `project ${command.projectId} changed before pull request discovery`, | |
| 876 | }); | |
| 877 | } | |
| 878 | const occurredAt = yield* nowIso; | |
| 879 | return { | |
| 880 | ...(yield* withEventBase({ | |
| 881 | aggregateKind: "thread", | |
| 882 | aggregateId: command.threadId, | |
| 883 | occurredAt, | |
| 884 | commandId: command.commandId, | |
| 885 | })), | |
| 886 | type: "thread.meta-updated", | |
| 887 | payload: { | |
| 888 | threadId: command.threadId, | |
| 889 | branchPullRequest: command.branchPullRequest, | |
| 890 | ...(command.linkedPullRequest !== undefined | |
| 891 | ? { linkedPullRequest: command.linkedPullRequest } | |
| 892 | : {}), | |
| 893 | updatedAt: thread.updatedAt, | |
| 894 | }, | |
| 895 | }; | |
| 896 | } | |
| 897 | ||
| 839 | 898 | case "thread.title.regeneration.complete": { |
| 840 | 899 | const thread = yield* requireThread({ |
| 841 | 900 | readModel, |
apps/server/src/orchestration/projector.test.ts+62| ... | ... | @@ -86,6 +86,7 @@ describe("orchestration projector", () => { |
| 86 | 86 | interactionMode: "default", |
| 87 | 87 | branch: null, |
| 88 | 88 | worktreePath: null, |
| 89 | branchPullRequest: null, | |
| 89 | 90 | latestTurn: null, |
| 90 | 91 | createdAt: now, |
| 91 | 92 | updatedAt: now, |
| ... | ... | @@ -105,6 +106,67 @@ describe("orchestration projector", () => { |
| 105 | 106 | ]); |
| 106 | 107 | }); |
| 107 | 108 | |
| 109 | effectIt.effect("sets and clears branch pull requests without changing manual links", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const now = "2026-01-01T00:00:00.000Z"; | |
| 112 | const eventFields = { | |
| 113 | aggregateKind: "thread" as const, | |
| 114 | aggregateId: "thread-1", | |
| 115 | occurredAt: now, | |
| 116 | commandId: null, | |
| 117 | }; | |
| 118 | let model = yield* projectEvent( | |
| 119 | createEmptyReadModel(now), | |
| 120 | makeEvent({ | |
| 121 | ...eventFields, | |
| 122 | sequence: 1, | |
| 123 | type: "thread.created", | |
| 124 | payload: { | |
| 125 | threadId: "thread-1", | |
| 126 | projectId: "project-1", | |
| 127 | title: "Pull request thread", | |
| 128 | modelSelection: { provider: "codex", model: "gpt-5-codex" }, | |
| 129 | runtimeMode: "full-access", | |
| 130 | branch: "feature", | |
| 131 | worktreePath: null, | |
| 132 | createdAt: now, | |
| 133 | updatedAt: now, | |
| 134 | }, | |
| 135 | }), | |
| 136 | ); | |
| 137 | const linkedPullRequest = { | |
| 138 | projectId: "project-1", | |
| 139 | repository: "pingdotgg/t3code", | |
| 140 | number: 42, | |
| 141 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 142 | }; | |
| 143 | const branchPullRequest = { | |
| 144 | ...linkedPullRequest, | |
| 145 | number: 43, | |
| 146 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 147 | }; | |
| 148 | const updates = [ | |
| 149 | { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, | |
| 150 | { payload: { title: "Renamed thread" }, expected: branchPullRequest }, | |
| 151 | { payload: { branchPullRequest: null }, expected: null }, | |
| 152 | ]; | |
| 153 | ||
| 154 | for (const [index, update] of updates.entries()) { | |
| 155 | model = yield* projectEvent( | |
| 156 | model, | |
| 157 | makeEvent({ | |
| 158 | ...eventFields, | |
| 159 | sequence: index + 2, | |
| 160 | type: "thread.meta-updated", | |
| 161 | payload: { threadId: "thread-1", updatedAt: now, ...update.payload }, | |
| 162 | }), | |
| 163 | ); | |
| 164 | expect(model.threads[0]?.branchPullRequest).toEqual(update.expected); | |
| 165 | expect(model.threads[0]?.linkedPullRequest).toEqual(linkedPullRequest); | |
| 166 | } | |
| 167 | }), | |
| 168 | ); | |
| 169 | ||
| 108 | 170 | it("fails when event payload cannot be decoded by runtime schema", async () => { |
| 109 | 171 | const now = "2026-01-01T00:00:00.000Z"; |
| 110 | 172 | const model = createEmptyReadModel(now); |
apps/server/src/orchestration/projector.ts+4| ... | ... | @@ -336,6 +336,7 @@ export function projectEvent( |
| 336 | 336 | interactionMode: payload.interactionMode, |
| 337 | 337 | branch: payload.branch, |
| 338 | 338 | worktreePath: payload.worktreePath, |
| 339 | branchPullRequest: null, | |
| 339 | 340 | latestTurn: null, |
| 340 | 341 | createdAt: payload.createdAt, |
| 341 | 342 | updatedAt: payload.updatedAt, |
| ... | ... | @@ -510,6 +511,9 @@ export function projectEvent( |
| 510 | 511 | ...(payload.linkedPullRequest !== undefined |
| 511 | 512 | ? { linkedPullRequest: payload.linkedPullRequest } |
| 512 | 513 | : {}), |
| 514 | ...(payload.branchPullRequest !== undefined | |
| 515 | ? { branchPullRequest: payload.branchPullRequest } | |
| 516 | : {}), | |
| 513 | 517 | updatedAt: payload.updatedAt, |
| 514 | 518 | }), |
| 515 | 519 | })), |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+17-1| ... | ... | @@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 462 | 462 | }), |
| 463 | 463 | ); |
| 464 | 464 | |
| 465 | it.effect("round-trips a linked pull request through the thread row", () => | |
| 465 | it.effect("round-trips manual and branch pull requests through the thread row", () => | |
| 466 | 466 | Effect.gen(function* () { |
| 467 | 467 | const threads = yield* ProjectionThreadRepository; |
| 468 | 468 | const linkedPullRequest = { |
| ... | ... | @@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 471 | 471 | number: 42, |
| 472 | 472 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 473 | 473 | }; |
| 474 | const branchPullRequest = { | |
| 475 | ...linkedPullRequest, | |
| 476 | number: 43, | |
| 477 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 478 | }; | |
| 474 | 479 | |
| 475 | 480 | yield* threads.upsert({ |
| 476 | 481 | threadId: ThreadId.make("thread-linked-pr"), |
| ... | ... | @@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 485 | 490 | branch: null, |
| 486 | 491 | worktreePath: null, |
| 487 | 492 | linkedPullRequest, |
| 493 | branchPullRequest, | |
| 488 | 494 | latestTurnId: null, |
| 489 | 495 | createdAt: "2026-03-24T00:00:00.000Z", |
| 490 | 496 | updatedAt: "2026-03-24T00:00:00.000Z", |
| ... | ... | @@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 504 | 510 | |
| 505 | 511 | const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); |
| 506 | 512 | assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); |
| 513 | assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest); | |
| 514 | ||
| 515 | const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId }); | |
| 516 | assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest); | |
| 507 | 517 | |
| 508 | 518 | const row = Option.getOrNull(persisted); |
| 509 | 519 | if (row === null) return yield* Effect.die("Expected linked thread row to exist."); |
| ... | ... | @@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 511 | 521 | |
| 512 | 522 | const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); |
| 513 | 523 | assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); |
| 524 | assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest); | |
| 525 | ||
| 526 | yield* threads.upsert({ ...row, branchPullRequest: null }); | |
| 527 | const branchCleared = yield* threads.getById({ threadId: row.threadId }); | |
| 528 | assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null); | |
| 529 | assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); | |
| 514 | 530 | }), |
| 515 | 531 | ); |
| 516 | 532 | }); |
apps/server/src/persistence/Layers/ProjectionThreads.ts+6| ... | ... | @@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( |
| 20 | 20 | Struct.assign({ |
| 21 | 21 | modelSelection: Schema.fromJsonString(ModelSelection), |
| 22 | 22 | linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), |
| 23 | branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), | |
| 23 | 24 | }), |
| 24 | 25 | ); |
| 25 | 26 | type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; |
| ... | ... | @@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 41 | 42 | branch, |
| 42 | 43 | worktree_path, |
| 43 | 44 | linked_pull_request_json, |
| 45 | branch_pull_request_json, | |
| 44 | 46 | latest_turn_id, |
| 45 | 47 | created_at, |
| 46 | 48 | updated_at, |
| ... | ... | @@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 70 | 72 | ${row.branch}, |
| 71 | 73 | ${row.worktreePath}, |
| 72 | 74 | ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, |
| 75 | ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, | |
| 73 | 76 | ${row.latestTurnId}, |
| 74 | 77 | ${row.createdAt}, |
| 75 | 78 | ${row.updatedAt}, |
| ... | ... | @@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 99 | 102 | branch = excluded.branch, |
| 100 | 103 | worktree_path = excluded.worktree_path, |
| 101 | 104 | linked_pull_request_json = excluded.linked_pull_request_json, |
| 105 | branch_pull_request_json = excluded.branch_pull_request_json, | |
| 102 | 106 | latest_turn_id = excluded.latest_turn_id, |
| 103 | 107 | created_at = excluded.created_at, |
| 104 | 108 | updated_at = excluded.updated_at, |
| ... | ... | @@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 135 | 139 | branch, |
| 136 | 140 | worktree_path AS "worktreePath", |
| 137 | 141 | linked_pull_request_json AS "linkedPullRequest", |
| 142 | branch_pull_request_json AS "branchPullRequest", | |
| 138 | 143 | latest_turn_id AS "latestTurnId", |
| 139 | 144 | created_at AS "createdAt", |
| 140 | 145 | updated_at AS "updatedAt", |
| ... | ... | @@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 173 | 178 | branch, |
| 174 | 179 | worktree_path AS "worktreePath", |
| 175 | 180 | linked_pull_request_json AS "linkedPullRequest", |
| 181 | branch_pull_request_json AS "branchPullRequest", | |
| 176 | 182 | latest_turn_id AS "latestTurnId", |
| 177 | 183 | created_at AS "createdAt", |
| 178 | 184 | updated_at AS "updatedAt", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t |
| 59 | 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; |
| 60 | 60 | import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; |
| 61 | 61 | import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; |
| 62 | import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; | |
| 62 | 63 | |
| 63 | 64 | /** |
| 64 | 65 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -118,6 +119,7 @@ export const migrationEntries = [ |
| 118 | 119 | [45, "ProjectionProjectsAutoPull", Migration0045], |
| 119 | 120 | [46, "RepairAutomaticSettlementTimestamps", Migration0046], |
| 120 | 121 | [47, "ProjectionProjectIcon", Migration0047], |
| 122 | [48, "ProjectionThreadBranchPullRequest", Migration0048], | |
| 121 | 123 | ] as const; |
| 122 | 124 | |
| 123 | 125 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | const columns = yield* sql<{ readonly name: string }>` | |
| 7 | PRAGMA table_info(projection_threads) | |
| 8 | `; | |
| 9 | ||
| 10 | if (!columns.some((column) => column.name === "branch_pull_request_json")) { | |
| 11 | yield* sql` | |
| 12 | ALTER TABLE projection_threads | |
| 13 | ADD COLUMN branch_pull_request_json TEXT | |
| 14 | `; | |
| 15 | } | |
| 16 | }); |
apps/server/src/persistence/Services/ProjectionThreads.ts+1| ... | ... | @@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ |
| 35 | 35 | branch: Schema.NullOr(Schema.String), |
| 36 | 36 | worktreePath: Schema.NullOr(Schema.String), |
| 37 | 37 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 38 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 38 | 39 | latestTurnId: Schema.NullOr(TurnId), |
| 39 | 40 | createdAt: IsoDateTime, |
| 40 | 41 | updatedAt: IsoDateTime, |
apps/server/src/project/RepositoryIdentityResolver.test.ts+46-19| ... | ... | @@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: { |
| 38 | 38 | ).pipe(Layer.provide(ProcessRunner.layer)); |
| 39 | 39 | |
| 40 | 40 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 41 | it.effect("reuses the cached Git root for repeated workspace lookups", () => { | |
| 41 | it.effect("refreshes the Git root only when requested", () => { | |
| 42 | 42 | const calls: Array<ReadonlyArray<string>> = []; |
| 43 | let rootPath = "/repo"; | |
| 43 | 44 | const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { |
| 44 | 45 | run: (input) => |
| 45 | 46 | Effect.sync(() => { |
| 46 | 47 | calls.push(input.args); |
| 47 | 48 | return { |
| 48 | 49 | stdout: input.args.includes("rev-parse") |
| 49 | ? "/repo\n" | |
| 50 | ? `${rootPath}\n` | |
| 50 | 51 | : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", |
| 51 | 52 | stderr: "", |
| 52 | 53 | code: ChildProcessSpawner.ExitCode(0), |
| ... | ... | @@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 66 | 67 | return Effect.gen(function* () { |
| 67 | 68 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 68 | 69 | const first = yield* resolver.resolve("/repo/packages/web"); |
| 70 | rootPath = "/repo/packages/web"; | |
| 69 | 71 | const second = yield* resolver.resolve("/repo/packages/web"); |
| 70 | 72 | |
| 71 | 73 | expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); |
| ... | ... | @@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 74 | 76 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], |
| 75 | 77 | ["-C", "/repo", "remote", "-v"], |
| 76 | 78 | ]); |
| 79 | ||
| 80 | const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); | |
| 81 | expect(refreshed?.rootPath).toBe("/repo/packages/web"); | |
| 82 | expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); | |
| 83 | expect(calls.slice(2)).toEqual([ | |
| 84 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], | |
| 85 | ["-C", "/repo/packages/web", "remote", "-v"], | |
| 86 | ]); | |
| 77 | 87 | }).pipe(Effect.provide(resolverLayer)); |
| 78 | 88 | }); |
| 79 | 89 | |
| ... | ... | @@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 197 | 207 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), |
| 198 | 208 | ); |
| 199 | 209 | |
| 200 | it.effect("prefers upstream over origin when both remotes are configured", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | const fileSystem = yield* FileSystem.FileSystem; | |
| 203 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 204 | prefix: "t3-repository-identity-upstream-test-", | |
| 205 | }); | |
| 206 | ||
| 207 | yield* git(cwd, ["init"]); | |
| 208 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | |
| 209 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | |
| 210 | it.effect.each(["add", "replace"] as const)( | |
| 211 | "refreshes the primary upstream after %s before cache expiry", | |
| 212 | (change) => | |
| 213 | Effect.gen(function* () { | |
| 214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 215 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 216 | prefix: "t3-repository-identity-upstream-test-", | |
| 217 | }); | |
| 210 | 218 | |
| 211 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 212 | const identity = yield* resolver.resolve(cwd); | |
| 219 | yield* git(cwd, ["init"]); | |
| 220 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | |
| 221 | if (change === "replace") { | |
| 222 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]); | |
| 223 | } | |
| 213 | 224 | |
| 214 | expect(identity).not.toBeNull(); | |
| 215 | expect(identity?.locator.remoteName).toBe("upstream"); | |
| 216 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 217 | expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 218 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | |
| 225 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 226 | const initialIdentity = yield* resolver.resolve(cwd); | |
| 227 | expect(initialIdentity?.canonicalKey).toBe( | |
| 228 | change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous", | |
| 229 | ); | |
| 230 | ||
| 231 | yield* git(cwd, [ | |
| 232 | "remote", | |
| 233 | change === "add" ? "add" : "set-url", | |
| 234 | "upstream", | |
| 235 | "git@github.com:T3Tools/t3code.git", | |
| 236 | ]); | |
| 237 | expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity); | |
| 238 | const identity = yield* resolver.resolve(cwd, { refresh: true }); | |
| 239 | ||
| 240 | expect(identity).not.toBeNull(); | |
| 241 | expect(identity?.locator.remoteName).toBe("upstream"); | |
| 242 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 243 | expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 244 | expect(yield* resolver.resolve(cwd)).toEqual(identity); | |
| 245 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | |
| 219 | 246 | ); |
| 220 | 247 | |
| 221 | 248 | it.effect("uses the last remote path segment as the repository name for nested groups", () => |
apps/server/src/project/RepositoryIdentityResolver.ts+7-2| ... | ... | @@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { |
| 25 | 25 | export class RepositoryIdentityResolver extends Context.Service< |
| 26 | 26 | RepositoryIdentityResolver, |
| 27 | 27 | { |
| 28 | readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | |
| 28 | readonly resolve: ( | |
| 29 | cwd: string, | |
| 30 | options?: { readonly refresh?: boolean }, | |
| 31 | ) => Effect.Effect<RepositoryIdentity | null>; | |
| 29 | 32 | } |
| 30 | 33 | >()("t3/project/RepositoryIdentityResolver") {} |
| 31 | 34 | |
| ... | ... | @@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
| 170 | 173 | |
| 171 | 174 | const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( |
| 172 | 175 | "RepositoryIdentityResolver.resolve", |
| 173 | )(function* (cwd) { | |
| 176 | )(function* (cwd, options) { | |
| 177 | if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); | |
| 174 | 178 | const cacheKey = yield* Cache.get(repositoryRootCache, cwd); |
| 175 | 179 | if (cacheKey === null) return null; |
| 180 | if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); | |
| 176 | 181 | return yield* Cache.get(repositoryIdentityCache, cacheKey); |
| 177 | 182 | }); |
| 178 | 183 |
apps/server/src/server.ts+2| ... | ... | @@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma |
| 71 | 71 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 72 | 72 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 73 | 73 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; |
| 74 | import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; | |
| 74 | 75 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 75 | 76 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 76 | 77 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| ... | ... | @@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 279 | 280 | Layer.provideMerge(CheckpointReactorLive), |
| 280 | 281 | Layer.provideMerge(ThreadDeletionReactorLive), |
| 281 | 282 | Layer.provideMerge(ThreadSettlementReactor.layer), |
| 283 | Layer.provideMerge(ThreadPullRequestReactor.layer), | |
| 282 | 284 | Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), |
| 283 | 285 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 284 | 286 | ); |
apps/web/src/components/BranchToolbarBranchSelector.tsx+8-11| ... | ... | @@ -47,11 +47,7 @@ import { |
| 47 | 47 | sanitizeNewRefName, |
| 48 | 48 | shouldIncludeBranchPickerItem, |
| 49 | 49 | } from "./BranchToolbar.logic"; |
| 50 | import { | |
| 51 | ChangeRequestStatusIcon, | |
| 52 | prStatusIndicator, | |
| 53 | resolveThreadPr, | |
| 54 | } from "./ThreadStatusIndicators"; | |
| 50 | import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; | |
| 55 | 51 | import { Button } from "./ui/button"; |
| 56 | 52 | import { Switch } from "./ui/switch"; |
| 57 | 53 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; |
| ... | ... | @@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({ |
| 617 | 613 | }); |
| 618 | 614 | |
| 619 | 615 | // PR pill shown next to the branch selector when the active branch has one. |
| 620 | const branchPr = resolveThreadPr({ | |
| 621 | threadBranch: resolveBranchToolbarPrBranch({ | |
| 622 | activeThreadBranch, | |
| 623 | resolvedActiveBranch, | |
| 624 | }), | |
| 625 | gitStatus: branchStatusQuery.data ?? null, | |
| 616 | const branchPrBranch = resolveBranchToolbarPrBranch({ | |
| 617 | activeThreadBranch, | |
| 618 | resolvedActiveBranch, | |
| 626 | 619 | }); |
| 620 | const branchPr = | |
| 621 | branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch | |
| 622 | ? (branchStatusQuery.data.pr ?? null) | |
| 623 | : null; | |
| 627 | 624 | const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); |
| 628 | 625 | // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's |
| 629 | 626 | // state-description tooltip. |
apps/web/src/components/ChatView.logic.test.ts+35| ... | ... | @@ -60,6 +60,7 @@ import { |
| 60 | 60 | shouldDockDraftHeroForSubmission, |
| 61 | 61 | shouldReleaseTimelineAnchorForToolActivity, |
| 62 | 62 | shouldOpenProactivePullRequest, |
| 63 | shouldRetargetThreadPullRequestPanel, | |
| 63 | 64 | shouldOpenProactiveTurnDiff, |
| 64 | 65 | shouldRenderPreviewMiniPlayer, |
| 65 | 66 | shouldShowBranchMismatchBanner, |
| ... | ... | @@ -249,6 +250,40 @@ describe("proactive panels", () => { |
| 249 | 250 | expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); |
| 250 | 251 | }); |
| 251 | 252 | |
| 253 | it("follows a changed server PR link without replacing an unrelated open panel", () => { | |
| 254 | const previous = { | |
| 255 | projectId: ProjectId.make("project-1"), | |
| 256 | repository: "pingdotgg/t3code", | |
| 257 | number: 42, | |
| 258 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 259 | }; | |
| 260 | const current = { | |
| 261 | ...previous, | |
| 262 | number: 43, | |
| 263 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 264 | }; | |
| 265 | const surface = { | |
| 266 | id: "pull-request:previous", | |
| 267 | kind: "pull-request", | |
| 268 | projectId: previous.projectId, | |
| 269 | repository: "PingDotGG/T3Code", | |
| 270 | number: previous.number, | |
| 271 | } satisfies RightPanelSurface; | |
| 272 | ||
| 273 | expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); | |
| 274 | expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); | |
| 275 | expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); | |
| 276 | expect( | |
| 277 | shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 }), | |
| 278 | ).toBe(false); | |
| 279 | expect( | |
| 280 | shouldRetargetThreadPullRequestPanel(previous, current, { | |
| 281 | ...surface, | |
| 282 | projectId: "another-project", | |
| 283 | }), | |
| 284 | ).toBe(false); | |
| 285 | }); | |
| 286 | ||
| 252 | 287 | it("opens the diff only when the observed running turn settles", () => { |
| 253 | 288 | const turnId = TurnId.make("turn-1"); |
| 254 | 289 | expect( |
apps/web/src/components/ChatView.logic.ts+19| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | type ScopedProjectRef, |
| 16 | 16 | type ScopedThreadRef, |
| 17 | 17 | type ThreadId, |
| 18 | type ThreadLinkedPullRequest, | |
| 18 | 19 | type TurnId, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| ... | ... | @@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice( |
| 132 | 133 | }; |
| 133 | 134 | } |
| 134 | 135 | |
| 136 | /** Follow a changed server link only when the panel still shows the previous linked PR. */ | |
| 137 | export function shouldRetargetThreadPullRequestPanel( | |
| 138 | previous: ThreadLinkedPullRequest | null, | |
| 139 | current: ThreadLinkedPullRequest | null, | |
| 140 | surface: RightPanelSurface | null, | |
| 141 | ): boolean { | |
| 142 | if (previous === null || current === null || surface?.kind !== "pull-request") return false; | |
| 143 | const previousRepository = previous.repository.toLowerCase(); | |
| 144 | return ( | |
| 145 | (previous.projectId !== current.projectId || | |
| 146 | previousRepository !== current.repository.toLowerCase() || | |
| 147 | previous.number !== current.number) && | |
| 148 | surface.projectId === previous.projectId && | |
| 149 | surface.repository.toLowerCase() === previousRepository && | |
| 150 | surface.number === previous.number | |
| 151 | ); | |
| 152 | } | |
| 153 | ||
| 135 | 154 | export function shouldOpenProactiveTurnDiff(input: { |
| 136 | 155 | previousRunningTurnId: TurnId | null | undefined; |
| 137 | 156 | runningTurnId: TurnId | null; |
apps/web/src/components/ChatView.tsx+34-194| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | type ResolvedKeybindingsConfig, |
| 25 | 25 | type ScopedThreadRef, |
| 26 | 26 | type ThreadId, |
| 27 | type ThreadLinkedPullRequest, | |
| 27 | 28 | type TurnId, |
| 28 | 29 | type KeybindingCommand, |
| 29 | 30 | OrchestrationThreadActivity, |
| ... | ... | @@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; |
| 188 | 189 | import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; |
| 189 | 190 | import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; |
| 190 | 191 | import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; |
| 191 | import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; | |
| 192 | import { RightPanelTabs } from "./RightPanelTabs"; | |
| 192 | 193 | import { AgentsPanel } from "./AgentsPanel"; |
| 193 | 194 | import { |
| 194 | 195 | deriveAgentPanelModel, |
| ... | ... | @@ -337,11 +338,6 @@ import { |
| 337 | 338 | shouldShowThreadErrorBanner, |
| 338 | 339 | ThreadErrorBanner, |
| 339 | 340 | } from "./chat/ThreadErrorBanner"; |
| 340 | import { | |
| 341 | resolveDisplayedThreadPr, | |
| 342 | threadChangeRequestSnapshotsAtom, | |
| 343 | useLinkedThreadPullRequest, | |
| 344 | } from "./ThreadStatusIndicators"; | |
| 345 | 341 | import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 346 | 342 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 347 | 343 | import { |
| ... | ... | @@ -380,6 +376,7 @@ import { |
| 380 | 376 | shouldShowBranchMismatchBanner, |
| 381 | 377 | shouldShowPlanFollowUpPrompt, |
| 382 | 378 | shouldOpenProactivePullRequest, |
| 379 | shouldRetargetThreadPullRequestPanel, | |
| 383 | 380 | shouldOpenProactiveTurnDiff, |
| 384 | 381 | shouldRenderPreviewMiniPlayer, |
| 385 | 382 | getStartedThreadModelChangeBlockReason, |
| ... | ... | @@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1845 | 1842 | ); |
| 1846 | 1843 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1847 | 1844 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 1848 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 1849 | 1845 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1850 | 1846 | readonly threadKey: string | null; |
| 1851 | 1847 | readonly messageId: MessageId | null; |
| ... | ... | @@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1864 | 1860 | const activeRightPanelSurface = useRightPanelStore((state) => |
| 1865 | 1861 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), |
| 1866 | 1862 | ); |
| 1867 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | |
| 1868 | const sidebarPrRefreshKeyRef = useRef<string | null>(null); | |
| 1869 | const threadPrRelinkKeysRef = useRef(new Map<string, string>()); | |
| 1870 | const threadPrRelinkWriteRef = useRef(Promise.resolve()); | |
| 1871 | 1863 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1872 | 1864 | const activePreviewServerEpoch = activePreviewState.serverEpoch; |
| 1873 | 1865 | const resolvePreviewRuntimeTabId = useMemo( |
| ... | ... | @@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) { |
| 3970 | 3962 | }, |
| 3971 | 3963 | [activeProject, activeThreadRef], |
| 3972 | 3964 | ); |
| 3973 | // The thread's own change request, placed against the project it belongs to. Without a | |
| 3974 | // project there is nothing to resolve it against, so the caller falls back to the browser. | |
| 3975 | const persistedLinkedThreadPullRequest = isServerThread | |
| 3976 | ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) | |
| 3977 | : (activeThread?.linkedPullRequest ?? null); | |
| 3978 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 3979 | const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( | |
| 3980 | activeThreadRef?.environmentId ?? null, | |
| 3981 | persistedLinkedThreadPullRequest, | |
| 3982 | ); | |
| 3983 | const replacementLinkedThreadPullRequest = useMemo(() => { | |
| 3984 | const detected = gitStatusQuery.data?.pr; | |
| 3985 | const threadBranch = activeThread?.branch; | |
| 3986 | const projectId = activeProject?.id; | |
| 3987 | if ( | |
| 3988 | persistedLinkedThreadPullRequest === null || | |
| 3989 | (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && | |
| 3990 | persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || | |
| 3991 | gitStatusQuery.data?.refName !== threadBranch || | |
| 3992 | detected?.state !== "open" || | |
| 3993 | detected.headRef !== threadBranch || | |
| 3994 | projectId === undefined || | |
| 3995 | activeProjectRepository === null || | |
| 3996 | (persistedLinkedThreadPullRequest.projectId === projectId && | |
| 3997 | persistedLinkedThreadPullRequest.repository.toLowerCase() === | |
| 3998 | activeProjectRepository.toLowerCase() && | |
| 3999 | persistedLinkedThreadPullRequest.number === detected.number) | |
| 4000 | ) { | |
| 4001 | return null; | |
| 4002 | } | |
| 4003 | return { | |
| 4004 | projectId, | |
| 4005 | repository: activeProjectRepository, | |
| 4006 | number: detected.number, | |
| 4007 | url: detected.url, | |
| 4008 | }; | |
| 4009 | }, [ | |
| 4010 | activeProject?.id, | |
| 4011 | activeProjectRepository, | |
| 4012 | activeThread?.branch, | |
| 4013 | gitStatusQuery.data, | |
| 4014 | persistedLinkedThreadPullRequest, | |
| 4015 | persistedLinkedThreadPullRequestStatus?.pr.state, | |
| 4016 | ]); | |
| 3965 | // The shell carries server PR updates even while thread detail is still loading. | |
| 3966 | const activeThreadMetadata = activeThreadShell ?? activeThread; | |
| 4017 | 3967 | const linkedThreadPullRequest = |
| 4018 | replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; | |
| 3968 | activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null; | |
| 3969 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 4019 | 3970 | const linkedThreadPullRequestKey = linkedThreadPullRequest |
| 4020 | 3971 | ? JSON.stringify([ |
| 4021 | 3972 | linkedThreadPullRequest.projectId, |
| ... | ... | @@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) { |
| 4023 | 3974 | linkedThreadPullRequest.number, |
| 4024 | 3975 | ]) |
| 4025 | 3976 | : null; |
| 4026 | const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; | |
| 4027 | const openThreadPullRequest = useCallback( | |
| 4028 | (number: number) => { | |
| 4029 | if (!supportsPullRequests || !activeThreadRef) { | |
| 4030 | return; | |
| 4031 | } | |
| 4032 | const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; | |
| 4033 | const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; | |
| 4034 | if (projectId === undefined || repository === null) return; | |
| 4035 | useRightPanelStore.getState().openPullRequest(activeThreadRef, { | |
| 4036 | projectId, | |
| 4037 | repository, | |
| 4038 | number, | |
| 4039 | }); | |
| 4040 | }, | |
| 4041 | [ | |
| 4042 | activeProject, | |
| 4043 | activeProjectRepository, | |
| 4044 | activeThreadRef, | |
| 4045 | linkedThreadPullRequest, | |
| 4046 | supportsPullRequests, | |
| 4047 | ], | |
| 4048 | ); | |
| 4049 | useEffect(() => { | |
| 4050 | if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { | |
| 4051 | return; | |
| 4052 | } | |
| 4053 | if (replacementLinkedThreadPullRequest === null) { | |
| 4054 | threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 4055 | return; | |
| 4056 | } | |
| 4057 | const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; | |
| 4058 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; | |
| 4059 | threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); | |
| 4060 | threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { | |
| 4061 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 4062 | const result = await updateThreadMetadata({ | |
| 4063 | environmentId: activeThreadRef.environmentId, | |
| 4064 | input: { | |
| 4065 | threadId: activeThreadRef.threadId, | |
| 4066 | linkedPullRequest: replacementLinkedThreadPullRequest, | |
| 4067 | }, | |
| 4068 | }); | |
| 4069 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 4070 | if (result._tag !== "Failure") return; | |
| 4071 | threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 4072 | if (isAtomCommandInterrupted(result)) return; | |
| 4073 | toastManager.add( | |
| 4074 | stackedThreadToast({ | |
| 4075 | type: "error", | |
| 4076 | title: "Unable to update the thread pull request", | |
| 4077 | description: chatActionErrorMessage(squashAtomCommandFailure(result)), | |
| 4078 | }), | |
| 4079 | ); | |
| 4080 | }); | |
| 4081 | }, [ | |
| 4082 | activeThreadKey, | |
| 4083 | activeThreadRef, | |
| 4084 | isServerThread, | |
| 4085 | replacementLinkedThreadPullRequest, | |
| 4086 | updateThreadMetadata, | |
| 4087 | ]); | |
| 3977 | const observedThreadPullRequestRef = useRef<{ | |
| 3978 | readonly threadKey: string; | |
| 3979 | readonly reference: ThreadLinkedPullRequest | null; | |
| 3980 | } | null>(null); | |
| 4088 | 3981 | const openProjectPullRequest = useCallback( |
| 4089 | 3982 | (number: number) => { |
| 4090 | 3983 | if ( |
| ... | ... | @@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4110 | 4003 | useEffect(() => { |
| 4111 | 4004 | if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { |
| 4112 | 4005 | proactivePanelObservationRef.current = null; |
| 4006 | observedThreadPullRequestRef.current = null; | |
| 4113 | 4007 | return; |
| 4114 | 4008 | } |
| 4115 | 4009 | const panels = useRightPanelStore.getState(); |
| ... | ... | @@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) { |
| 4125 | 4019 | userActionRevision, |
| 4126 | 4020 | } = observation; |
| 4127 | 4021 | const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef); |
| 4022 | const previousPullRequest = observedThreadPullRequestRef.current; | |
| 4023 | observedThreadPullRequestRef.current = { | |
| 4024 | threadKey: activeThreadKey, | |
| 4025 | reference: linkedThreadPullRequest, | |
| 4026 | }; | |
| 4128 | 4027 | const followSelectedPullRequest = |
| 4129 | replacementLinkedThreadPullRequest !== null && | |
| 4130 | openSurface?.kind === "pull-request" && | |
| 4131 | persistedLinkedThreadPullRequest !== null && | |
| 4132 | openSurface.projectId === persistedLinkedThreadPullRequest.projectId && | |
| 4133 | openSurface.repository.toLowerCase() === | |
| 4134 | persistedLinkedThreadPullRequest.repository.toLowerCase() && | |
| 4135 | openSurface.number === persistedLinkedThreadPullRequest.number; | |
| 4028 | previousPullRequest?.threadKey === activeThreadKey && | |
| 4029 | shouldRetargetThreadPullRequestPanel( | |
| 4030 | previousPullRequest.reference, | |
| 4031 | linkedThreadPullRequest, | |
| 4032 | openSurface, | |
| 4033 | ); | |
| 4136 | 4034 | // Following the selected linked PR does not open an unrelated panel, so it |
| 4137 | 4035 | // remains available with proactive panels off. It still respects a later choice. |
| 4138 | if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) { | |
| 4036 | if (followSelectedPullRequest && linkedThreadPullRequest !== null) { | |
| 4139 | 4037 | panels.openProactive( |
| 4140 | 4038 | activeThreadRef, |
| 4141 | pullRequestSurface(replacementLinkedThreadPullRequest), | |
| 4039 | pullRequestSurface(linkedThreadPullRequest), | |
| 4142 | 4040 | userActionRevision, |
| 4143 | 4041 | ); |
| 4144 | 4042 | } |
| ... | ... | @@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4207 | 4105 | linkedThreadPullRequest, |
| 4208 | 4106 | linkedThreadPullRequestKey, |
| 4209 | 4107 | onDiffPanelOpen, |
| 4210 | persistedLinkedThreadPullRequest, | |
| 4211 | 4108 | pullRequestsCapabilityKnown, |
| 4212 | replacementLinkedThreadPullRequest, | |
| 4213 | 4109 | settings.proactivePanelsEnabled, |
| 4214 | 4110 | shouldUseRightPanelSheet, |
| 4215 | 4111 | supportsPullRequests, |
| ... | ... | @@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) { |
| 5223 | 5119 | resizeObserver.disconnect(); |
| 5224 | 5120 | }; |
| 5225 | 5121 | }, [composerOverlayElement, publishComposerOverlayHeight]); |
| 5226 | const activeThreadPr = | |
| 5227 | replacementLinkedThreadPullRequest !== null | |
| 5228 | ? (gitStatusQuery.data?.pr ?? null) | |
| 5229 | : resolveDisplayedThreadPr({ | |
| 5230 | threadBranch: activeThread?.branch ?? null, | |
| 5231 | gitStatus: gitStatusQuery.data ?? null, | |
| 5232 | snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, | |
| 5233 | retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, | |
| 5234 | linkedPullRequest: linkedThreadPullRequest, | |
| 5235 | linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, | |
| 5236 | }); | |
| 5237 | const handlePullRequestTabStatusChange = useCallback( | |
| 5238 | (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => { | |
| 5239 | if ( | |
| 5240 | threadRepository?.toLowerCase() !== status.repository.toLowerCase() || | |
| 5241 | activeThreadPr?.number !== status.number || | |
| 5242 | activeThreadPr.state === status.state | |
| 5243 | ) { | |
| 5244 | sidebarPrRefreshKeyRef.current = null; | |
| 5245 | return; | |
| 5246 | } | |
| 5247 | const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; | |
| 5248 | if (sidebarPrRefreshKeyRef.current === refreshKey) return; | |
| 5249 | sidebarPrRefreshKeyRef.current = refreshKey; | |
| 5250 | if (activeThreadRef === null || gitCwd === null) return; | |
| 5251 | void refreshVcsStatus({ | |
| 5252 | environmentId: activeThreadRef.environmentId, | |
| 5253 | input: { cwd: gitCwd }, | |
| 5254 | }).then(() => { | |
| 5255 | if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 5256 | sidebarPrRefreshKeyRef.current = null; | |
| 5257 | } | |
| 5258 | }); | |
| 5259 | }, | |
| 5260 | [ | |
| 5261 | activeThreadKey, | |
| 5262 | activeThreadPr?.number, | |
| 5263 | activeThreadPr?.state, | |
| 5264 | activeThreadRef, | |
| 5265 | gitCwd, | |
| 5266 | refreshVcsStatus, | |
| 5267 | threadRepository, | |
| 5268 | ], | |
| 5269 | ); | |
| 5270 | 5122 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); |
| 5271 | 5123 | const activeThreadReferenceCopyTarget = useMemo( |
| 5272 | 5124 | () => |
| ... | ... | @@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) { |
| 5276 | 5128 | threadId: activeThreadId, |
| 5277 | 5129 | openPanelPullRequestUrl, |
| 5278 | 5130 | linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, |
| 5279 | detectedPullRequestUrl: activeThreadPr?.url ?? null, | |
| 5280 | 5131 | }), |
| 5281 | [ | |
| 5282 | activeThreadId, | |
| 5283 | activeThreadPr?.url, | |
| 5284 | isServerThread, | |
| 5285 | linkedThreadPullRequest?.url, | |
| 5286 | openPanelPullRequestUrl, | |
| 5287 | ], | |
| 5132 | [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], | |
| 5288 | 5133 | ); |
| 5289 | 5134 | const copyActiveThreadReference = useCallback(() => { |
| 5290 | 5135 | const target = activeThreadReferenceCopyTarget; |
| ... | ... | @@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) { |
| 5310 | 5155 | }, |
| 5311 | 5156 | ); |
| 5312 | 5157 | }, [activeThreadReferenceCopyTarget]); |
| 5313 | // The right panel offers the thread's own change request, so it can only offer it once the | |
| 5314 | // branch has one; until then the picker says so rather than opening an empty panel. | |
| 5315 | 5158 | const addPullRequestSurface = useCallback(() => { |
| 5316 | if (activeThreadPr === null) return; | |
| 5317 | openThreadPullRequest(activeThreadPr.number); | |
| 5318 | }, [activeThreadPr, openThreadPullRequest]); | |
| 5319 | const pullRequestSurfaceAvailable = | |
| 5320 | supportsPullRequests && activeThreadPr !== null && threadRepository !== null; | |
| 5159 | if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) | |
| 5160 | return; | |
| 5161 | useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); | |
| 5162 | }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); | |
| 5163 | const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; | |
| 5321 | 5164 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 5322 | 5165 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; |
| 5323 | 5166 | const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; |
| ... | ... | @@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) { |
| 7849 | 7692 | context={ |
| 7850 | 7693 | isThreadOwnPullRequest( |
| 7851 | 7694 | { |
| 7852 | projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, | |
| 7853 | repository: threadRepository, | |
| 7854 | number: activeThreadPr?.number ?? null, | |
| 7695 | projectId: linkedThreadPullRequest?.projectId ?? null, | |
| 7696 | repository: linkedThreadPullRequest?.repository ?? null, | |
| 7697 | number: linkedThreadPullRequest?.number ?? null, | |
| 7855 | 7698 | }, |
| 7856 | 7699 | { |
| 7857 | 7700 | projectId: renderedRightPanelSurface.projectId, |
| ... | ... | @@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7863 | 7706 | : "page" |
| 7864 | 7707 | } |
| 7865 | 7708 | composerDraftTarget={composerDraftTarget} |
| 7866 | {...(linkedThreadPullRequest === null | |
| 7867 | ? { onStateChange: handlePullRequestTabStatusChange } | |
| 7868 | : {})} | |
| 7869 | 7709 | /> |
| 7870 | 7710 | ) : renderedRightPanelSurface?.kind === "agents" ? ( |
| 7871 | 7711 | <AgentsPanel |
apps/web/src/components/CommandPalette.tsx+4-39| ... | ... | @@ -1,10 +1,6 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | scopedThreadKey, | |
| 5 | scopeProjectRef, | |
| 6 | scopeThreadRef, | |
| 7 | } from "@t3tools/client-runtime/environment"; | |
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 8 | 4 | import { |
| 9 | 5 | canCreateProjectInEnvironment, |
| 10 | 6 | getCloneDestinationBrowsePath, |
| ... | ... | @@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem"; |
| 81 | 77 | import { projectEnvironment } from "../state/projects"; |
| 82 | 78 | import { useEnvironmentQuery } from "../state/query"; |
| 83 | 79 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 84 | import { vcsEnvironment } from "../state/vcs"; | |
| 85 | 80 | import { useAtomCommand } from "../state/use-atom-command"; |
| 86 | 81 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 87 | 82 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 88 | import { useProject, useProjects, useThreadShells } from "../state/entities"; | |
| 83 | import { useProjects, useThreadShells } from "../state/entities"; | |
| 89 | 84 | import { useThreadSearch } from "../state/queries"; |
| 90 | import * as ThreadPr from "./ThreadStatusIndicators"; | |
| 91 | 85 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 92 | 86 | import { |
| 93 | 87 | appendBrowsePathSegment, |
| ... | ... | @@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: { |
| 605 | 599 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 606 | 600 | useHandleNewThread(); |
| 607 | 601 | const projects = useProjects(); |
| 608 | const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom); | |
| 609 | const activeThreadProject = useProject( | |
| 610 | activeThread === null | |
| 611 | ? null | |
| 612 | : scopeProjectRef(activeThread.environmentId, activeThread.projectId), | |
| 613 | ); | |
| 614 | const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null; | |
| 615 | const activeThreadGitStatus = useEnvironmentQuery( | |
| 616 | activeThread != null && | |
| 617 | activeThread.linkedPullRequest == null && | |
| 618 | activeThread.branch !== null && | |
| 619 | activeThreadCwd !== null | |
| 620 | ? vcsEnvironment.status({ | |
| 621 | environmentId: activeThread.environmentId, | |
| 622 | input: { cwd: activeThreadCwd }, | |
| 623 | }) | |
| 624 | : null, | |
| 625 | ).data; | |
| 626 | const detectedPullRequestUrl = | |
| 627 | activeThread == null || activeThread.linkedPullRequest != null | |
| 628 | ? null | |
| 629 | : (ThreadPr.resolveDisplayedThreadPr({ | |
| 630 | threadBranch: activeThread.branch, | |
| 631 | gitStatus: activeThreadGitStatus ?? null, | |
| 632 | snapshot: changeRequestSnapshotByKey.get( | |
| 633 | scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)), | |
| 634 | ), | |
| 635 | retainTerminalOnBranchMismatch: activeThread.worktreePath === null, | |
| 636 | })?.url ?? null); | |
| 637 | 602 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl( |
| 638 | 603 | activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, |
| 639 | 604 | ); |
| ... | ... | @@ -643,8 +608,8 @@ function OpenCommandPaletteDialog(props: { |
| 643 | 608 | : resolveThreadReferenceCopyTarget({ |
| 644 | 609 | threadId: activeThread.id, |
| 645 | 610 | openPanelPullRequestUrl, |
| 646 | linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null, | |
| 647 | detectedPullRequestUrl, | |
| 611 | linkedPullRequestUrl: | |
| 612 | activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null, | |
| 648 | 613 | }); |
| 649 | 614 | const copyActiveThreadReference = useCallback(async () => { |
| 650 | 615 | const target = activeThreadReferenceCopyTarget; |
apps/web/src/components/LegacySidebar.tsx+5-50| ... | ... | @@ -16,7 +16,6 @@ import { |
| 16 | 16 | ChangeRequestStatusIcon, |
| 17 | 17 | prStatusIndicator, |
| 18 | 18 | PrStatusTooltipContent, |
| 19 | resolveThreadPr, | |
| 20 | 19 | terminalStatusFromRunningIds, |
| 21 | 20 | ThreadStatusLabel, |
| 22 | 21 | ThreadWorktreeIndicator, |
| ... | ... | @@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus"; |
| 83 | 82 | import { isMacPlatform } from "../lib/utils"; |
| 84 | 83 | import { |
| 85 | 84 | readThreadShell, |
| 86 | useProject, | |
| 87 | 85 | useProjects, |
| 88 | 86 | useThreadShells, |
| 89 | 87 | useThreadShellsForProjectRefs, |
| ... | ... | @@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; |
| 116 | 114 | |
| 117 | 115 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 118 | 116 | import { projectEnvironment } from "../state/projects"; |
| 119 | import { useEnvironmentQuery } from "../state/query"; | |
| 120 | 117 | import { threadEnvironment, useEnvironmentThread } from "../state/threads"; |
| 121 | import { vcsEnvironment } from "../state/vcs"; | |
| 122 | 118 | import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 123 | 119 | import { |
| 124 | 120 | buildThreadRouteParams, |
| ... | ... | @@ -191,7 +187,6 @@ import { |
| 191 | 187 | orderItemsByPreferredIds, |
| 192 | 188 | shouldClearThreadSelectionOnMouseDown, |
| 193 | 189 | sortProjectsForSidebar, |
| 194 | useRetainedValue, | |
| 195 | 190 | useSidebarRowSubscriptionLease, |
| 196 | 191 | useThreadJumpHintVisibility, |
| 197 | 192 | ThreadStatusPill, |
| ... | ... | @@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: { |
| 314 | 309 | |
| 315 | 310 | interface SidebarThreadRowProps { |
| 316 | 311 | thread: SidebarThreadSummary; |
| 317 | projectCwd: string | null; | |
| 318 | 312 | orderedProjectThreadKeys: readonly string[]; |
| 319 | 313 | isActive: boolean; |
| 320 | 314 | openPullRequestsInRightPanel: boolean; |
| ... | ... | @@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 416 | 410 | const threadEnvironmentLabel = isRemoteThread |
| 417 | 411 | ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) |
| 418 | 412 | : null; |
| 419 | // For grouped projects, the thread may belong to a different environment | |
| 420 | // than the representative project. Look up the thread's own project cwd | |
| 421 | // so git status (and thus PR detection) queries the correct path. | |
| 422 | const threadProject = useProject( | |
| 423 | useMemo( | |
| 424 | () => scopeProjectRef(thread.environmentId, thread.projectId), | |
| 425 | [thread.environmentId, thread.projectId], | |
| 426 | ), | |
| 427 | ); | |
| 428 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; | |
| 429 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | |
| 430 | const gitStatus = useEnvironmentQuery( | |
| 431 | leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null | |
| 432 | ? vcsEnvironment.status({ | |
| 433 | environmentId: thread.environmentId, | |
| 434 | input: { cwd: gitCwd }, | |
| 435 | }) | |
| 436 | : null, | |
| 437 | ); | |
| 438 | 413 | const isHighlighted = isActive || isSelected; |
| 439 | 414 | const handleOpenDiscoveredPort = useCallback( |
| 440 | 415 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| ... | ... | @@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 470 | 445 | }, |
| 471 | 446 | }); |
| 472 | 447 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 473 | leaseLiveStatus ? thread.environmentId : null, | |
| 474 | leaseLiveStatus ? thread.linkedPullRequest : null, | |
| 475 | ); | |
| 476 | const visibleGitStatus = useRetainedValue( | |
| 477 | JSON.stringify([thread.environmentId, gitCwd]), | |
| 478 | gitStatus.data, | |
| 479 | ); | |
| 480 | const visibleLinkedPullRequestStatus = useRetainedValue( | |
| 481 | thread.linkedPullRequest === null | |
| 482 | ? null | |
| 483 | : JSON.stringify([thread.environmentId, thread.linkedPullRequest]), | |
| 484 | linkedPullRequestStatus, | |
| 485 | ); | |
| 486 | const pr = | |
| 487 | thread.linkedPullRequest == null | |
| 488 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) | |
| 489 | : (visibleLinkedPullRequestStatus?.pr ?? null); | |
| 490 | const prStatus = prStatusIndicator( | |
| 491 | pr, | |
| 492 | visibleLinkedPullRequestStatus?.sourceControlProvider ?? | |
| 493 | visibleGitStatus?.sourceControlProvider, | |
| 448 | thread.environmentId, | |
| 449 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 450 | leaseLiveStatus, | |
| 494 | 451 | ); |
| 452 | const pr = linkedPullRequestStatus?.pr ?? null; | |
| 453 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | |
| 495 | 454 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 496 | 455 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| 497 | 456 | const threadMetaClassName = isConfirmingArchive |
| ... | ... | @@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps { |
| 940 | 899 | showEmptyThreadState: boolean; |
| 941 | 900 | shouldShowThreadPanel: boolean; |
| 942 | 901 | isThreadListExpanded: boolean; |
| 943 | projectCwd: string; | |
| 944 | 902 | activeRouteThreadKey: string | null; |
| 945 | 903 | openPullRequestsInRightPanel: boolean; |
| 946 | 904 | threadJumpLabelByKey: ReadonlyMap<string, string>; |
| ... | ... | @@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 996 | 954 | showEmptyThreadState, |
| 997 | 955 | shouldShowThreadPanel, |
| 998 | 956 | isThreadListExpanded, |
| 999 | projectCwd, | |
| 1000 | 957 | activeRouteThreadKey, |
| 1001 | 958 | openPullRequestsInRightPanel, |
| 1002 | 959 | threadJumpLabelByKey, |
| ... | ... | @@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 1048 | 1005 | <SidebarThreadRow |
| 1049 | 1006 | key={threadKey} |
| 1050 | 1007 | thread={thread} |
| 1051 | projectCwd={projectCwd} | |
| 1052 | 1008 | orderedProjectThreadKeys={orderedProjectThreadKeys} |
| 1053 | 1009 | isActive={activeRouteThreadKey === threadKey} |
| 1054 | 1010 | openPullRequestsInRightPanel={openPullRequestsInRightPanel} |
| ... | ... | @@ -2444,7 +2400,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2444 | 2400 | showEmptyThreadState={showEmptyThreadState} |
| 2445 | 2401 | shouldShowThreadPanel={shouldShowThreadPanel} |
| 2446 | 2402 | isThreadListExpanded={isThreadListExpanded} |
| 2447 | projectCwd={project.workspaceRoot} | |
| 2448 | 2403 | activeRouteThreadKey={activeRouteThreadKey} |
| 2449 | 2404 | openPullRequestsInRightPanel={openPullRequestsInRightPanel} |
| 2450 | 2405 | threadJumpLabelByKey={threadJumpLabelByKey} |
apps/web/src/components/Sidebar.tsx+5-58| ... | ... | @@ -167,15 +167,9 @@ import { |
| 167 | 167 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 168 | 168 | import { |
| 169 | 169 | ThreadWorktreeIndicator, |
| 170 | nextThreadChangeRequestSnapshot, | |
| 171 | 170 | prStatusIndicator, |
| 172 | resolveDisplayedThreadPr, | |
| 173 | resolveDisplayedThreadPrProvider, | |
| 174 | setThreadChangeRequestSnapshot, | |
| 175 | 171 | settledPrHoverColorClass, |
| 176 | 172 | terminalStatusFromRunningIds, |
| 177 | threadChangeRequestSnapshotsAtom, | |
| 178 | type ThreadChangeRequestSnapshot, | |
| 179 | 173 | type TerminalStatusIndicator, |
| 180 | 174 | useLinkedThreadPullRequest, |
| 181 | 175 | } from "./ThreadStatusIndicators"; |
| ... | ... | @@ -795,16 +789,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 795 | 789 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 796 | 790 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 797 | 791 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| 798 | changeRequestSnapshot: ThreadChangeRequestSnapshot | null; | |
| 799 | onChangeRequestSnapshot: ( | |
| 800 | threadKey: string, | |
| 801 | snapshot: ThreadChangeRequestSnapshot | null, | |
| 802 | ) => void; | |
| 803 | 792 | }) { |
| 804 | 793 | const { |
| 805 | 794 | isRenaming, |
| 806 | changeRequestSnapshot, | |
| 807 | onChangeRequestSnapshot, | |
| 808 | 795 | onCancelRename, |
| 809 | 796 | onCommitRename, |
| 810 | 797 | onContextMenu, |
| ... | ... | @@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 856 | 843 | |
| 857 | 844 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 858 | 845 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 859 | leaseLiveStatus ? thread.environmentId : null, | |
| 860 | leaseLiveStatus ? thread.linkedPullRequest : null, | |
| 846 | thread.environmentId, | |
| 847 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 848 | leaseLiveStatus, | |
| 861 | 849 | ); |
| 862 | 850 | const gitStatus = useEnvironmentQuery( |
| 863 | 851 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| ... | ... | @@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 871 | 859 | JSON.stringify([thread.environmentId, gitCwd]), |
| 872 | 860 | gitStatus.data, |
| 873 | 861 | ); |
| 874 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; | |
| 875 | const pr = resolveDisplayedThreadPr({ | |
| 876 | threadBranch: thread.branch, | |
| 877 | gitStatus: visibleGitStatus, | |
| 878 | snapshot: changeRequestSnapshot, | |
| 879 | retainTerminalOnBranchMismatch, | |
| 880 | linkedPullRequest: thread.linkedPullRequest, | |
| 881 | linkedPullRequestStatus, | |
| 882 | }); | |
| 862 | const pr = linkedPullRequestStatus?.pr ?? null; | |
| 883 | 863 | |
| 884 | 864 | // Same semantics as the legacy sidebar (never-visited counts as read): |
| 885 | 865 | // switching sidebars must not light up every historical thread as unread. |
| ... | ... | @@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 972 | 952 | activeThreadBranch: thread.branch, |
| 973 | 953 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 974 | 954 | }); |
| 975 | const prProvider = resolveDisplayedThreadPrProvider({ | |
| 976 | threadBranch: thread.branch, | |
| 977 | gitStatus: visibleGitStatus, | |
| 978 | snapshot: changeRequestSnapshot, | |
| 979 | retainTerminalOnBranchMismatch, | |
| 980 | linkedPullRequest: thread.linkedPullRequest, | |
| 981 | linkedPullRequestStatus, | |
| 982 | }); | |
| 983 | const prStatus = prStatusIndicator(pr, prProvider); | |
| 955 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | |
| 984 | 956 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; |
| 985 | useEffect(() => { | |
| 986 | const nextSnapshot = nextThreadChangeRequestSnapshot({ | |
| 987 | threadBranch: thread.branch, | |
| 988 | gitStatus: visibleGitStatus, | |
| 989 | snapshot: changeRequestSnapshot, | |
| 990 | retainTerminalOnBranchMismatch, | |
| 991 | linkedPullRequest: thread.linkedPullRequest, | |
| 992 | linkedPullRequestStatus, | |
| 993 | }); | |
| 994 | if (nextSnapshot === undefined) return; | |
| 995 | onChangeRequestSnapshot(threadKey, nextSnapshot); | |
| 996 | }, [ | |
| 997 | changeRequestSnapshot, | |
| 998 | visibleGitStatus, | |
| 999 | linkedPullRequestStatus, | |
| 1000 | onChangeRequestSnapshot, | |
| 1001 | retainTerminalOnBranchMismatch, | |
| 1002 | thread.branch, | |
| 1003 | thread.linkedPullRequest, | |
| 1004 | threadKey, | |
| 1005 | ]); | |
| 1006 | 957 | |
| 1007 | 958 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1008 | 959 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| ... | ... | @@ -2104,8 +2055,6 @@ export default function Sidebar() { |
| 2104 | 2055 | // fresh clock whenever it recomputes. |
| 2105 | 2056 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 2106 | 2057 | |
| 2107 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 2108 | ||
| 2109 | 2058 | // Project scope: one menu above the list. Scoping filters the list without |
| 2110 | 2059 | // making the header width depend on the number or length of project names. |
| 2111 | 2060 | // The selection lives in the persisted UI store next to the other sidebar |
| ... | ... | @@ -4092,8 +4041,6 @@ export default function Sidebar() { |
| 4092 | 4041 | onUnsnooze={attemptUnsnooze} |
| 4093 | 4042 | onUnpin={attemptUnpin} |
| 4094 | 4043 | onAcknowledgeWoke={acknowledgeWoke} |
| 4095 | changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} | |
| 4096 | onChangeRequestSnapshot={setThreadChangeRequestSnapshot} | |
| 4097 | 4044 | /> |
| 4098 | 4045 | ); |
| 4099 | 4046 | }; |
apps/web/src/components/ThreadStatusIndicators.test.ts-525| ... | ... | @@ -1,7 +1,5 @@ |
| 1 | 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 5 | 3 | import { |
| 6 | 4 | GitMergeIcon, |
| 7 | 5 | GitPullRequestClosedIcon, |
| ... | ... | @@ -11,15 +9,8 @@ import { |
| 11 | 9 | |
| 12 | 10 | import { |
| 13 | 11 | ChangeRequestStatusIcon, |
| 14 | nextThreadChangeRequestSnapshot, | |
| 15 | 12 | prStatusIndicator, |
| 16 | resolveDisplayedThreadPr, | |
| 17 | resolveDisplayedThreadPrProvider, | |
| 18 | resolveThreadPr, | |
| 19 | 13 | settledPrHoverColorClass, |
| 20 | threadChangeRequestSnapshotsEqual, | |
| 21 | threadChangeRequestSnapshotsAtom, | |
| 22 | type ThreadChangeRequestSnapshot, | |
| 23 | 14 | } from "./ThreadStatusIndicators"; |
| 24 | 15 | import { newestPullRequestSummary } from "../state/pullRequests"; |
| 25 | 16 | |
| ... | ... | @@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| 57 | 48 | }; |
| 58 | 49 | } |
| 59 | 50 | |
| 60 | function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> { | |
| 61 | return { | |
| 62 | number: 42, | |
| 63 | title: "Feature PR", | |
| 64 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 65 | baseRef: "main", | |
| 66 | headRef: "feature/current", | |
| 67 | state: "merged", | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | function snapshotFor( | |
| 72 | branch: string, | |
| 73 | pr: NonNullable<VcsStatusResult["pr"]>, | |
| 74 | sourceControlProvider?: VcsStatusResult["sourceControlProvider"], | |
| 75 | ): ThreadChangeRequestSnapshot { | |
| 76 | return { branch, pr, sourceControlProvider }; | |
| 77 | } | |
| 78 | ||
| 79 | 51 | function pullRequestSummary( |
| 80 | 52 | state: PullRequestSummary["state"], |
| 81 | 53 | updatedAt: string, |
| ... | ... | @@ -117,503 +89,6 @@ describe("shared pull request state", () => { |
| 117 | 89 | }); |
| 118 | 90 | }); |
| 119 | 91 | |
| 120 | describe("resolveThreadPr", () => { | |
| 121 | it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { | |
| 122 | expect( | |
| 123 | resolveThreadPr({ | |
| 124 | threadBranch: "feature/other", | |
| 125 | gitStatus: status(), | |
| 126 | }), | |
| 127 | ).toBeNull(); | |
| 128 | }); | |
| 129 | ||
| 130 | it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { | |
| 131 | expect( | |
| 132 | resolveThreadPr({ | |
| 133 | threadBranch: "stack/base", | |
| 134 | gitStatus: status(), | |
| 135 | }), | |
| 136 | ).toBeNull(); | |
| 137 | }); | |
| 138 | ||
| 139 | it("hides PR indicators when thread branch metadata is missing", () => { | |
| 140 | expect( | |
| 141 | resolveThreadPr({ | |
| 142 | threadBranch: null, | |
| 143 | gitStatus: status(), | |
| 144 | }), | |
| 145 | ).toBeNull(); | |
| 146 | }); | |
| 147 | ||
| 148 | it("shows the PR when the live checkout matches the stored thread branch", () => { | |
| 149 | const gitStatus = status(); | |
| 150 | ||
| 151 | expect( | |
| 152 | resolveThreadPr({ | |
| 153 | threadBranch: "feature/current", | |
| 154 | gitStatus, | |
| 155 | }), | |
| 156 | ).toBe(gitStatus.pr); | |
| 157 | }); | |
| 158 | }); | |
| 159 | ||
| 160 | describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { | |
| 161 | const featureBranch = "feature/current"; | |
| 162 | const mergedPr = mergedFeaturePr(); | |
| 163 | const linkedPullRequest = { | |
| 164 | projectId: ProjectId.make("project-1"), | |
| 165 | repository: "pingdotgg/t3code", | |
| 166 | number: 42, | |
| 167 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 168 | }; | |
| 169 | const provider = { | |
| 170 | kind: "github" as const, | |
| 171 | name: "GitHub", | |
| 172 | baseUrl: "https://github.com", | |
| 173 | }; | |
| 174 | ||
| 175 | it("returns the live merged PR when the checkout matches the feature branch", () => { | |
| 176 | const gitStatus = status({ | |
| 177 | refName: featureBranch, | |
| 178 | pr: mergedPr, | |
| 179 | sourceControlProvider: provider, | |
| 180 | }); | |
| 181 | ||
| 182 | expect( | |
| 183 | resolveDisplayedThreadPr({ | |
| 184 | threadBranch: featureBranch, | |
| 185 | gitStatus, | |
| 186 | snapshot: undefined, | |
| 187 | retainTerminalOnBranchMismatch: true, | |
| 188 | }), | |
| 189 | ).toBe(mergedPr); | |
| 190 | expect( | |
| 191 | resolveDisplayedThreadPrProvider({ | |
| 192 | threadBranch: featureBranch, | |
| 193 | gitStatus, | |
| 194 | snapshot: undefined, | |
| 195 | retainTerminalOnBranchMismatch: true, | |
| 196 | }), | |
| 197 | ).toEqual(provider); | |
| 198 | }); | |
| 199 | ||
| 200 | it("shows a linked pull request when the checkout has a different branch", () => { | |
| 201 | const linkedPullRequestStatus = { | |
| 202 | pr: mergedPr, | |
| 203 | sourceControlProvider: provider, | |
| 204 | }; | |
| 205 | ||
| 206 | expect( | |
| 207 | resolveDisplayedThreadPr({ | |
| 208 | threadBranch: "feature/other", | |
| 209 | gitStatus: status({ refName: "feature/other", pr: null }), | |
| 210 | snapshot: undefined, | |
| 211 | retainTerminalOnBranchMismatch: false, | |
| 212 | linkedPullRequest, | |
| 213 | linkedPullRequestStatus, | |
| 214 | }), | |
| 215 | ).toEqual(mergedPr); | |
| 216 | expect( | |
| 217 | resolveDisplayedThreadPrProvider({ | |
| 218 | threadBranch: "feature/other", | |
| 219 | gitStatus: status({ refName: "feature/other", pr: null }), | |
| 220 | snapshot: undefined, | |
| 221 | retainTerminalOnBranchMismatch: false, | |
| 222 | linkedPullRequest, | |
| 223 | linkedPullRequestStatus, | |
| 224 | }), | |
| 225 | ).toEqual(provider); | |
| 226 | expect( | |
| 227 | nextThreadChangeRequestSnapshot({ | |
| 228 | threadBranch: "feature/other", | |
| 229 | gitStatus: status({ refName: "feature/other", pr: null }), | |
| 230 | snapshot: undefined, | |
| 231 | retainTerminalOnBranchMismatch: false, | |
| 232 | linkedPullRequest, | |
| 233 | linkedPullRequestStatus, | |
| 234 | }), | |
| 235 | ).toEqual({ | |
| 236 | branch: "feature/other", | |
| 237 | pr: mergedPr, | |
| 238 | sourceControlProvider: provider, | |
| 239 | linkedPullRequest, | |
| 240 | }); | |
| 241 | }); | |
| 242 | ||
| 243 | it("keeps a matching linked pull request snapshot while its status reloads", () => { | |
| 244 | const snapshot = { | |
| 245 | ...snapshotFor(featureBranch, mergedPr, provider), | |
| 246 | linkedPullRequest, | |
| 247 | }; | |
| 248 | ||
| 249 | expect( | |
| 250 | resolveDisplayedThreadPr({ | |
| 251 | threadBranch: null, | |
| 252 | gitStatus: null, | |
| 253 | snapshot, | |
| 254 | retainTerminalOnBranchMismatch: false, | |
| 255 | linkedPullRequest, | |
| 256 | linkedPullRequestStatus: null, | |
| 257 | }), | |
| 258 | ).toEqual(mergedPr); | |
| 259 | expect( | |
| 260 | nextThreadChangeRequestSnapshot({ | |
| 261 | threadBranch: null, | |
| 262 | gitStatus: null, | |
| 263 | snapshot, | |
| 264 | retainTerminalOnBranchMismatch: false, | |
| 265 | linkedPullRequest, | |
| 266 | linkedPullRequestStatus: null, | |
| 267 | }), | |
| 268 | ).toBeUndefined(); | |
| 269 | }); | |
| 270 | ||
| 271 | it("clears an old snapshot when a different pull request is linked", () => { | |
| 272 | const snapshot = { | |
| 273 | ...snapshotFor(featureBranch, mergedPr, provider), | |
| 274 | linkedPullRequest: { ...linkedPullRequest, number: 41 }, | |
| 275 | }; | |
| 276 | ||
| 277 | expect( | |
| 278 | nextThreadChangeRequestSnapshot({ | |
| 279 | threadBranch: featureBranch, | |
| 280 | gitStatus: null, | |
| 281 | snapshot, | |
| 282 | retainTerminalOnBranchMismatch: true, | |
| 283 | linkedPullRequest, | |
| 284 | linkedPullRequestStatus: null, | |
| 285 | }), | |
| 286 | ).toBeNull(); | |
| 287 | }); | |
| 288 | ||
| 289 | it("removes a linked pull request snapshot after the link is cleared", () => { | |
| 290 | const snapshot = { | |
| 291 | ...snapshotFor(featureBranch, mergedPr, provider), | |
| 292 | linkedPullRequest, | |
| 293 | }; | |
| 294 | ||
| 295 | expect( | |
| 296 | resolveDisplayedThreadPr({ | |
| 297 | threadBranch: featureBranch, | |
| 298 | gitStatus: status({ refName: "main", pr: null }), | |
| 299 | snapshot, | |
| 300 | retainTerminalOnBranchMismatch: true, | |
| 301 | }), | |
| 302 | ).toBeNull(); | |
| 303 | expect( | |
| 304 | nextThreadChangeRequestSnapshot({ | |
| 305 | threadBranch: featureBranch, | |
| 306 | gitStatus: null, | |
| 307 | snapshot, | |
| 308 | retainTerminalOnBranchMismatch: true, | |
| 309 | }), | |
| 310 | ).toBeNull(); | |
| 311 | }); | |
| 312 | ||
| 313 | it("after caching a merged PR, resolves main status back to the cached feature PR", () => { | |
| 314 | const matchingStatus = status({ | |
| 315 | refName: featureBranch, | |
| 316 | pr: mergedPr, | |
| 317 | sourceControlProvider: provider, | |
| 318 | }); | |
| 319 | const cached = nextThreadChangeRequestSnapshot({ | |
| 320 | threadBranch: featureBranch, | |
| 321 | gitStatus: matchingStatus, | |
| 322 | snapshot: undefined, | |
| 323 | retainTerminalOnBranchMismatch: true, | |
| 324 | }); | |
| 325 | expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); | |
| 326 | ||
| 327 | const mainStatus = status({ | |
| 328 | refName: "main", | |
| 329 | isDefaultRef: true, | |
| 330 | pr: { | |
| 331 | number: 99, | |
| 332 | title: "Unrelated main PR", | |
| 333 | url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 334 | baseRef: "main", | |
| 335 | headRef: "main", | |
| 336 | state: "open", | |
| 337 | }, | |
| 338 | sourceControlProvider: provider, | |
| 339 | }); | |
| 340 | ||
| 341 | expect( | |
| 342 | resolveDisplayedThreadPr({ | |
| 343 | threadBranch: featureBranch, | |
| 344 | gitStatus: mainStatus, | |
| 345 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 346 | retainTerminalOnBranchMismatch: true, | |
| 347 | }), | |
| 348 | ).toEqual(mergedPr); | |
| 349 | expect( | |
| 350 | resolveDisplayedThreadPrProvider({ | |
| 351 | threadBranch: featureBranch, | |
| 352 | gitStatus: mainStatus, | |
| 353 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 354 | retainTerminalOnBranchMismatch: true, | |
| 355 | }), | |
| 356 | ).toEqual(provider); | |
| 357 | }); | |
| 358 | ||
| 359 | it("never attaches a PR reported by main to the feature thread", () => { | |
| 360 | const mainPr = { | |
| 361 | number: 99, | |
| 362 | title: "Unrelated main PR", | |
| 363 | url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 364 | baseRef: "develop", | |
| 365 | headRef: "main", | |
| 366 | state: "merged" as const, | |
| 367 | }; | |
| 368 | expect( | |
| 369 | resolveDisplayedThreadPr({ | |
| 370 | threadBranch: featureBranch, | |
| 371 | gitStatus: status({ refName: "main", pr: mainPr }), | |
| 372 | snapshot: undefined, | |
| 373 | retainTerminalOnBranchMismatch: true, | |
| 374 | }), | |
| 375 | ).toBeNull(); | |
| 376 | expect( | |
| 377 | nextThreadChangeRequestSnapshot({ | |
| 378 | threadBranch: featureBranch, | |
| 379 | gitStatus: status({ refName: "main", pr: mainPr }), | |
| 380 | snapshot: undefined, | |
| 381 | retainTerminalOnBranchMismatch: true, | |
| 382 | }), | |
| 383 | ).toBeNull(); | |
| 384 | }); | |
| 385 | ||
| 386 | it("does not show a cached open PR across a branch mismatch", () => { | |
| 387 | const openSnapshot = snapshotFor(featureBranch, { | |
| 388 | ...mergedPr, | |
| 389 | state: "open", | |
| 390 | title: "Still open", | |
| 391 | }); | |
| 392 | ||
| 393 | expect( | |
| 394 | resolveDisplayedThreadPr({ | |
| 395 | threadBranch: featureBranch, | |
| 396 | gitStatus: status({ refName: "main", pr: null }), | |
| 397 | snapshot: openSnapshot, | |
| 398 | retainTerminalOnBranchMismatch: true, | |
| 399 | }), | |
| 400 | ).toBeNull(); | |
| 401 | }); | |
| 402 | ||
| 403 | it("retains a cached closed PR across a branch mismatch", () => { | |
| 404 | const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; | |
| 405 | const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); | |
| 406 | ||
| 407 | expect( | |
| 408 | resolveDisplayedThreadPr({ | |
| 409 | threadBranch: featureBranch, | |
| 410 | gitStatus: status({ refName: "main", pr: null }), | |
| 411 | snapshot: closedSnapshot, | |
| 412 | retainTerminalOnBranchMismatch: true, | |
| 413 | }), | |
| 414 | ).toEqual(closedPr); | |
| 415 | }); | |
| 416 | ||
| 417 | it("does not retain or display a terminal PR when a worktree switches branches", () => { | |
| 418 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 419 | const mismatchedStatus = status({ refName: "feature/other", pr: null }); | |
| 420 | ||
| 421 | expect( | |
| 422 | resolveDisplayedThreadPr({ | |
| 423 | threadBranch: featureBranch, | |
| 424 | gitStatus: mismatchedStatus, | |
| 425 | snapshot: terminalSnapshot, | |
| 426 | retainTerminalOnBranchMismatch: false, | |
| 427 | }), | |
| 428 | ).toBeNull(); | |
| 429 | expect( | |
| 430 | resolveDisplayedThreadPrProvider({ | |
| 431 | threadBranch: featureBranch, | |
| 432 | gitStatus: mismatchedStatus, | |
| 433 | snapshot: terminalSnapshot, | |
| 434 | retainTerminalOnBranchMismatch: false, | |
| 435 | }), | |
| 436 | ).toBeUndefined(); | |
| 437 | expect( | |
| 438 | nextThreadChangeRequestSnapshot({ | |
| 439 | threadBranch: featureBranch, | |
| 440 | gitStatus: mismatchedStatus, | |
| 441 | snapshot: terminalSnapshot, | |
| 442 | retainTerminalOnBranchMismatch: false, | |
| 443 | }), | |
| 444 | ).toBeNull(); | |
| 445 | }); | |
| 446 | ||
| 447 | it("retains a local terminal snapshot when thread metadata follows the new branch", () => { | |
| 448 | const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); | |
| 449 | ||
| 450 | expect( | |
| 451 | resolveDisplayedThreadPr({ | |
| 452 | threadBranch: featureBranch, | |
| 453 | gitStatus: status({ refName: "main", pr: null }), | |
| 454 | snapshot: otherBranchSnapshot, | |
| 455 | retainTerminalOnBranchMismatch: true, | |
| 456 | }), | |
| 457 | ).toEqual(mergedPr); | |
| 458 | }); | |
| 459 | ||
| 460 | it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { | |
| 461 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 462 | ||
| 463 | expect( | |
| 464 | nextThreadChangeRequestSnapshot({ | |
| 465 | threadBranch: "main", | |
| 466 | gitStatus: status({ refName: "main", pr: null }), | |
| 467 | snapshot: terminalSnapshot, | |
| 468 | retainTerminalOnBranchMismatch: true, | |
| 469 | }), | |
| 470 | ).toBeUndefined(); | |
| 471 | expect( | |
| 472 | resolveDisplayedThreadPr({ | |
| 473 | threadBranch: "main", | |
| 474 | gitStatus: status({ refName: "main", pr: null }), | |
| 475 | snapshot: terminalSnapshot, | |
| 476 | retainTerminalOnBranchMismatch: true, | |
| 477 | }), | |
| 478 | ).toEqual(mergedPr); | |
| 479 | }); | |
| 480 | ||
| 481 | it("clears an open snapshot when a local thread moves to a branch with no PR", () => { | |
| 482 | const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 483 | ||
| 484 | expect( | |
| 485 | nextThreadChangeRequestSnapshot({ | |
| 486 | threadBranch: "main", | |
| 487 | gitStatus: status({ refName: "main", pr: null }), | |
| 488 | snapshot: openSnapshot, | |
| 489 | retainTerminalOnBranchMismatch: true, | |
| 490 | }), | |
| 491 | ).toBeNull(); | |
| 492 | }); | |
| 493 | ||
| 494 | it("clears an open snapshot when a local checkout moves to a different branch", () => { | |
| 495 | const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 496 | ||
| 497 | expect( | |
| 498 | nextThreadChangeRequestSnapshot({ | |
| 499 | threadBranch: featureBranch, | |
| 500 | gitStatus: status({ refName: "main", pr: null }), | |
| 501 | snapshot: openSnapshot, | |
| 502 | retainTerminalOnBranchMismatch: true, | |
| 503 | }), | |
| 504 | ).toBeNull(); | |
| 505 | }); | |
| 506 | ||
| 507 | it("clears a retained snapshot when the thread branch is cleared", () => { | |
| 508 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 509 | ||
| 510 | expect( | |
| 511 | nextThreadChangeRequestSnapshot({ | |
| 512 | threadBranch: null, | |
| 513 | gitStatus: status({ refName: "main", pr: null }), | |
| 514 | snapshot: terminalSnapshot, | |
| 515 | retainTerminalOnBranchMismatch: true, | |
| 516 | }), | |
| 517 | ).toBeNull(); | |
| 518 | expect( | |
| 519 | resolveDisplayedThreadPr({ | |
| 520 | threadBranch: null, | |
| 521 | gitStatus: status({ refName: "main", pr: null }), | |
| 522 | snapshot: terminalSnapshot, | |
| 523 | retainTerminalOnBranchMismatch: true, | |
| 524 | }), | |
| 525 | ).toBeNull(); | |
| 526 | expect( | |
| 527 | resolveDisplayedThreadPrProvider({ | |
| 528 | threadBranch: null, | |
| 529 | gitStatus: status({ refName: "main", pr: null }), | |
| 530 | snapshot: terminalSnapshot, | |
| 531 | retainTerminalOnBranchMismatch: true, | |
| 532 | }), | |
| 533 | ).toBeUndefined(); | |
| 534 | }); | |
| 535 | ||
| 536 | it("does not erase a terminal snapshot when VCS data is missing", () => { | |
| 537 | const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 538 | ||
| 539 | expect( | |
| 540 | nextThreadChangeRequestSnapshot({ | |
| 541 | threadBranch: featureBranch, | |
| 542 | gitStatus: null, | |
| 543 | snapshot: terminalSnapshot, | |
| 544 | retainTerminalOnBranchMismatch: true, | |
| 545 | }), | |
| 546 | ).toBeUndefined(); | |
| 547 | expect( | |
| 548 | resolveDisplayedThreadPr({ | |
| 549 | threadBranch: featureBranch, | |
| 550 | gitStatus: null, | |
| 551 | snapshot: terminalSnapshot, | |
| 552 | retainTerminalOnBranchMismatch: true, | |
| 553 | }), | |
| 554 | ).toEqual(mergedPr); | |
| 555 | }); | |
| 556 | ||
| 557 | it("retains a merged PR after a main checkout", () => { | |
| 558 | const matchingStatus = status({ | |
| 559 | refName: featureBranch, | |
| 560 | pr: mergedPr, | |
| 561 | sourceControlProvider: provider, | |
| 562 | }); | |
| 563 | const cached = nextThreadChangeRequestSnapshot({ | |
| 564 | threadBranch: featureBranch, | |
| 565 | gitStatus: matchingStatus, | |
| 566 | snapshot: undefined, | |
| 567 | retainTerminalOnBranchMismatch: true, | |
| 568 | }); | |
| 569 | expect(cached).not.toBeNull(); | |
| 570 | expect(cached).not.toBeUndefined(); | |
| 571 | ||
| 572 | const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); | |
| 573 | const displayed = resolveDisplayedThreadPr({ | |
| 574 | threadBranch: "main", | |
| 575 | gitStatus: mainStatus, | |
| 576 | snapshot: cached as ThreadChangeRequestSnapshot, | |
| 577 | retainTerminalOnBranchMismatch: true, | |
| 578 | }); | |
| 579 | expect(displayed?.state).toBe("merged"); | |
| 580 | }); | |
| 581 | ||
| 582 | it("refreshes a cached snapshot when a pull request becomes ready", () => { | |
| 583 | const readyPr = { ...mergedPr, state: "open" as const }; | |
| 584 | const draftPr = { ...readyPr, isDraft: true }; | |
| 585 | ||
| 586 | expect( | |
| 587 | threadChangeRequestSnapshotsEqual( | |
| 588 | snapshotFor(featureBranch, draftPr), | |
| 589 | snapshotFor(featureBranch, readyPr), | |
| 590 | ), | |
| 591 | ).toBe(false); | |
| 592 | }); | |
| 593 | }); | |
| 594 | ||
| 595 | describe("threadChangeRequestSnapshotsAtom", () => { | |
| 596 | it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => | |
| 597 | Effect.gen(function* () { | |
| 598 | const registry = AtomRegistry.make(); | |
| 599 | const threadKey = "environment-1:thread-1"; | |
| 600 | const snapshot = snapshotFor("feature/current", mergedFeaturePr()); | |
| 601 | ||
| 602 | const unmount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 603 | registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); | |
| 604 | unmount(); | |
| 605 | ||
| 606 | yield* Effect.yieldNow; | |
| 607 | ||
| 608 | const remount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 609 | expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); | |
| 610 | ||
| 611 | remount(); | |
| 612 | registry.dispose(); | |
| 613 | }), | |
| 614 | ); | |
| 615 | }); | |
| 616 | ||
| 617 | 92 | describe("prStatusIndicator", () => { |
| 618 | 93 | it("formats PR tooltips with number, uppercase status, and title", () => { |
| 619 | 94 | expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ |
apps/web/src/components/ThreadStatusIndicators.tsx+8-303| ... | ... | @@ -1,8 +1,4 @@ |
| 1 | import { | |
| 2 | scopeProjectRef, | |
| 3 | scopedThreadKey, | |
| 4 | scopeThreadRef, | |
| 5 | } from "@t3tools/client-runtime/environment"; | |
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 6 | 2 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 7 | 3 | import { |
| 8 | 4 | type EnvironmentId, |
| ... | ... | @@ -10,17 +6,13 @@ import { |
| 10 | 6 | type ThreadLinkedPullRequest, |
| 11 | 7 | type VcsStatusResult, |
| 12 | 8 | } from "@t3tools/contracts"; |
| 13 | import { Atom } from "effect/unstable/reactivity"; | |
| 14 | 9 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | 10 | import { useMemo } from "react"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 17 | 11 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 12 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 19 | import { useProject } from "../state/entities"; | |
| 20 | 13 | import { useEnvironmentQuery } from "../state/query"; |
| 21 | 14 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; |
| 22 | 15 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 23 | import { vcsEnvironment } from "../state/vcs"; | |
| 24 | 16 | import { useUiStateStore } from "../uiStateStore"; |
| 25 | 17 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; |
| 26 | 18 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| ... | ... | @@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus { |
| 51 | 43 | readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; |
| 52 | 44 | } |
| 53 | 45 | |
| 46 | /** Keep cached summaries visible when an offscreen row stops live queries. */ | |
| 54 | 47 | export function useLinkedThreadPullRequest( |
| 55 | 48 | environmentId: EnvironmentId | null, |
| 56 | 49 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 50 | enabled = true, | |
| 57 | 51 | ): LinkedThreadPullRequestStatus | null { |
| 58 | 52 | const queried = useEnvironmentQuery( |
| 59 | environmentId === null || linkedPullRequest == null | |
| 53 | !enabled || environmentId === null || linkedPullRequest == null | |
| 60 | 54 | ? null |
| 61 | 55 | : linkedPullRequestDetailAtom({ |
| 62 | 56 | environmentId, |
| ... | ... | @@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } |
| 178 | 172 | ); |
| 179 | 173 | } |
| 180 | 174 | |
| 181 | export function resolveThreadPr(input: { | |
| 182 | threadBranch: string | null; | |
| 183 | gitStatus: VcsStatusResult | null; | |
| 184 | }): ThreadPr | null { | |
| 185 | const { threadBranch, gitStatus } = input; | |
| 186 | if (gitStatus === null) { | |
| 187 | return null; | |
| 188 | } | |
| 189 | ||
| 190 | if (threadBranch === null || gitStatus.refName !== threadBranch) { | |
| 191 | return null; | |
| 192 | } | |
| 193 | ||
| 194 | return gitStatus.pr ?? null; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement | |
| 199 | * partitions move them, so terminal PR metadata must live above the row. | |
| 200 | */ | |
| 201 | export interface ThreadChangeRequestSnapshot { | |
| 202 | readonly branch: string; | |
| 203 | readonly pr: NonNullable<ThreadPr>; | |
| 204 | readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; | |
| 205 | readonly linkedPullRequest?: ThreadLinkedPullRequest; | |
| 206 | } | |
| 207 | ||
| 208 | export const threadChangeRequestSnapshotsAtom = Atom.make< | |
| 209 | ReadonlyMap<string, ThreadChangeRequestSnapshot> | |
| 210 | >(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); | |
| 211 | ||
| 212 | function isTerminalChangeRequestState( | |
| 213 | state: NonNullable<ThreadPr>["state"], | |
| 214 | ): state is "merged" | "closed" { | |
| 215 | return state === "merged" || state === "closed"; | |
| 216 | } | |
| 217 | ||
| 218 | function sourceControlProvidersEqual( | |
| 219 | left: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 220 | right: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 221 | ): boolean { | |
| 222 | if (left === right) return true; | |
| 223 | if (left == null || right == null) return left == null && right == null; | |
| 224 | return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; | |
| 225 | } | |
| 226 | ||
| 227 | function linkedPullRequestsEqual( | |
| 228 | left: ThreadLinkedPullRequest | null | undefined, | |
| 229 | right: ThreadLinkedPullRequest | null | undefined, | |
| 230 | ): boolean { | |
| 231 | if (left == null || right == null) return left == null && right == null; | |
| 232 | return ( | |
| 233 | left.projectId === right.projectId && | |
| 234 | left.repository === right.repository && | |
| 235 | left.number === right.number && | |
| 236 | left.url === right.url | |
| 237 | ); | |
| 238 | } | |
| 239 | ||
| 240 | export function threadChangeRequestSnapshotsEqual( | |
| 241 | left: ThreadChangeRequestSnapshot, | |
| 242 | right: ThreadChangeRequestSnapshot, | |
| 243 | ): boolean { | |
| 244 | return ( | |
| 245 | left.branch === right.branch && | |
| 246 | left.pr.number === right.pr.number && | |
| 247 | left.pr.title === right.pr.title && | |
| 248 | left.pr.url === right.pr.url && | |
| 249 | left.pr.baseRef === right.pr.baseRef && | |
| 250 | left.pr.headRef === right.pr.headRef && | |
| 251 | left.pr.state === right.pr.state && | |
| 252 | left.pr.isDraft === right.pr.isDraft && | |
| 253 | (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && | |
| 254 | sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && | |
| 255 | linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) | |
| 256 | ); | |
| 257 | } | |
| 258 | ||
| 259 | export function setThreadChangeRequestSnapshot( | |
| 260 | threadKey: string, | |
| 261 | snapshot: ThreadChangeRequestSnapshot | null, | |
| 262 | ): void { | |
| 263 | appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { | |
| 264 | const existing = current.get(threadKey); | |
| 265 | if (snapshot === null) { | |
| 266 | if (existing === undefined) return [false, current]; | |
| 267 | const next = new Map(current); | |
| 268 | next.delete(threadKey); | |
| 269 | return [true, next]; | |
| 270 | } | |
| 271 | if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { | |
| 272 | return [false, current]; | |
| 273 | } | |
| 274 | const next = new Map(current); | |
| 275 | next.set(threadKey, snapshot); | |
| 276 | return [true, next]; | |
| 277 | }); | |
| 278 | } | |
| 279 | ||
| 280 | /** | |
| 281 | * Authoritative snapshot update from live VCS status. | |
| 282 | * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone | |
| 283 | * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear | |
| 284 | * - snapshot: matching branch reports a PR — store/replace | |
| 285 | */ | |
| 286 | export function nextThreadChangeRequestSnapshot(input: { | |
| 287 | threadBranch: string | null; | |
| 288 | gitStatus: VcsStatusResult | null; | |
| 289 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 290 | retainTerminalOnBranchMismatch: boolean; | |
| 291 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 292 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 293 | }): ThreadChangeRequestSnapshot | null | undefined { | |
| 294 | const { | |
| 295 | threadBranch, | |
| 296 | gitStatus, | |
| 297 | snapshot, | |
| 298 | retainTerminalOnBranchMismatch, | |
| 299 | linkedPullRequest, | |
| 300 | linkedPullRequestStatus, | |
| 301 | } = input; | |
| 302 | if (linkedPullRequest != null) { | |
| 303 | if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { | |
| 304 | return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 305 | ? undefined | |
| 306 | : null; | |
| 307 | } | |
| 308 | return { | |
| 309 | branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, | |
| 310 | pr: linkedPullRequestStatus.pr, | |
| 311 | sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, | |
| 312 | linkedPullRequest, | |
| 313 | }; | |
| 314 | } | |
| 315 | if (gitStatus === null) { | |
| 316 | return snapshot?.linkedPullRequest === undefined ? undefined : null; | |
| 317 | } | |
| 318 | if (threadBranch === null) { | |
| 319 | return null; | |
| 320 | } | |
| 321 | if (gitStatus.refName !== threadBranch) { | |
| 322 | return retainTerminalOnBranchMismatch && | |
| 323 | snapshot != null && | |
| 324 | snapshot.linkedPullRequest === undefined && | |
| 325 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 326 | ? undefined | |
| 327 | : null; | |
| 328 | } | |
| 329 | if (gitStatus.pr == null) { | |
| 330 | if ( | |
| 331 | retainTerminalOnBranchMismatch && | |
| 332 | snapshot != null && | |
| 333 | snapshot.linkedPullRequest === undefined && | |
| 334 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 335 | ) { | |
| 336 | return undefined; | |
| 337 | } | |
| 338 | return null; | |
| 339 | } | |
| 340 | return { | |
| 341 | branch: threadBranch, | |
| 342 | pr: gitStatus.pr, | |
| 343 | sourceControlProvider: gitStatus.sourceControlProvider, | |
| 344 | }; | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * Live PR when the checkout matches the thread branch; otherwise, for local | |
| 349 | * checkouts only, a cached merged/closed PR for the thread. Local thread | |
| 350 | * metadata follows the shared checkout, so the cached branch intentionally | |
| 351 | * survives that metadata changing to the newly checked-out branch. Open PRs | |
| 352 | * are never retained — their state can still change. | |
| 353 | */ | |
| 354 | export function resolveDisplayedThreadPr(input: { | |
| 355 | threadBranch: string | null; | |
| 356 | gitStatus: VcsStatusResult | null; | |
| 357 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 358 | retainTerminalOnBranchMismatch: boolean; | |
| 359 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 360 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 361 | }): ThreadPr | null { | |
| 362 | const { | |
| 363 | threadBranch, | |
| 364 | gitStatus, | |
| 365 | snapshot, | |
| 366 | retainTerminalOnBranchMismatch, | |
| 367 | linkedPullRequest, | |
| 368 | linkedPullRequestStatus, | |
| 369 | } = input; | |
| 370 | if (linkedPullRequest != null) { | |
| 371 | return ( | |
| 372 | linkedPullRequestStatus?.pr ?? | |
| 373 | (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 374 | ? (snapshot?.pr ?? null) | |
| 375 | : null) | |
| 376 | ); | |
| 377 | } | |
| 378 | if ( | |
| 379 | threadBranch !== null && | |
| 380 | gitStatus !== null && | |
| 381 | gitStatus.refName === threadBranch && | |
| 382 | gitStatus.pr != null | |
| 383 | ) { | |
| 384 | return gitStatus.pr; | |
| 385 | } | |
| 386 | ||
| 387 | if ( | |
| 388 | threadBranch !== null && | |
| 389 | retainTerminalOnBranchMismatch && | |
| 390 | snapshot != null && | |
| 391 | snapshot.linkedPullRequest === undefined && | |
| 392 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 393 | ) { | |
| 394 | return snapshot.pr; | |
| 395 | } | |
| 396 | ||
| 397 | return null; | |
| 398 | } | |
| 399 | ||
| 400 | export function resolveDisplayedThreadPrProvider(input: { | |
| 401 | threadBranch: string | null; | |
| 402 | gitStatus: VcsStatusResult | null; | |
| 403 | snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 404 | retainTerminalOnBranchMismatch: boolean; | |
| 405 | linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 406 | linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 407 | }): VcsStatusResult["sourceControlProvider"] | undefined { | |
| 408 | const { | |
| 409 | threadBranch, | |
| 410 | gitStatus, | |
| 411 | snapshot, | |
| 412 | retainTerminalOnBranchMismatch, | |
| 413 | linkedPullRequest, | |
| 414 | linkedPullRequestStatus, | |
| 415 | } = input; | |
| 416 | if (linkedPullRequest != null) { | |
| 417 | return ( | |
| 418 | linkedPullRequestStatus?.sourceControlProvider ?? | |
| 419 | (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 420 | ? snapshot?.sourceControlProvider | |
| 421 | : undefined) | |
| 422 | ); | |
| 423 | } | |
| 424 | if ( | |
| 425 | threadBranch !== null && | |
| 426 | gitStatus !== null && | |
| 427 | gitStatus.refName === threadBranch && | |
| 428 | gitStatus.pr != null | |
| 429 | ) { | |
| 430 | return gitStatus.sourceControlProvider; | |
| 431 | } | |
| 432 | ||
| 433 | if ( | |
| 434 | threadBranch !== null && | |
| 435 | retainTerminalOnBranchMismatch && | |
| 436 | snapshot != null && | |
| 437 | snapshot.linkedPullRequest === undefined && | |
| 438 | isTerminalChangeRequestState(snapshot.pr.state) | |
| 439 | ) { | |
| 440 | return snapshot.sourceControlProvider; | |
| 441 | } | |
| 442 | ||
| 443 | return undefined; | |
| 444 | } | |
| 445 | ||
| 446 | 175 | export function terminalStatusFromRunningIds( |
| 447 | 176 | runningTerminalIds: ReadonlyArray<string>, |
| 448 | 177 | ): TerminalStatusIndicator | null { |
| ... | ... | @@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 551 | 280 | const lastVisitedAt = useUiStateStore( |
| 552 | 281 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| 553 | 282 | ); |
| 554 | const threadProject = useProject( | |
| 555 | useMemo( | |
| 556 | () => scopeProjectRef(thread.environmentId, thread.projectId), | |
| 557 | [thread.environmentId, thread.projectId], | |
| 558 | ), | |
| 559 | ); | |
| 560 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; | |
| 561 | const gitCwd = thread.worktreePath ?? threadProjectCwd; | |
| 562 | const linkedPullRequest = useLinkedThreadPullRequest( | |
| 283 | const pullRequest = useLinkedThreadPullRequest( | |
| 563 | 284 | thread.environmentId, |
| 564 | thread.linkedPullRequest, | |
| 565 | ); | |
| 566 | const gitStatus = useEnvironmentQuery( | |
| 567 | thread.linkedPullRequest == null && | |
| 568 | (thread.branch != null || thread.worktreePath !== null) && | |
| 569 | gitCwd !== null | |
| 570 | ? vcsEnvironment.status({ | |
| 571 | environmentId: thread.environmentId, | |
| 572 | input: { cwd: gitCwd }, | |
| 573 | }) | |
| 574 | : null, | |
| 575 | ); | |
| 576 | const pr = | |
| 577 | thread.linkedPullRequest == null | |
| 578 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | |
| 579 | : (linkedPullRequest?.pr ?? null); | |
| 580 | const prStatus = prStatusIndicator( | |
| 581 | pr, | |
| 582 | linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | |
| 285 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 583 | 286 | ); |
| 287 | const pr = pullRequest?.pr ?? null; | |
| 288 | const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); | |
| 584 | 289 | const threadStatus = resolveThreadStatusPill({ |
| 585 | 290 | thread: { |
| 586 | 291 | ...thread, |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx-12| ... | ... | @@ -8,7 +8,6 @@ import { |
| 8 | 8 | type PullRequestListEntry, |
| 9 | 9 | type PullRequestUpdateMethod, |
| 10 | 10 | type PullRequestRef, |
| 11 | type PullRequestState, | |
| 12 | 11 | resolveEnvironmentMachineKind, |
| 13 | 12 | type ScopedThreadRef, |
| 14 | 13 | } from "@t3tools/contracts"; |
| ... | ... | @@ -454,7 +453,6 @@ export function PullRequestDetailPanel({ |
| 454 | 453 | refreshToken: forcedRefreshToken = 0, |
| 455 | 454 | onActed, |
| 456 | 455 | onClose, |
| 457 | onStateChange, | |
| 458 | 456 | context = "page", |
| 459 | 457 | composerDraftTarget, |
| 460 | 458 | }: { |
| ... | ... | @@ -482,8 +480,6 @@ export function PullRequestDetailPanel({ |
| 482 | 480 | onActed?: () => void; |
| 483 | 481 | /** Page-owned detail columns use this to clear the selected pull request. */ |
| 484 | 482 | onClose?: () => void; |
| 485 | /** Keeps surrounding inferred thread state in step with refreshed host state. */ | |
| 486 | onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; | |
| 487 | 483 | /** |
| 488 | 484 | * Beside a thread, the checkout affordance disappears: the panel is showing that thread's |
| 489 | 485 | * own pull request, so the branch is already under the reader's feet — and checking it out |
| ... | ... | @@ -714,14 +710,6 @@ export function PullRequestDetailPanel({ |
| 714 | 710 | } |
| 715 | 711 | activityRevision.current = next; |
| 716 | 712 | }, [activityQuery.refresh, coreDetail, tabScopeKey]); |
| 717 | useLayoutEffect(() => { | |
| 718 | if (!resolvedCoreDetail) return; | |
| 719 | onStateChange?.({ | |
| 720 | repository: resolvedCoreDetail.repository, | |
| 721 | number: resolvedCoreDetail.number, | |
| 722 | state: resolvedCoreDetail.state, | |
| 723 | }); | |
| 724 | }, [onStateChange, resolvedCoreDetail]); | |
| 725 | 713 | // Reuse activity and diff until core detail reports a changed revision. Keyed by |
| 726 | 714 | // the pull request rather than by the panel, because this one panel shows a different pull |
| 727 | 715 | // request every time it is opened. |
docs/user/thread-sidebar.md+6-1| ... | ... | @@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads. |
| 49 | 49 | |
| 50 | 50 | ## Link a pull request |
| 51 | 51 | |
| 52 | The server finds the PR for each unsettled thread's saved branch, even when your | |
| 53 | apps are closed. Settled threads keep their saved links. Update the server if | |
| 54 | automatic branch links do not appear. | |
| 55 | ||
| 52 | 56 | On web and desktop, right-click a pull request link in a thread and choose |
| 53 | **Link to thread**. Use **Unlink from thread** on the same link to remove it. | |
| 57 | **Link to thread** to select a different PR. Use **Unlink from thread** on the | |
| 58 | same link to return to the branch PR, if one exists. | |
| 54 | 59 | The linked pull request participates in automatic settlement. |
| 55 | 60 | |
| 56 | 61 | ## Find and reference work |
packages/client-runtime/src/state/threadReducer.test.ts+56-41| ... | ... | @@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => { |
| 306 | 306 | } |
| 307 | 307 | }); |
| 308 | 308 | |
| 309 | it("sets and clears a linked pull request", () => { | |
| 310 | const linkedPullRequest = { | |
| 311 | projectId: ProjectId.make("project-1"), | |
| 312 | repository: "pingdotgg/t3code", | |
| 313 | number: 42, | |
| 314 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 315 | }; | |
| 316 | const linked = applyThreadDetailEvent(baseThread, { | |
| 317 | ...baseEventFields, | |
| 318 | sequence: 5, | |
| 319 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 320 | aggregateKind: "thread", | |
| 321 | aggregateId: ThreadId.make("thread-1"), | |
| 322 | type: "thread.meta-updated", | |
| 323 | payload: { | |
| 324 | threadId: ThreadId.make("thread-1"), | |
| 325 | linkedPullRequest, | |
| 326 | updatedAt: "2026-04-01T05:00:00.000Z", | |
| 327 | }, | |
| 328 | }); | |
| 309 | it.each(["linkedPullRequest", "branchPullRequest"] as const)( | |
| 310 | "sets and clears %s without changing the other link", | |
| 311 | (field) => { | |
| 312 | const linkedPullRequest = { | |
| 313 | projectId: ProjectId.make("project-1"), | |
| 314 | repository: "pingdotgg/t3code", | |
| 315 | number: 42, | |
| 316 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 317 | }; | |
| 318 | const otherField = | |
| 319 | field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest"; | |
| 320 | const otherPullRequest = { | |
| 321 | ...linkedPullRequest, | |
| 322 | number: 43, | |
| 323 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 324 | }; | |
| 325 | const linked = applyThreadDetailEvent( | |
| 326 | { ...baseThread, [otherField]: otherPullRequest }, | |
| 327 | { | |
| 328 | ...baseEventFields, | |
| 329 | sequence: 5, | |
| 330 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 331 | aggregateKind: "thread", | |
| 332 | aggregateId: ThreadId.make("thread-1"), | |
| 333 | type: "thread.meta-updated", | |
| 334 | payload: { | |
| 335 | threadId: ThreadId.make("thread-1"), | |
| 336 | [field]: linkedPullRequest, | |
| 337 | updatedAt: "2026-04-01T05:00:00.000Z", | |
| 338 | }, | |
| 339 | }, | |
| 340 | ); | |
| 329 | 341 | |
| 330 | expect(linked.kind).toBe("updated"); | |
| 331 | if (linked.kind !== "updated") return; | |
| 332 | expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest); | |
| 342 | expect(linked.kind).toBe("updated"); | |
| 343 | if (linked.kind !== "updated") return; | |
| 344 | expect(linked.thread[field]).toEqual(linkedPullRequest); | |
| 345 | expect(linked.thread[otherField]).toEqual(otherPullRequest); | |
| 333 | 346 | |
| 334 | const cleared = applyThreadDetailEvent(linked.thread, { | |
| 335 | ...baseEventFields, | |
| 336 | sequence: 6, | |
| 337 | occurredAt: "2026-04-01T06:00:00.000Z", | |
| 338 | aggregateKind: "thread", | |
| 339 | aggregateId: ThreadId.make("thread-1"), | |
| 340 | type: "thread.meta-updated", | |
| 341 | payload: { | |
| 342 | threadId: ThreadId.make("thread-1"), | |
| 343 | linkedPullRequest: null, | |
| 344 | updatedAt: "2026-04-01T06:00:00.000Z", | |
| 345 | }, | |
| 346 | }); | |
| 347 | const cleared = applyThreadDetailEvent(linked.thread, { | |
| 348 | ...baseEventFields, | |
| 349 | sequence: 6, | |
| 350 | occurredAt: "2026-04-01T06:00:00.000Z", | |
| 351 | aggregateKind: "thread", | |
| 352 | aggregateId: ThreadId.make("thread-1"), | |
| 353 | type: "thread.meta-updated", | |
| 354 | payload: { | |
| 355 | threadId: ThreadId.make("thread-1"), | |
| 356 | [field]: null, | |
| 357 | updatedAt: "2026-04-01T06:00:00.000Z", | |
| 358 | }, | |
| 359 | }); | |
| 347 | 360 | |
| 348 | expect(cleared.kind).toBe("updated"); | |
| 349 | if (cleared.kind === "updated") { | |
| 350 | expect(cleared.thread.linkedPullRequest).toBeNull(); | |
| 351 | } | |
| 352 | }); | |
| 361 | expect(cleared.kind).toBe("updated"); | |
| 362 | if (cleared.kind === "updated") { | |
| 363 | expect(cleared.thread[field]).toBeNull(); | |
| 364 | expect(cleared.thread[otherField]).toEqual(otherPullRequest); | |
| 365 | } | |
| 366 | }, | |
| 367 | ); | |
| 353 | 368 | }); |
| 354 | 369 | |
| 355 | 370 | describe("thread.message-sent", () => { |
packages/client-runtime/src/state/threadReducer.ts+4| ... | ... | @@ -97,6 +97,7 @@ export function applyThreadDetailEvent( |
| 97 | 97 | interactionMode: event.payload.interactionMode, |
| 98 | 98 | branch: event.payload.branch, |
| 99 | 99 | worktreePath: event.payload.worktreePath, |
| 100 | branchPullRequest: null, | |
| 100 | 101 | latestTurn: null, |
| 101 | 102 | createdAt: event.payload.createdAt, |
| 102 | 103 | updatedAt: event.payload.updatedAt, |
| ... | ... | @@ -240,6 +241,9 @@ export function applyThreadDetailEvent( |
| 240 | 241 | ...(event.payload.linkedPullRequest !== undefined |
| 241 | 242 | ? { linkedPullRequest: event.payload.linkedPullRequest } |
| 242 | 243 | : {}), |
| 244 | ...(event.payload.branchPullRequest !== undefined | |
| 245 | ? { branchPullRequest: event.payload.branchPullRequest } | |
| 246 | : {}), | |
| 243 | 247 | updatedAt: event.payload.updatedAt, |
| 244 | 248 | }, |
| 245 | 249 | }; |
packages/contracts/src/orchestration.test.ts+42| ... | ... | @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as Schema from "effect/Schema"; |
| 5 | import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; | |
| 5 | 6 | |
| 6 | 7 | import { |
| 7 | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | ... | @@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () => |
| 837 | 838 | }), |
| 838 | 839 | ); |
| 839 | 840 | |
| 841 | it.effect("accepts pull request synchronization only as an internal command", () => | |
| 842 | Effect.gen(function* () { | |
| 843 | const pullRequest = { | |
| 844 | projectId: ProjectId.make("project-1"), | |
| 845 | repository: "pingdotgg/t3code", | |
| 846 | number: 42, | |
| 847 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 848 | }; | |
| 849 | const command = { | |
| 850 | type: "thread.pull-request.sync" as const, | |
| 851 | commandId: CommandId.make("cmd-pull-request-sync"), | |
| 852 | threadId: ThreadId.make("thread-1"), | |
| 853 | projectId: pullRequest.projectId, | |
| 854 | snapshotSequence: 12, | |
| 855 | expected: { | |
| 856 | workspaceRoot: "/workspace/project", | |
| 857 | branch: "feature", | |
| 858 | worktreePath: null, | |
| 859 | linkedPullRequest: null, | |
| 860 | branchPullRequest: null, | |
| 861 | }, | |
| 862 | branchPullRequest: pullRequest, | |
| 863 | linkedPullRequest: pullRequest, | |
| 864 | }; | |
| 865 | ||
| 866 | assert.deepStrictEqual(yield* decodeOrchestrationCommand(command), command); | |
| 867 | assert.ok(yield* decodeClientOrchestrationCommand(command).pipe(Effect.flip)); | |
| 868 | ||
| 869 | const cleared = { ...command, branchPullRequest: null }; | |
| 870 | assert.deepStrictEqual(yield* decodeOrchestrationCommand(cleared), cleared); | |
| 871 | ||
| 872 | const metadata = yield* decodeClientOrchestrationCommand({ | |
| 873 | type: "thread.meta.update", | |
| 874 | commandId: "cmd-forged-branch-pull-request", | |
| 875 | threadId: "thread-1", | |
| 876 | branchPullRequest: pullRequest, | |
| 877 | }); | |
| 878 | assert.isFalse("branchPullRequest" in metadata); | |
| 879 | }), | |
| 880 | ); | |
| 881 | ||
| 840 | 882 | it.effect("rejects an explicit title combined with title regeneration", () => |
| 841 | 883 | Effect.gen(function* () { |
| 842 | 884 | const result = yield* Effect.exit( |
packages/contracts/src/orchestration.ts+21| ... | ... | @@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 494 | 494 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 495 | 495 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 496 | 496 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 497 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 497 | 498 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 498 | 499 | createdAt: IsoDateTime, |
| 499 | 500 | updatedAt: IsoDateTime, |
| ... | ... | @@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 572 | 573 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 573 | 574 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 574 | 575 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 576 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 575 | 577 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 576 | 578 | createdAt: IsoDateTime, |
| 577 | 579 | updatedAt: IsoDateTime, |
| ... | ... | @@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ |
| 1183 | 1185 | title: Schema.optional(TrimmedNonEmptyString), |
| 1184 | 1186 | }); |
| 1185 | 1187 | |
| 1188 | const ThreadPullRequestSyncCommand = Schema.Struct({ | |
| 1189 | type: Schema.Literal("thread.pull-request.sync"), | |
| 1190 | commandId: CommandId, | |
| 1191 | threadId: ThreadId, | |
| 1192 | projectId: ProjectId, | |
| 1193 | snapshotSequence: NonNegativeInt, | |
| 1194 | expected: Schema.Struct({ | |
| 1195 | workspaceRoot: TrimmedNonEmptyString, | |
| 1196 | branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 1197 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 1198 | linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1199 | branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1200 | }), | |
| 1201 | branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1202 | linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), | |
| 1203 | }); | |
| 1204 | ||
| 1186 | 1205 | const InternalOrchestrationCommand = Schema.Union([ |
| 1187 | 1206 | ThreadAutoSettleCommand, |
| 1188 | 1207 | ThreadSessionSetCommand, |
| ... | ... | @@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([ |
| 1194 | 1213 | ThreadActivityAppendCommand, |
| 1195 | 1214 | ThreadRevertCompleteCommand, |
| 1196 | 1215 | ThreadTitleRegenerationCompleteCommand, |
| 1216 | ThreadPullRequestSyncCommand, | |
| 1197 | 1217 | ]); |
| 1198 | 1218 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; |
| 1199 | 1219 | |
| ... | ... | @@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1367 | 1387 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1368 | 1388 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1369 | 1389 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 1390 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 1370 | 1391 | updatedAt: IsoDateTime, |
| 1371 | 1392 | }); |
| 1372 | 1393 |
packages/shared/src/threadReference.test.ts+2-17| ... | ... | @@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => { |
| 9 | 9 | threadId: "thread-1", |
| 10 | 10 | openPanelPullRequestUrl: null, |
| 11 | 11 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 12 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 13 | 12 | }), |
| 14 | 13 | ).toBeNull(); |
| 15 | 14 | }); |
| 16 | 15 | |
| 17 | it("prefers the open panel pull request over linked and detected pull requests", () => { | |
| 16 | it("prefers the open panel pull request over the thread pull request", () => { | |
| 18 | 17 | expect( |
| 19 | 18 | resolveThreadReferenceCopyTarget({ |
| 20 | 19 | threadId: "thread-1", |
| 21 | 20 | openPanelPullRequestUrl: "https://github.com/t3/pr/14", |
| 22 | 21 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 23 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 24 | 22 | }), |
| 25 | 23 | ).toMatchObject({ |
| 26 | 24 | kind: "pull-request", |
| ... | ... | @@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => { |
| 29 | 27 | }); |
| 30 | 28 | }); |
| 31 | 29 | |
| 32 | it("prefers a durable linked pull request", () => { | |
| 30 | it("uses the thread pull request when no panel is open", () => { | |
| 33 | 31 | expect( |
| 34 | 32 | resolveThreadReferenceCopyTarget({ |
| 35 | 33 | threadId: "thread-1", |
| 36 | 34 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 37 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 38 | 35 | }), |
| 39 | 36 | ).toMatchObject({ |
| 40 | 37 | kind: "pull-request", |
| ... | ... | @@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => { |
| 43 | 40 | }); |
| 44 | 41 | }); |
| 45 | 42 | |
| 46 | it("uses a pull request detected from the active branch", () => { | |
| 47 | expect( | |
| 48 | resolveThreadReferenceCopyTarget({ | |
| 49 | threadId: "thread-1", | |
| 50 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 51 | }), | |
| 52 | ).toMatchObject({ | |
| 53 | kind: "pull-request", | |
| 54 | value: "https://github.com/t3/pr/13", | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | 43 | it("falls back to the thread ID", () => { |
| 59 | 44 | expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ |
| 60 | 45 | kind: "thread", |
packages/shared/src/threadReference.ts+1-3| ... | ... | @@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: { |
| 11 | 11 | /** Undefined means no PR panel; null means its URL is not available yet. */ |
| 12 | 12 | readonly openPanelPullRequestUrl?: string | null | undefined; |
| 13 | 13 | readonly linkedPullRequestUrl?: string | null; |
| 14 | readonly detectedPullRequestUrl?: string | null; | |
| 15 | 14 | }): ThreadReferenceCopyTarget | null { |
| 16 | 15 | if (input.openPanelPullRequestUrl === null) return null; |
| 17 | const pullRequestUrl = | |
| 18 | input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; | |
| 16 | const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl; | |
| 19 | 17 | return pullRequestUrl |
| 20 | 18 | ? { |
| 21 | 19 | kind: "pull-request", |