History

feat(pull-requests): link multiple pull requests to threads (#10839)

Julius Marminge committed 2 months ago

Commit afb84891 parent 383cc40172 files · +12057 −908Browse files

Unified · Split

172 files
  1. apps/mobile/src/components/AppSymbol.tsx +2 −0
  2. apps/mobile/src/features/archive/archivedThreadList.test.ts +1 −0
  3. apps/mobile/src/features/home/homeListItems.test.ts +1 −0
  4. apps/mobile/src/features/home/homeThreadList.test.ts +1 −0
  5. apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +1 −0
  6. apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +58 −1
  7. apps/mobile/src/features/threads/thread-list-items.tsx +25 −9
  8. apps/mobile/src/features/threads/thread-list-v2-items.tsx +29 −14
  9. apps/mobile/src/features/threads/threadListV2.test.ts +1 −0
  10. apps/mobile/src/lib/threadActivity.test.ts +1 −0
  11. apps/mobile/src/state/pending-thread-creation.ts +1 −0
  12. apps/mobile/src/state/thread-pr-presentation.ts +64 −2
  13. apps/mobile/src/state/use-selected-thread-git-actions.ts +2 −0
  14. apps/mobile/src/state/use-thread-pr.test.ts +145 −2
  15. apps/mobile/src/state/use-thread-pr.ts +25 −5
  16. apps/mobile/src/state/use-thread-selection.ts +1 −0
  17. apps/server/integration/OrchestrationEngineHarness.integration.ts +8 −0
  18. apps/server/src/auth/RpcAuthorization.ts +2 −0
  19. apps/server/src/environment/ServerEnvironment.test.ts +1 −0
  20. apps/server/src/environment/ServerEnvironment.ts +1 −0
  21. apps/server/src/git/linkCreatedPullRequest.test.ts +230 −0
  22. apps/server/src/git/linkCreatedPullRequest.ts +98 −0
  23. apps/server/src/mcp/McpHttpServer.test.ts +47 −0
  24. apps/server/src/mcp/McpHttpServer.ts +10 −1
  25. apps/server/src/mcp/McpInvocationContext.test.ts +27 −0
  26. apps/server/src/mcp/McpInvocationContext.ts +30 −15
  27. apps/server/src/mcp/McpProviderSession.ts +2 −0
  28. apps/server/src/mcp/McpSessionRegistry.test.ts +28 −0
  29. apps/server/src/mcp/McpSessionRegistry.ts +10 −2
  30. apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +418 −0
  31. apps/server/src/mcp/toolkits/pullRequests/handlers.ts +243 −0
  32. apps/server/src/mcp/toolkits/pullRequests/tools.ts +231 −0
  33. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +35 −5
  34. apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +12 −0
  35. apps/server/src/orchestration/Layers/OrchestrationReactor.ts +3 −0
  36. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +237 −0
  37. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +104 −0
  38. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +146 −17
  39. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +423 −187
  40. apps/server/src/orchestration/PullRequestSyncReactor.test.ts +704 −0
  41. apps/server/src/orchestration/PullRequestSyncReactor.ts +328 −0
  42. apps/server/src/orchestration/Schemas.ts +6 −0
  43. apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +1 −0
  44. apps/server/src/orchestration/ThreadPullRequestReactor.ts +6 −13
  45. apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +74 −0
  46. apps/server/src/orchestration/ThreadSettlementPolicy.ts +24 −1
  47. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +52 −0
  48. apps/server/src/orchestration/ThreadSettlementReactor.ts +3 −1
  49. apps/server/src/orchestration/commandInvariants.test.ts +2 −0
  50. apps/server/src/orchestration/decider.active-order.test.ts +1 −0
  51. apps/server/src/orchestration/decider.pinned.test.ts +1 −0
  52. apps/server/src/orchestration/decider.pullRequests.test.ts +532 −0
  53. apps/server/src/orchestration/decider.questionAttachments.test.ts +1 −0
  54. apps/server/src/orchestration/decider.settled.test.ts +1 −0
  55. apps/server/src/orchestration/decider.snoozed.test.ts +1 −0
  56. apps/server/src/orchestration/decider.titleRegeneration.test.ts +1 −0
  57. apps/server/src/orchestration/decider.ts +221 −0
  58. apps/server/src/orchestration/decider.userInputDismiss.test.ts +1 −0
  59. apps/server/src/orchestration/projector.pullRequests.test.ts +417 −0
  60. apps/server/src/orchestration/projector.test.ts +26 −1
  61. apps/server/src/orchestration/projector.ts +212 −25
  62. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +167 −1
  63. apps/server/src/persistence/Migrations.ts +2 −0
  64. apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts +181 −0
  65. apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts +92 −0
  66. apps/server/src/persistence/ProjectionThreadPullRequests.ts +266 −0
  67. apps/server/src/project/AgentSessionImporter.test.ts +1 −0
  68. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +2 −2
  69. apps/server/src/provider/Layers/CodexAdapter.ts +1 −0
  70. apps/server/src/provider/Layers/CodexSessionRuntime.ts +11 −2
  71. apps/server/src/provider/Layers/ProviderService.test.ts +14 −30
  72. apps/server/src/provider/Layers/ProviderService.ts +13 −23
  73. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +1 −0
  74. apps/server/src/provider/RuntimeInstructions.test.ts +8 −0
  75. apps/server/src/provider/RuntimeInstructions.ts +5 −1
  76. apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +227 −26
  77. apps/server/src/pullRequest/GitHubPullRequestCli.ts +94 −42
  78. apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +61 −0
  79. apps/server/src/pullRequest/GitHubPullRequestProvider.ts +15 −1
  80. apps/server/src/pullRequest/PullRequestProvider.ts +36 −0
  81. apps/server/src/pullRequest/PullRequestService.test.ts +323 −39
  82. apps/server/src/pullRequest/PullRequestService.ts +202 −59
  83. apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +79 −0
  84. apps/server/src/pullRequest/gitHubPullRequestJson.ts +66 −0
  85. apps/server/src/pullRequest/linkedThreads.test.ts +117 −0
  86. apps/server/src/pullRequest/linkedThreads.ts +39 −0
  87. apps/server/src/pullRequest/pullRequestSyncKey.test.ts +46 −0
  88. apps/server/src/pullRequest/pullRequestSyncKey.ts +44 −0
  89. apps/server/src/relay/AgentAwarenessRelay.test.ts +3 −0
  90. apps/server/src/server.test.ts +10 −1
  91. apps/server/src/server.ts +2 −0
  92. apps/server/src/ws.ts +89 −8
  93. apps/web/src/components/ChatMarkdown.test.tsx +2 −2
  94. apps/web/src/components/ChatMarkdown.tsx +25 −50
  95. apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +2 −2
  96. apps/web/src/components/ChatView.logic.test.ts +2 −0
  97. apps/web/src/components/ChatView.logic.ts +1 −0
  98. apps/web/src/components/ChatView.tsx +30 −2
  99. apps/web/src/components/CommandPalette.logic.test.ts +1 −0
  100. apps/web/src/components/CommandPalette.tsx +38 −1
  101. apps/web/src/components/GitActionsControl.tsx +3 −0
  102. apps/web/src/components/LegacySidebar.tsx +45 −4
  103. apps/web/src/components/RightPanelTabs.test.tsx +48 −0
  104. apps/web/src/components/RightPanelTabs.tsx +97 −3
  105. apps/web/src/components/Sidebar.logic.test.ts +1 −0
  106. apps/web/src/components/Sidebar.tsx +114 −11
  107. apps/web/src/components/ThreadStatusIndicators.test.tsx +45 −2
  108. apps/web/src/components/ThreadStatusIndicators.tsx +161 −28
  109. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +1 −0
  110. apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx +54 −0
  111. apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts +136 −0
  112. apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +254 −0
  113. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +116 −8
  114. apps/web/src/components/pullRequest/PullRequestStackMap.tsx +87 −0
  115. apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +11 −4
  116. apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +255 −0
  117. apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +296 −0
  118. apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +36 −0
  119. apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +11 −3
  120. apps/web/src/components/pullRequest/pullRequestListLines.test.ts +76 −0
  121. apps/web/src/components/pullRequest/pullRequestListLines.ts +49 −0
  122. apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts +30 −1
  123. apps/web/src/components/pullRequest/pullRequestReviewStore.ts +11 −8
  124. apps/web/src/components/ui/button.tsx +21 −0
  125. apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +5 −0
  126. apps/web/src/hooks/usePullRequestLinking.ts +104 −0
  127. apps/web/src/hooks/useSupportsMultiplePullRequests.ts +10 −0
  128. apps/web/src/lib/openPullRequestLink.test.ts +76 −0
  129. apps/web/src/lib/openPullRequestLink.ts +76 −182
  130. apps/web/src/lib/threadSort.test.ts +1 −0
  131. apps/web/src/rightPanelStore.test.ts +12 −0
  132. apps/web/src/rightPanelStore.ts +12 −1
  133. apps/web/src/routes/_chat.pull-requests.tsx +2 −0
  134. apps/web/src/state/pullRequests.ts +3 −0
  135. apps/web/src/state/sourceControlActions.ts +2 −0
  136. apps/web/src/worktreeCleanup.test.ts +1 −0
  137. docs/internals/glossary.md +8 −0
  138. docs/internals/overview.md +19 −0
  139. docs/user/source-control.md +24 −0
  140. packages/client-runtime/package.json +4 −0
  141. packages/client-runtime/src/operations/commands.ts +20 −0
  142. packages/client-runtime/src/state/entities.test.ts +1 −0
  143. packages/client-runtime/src/state/environmentHttpAuth.test.ts +1 −0
  144. packages/client-runtime/src/state/pullRequests.test.ts +89 −38
  145. packages/client-runtime/src/state/pullRequests.ts +27 −2
  146. packages/client-runtime/src/state/shellReducer.test.ts +1 −0
  147. packages/client-runtime/src/state/threadCommands.ts +18 −0
  148. packages/client-runtime/src/state/threadReducer.test.ts +119 −0
  149. packages/client-runtime/src/state/threadReducer.ts +60 −0
  150. packages/client-runtime/src/state/threads-atoms.test.ts +1 −0
  151. packages/client-runtime/src/state/threads-pagination.test.ts +1 −0
  152. packages/client-runtime/src/state/threads-sync.test.ts +1 −0
  153. packages/client-runtime/src/state/vcsAction.test.ts +11 −2
  154. packages/client-runtime/src/state/vcsAction.ts +4 −0
  155. packages/client-runtime/src/threadPullRequestCompatibility.test.ts +115 −0
  156. packages/client-runtime/src/threadPullRequestCompatibility.ts +75 −0
  157. packages/contracts/src/environment.ts +6 −1
  158. packages/contracts/src/git.ts +2 −0
  159. packages/contracts/src/orchestration.test.ts +161 −11
  160. packages/contracts/src/orchestration.ts +168 −0
  161. packages/contracts/src/previewAutomation.ts +21 −4
  162. packages/contracts/src/pullRequest.ts +49 −0
  163. packages/contracts/src/rpc.ts +18 −0
  164. packages/shared/package.json +8 −0
  165. packages/shared/src/changeRequestUrl.test.ts +142 −0
  166. packages/shared/src/changeRequestUrl.ts +210 −0
  167. packages/shared/src/sourceControl.test.ts +32 −0
  168. packages/shared/src/sourceControl.ts +45 −1
  169. packages/shared/src/threadPullRequests.test.ts +351 −0
  170. packages/shared/src/threadPullRequests.ts +271 −0
  171. packages/shared/src/threadReference.test.ts +66 −0
  172. packages/shared/src/threadReference.ts +9 −1
apps/mobile/src/components/AppSymbol.tsx+2−0
@@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch"; · 77 unmodified lines
7878 import IconServer from "@tabler/icons-react-native/IconServer";
7979 import IconSettings from "@tabler/icons-react-native/IconSettings";
8080 import IconSparkles from "@tabler/icons-react-native/IconSparkles";
81+import IconStack2 from "@tabler/icons-react-native/IconStack2";
8182 import IconSun from "@tabler/icons-react-native/IconSun";
8283 import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
8384 import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
@@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 16 unmodified lines
100101 "arrow.right.circle": IconArrowRightCircle,
101102 "arrow.triangle.branch": IconGitBranch,
102103 "arrow.triangle.pull": IconGitPullRequest,
104+ "square.3.layers.3d": IconStack2,
103105 "arrow.turn.left.up": IconArrowBackUp,
104106 "arrow.up": IconArrowUp,
105107 "arrow.up.circle": IconArrowUpCircle,
apps/mobile/src/features/archive/archivedThreadList.test.ts+1−0
@@ -31,6 +31,7 @@ function makeThread( · 30 unmodified lines
3131 interactionMode: "default",
3232 branch: null,
3333 worktreePath: null,
34+ pullRequests: [],
3435 latestTurn: null,
3536 createdAt: "2026-06-01T00:00:00.000Z",
3637 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/home/homeListItems.test.ts+1−0
@@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { · 42 unmodified lines
4343 interactionMode: "default",
4444 branch: null,
4545 worktreePath: null,
46+ pullRequests: [],
4647 latestTurn: null,
4748 createdAt: "2026-06-01T00:00:00.000Z",
4849 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/home/homeThreadList.test.ts+1−0
@@ -36,6 +36,7 @@ function makeThread( · 35 unmodified lines
3636 interactionMode: "default",
3737 branch: null,
3838 worktreePath: null,
39+ pullRequests: [],
3940 latestTurn: null,
4041 createdAt: "2026-06-01T00:00:00.000Z",
4142 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+1−0
@@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({ · 43 unmodified lines
4444 ? null
4545 : resolveThreadReferenceCopyTarget({
4646 threadId: activeThread?.id ?? activeThreadRef.threadId,
47+ pullRequests: activeThread?.pullRequests,
4748 linkedPullRequestUrl:
4849 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
4950 }),
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+58−1
@@ -4,6 +4,10 @@ import { · 3 unmodified lines
44 getGitActionDisabledReason,
55 requiresDefaultBranchConfirmation,
66 } from "@t3tools/client-runtime/state/vcs";
7+import {
8+ resolveThreadPullRequestChains,
9+ threadPullRequestKeyOf,
10+} from "@t3tools/shared/threadPullRequests";
711 import { EnvironmentId, ThreadId } from "@t3tools/contracts";
812 import {
913 CommonActions,
@@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { · 39 unmodified lines
4953 const isInspector = presentation === "inspector";
5054 const environmentId = EnvironmentId.make(props.route.params.environmentId);
5155 const threadId = ThreadId.make(props.route.params.threadId);
52− const { selectedThread } = useThreadSelection();
56+ const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection();
5357 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
58+ const supportsLinkedPrSnapshots =
59+ selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true;
60+ const linkedPrChains = useMemo(
61+ () =>
62+ resolveThreadPullRequestChains(
63+ supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [],
64+ ),
65+ [selectedThread?.pullRequests, supportsLinkedPrSnapshots],
66+ );
5467 const gitState = useSelectedThreadGitState();
5568 const gitActions = useSelectedThreadGitActions();
5669 const theme = useUniwindTheme();
@@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { · 228 unmodified lines
285298 />
286299 </View>
287300
301+ {linkedPrChains.length > 0 ? (
302+ <View className="gap-2">
303+ <Text className="px-1 text-xs font-t3-bold text-foreground-muted">
304+ Linked pull requests
305+ </Text>
306+ {linkedPrChains.map((chain) => (
307+ <View
308+ key={threadPullRequestKeyOf(chain.layers[0]!)}
309+ className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
310+ >
311+ {chain.layers.length > 1 ? (
312+ <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
313+ <SymbolView
314+ name="square.3.layers.3d"
315+ size={14}
316+ tintColorClassName="accent-foreground-muted"
317+ />
318+ <Text className="text-xs text-foreground-muted">
319+ {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs
320+ · bottom to top
321+ </Text>
322+ </View>
323+ ) : null}
324+ {chain.layers.map((link, index) => (
325+ <View key={threadPullRequestKeyOf(link)}>
326+ {index > 0 ? <View className="ml-12 h-px bg-border" /> : null}
327+ <SheetListRow
328+ icon="arrow.triangle.pull"
329+ title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
330+ subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`}
331+ onPress={() => {
332+ void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
333+ if (!opened)
334+ Alert.alert("Unable to open PR", "The pull request could not be opened.");
335+ });
336+ }}
337+ />
338+ </View>
339+ ))}
340+ </View>
341+ ))}
342+ </View>
343+ ) : null}
344+
288345 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
289346 </ScrollView>
290347 );
apps/mobile/src/features/threads/thread-list-items.tsx+25−9
@@ -56,6 +56,7 @@ function pullRequestTintColor( · 55 unmodified lines
5656 return dark ? "#34d399" : "#059669";
5757 case "merged":
5858 return dark ? "#a78bfa" : "#7c3aed";
59+ case null:
5960 case "closed":
6061 return dark ? "#a1a1aa" : "#71717a";
6162 }
@@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 556 unmodified lines
618619 </>
619620 ) : null}
620621 {pr !== null ? (
621− <View className="flex-row items-center gap-0.5">
622− <PullRequestIcon
623− size={compact ? 13 : 11}
624− color={
625− visuallySelected
626− ? String(effectiveSelectedForeground)
627− : pullRequestTintColor(pr, colorScheme)
628− }
629− />
622+ <View
623+ className="flex-row items-center gap-0.5"
624+ accessibilityLabel={pr.accessibilityLabel}
625+ >
626+ {pr.kind === "stack" ? (
627+ <SymbolView
628+ name="square.3.layers.3d"
629+ size={compact ? 13 : 11}
630+ tintColor={
631+ visuallySelected
632+ ? String(effectiveSelectedForeground)
633+ : pullRequestTintColor(pr, colorScheme)
634+ }
635+ />
636+ ) : (
637+ <PullRequestIcon
638+ size={compact ? 13 : 11}
639+ color={
640+ visuallySelected
641+ ? String(effectiveSelectedForeground)
642+ : pullRequestTintColor(pr, colorScheme)
643+ }
644+ />
645+ )}
630646 <Text
631647 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
632648 visuallySelected
apps/mobile/src/features/threads/thread-list-v2-items.tsx+29−14
@@ -870,20 +870,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 869 unmodified lines
870870 <View className="flex-1" />
871871 )}
872872 {pr ? (
873− <Text
874− accessibilityLabel={pr.accessibilityLabel}
875− className={cn(
876− "text-xs",
877− selected
878− ? materialYouStyleLayoutActive
879− ? "text-thread-selected-foreground"
880− : "text-user-bubble-foreground"
881− : pr.textClassName,
882− )}
883− style={{ fontFamily: MONO_FONT }}
884− >
885− #{pr.label}
886− </Text>
873+ <View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
874+ {pr.kind === "stack" ? (
875+ <SymbolView
876+ name="square.3.layers.3d"
877+ size={12}
878+ tintColorClassName={
879+ selected
880+ ? materialYouStyleLayoutActive
881+ ? "accent-thread-selected-foreground"
882+ : "accent-user-bubble-foreground"
883+ : "accent-foreground-muted"
884+ }
885+ />
886+ ) : null}
887+ <Text
888+ accessibilityLabel={pr.accessibilityLabel}
889+ className={cn(
890+ "text-xs",
891+ selected
892+ ? materialYouStyleLayoutActive
893+ ? "text-thread-selected-foreground"
894+ : "text-user-bubble-foreground"
895+ : pr.textClassName,
896+ )}
897+ style={{ fontFamily: MONO_FONT }}
898+ >
899+ {pr.kind === "stack" ? pr.label : `#${pr.label}`}
900+ </Text>
901+ </View>
887902 ) : null}
888903 {props.providerInstance ? (
889904 <ProviderInstanceIcon
apps/mobile/src/features/threads/threadListV2.test.ts+1−0
@@ -47,6 +47,7 @@ function makeThread( · 46 unmodified lines
4747 interactionMode: "default",
4848 branch: null,
4949 worktreePath: null,
50+ pullRequests: [],
5051 latestTurn: null,
5152 createdAt: "2026-06-01T00:00:00.000Z",
5253 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/lib/threadActivity.test.ts+1−0
@@ -258,6 +258,7 @@ function makeThread( · 257 unmodified lines
258258 interactionMode: "default",
259259 branch: null,
260260 worktreePath: null,
261+ pullRequests: [],
261262 latestTurn: null,
262263 createdAt: "2026-04-01T00:00:00.000Z",
263264 updatedAt: "2026-04-01T00:00:00.000Z",
apps/mobile/src/state/pending-thread-creation.ts+1−0
@@ -144,6 +144,7 @@ export function pendingThreadCreationShell( · 143 unmodified lines
144144 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
145145 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
146146 branch: creation.branch,
147+ pullRequests: [],
147148 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
148149 linkedPullRequest: null,
149150 latestTurn: null,
apps/mobile/src/state/thread-pr-presentation.ts+64−2
@@ -1,11 +1,22 @@
1−import type { VcsStatusResult } from "@t3tools/contracts";
1+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2+import type {
3+ ExecutionEnvironmentCapabilities,
4+ ThreadPullRequestLink,
5+ VcsStatusResult,
6+} from "@t3tools/contracts";
27 import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
38
9+import {
10+ resolveThreadCurrentPullRequestLink,
11+ resolveThreadPullRequestBadge,
12+} from "@t3tools/shared/threadPullRequests";
13+
414 export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
515
616 export interface ThreadPrPresentation {
717 readonly number: number;
8− readonly state: ThreadPr["state"];
18+ readonly state: ThreadPr["state"] | null;
19+ readonly kind: "pull-request" | "stack";
920 readonly isDraft: boolean;
1021 /** Provider-side last activity, bounding when a terminal state landed. */
1122 readonly updatedAt: string | null;
@@ -30,6 +41,7 @@ export function presentThreadPr( · 18 unmodified lines
3041 const presentation = resolveChangeRequestPresentation(provider);
3142 const isDraft = pr.state === "open" && pr.isDraft === true;
3243 return {
44+ kind: "pull-request",
3345 number: pr.number,
3446 state: pr.state,
3547 isDraft,
@@ -40,3 +52,53 @@ export function presentThreadPr( · 4 unmodified lines
4052 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
4153 };
4254 }
55+
56+/** Persisted links render immediately, including links awaiting their first host sync. */
57+export function presentThreadLinkedPullRequests(
58+ links: ReadonlyArray<ThreadPullRequestLink>,
59+): ThreadPrPresentation | null {
60+ const link = resolveThreadCurrentPullRequestLink(links);
61+ const badge = resolveThreadPullRequestBadge(links);
62+ if (link === null || badge === null) return null;
63+ const snapshot = link.snapshot;
64+ const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
65+ const isDraft = snapshot?.isDraft === true && state === "open";
66+ const label =
67+ badge.kind === "stack"
68+ ? String(badge.layers)
69+ : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`;
70+ return {
71+ kind: badge.kind,
72+ number: link.number,
73+ state,
74+ isDraft,
75+ updatedAt: snapshot?.updatedAt ?? null,
76+ url: link.url,
77+ label,
78+ accessibilityLabel:
79+ badge.kind === "stack"
80+ ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
81+ : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
82+ textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state],
83+ };
84+}
85+
86+/** Only the array capability replaces legacy references with persisted snapshots. */
87+export function resolveThreadPrSource(
88+ thread: Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">,
89+ capabilities:
90+ | Pick<ExecutionEnvironmentCapabilities, "threadPullRequests" | "threadPullRequestLinking">
91+ | undefined,
92+) {
93+ const supportsSnapshots = capabilities?.threadPullRequests === true;
94+ const linkedPresentation = supportsSnapshots
95+ ? presentThreadLinkedPullRequests(thread.pullRequests)
96+ : null;
97+ const pullRequestRef =
98+ linkedPresentation !== null
99+ ? null
100+ : ((supportsSnapshots
101+ ? thread.branchPullRequest
102+ : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null);
103+ return { linkedPresentation, pullRequestRef };
104+}
apps/mobile/src/state/use-selected-thread-git-actions.ts+2−0
@@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() { · 329 unmodified lines
330330 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
331331 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
332332 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
333+ // A pull request the action opens is linked to the thread it ran beside.
334+ threadId: thread.id,
333335 });
334336 if (AsyncResult.isFailure(result)) {
335337 return result;
apps/mobile/src/state/use-thread-pr.test.ts+145−2
@@ -1,7 +1,11 @@
1−import type { VcsStatusResult } from "@t3tools/contracts";
1+import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
22 import { describe, expect, it } from "vite-plus/test";
33
4−import { presentThreadPr } from "./thread-pr-presentation";
4+import {
5+ presentThreadLinkedPullRequests,
6+ presentThreadPr,
7+ resolveThreadPrSource,
8+} from "./thread-pr-presentation";
59
610 const pullRequest: NonNullable<VcsStatusResult["pr"]> = {
711 number: 3774,
@@ -43,3 +47,142 @@ describe("presentThreadPr", () => { · 35 unmodified lines
4347 });
4448 });
4549 });
50+
51+function linkedPr(
52+ number: number,
53+ overrides: Partial<ThreadPullRequestLink> = {},
54+): ThreadPullRequestLink {
55+ return {
56+ host: "github.com",
57+ repository: "t3tools/t3code",
58+ number,
59+ url: `https://github.com/t3tools/t3code/pull/${number}`,
60+ source: "manual",
61+ linkedAt: "2026-09-08T00:00:00.000Z",
62+ stack: null,
63+ snapshot: {
64+ state: "open",
65+ title: `Change ${number}`,
66+ headBranch: `change-${number}`,
67+ baseBranch: "main",
68+ isDraft: false,
69+ updatedAt: null,
70+ syncedAt: "2026-09-08T00:00:00.000Z",
71+ },
72+ ...overrides,
73+ };
74+}
75+
76+describe("presentThreadLinkedPullRequests", () => {
77+ it("renders unsynced links with neutral pending status", () => {
78+ expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({
79+ number: 1,
80+ label: "1",
81+ state: null,
82+ textClassName: "text-foreground-muted",
83+ accessibilityLabel: "#1 pull request status pending",
84+ });
85+ });
86+
87+ it("counts unrelated links without labelling them a stack", () => {
88+ expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
89+ kind: "pull-request",
90+ label: "1 +1",
91+ });
92+ });
93+
94+ it("uses the top of a derived stack even when its bottom was linked later", () => {
95+ const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });
96+ const top = linkedPr(2);
97+ expect(
98+ presentThreadLinkedPullRequests([
99+ bottom,
100+ {
101+ ...top,
102+ snapshot: { ...top.snapshot!, baseBranch: "change-1" },
103+ },
104+ ]),
105+ ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url });
106+ });
107+
108+ it("hides dismissed stack members", () => {
109+ expect(
110+ presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]),
111+ ).toBeNull();
112+ });
113+
114+ it("retains merged state from the persisted snapshot", () => {
115+ const link = linkedPr(1);
116+ expect(
117+ presentThreadLinkedPullRequests([
118+ { ...link, snapshot: { ...link.snapshot!, state: "merged" } },
119+ ]),
120+ ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" });
121+ });
122+});
123+
124+describe("resolveThreadPrSource compatibility", () => {
125+ const legacyRef = {
126+ projectId: ProjectId.make("project"),
127+ repository: "t3tools/t3code",
128+ number: 1,
129+ url: "https://github.com/t3tools/t3code/pull/1",
130+ };
131+ const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" };
132+
133+ it("polls the legacy reference when only the older linking capability exists", () => {
134+ expect(
135+ resolveThreadPrSource(
136+ { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
137+ { threadPullRequestLinking: true },
138+ ),
139+ ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
140+ });
141+
142+ it("keeps the legacy reference for a server without either capability", () => {
143+ expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({
144+ linkedPresentation: null,
145+ pullRequestRef: legacyRef,
146+ });
147+ });
148+
149+ it("ignores stale snapshots after reconnecting to a server without array support", () => {
150+ expect(
151+ resolveThreadPrSource(
152+ { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef },
153+ { threadPullRequestLinking: true, threadPullRequests: false },
154+ ),
155+ ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
156+ });
157+
158+ it("uses snapshots without polling when both capabilities are advertised", () => {
159+ expect(
160+ resolveThreadPrSource(
161+ { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
162+ { threadPullRequestLinking: true, threadPullRequests: true },
163+ ),
164+ ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null });
165+ });
166+
167+ it("does not revive a removed modern link from the compatibility reference", () => {
168+ expect(
169+ resolveThreadPrSource(
170+ { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
171+ { threadPullRequests: true },
172+ ),
173+ ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef });
174+ });
175+
176+ it("does not poll when a modern link is waiting for its first snapshot", () => {
177+ expect(
178+ resolveThreadPrSource(
179+ {
180+ pullRequests: [linkedPr(3, { snapshot: null })],
181+ linkedPullRequest: legacyRef,
182+ branchPullRequest: branchRef,
183+ },
184+ { threadPullRequests: true },
185+ ),
186+ ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null });
187+ });
188+});
apps/mobile/src/state/use-thread-pr.ts+25−5
@@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react"; · 9 unmodified lines
1010
1111 import { connectionAtomRuntime } from "../connection/runtime";
1212 import { appAtomRegistry } from "./atom-registry";
13+import { serverEnvironment } from "./server";
1314 import { useEnvironmentQuery } from "./query";
14−import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15+import {
16+ resolveThreadPrSource,
17+ presentThreadPr,
18+ type ThreadPrPresentation,
19+} from "./thread-pr-presentation";
1520
1621 const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1722 const MAX_THREAD_PR_SNAPSHOTS = 500;
@@ -35,11 +40,26 @@ export { · 17 unmodified lines
3540 } from "./thread-pr-presentation";
3641
3742 /**
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+ * Linked PRs use server snapshots. Branch fallback and legacy references share
44+ * a live summary request across visible rows in the same environment.
4045 */
4146 export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
42− const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null;
47+ const supportsLinks = useAtomValue(
48+ serverEnvironment.configValueAtom(thread.environmentId),
49+ (config) => config?.environment.capabilities.threadPullRequests === true,
50+ );
51+ const { linkedPresentation, pullRequestRef } = useMemo(
52+ () =>
53+ resolveThreadPrSource(
54+ {
55+ pullRequests: thread.pullRequests,
56+ linkedPullRequest: thread.linkedPullRequest,
57+ branchPullRequest: thread.branchPullRequest,
58+ },
59+ { threadPullRequests: supportsLinks },
60+ ),
61+ [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks],
62+ );
4363 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
4464 const snapshotIdentity = JSON.stringify(pullRequestRef);
4565 // Select this row's entry so writes for other rows do not re-render it.
@@ -101,5 +121,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio · 55 unmodified lines
101121 });
102122 }, [live, snapshotIdentity, threadKey]);
103123
104− return live === undefined ? snapshot : live;
124+ return linkedPresentation ?? (live === undefined ? snapshot : live);
105125 }
apps/mobile/src/state/use-thread-selection.ts+1−0
@@ -64,6 +64,7 @@ function threadDetailToShell( · 63 unmodified lines
6464 branch: thread.branch,
6565 worktreePath: thread.worktreePath,
6666 linkedPullRequest: thread.linkedPullRequest ?? null,
67+ pullRequests: thread.pullRequests,
6768 branchPullRequest: thread.branchPullRequest ?? null,
6869 latestTurn: thread.latestTurn,
6970 createdAt: thread.createdAt,
apps/server/integration/OrchestrationEngineHarness.integration.ts+8−0
@@ -67,6 +67,7 @@ import { · 66 unmodified lines
6767 } from "../src/orchestration/Services/OrchestrationEngine.ts";
6868 import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
6969 import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70+import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts";
7071 import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
7172 import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
7273 import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
@@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = ( · 331 unmodified lines
404405 drain: Effect.void,
405406 }),
406407 ),
408+ Layer.provideMerge(
409+ Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
410+ start: () => Effect.void,
411+ drain: Effect.void,
412+ requestSync: () => Effect.void,
413+ }),
414+ ),
407415 Layer.provideMerge(
408416 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
409417 publishThread: () => Effect.void,
apps/server/src/auth/RpcAuthorization.ts+2−0
@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = { · 67 unmodified lines
6868 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
6969 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
7070 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
71+ [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
72+ [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
7173 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
7274 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
7375 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
apps/server/src/environment/ServerEnvironment.test.ts+1−0
@@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { · 168 unmodified lines
169169 expect(second.capabilities.usagePriceOverrides).toBe(true);
170170 expect(second.capabilities.threadActiveReorder).toBe(true);
171171 expect(second.capabilities.threadTitleRegeneration).toBe(true);
172+ expect(second.capabilities.threadPullRequests).toBe(true);
172173 expect(second.capabilities.threadPullRequestLinking).toBe(true);
173174 expect(second.capabilities.agentActivityPublishing).toBe(false);
174175 }),
apps/server/src/environment/ServerEnvironment.ts+1−0
@@ -230,6 +230,7 @@ export const make = Effect.gen(function* () { · 229 unmodified lines
230230 threadPinReorder: true,
231231 threadActiveReorder: true,
232232 threadTitleRegeneration: true,
233+ threadPullRequests: true,
233234 threadPullRequestLinking: true,
234235 environmentIcon: true,
235236 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
apps/server/src/git/linkCreatedPullRequest.test.ts+230−0
@@ -0,0 +1,230 @@
1+import {
2+ CommandId,
3+ ProjectId,
4+ ProviderInstanceId,
5+ ThreadId,
6+ type GitRunStackedActionResult,
7+ type OrchestrationCommand,
8+ type OrchestrationProjectShell,
9+ type OrchestrationThreadShell,
10+} from "@t3tools/contracts";
11+import { describe, expect, it } from "@effect/vitest";
12+import * as Effect from "effect/Effect";
13+import * as Layer from "effect/Layer";
14+import * as Option from "effect/Option";
15+import * as Ref from "effect/Ref";
16+import * as Stream from "effect/Stream";
17+
18+import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
19+import {
20+ OrchestrationEngineService,
21+ type OrchestrationEngineShape,
22+} from "../orchestration/Services/OrchestrationEngine.ts";
23+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
24+import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts";
25+
26+const PROJECT_ID = ProjectId.make("project-1");
27+const THREAD_ID = ThreadId.make("thread-1");
28+const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test"));
29+
30+const project: OrchestrationProjectShell = {
31+ id: PROJECT_ID,
32+ title: "Project",
33+ workspaceRoot: "/workspace/project",
34+ defaultModelSelection: null,
35+ scripts: [],
36+ repositoryIdentity: {
37+ canonicalKey: "github.acme.test/platform/api",
38+ locator: {
39+ source: "git-remote",
40+ remoteName: "origin",
41+ remoteUrl: "git@github.acme.test:Platform/API.git",
42+ },
43+ provider: "github",
44+ displayName: "Platform/API",
45+ owner: "Platform",
46+ name: "API",
47+ },
48+ createdAt: "2026-08-01T00:00:00.000Z",
49+ updatedAt: "2026-08-01T00:00:00.000Z",
50+};
51+
52+const thread: OrchestrationThreadShell = {
53+ id: THREAD_ID,
54+ projectId: PROJECT_ID,
55+ title: "Thread",
56+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
57+ runtimeMode: "full-access",
58+ interactionMode: "default",
59+ branch: null,
60+ worktreePath: null,
61+ pullRequests: [],
62+ latestTurn: null,
63+ createdAt: "2026-08-01T00:00:00.000Z",
64+ updatedAt: "2026-08-20T00:00:00.000Z",
65+ archivedAt: null,
66+ settledOverride: null,
67+ settledAt: null,
68+ session: null,
69+ latestUserMessageAt: "2026-08-20T00:00:00.000Z",
70+ hasPendingApprovals: false,
71+ hasPendingUserInput: false,
72+ hasActionableProposedPlan: false,
73+};
74+
75+function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> {
76+ return { pr };
77+}
78+
79+const makeDependencies = (
80+ dispatch: OrchestrationEngineShape["dispatch"],
81+ threadShell: OrchestrationThreadShell | null = thread,
82+) =>
83+ Layer.mergeAll(
84+ Layer.mock(ProjectionSnapshotQuery)({
85+ getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)),
86+ getProjectShellById: () => Effect.succeed(Option.some(project)),
87+ }),
88+ Layer.mock(OrchestrationEngineService)({
89+ readEvents: () => Stream.empty,
90+ dispatch,
91+ streamDomainEvents: Stream.empty,
92+ latestSequence: Effect.succeed(0),
93+ }),
94+ );
95+
96+const recordingDispatch = Effect.fn("recordingDispatch")(function* () {
97+ const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
98+ const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
99+ Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 }));
100+ return { commands, dispatch };
101+});
102+
103+describe("createdPullRequestKey", () => {
104+ it("reads host and repository from the URL when it is recognisable", () => {
105+ expect(
106+ createdPullRequestKey(
107+ prResult({
108+ status: "created",
109+ number: 12,
110+ url: "https://github.com/Other/Fork/pull/12",
111+ }),
112+ project,
113+ ),
114+ ).toEqual({
115+ host: "github.com",
116+ repository: "other/fork",
117+ number: 12,
118+ url: "https://github.com/Other/Fork/pull/12",
119+ });
120+ });
121+
122+ it("falls back to the project's host and repository for an unreadable URL", () => {
123+ expect(
124+ createdPullRequestKey(
125+ prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }),
126+ project,
127+ ),
128+ ).toEqual({
129+ host: "github.acme.test",
130+ repository: "platform/api",
131+ number: 3,
132+ url: "https://ghe.internal/x/3",
133+ });
134+ expect(
135+ createdPullRequestKey(
136+ prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }),
137+ undefined,
138+ ),
139+ ).toBeNull();
140+ });
141+
142+ it("yields nothing when no pull request came out of the action", () => {
143+ expect(
144+ createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project),
145+ ).toBeNull();
146+ expect(
147+ createdPullRequestKey(
148+ prResult({ status: "created", url: "https://github.com/a/b/pull/1" }),
149+ project,
150+ ),
151+ ).toBeNull();
152+ expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull();
153+ });
154+});
155+
156+describe("linkCreatedPullRequest", () => {
157+ it.effect("links a created pull request to the thread with source created", () =>
158+ Effect.gen(function* () {
159+ const { commands, dispatch } = yield* recordingDispatch();
160+ yield* linkCreatedPullRequest({
161+ threadId: THREAD_ID,
162+ result: prResult({
163+ status: "created",
164+ number: 42,
165+ url: "https://github.com/t3tools/t3code/pull/42",
166+ }),
167+ commandId,
168+ }).pipe(Effect.provide(makeDependencies(dispatch)));
169+
170+ expect(yield* Ref.get(commands)).toEqual([
171+ {
172+ type: "thread.pull-request.link",
173+ commandId: "server:pr-created-link:test",
174+ threadId: THREAD_ID,
175+ host: "github.com",
176+ repository: "t3tools/t3code",
177+ number: 42,
178+ url: "https://github.com/t3tools/t3code/pull/42",
179+ source: "created",
180+ },
181+ ]);
182+ }),
183+ );
184+
185+ it.effect("dispatches nothing when the action produced no pull request", () =>
186+ Effect.gen(function* () {
187+ const { commands, dispatch } = yield* recordingDispatch();
188+ const dependencies = makeDependencies(dispatch);
189+ yield* linkCreatedPullRequest({
190+ threadId: THREAD_ID,
191+ result: prResult({ status: "skipped_not_requested" }),
192+ commandId,
193+ }).pipe(Effect.provide(dependencies));
194+ yield* linkCreatedPullRequest({
195+ threadId: THREAD_ID,
196+ result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }),
197+ commandId,
198+ }).pipe(Effect.provide(dependencies));
199+
200+ expect(yield* Ref.get(commands)).toEqual([]);
201+ }),
202+ );
203+
204+ it.effect("swallows an already-linked rejection and other dispatch failures", () =>
205+ Effect.gen(function* () {
206+ const rejecting: OrchestrationEngineShape["dispatch"] = (command) =>
207+ Effect.fail(
208+ new OrchestrationCommandInvariantError({
209+ commandType: command.type,
210+ detail: "already linked",
211+ }),
212+ );
213+ const result = prResult({
214+ status: "opened_existing",
215+ number: 7,
216+ url: "https://github.com/t3tools/t3code/pull/7",
217+ });
218+ yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
219+ Effect.provide(makeDependencies(rejecting)),
220+ );
221+ yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
222+ Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))),
223+ );
224+ // A thread that vanished between the action and the link is not an error either.
225+ yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
226+ Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)),
227+ );
228+ }),
229+ );
230+});
apps/server/src/git/linkCreatedPullRequest.ts+98−0
@@ -0,0 +1,98 @@
1+import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
2+import {
3+ type CommandId,
4+ pullRequestHostOf,
5+ type GitRunStackedActionResult,
6+ type OrchestrationProjectShell,
7+ type SourceControlProviderKind,
8+ type ThreadId,
9+} from "@t3tools/contracts";
10+import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11+import * as Cause from "effect/Cause";
12+import * as Effect from "effect/Effect";
13+import * as Option from "effect/Option";
14+
15+import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
16+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
17+
18+export interface CreatedPullRequestKey {
19+ readonly host: string;
20+ readonly repository: string;
21+ readonly number: number;
22+ readonly url: string;
23+}
24+
25+/**
26+ * The identity a stacked action's pull request should be linked under, or
27+ * null when the action did not leave one behind. Reading the host and
28+ * repository from the URL keeps the link host-level even when the checkout's
29+ * remote differs from where the PR was opened (a fork, say); the project is
30+ * only consulted when the URL is one this cannot read.
31+ */
32+export function createdPullRequestKey(
33+ result: Pick<GitRunStackedActionResult, "pr">,
34+ project: OrchestrationProjectShell | undefined,
35+): CreatedPullRequestKey | null {
36+ const { status, number, url } = result.pr;
37+ if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) {
38+ return null;
39+ }
40+ const parsed = parseChangeRequestUrl(url);
41+ if (parsed !== null) return { ...parsed, url };
42+ const identity = project?.repositoryIdentity;
43+ const kind = identity?.provider as SourceControlProviderKind | undefined;
44+ const repository = sourceControlRepositorySelector(identity);
45+ if (!identity || kind === undefined || repository === null) return null;
46+ return {
47+ host: pullRequestHostOf(identity, kind),
48+ repository: repository.toLowerCase(),
49+ number,
50+ url,
51+ };
52+}
53+
54+/**
55+ * Links the pull request a `create_pr`-shaped action produced to the thread it
56+ * ran beside. Never fails: the git action already succeeded and its result is
57+ * on its way to the client, so a link that cannot be made is logged and
58+ * dropped. A duplicate link is the decider saying the thread already knew.
59+ */
60+export const linkCreatedPullRequest = <E>(input: {
61+ readonly threadId: ThreadId;
62+ readonly result: Pick<GitRunStackedActionResult, "pr">;
63+ readonly commandId: Effect.Effect<CommandId, E>;
64+}): Effect.Effect<
65+ void,
66+ never,
67+ OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery
68+> =>
69+ Effect.gen(function* () {
70+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
71+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
72+ const thread = yield* snapshots.getThreadShellById(input.threadId);
73+ if (Option.isNone(thread)) return;
74+ const project = Option.getOrUndefined(
75+ yield* snapshots.getProjectShellById(thread.value.projectId),
76+ );
77+ const key = createdPullRequestKey(input.result, project);
78+ if (key === null) return;
79+ const commandId = yield* input.commandId;
80+ yield* engine
81+ .dispatch({
82+ type: "thread.pull-request.link",
83+ commandId,
84+ threadId: input.threadId,
85+ ...key,
86+ source: "created",
87+ })
88+ .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void }));
89+ }).pipe(
90+ Effect.withSpan("linkCreatedPullRequest"),
91+ Effect.catchCause((cause) =>
92+ Cause.hasInterruptsOnly(cause)
93+ ? Effect.failCause(cause as Cause.Cause<never>)
94+ : Effect.logWarning("failed to link created pull request to thread", {
95+ threadId: input.threadId,
96+ }),
97+ ),
98+ );
apps/server/src/mcp/McpHttpServer.test.ts+47−0
@@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred"; · 5 unmodified lines
66 import * as Effect from "effect/Effect";
77 import * as FileSystem from "effect/FileSystem";
88 import * as Layer from "effect/Layer";
9+import * as Option from "effect/Option";
910 import * as Path from "effect/Path";
1011 import * as Schema from "effect/Schema";
1112 import * as Stream from "effect/Stream";
1213 import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
1314 import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
1415
16+import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
17+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1518 import * as ServerConfig from "../config.ts";
1619 import * as McpHttpServer from "./McpHttpServer.ts";
1720 import * as McpInvocationContext from "./McpInvocationContext.ts";
@@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( · 30 unmodified lines
4851 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
4952 Layer.provideMerge(NodeServices.layer),
5053 );
54+const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe(
55+ Layer.provideMerge(McpServer.McpServer.layer),
56+ Layer.provide(
57+ Layer.mergeAll(
58+ Layer.mock(ProjectionSnapshotQuery)({
59+ getThreadShellById: () => Effect.succeed(Option.none()),
60+ }),
61+ Layer.mock(OrchestrationEngineService)({}),
62+ NodeServices.layer,
63+ ),
64+ ),
65+);
5166
5267 const snapshotResult = {
5368 url: "http://example.test/",
@@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () => · 291 unmodified lines
345360 ).pipe(Effect.provide(TestLayer)),
346361 );
347362
363+it.effect(
364+ "registers the pull request toolkit and surfaces a missing capability as a tool error",
365+ () =>
366+ Effect.gen(function* () {
367+ const server = yield* McpServer.McpServer;
368+ const names = server.tools.map(({ tool }) => tool.name);
369+ expect(names).toEqual(
370+ expect.arrayContaining([
371+ "link_pull_request",
372+ "unlink_pull_request",
373+ "list_thread_pull_requests",
374+ ]),
375+ );
376+ const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request");
377+ expect(linkTool?.tool.annotations?.idempotentHint).toBe(true);
378+ expect(linkTool?.tool.annotations?.openWorldHint).toBe(false);
379+ expect(linkTool?.tool.description).toContain("Register every pull request you open");
380+
381+ const denied = yield* server
382+ .callTool({ name: "list_thread_pull_requests", arguments: {} })
383+ .pipe(
384+ // A preview-only credential: the token predates the toolkit or was minted elsewhere.
385+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
386+ Effect.provideService(McpSchema.McpServerClient, client),
387+ );
388+ expect(denied.isError).toBe(true);
389+ expect(denied.content).toEqual([
390+ { type: "text", text: "MCP credential does not grant the pull-requests capability." },
391+ ]);
392+ }).pipe(Effect.provide(PullRequestsTestLayer)),
393+);
394+
348395 it.effect("keeps the snapshot text under the agent's output ceiling", () =>
349396 Effect.scoped(
350397 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+10−1
@@ -28,6 +28,8 @@ import { · 27 unmodified lines
2828 PreviewSnapshotToolkit,
2929 PreviewStandardToolkit,
3030 } from "./toolkits/preview/tools.ts";
31+import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
32+import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
3133
3234 const unauthorized = HttpServerResponse.jsonUnsafe(
3335 {
@@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( · 403 unmodified lines
437439 PreviewSnapshotRegistrationLive,
438440 );
439441
442+export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe(
443+ Layer.provide(PullRequestsToolkitHandlersLive),
444+);
445+
440446 const McpTransportLive = McpServer.layerHttp({
441447 name: "T3 Code",
442448 version: packageJson.version,
@@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({ · 1 unmodified line
444450 protocols: [McpProtocol.v2025_06_18],
445451 }).pipe(Layer.provide(McpAuthMiddlewareLive));
446452
447−export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive));
453+export const layer = Layer.mergeAll(
454+ PreviewToolkitRegistrationLive,
455+ PullRequestsToolkitRegistrationLive,
456+).pipe(Layer.provideMerge(McpTransportLive));
apps/server/src/mcp/McpInvocationContext.test.ts+27−0
@@ -1,6 +1,7 @@
11 import { expect, it } from "@effect/vitest";
22 import {
33 EnvironmentId,
4+ McpCapabilityUnavailableError,
45 PreviewAutomationUnavailableError,
56 ProviderInstanceId,
67 ThreadId,
@@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav · 29 unmodified lines
3637 expect(error.message).toBe("MCP credential does not grant the preview capability.");
3738 });
3839 });
40+
41+it.effect("reports other missing capabilities with the neutral error", () => {
42+ const invocation: McpInvocationContext.McpInvocationScope = {
43+ environmentId: EnvironmentId.make("environment-1"),
44+ threadId: ThreadId.make("thread-1"),
45+ providerSessionId: "provider-session-1",
46+ providerInstanceId: ProviderInstanceId.make("codex"),
47+ capabilities: new Set(["preview"]),
48+ issuedAt: 1,
49+ };
50+
51+ return Effect.gen(function* () {
52+ const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe(
53+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
54+ Effect.flip,
55+ );
56+
57+ expect(error).toBeInstanceOf(McpCapabilityUnavailableError);
58+ expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId });
59+
60+ const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe(
61+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
62+ );
63+ expect(scope).toBe(invocation);
64+ });
65+});
apps/server/src/mcp/McpInvocationContext.ts+30−15
@@ -1,5 +1,6 @@
11 import {
22 type EnvironmentId,
3+ McpCapabilityUnavailableError,
34 PreviewAutomationUnavailableError,
45 type ProviderInstanceId,
56 type ThreadId,
@@ -7,7 +8,7 @@ import { · 1 unmodified line
78 import * as Context from "effect/Context";
89 import * as Effect from "effect/Effect";
910
10−export type McpCapability = "preview";
11+export type McpCapability = "preview" | "pull-requests";
1112
1213 export interface McpInvocationScope {
1314 readonly environmentId: EnvironmentId;
@@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service< · 9 unmodified lines
2324 McpInvocationScope
2425 >()("t3/mcp/McpInvocationContext") {}
2526
26−export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* (
27+/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */
28+export type McpCapabilityError<C extends McpCapability> = C extends "preview"
29+ ? PreviewAutomationUnavailableError
30+ : McpCapabilityUnavailableError;
31+
32+const missingCapability = (
33+ invocation: McpInvocationScope,
2734 capability: McpCapability,
28−) {
29− const invocation = yield* McpInvocationContext;
30− if (!invocation.capabilities.has(capability)) {
31− return yield* new PreviewAutomationUnavailableError({
32− capability,
33− environmentId: invocation.environmentId,
34− threadId: invocation.threadId,
35− providerSessionId: invocation.providerSessionId,
36− providerInstanceId: invocation.providerInstanceId,
37− });
38− }
39− return invocation;
40−});
35+): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
36+ const fields = {
37+ environmentId: invocation.environmentId,
38+ threadId: invocation.threadId,
39+ providerSessionId: invocation.providerSessionId,
40+ providerInstanceId: invocation.providerInstanceId,
41+ };
42+ return capability === "preview"
43+ ? new PreviewAutomationUnavailableError({ capability, ...fields })
44+ : new McpCapabilityUnavailableError({ capability, ...fields });
45+};
46+
47+export const requireMcpCapability = <const C extends McpCapability>(
48+ capability: C,
49+): Effect.Effect<McpInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
50+ Effect.flatMap(McpInvocationContext, (invocation) =>
51+ invocation.capabilities.has(capability)
52+ ? Effect.succeed(invocation)
53+ : // The conditional type narrows what the literal argument decided at runtime.
54+ Effect.fail(missingCapability(invocation, capability) as McpCapabilityError<C>),
55+ ).pipe(Effect.withSpan("mcp.requireCapability"));
apps/server/src/mcp/McpProviderSession.ts+2−0
@@ -7,6 +7,8 @@ export interface McpProviderSessionConfig { · 6 unmodified lines
77 readonly providerInstanceId: ProviderInstanceId;
88 readonly endpoint: string;
99 readonly authorizationHeader: string;
10+ /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */
11+ readonly preview: boolean;
1012 }
1113
1214 const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
apps/server/src/mcp/McpSessionRegistry.test.ts+28−0
@@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t · 38 unmodified lines
3939 const issued = yield* registry.issue({
4040 threadId,
4141 providerInstanceId: ProviderInstanceId.make("codex"),
42+ preview: true,
4243 });
4344 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
4445 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
@@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t · 9 unmodified lines
5455 }),
5556 );
5657
58+it.effect("always grants pull-requests and gates preview on the request", () =>
59+ Effect.gen(function* () {
60+ const registry = yield* makeRegistry(() => 1_000);
61+ const withPreview = yield* registry.issue({
62+ threadId: ThreadId.make("thread-preview"),
63+ providerInstanceId: ProviderInstanceId.make("codex"),
64+ preview: true,
65+ });
66+ const withoutPreview = yield* registry.issue({
67+ threadId: ThreadId.make("thread-no-preview"),
68+ providerInstanceId: ProviderInstanceId.make("codex"),
69+ preview: false,
70+ });
71+ const capabilitiesOf = (issued: typeof withPreview) =>
72+ registry
73+ .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, ""))
74+ .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort()));
75+
76+ expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]);
77+ expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]);
78+ }),
79+);
80+
5781 it.effect("builds MCP endpoints from the bound server host", () =>
5882 Effect.gen(function* () {
5983 const cases = [
@@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () => · 8 unmodified lines
6892 const issued = yield* registry.issue({
6993 threadId: ThreadId.make(`thread-${hostname}`),
7094 providerInstanceId: ProviderInstanceId.make("codex"),
95+ preview: true,
7196 });
7297 expect(issued.config.endpoint).toBe(expectedEndpoint);
7398 }
@@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life", · 7 unmodified lines
81106 const issued = yield* registry.issue({
82107 threadId: ThreadId.make("thread-2"),
83108 providerInstanceId: ProviderInstanceId.make("claude"),
109+ preview: true,
84110 });
85111 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
86112 timestamp += 101;
@@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", · 9 unmodified lines
96122 const issued = yield* registry.issue({
97123 threadId,
98124 providerInstanceId: ProviderInstanceId.make("claude"),
125+ preview: true,
99126 });
100127 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
101128
@@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () => · 15 unmodified lines
117144 const issued = yield* registry.issue({
118145 threadId: ThreadId.make("thread-4"),
119146 providerInstanceId: ProviderInstanceId.make("codex"),
147+ preview: true,
120148 });
121149 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
122150
apps/server/src/mcp/McpSessionRegistry.ts+10−2
@@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts"; · 13 unmodified lines
1414 export interface McpCredentialRequest {
1515 readonly threadId: ThreadId;
1616 readonly providerInstanceId: ProviderInstanceId;
17+ /**
18+ * Whether the credential may drive the user's browser. The pull request
19+ * toolkit is always granted: it only touches the thread's own links.
20+ */
21+ readonly preview: boolean;
1722 }
1823
1924 export interface McpIssuedCredential {
@@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions { · 48 unmodified lines
6873 *
6974 * The bound matters because `/mcp` is mounted outside the environment auth
7075 * stack and is reachable on whatever host the server binds to, so this token is
71− * the only thing guarding the preview toolkit on a remote-reachable server.
76+ * the only thing guarding the `t3-code` toolkits on a remote-reachable server.
7277 */
7378 const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;
7479
@@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( · 53 unmodified lines
128133 threadId: ThreadId.make(request.threadId),
129134 providerSessionId,
130135 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
131− capabilities: new Set(["preview"]),
136+ capabilities: new Set<McpInvocationContext.McpCapability>(
137+ request.preview ? ["pull-requests", "preview"] : ["pull-requests"],
138+ ),
132139 issuedAt,
133140 };
134141 yield* SynchronizedRef.update(state, ({ records }) => {
@@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( · 9 unmodified lines
144151 providerInstanceId: scope.providerInstanceId,
145152 endpoint,
146153 authorizationHeader: `Bearer ${rawToken}`,
154+ preview: request.preview,
147155 },
148156 };
149157 },
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+418−0
@@ -0,0 +1,418 @@
1+import {
2+ EnvironmentId,
3+ ProjectId,
4+ ProviderInstanceId,
5+ ThreadId,
6+ type OrchestrationCommand,
7+ type OrchestrationProjectShell,
8+ type OrchestrationThreadShell,
9+ type ThreadPullRequestLink,
10+} from "@t3tools/contracts";
11+import { describe, expect, it } from "@effect/vitest";
12+import * as Crypto from "effect/Crypto";
13+import * as Effect from "effect/Effect";
14+import * as Layer from "effect/Layer";
15+import * as Option from "effect/Option";
16+import * as Ref from "effect/Ref";
17+import * as Stream from "effect/Stream";
18+import type { Tool } from "effect/unstable/ai";
19+
20+import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts";
21+import {
22+ OrchestrationEngineService,
23+ type OrchestrationEngineShape,
24+} from "../../../orchestration/Services/OrchestrationEngine.ts";
25+import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
26+import * as McpInvocationContext from "../../McpInvocationContext.ts";
27+import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts";
28+import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
29+
30+const PROJECT_ID = ProjectId.make("project-1");
31+const THREAD_ID = ThreadId.make("thread-1");
32+
33+const testCrypto = Crypto.make({
34+ randomBytes: (size) => new Uint8Array(size).fill(7),
35+ digest: (_algorithm, data) => Effect.succeed(data),
36+});
37+
38+const invocation = (
39+ capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,
40+): McpInvocationContext.McpInvocationScope => ({
41+ environmentId: EnvironmentId.make("environment-1"),
42+ threadId: THREAD_ID,
43+ providerSessionId: "provider-session-1",
44+ providerInstanceId: ProviderInstanceId.make("codex"),
45+ capabilities: new Set(capabilities),
46+ issuedAt: 1,
47+});
48+
49+function makeProject(
50+ repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = {
51+ canonicalKey: "github.com/t3tools/t3code",
52+ locator: {
53+ source: "git-remote",
54+ remoteName: "origin",
55+ remoteUrl: "git@github.com:T3Tools/T3Code.git",
56+ },
57+ provider: "github",
58+ displayName: "T3Tools/T3Code",
59+ owner: "T3Tools",
60+ name: "T3Code",
61+ },
62+): OrchestrationProjectShell {
63+ return {
64+ id: PROJECT_ID,
65+ title: "Project",
66+ workspaceRoot: "/workspace/project",
67+ defaultModelSelection: null,
68+ scripts: [],
69+ repositoryIdentity,
70+ createdAt: "2026-08-01T00:00:00.000Z",
71+ updatedAt: "2026-08-01T00:00:00.000Z",
72+ };
73+}
74+
75+function makeThread(pullRequests: ReadonlyArray<ThreadPullRequestLink>): OrchestrationThreadShell {
76+ return {
77+ id: THREAD_ID,
78+ projectId: PROJECT_ID,
79+ title: "Thread",
80+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
81+ runtimeMode: "full-access",
82+ interactionMode: "default",
83+ branch: null,
84+ worktreePath: null,
85+ pullRequests,
86+ latestTurn: null,
87+ createdAt: "2026-08-01T00:00:00.000Z",
88+ updatedAt: "2026-08-20T00:00:00.000Z",
89+ archivedAt: null,
90+ settledOverride: null,
91+ settledAt: null,
92+ session: null,
93+ latestUserMessageAt: "2026-08-20T00:00:00.000Z",
94+ hasPendingApprovals: false,
95+ hasPendingUserInput: false,
96+ hasActionableProposedPlan: false,
97+ };
98+}
99+
100+function makeLink(
101+ number: number,
102+ overrides: Partial<ThreadPullRequestLink> & {
103+ readonly headBranch?: string;
104+ readonly baseBranch?: string;
105+ } = {},
106+): ThreadPullRequestLink {
107+ const { headBranch, baseBranch, ...rest } = overrides;
108+ return {
109+ host: "github.com",
110+ repository: "t3tools/t3code",
111+ number,
112+ url: `https://github.com/t3tools/t3code/pull/${number}`,
113+ source: "manual",
114+ linkedAt: "2026-08-10T00:00:00.000Z",
115+ snapshot:
116+ headBranch === undefined
117+ ? null
118+ : {
119+ state: "open",
120+ title: `PR ${number}`,
121+ headBranch,
122+ baseBranch: baseBranch ?? "main",
123+ isDraft: false,
124+ updatedAt: null,
125+ syncedAt: "2026-08-27T00:00:00.000Z",
126+ },
127+ stack: null,
128+ ...rest,
129+ };
130+}
131+
132+interface HarnessOptions {
133+ readonly thread?: OrchestrationThreadShell | null;
134+ readonly project?: OrchestrationProjectShell | null;
135+ readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null;
136+}
137+
138+const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
139+ options: HarnessOptions = {},
140+) {
141+ const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
142+ const thread = options.thread === undefined ? makeThread([]) : options.thread;
143+ const project = options.project === undefined ? makeProject() : options.project;
144+ const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
145+ Effect.gen(function* () {
146+ const rejection = options.reject?.(command) ?? null;
147+ if (rejection !== null) return yield* rejection;
148+ yield* Ref.update(commands, (recorded) => [...recorded, command]);
149+ return { sequence: 1 };
150+ });
151+ const dependencies = Layer.mergeAll(
152+ Layer.mock(ProjectionSnapshotQuery)({
153+ getThreadShellById: (threadId) =>
154+ Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()),
155+ getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)),
156+ }),
157+ Layer.mock(OrchestrationEngineService)({
158+ readEvents: () => Stream.empty,
159+ dispatch,
160+ streamDomainEvents: Stream.empty,
161+ latestSequence: Effect.succeed(0),
162+ }),
163+ Layer.succeed(Crypto.Crypto, testCrypto),
164+ );
165+ const toolkit = yield* PullRequestsToolkit.pipe(
166+ Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))),
167+ );
168+ const call = <Name extends keyof typeof PullRequestsToolkit.tools>(
169+ name: Name,
170+ params: Parameters<typeof toolkit.handle<Name>>[1],
171+ capabilities: ReadonlyArray<McpInvocationContext.McpCapability> = ["pull-requests"],
172+ ) =>
173+ toolkit.handle(name, params).pipe(
174+ Stream.unwrap,
175+ Stream.runCollect,
176+ // Failure mode is "error", so a delivered result is always the success shape.
177+ Effect.map(
178+ (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>,
179+ ),
180+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)),
181+ Effect.provide(dependencies),
182+ );
183+ return { commands, call };
184+});
185+
186+describe("pull request toolkit handlers", () => {
187+ it.effect("refuses a credential without the pull-requests capability", () =>
188+ Effect.gen(function* () {
189+ const harness = yield* makeHarness();
190+ const error = yield* harness
191+ .call("list_thread_pull_requests", {}, ["preview"])
192+ .pipe(Effect.flip);
193+ expect(error).toMatchObject({
194+ _tag: "McpCapabilityUnavailableError",
195+ capability: "pull-requests",
196+ threadId: THREAD_ID,
197+ });
198+ expect(yield* Ref.get(harness.commands)).toEqual([]);
199+ }),
200+ );
201+
202+ it.effect("links by URL with source agent on the token's thread", () =>
203+ Effect.gen(function* () {
204+ const harness = yield* makeHarness();
205+ const result = yield* harness.call("link_pull_request", {
206+ url: "https://github.com/T3Tools/T3Code/pull/123/files",
207+ });
208+ expect(result).toEqual({
209+ host: "github.com",
210+ repository: "t3tools/t3code",
211+ number: 123,
212+ url: "https://github.com/T3Tools/T3Code/pull/123/files",
213+ alreadyLinked: false,
214+ });
215+ expect(yield* Ref.get(harness.commands)).toMatchObject([
216+ {
217+ type: "thread.pull-request.link",
218+ threadId: THREAD_ID,
219+ host: "github.com",
220+ repository: "t3tools/t3code",
221+ number: 123,
222+ source: "agent",
223+ },
224+ ]);
225+ }),
226+ );
227+
228+ it.effect("links by repository and number, defaulting the host to the project's", () =>
229+ Effect.gen(function* () {
230+ const harness = yield* makeHarness();
231+ const result = yield* harness.call("link_pull_request", {
232+ repository: "T3Tools/Other",
233+ number: 7,
234+ });
235+ expect(result).toEqual({
236+ host: "github.com",
237+ repository: "t3tools/other",
238+ number: 7,
239+ url: "https://github.com/t3tools/other/pull/7",
240+ alreadyLinked: false,
241+ });
242+ }),
243+ );
244+
245+ it.effect("builds the URL in the project host's own shape", () =>
246+ Effect.gen(function* () {
247+ const harness = yield* makeHarness({
248+ project: makeProject({
249+ canonicalKey: "gitlab.com/group/sub/project",
250+ locator: {
251+ source: "git-remote",
252+ remoteName: "origin",
253+ remoteUrl: "git@gitlab.com:group/sub/project.git",
254+ },
255+ provider: "gitlab",
256+ displayName: "group/sub/project",
257+ }),
258+ });
259+ const result = yield* harness.call("link_pull_request", {
260+ repository: "group/sub/project",
261+ number: 42,
262+ });
263+ expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42");
264+ expect(result.host).toBe("gitlab.com");
265+ }),
266+ );
267+
268+ it.effect("rejects a target that names neither a URL nor repository and number", () =>
269+ Effect.gen(function* () {
270+ const harness = yield* makeHarness();
271+ const error = yield* harness
272+ .call("link_pull_request", { repository: "x/y" })
273+ .pipe(Effect.flip);
274+ expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" });
275+ const unknown = yield* harness
276+ .call("link_pull_request", {
277+ url: "https://github.com/t3tools/t3code/issues/1?token=private-value",
278+ })
279+ .pipe(Effect.flip);
280+ expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" });
281+ expect(unknown.message).not.toContain("private-value");
282+ expect(yield* Ref.get(harness.commands)).toEqual([]);
283+ }),
284+ );
285+
286+ it.effect("treats a duplicate link as alreadyLinked rather than an error", () =>
287+ Effect.gen(function* () {
288+ const harness = yield* makeHarness({
289+ thread: makeThread([makeLink(123)]),
290+ reject: (command) =>
291+ command.type === "thread.pull-request.link"
292+ ? new OrchestrationCommandInvariantError({
293+ commandType: command.type,
294+ detail: "already linked",
295+ })
296+ : null,
297+ });
298+ const result = yield* harness.call("link_pull_request", {
299+ url: "https://github.com/t3tools/t3code/pull/123",
300+ });
301+ expect(result.alreadyLinked).toBe(true);
302+ }),
303+ );
304+
305+ it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () =>
306+ Effect.gen(function* () {
307+ const harness = yield* makeHarness({
308+ thread: makeThread([makeLink(5)]),
309+ reject: (command) =>
310+ command.type === "thread.pull-request.unlink" && command.number !== 5
311+ ? new OrchestrationCommandInvariantError({
312+ commandType: command.type,
313+ detail: "not linked",
314+ })
315+ : null,
316+ });
317+ const linked = yield* harness.call("unlink_pull_request", {
318+ repository: "t3tools/t3code",
319+ number: 5,
320+ });
321+ expect(linked).toEqual({
322+ host: "github.com",
323+ repository: "t3tools/t3code",
324+ number: 5,
325+ wasLinked: true,
326+ });
327+ const missing = yield* harness.call("unlink_pull_request", {
328+ url: "https://github.com/t3tools/t3code/pull/9",
329+ });
330+ expect(missing.wasLinked).toBe(false);
331+ expect(yield* Ref.get(harness.commands)).toMatchObject([
332+ { type: "thread.pull-request.unlink", number: 5 },
333+ ]);
334+ }),
335+ );
336+
337+ it.effect("fails cleanly when the token's thread no longer exists", () =>
338+ Effect.gen(function* () {
339+ const harness = yield* makeHarness({ thread: null });
340+ const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip);
341+ expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID });
342+ }),
343+ );
344+
345+ it.effect("lists visible links with host state and derived chain order", () =>
346+ Effect.gen(function* () {
347+ const harness = yield* makeHarness({
348+ thread: makeThread([
349+ makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }),
350+ makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }),
351+ makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }),
352+ makeLink(9, { source: "stack-dismissed" }),
353+ makeLink(10),
354+ ]),
355+ });
356+ const result = yield* harness.call("list_thread_pull_requests", {});
357+ expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]);
358+ expect(result.pullRequests[0]).toEqual({
359+ host: "github.com",
360+ repository: "t3tools/t3code",
361+ number: 3,
362+ url: "https://github.com/t3tools/t3code/pull/3",
363+ source: "agent",
364+ state: "open",
365+ title: "PR 3",
366+ headBranch: "feat-c",
367+ baseBranch: "feat-b",
368+ isDraft: false,
369+ stack: { kind: "derived", position: 3, size: 3 },
370+ });
371+ expect(result.pullRequests[3]).toMatchObject({
372+ number: 10,
373+ state: null,
374+ title: null,
375+ headBranch: null,
376+ stack: null,
377+ });
378+ expect(result.chains).toEqual([
379+ { kind: "derived", numbers: [1, 2, 3] },
380+ { kind: "derived", numbers: [10] },
381+ ]);
382+ }),
383+ );
384+});
385+
386+describe("listThreadPullRequests", () => {
387+ it("reports a native stack position for each member", () => {
388+ const stack = {
389+ kind: "native" as const,
390+ id: "stack-1",
391+ number: 1,
392+ url: "https://github.com/t3tools/t3code/stack/1",
393+ base: "main",
394+ layers: [
395+ { number: 1, headBranch: "a", state: "open" as const },
396+ { number: 2, headBranch: "b", state: "open" as const },
397+ ],
398+ };
399+ const result = listThreadPullRequests({
400+ pullRequests: [
401+ makeLink(2, { stack, source: "stack" }),
402+ makeLink(1, { stack, source: "created" }),
403+ ],
404+ });
405+ expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([
406+ [2, { kind: "native", position: 2, size: 2 }],
407+ [1, { kind: "native", position: 1, size: 2 }],
408+ ]);
409+ expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]);
410+ });
411+});
412+
413+it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => {
414+ const cause = new Error("database internals");
415+ const failure = new PullRequestLinkFailedError({ cause });
416+ expect(failure.message).toBe("Could not link the pull request.");
417+ expect(failure.cause).toBe(cause);
418+});
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+243−0
@@ -0,0 +1,243 @@
1+import {
2+ CommandId,
3+ pullRequestHostOf,
4+ type OrchestrationProjectShell,
5+ type OrchestrationThreadShell,
6+ type SourceControlProviderKind,
7+ type ThreadId,
8+ type ThreadPullRequestLink,
9+} from "@t3tools/contracts";
10+import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11+import {
12+ resolveThreadPullRequestChains,
13+ threadPullRequestKeyOf,
14+ visibleThreadPullRequests,
15+} from "@t3tools/shared/threadPullRequests";
16+import * as Cause from "effect/Cause";
17+import * as Crypto from "effect/Crypto";
18+import * as Effect from "effect/Effect";
19+import * as Option from "effect/Option";
20+
21+import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts";
22+import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
23+import * as McpInvocationContext from "../../McpInvocationContext.ts";
24+import {
25+ type ListThreadPullRequestsResult,
26+ PullRequestLinkFailedError,
27+ PullRequestUrlInvalidError,
28+ PullRequestTargetIncompleteError,
29+ PullRequestHostRequiredError,
30+ PullRequestUnlinkFailedError,
31+ PullRequestListFailedError,
32+ type PullRequestTargetInput,
33+ PullRequestThreadNotFoundError,
34+ PullRequestsToolkit,
35+ type ThreadPullRequestEntry,
36+} from "./tools.ts";
37+
38+interface ResolvedTarget {
39+ readonly host: string;
40+ readonly repository: string;
41+ readonly number: number;
42+ readonly url: string;
43+}
44+
45+/** The project's host and provider supply defaults for a repository-and-number input. */
46+function projectHostAndProvider(project: OrchestrationProjectShell | undefined): {
47+ readonly host: string | null;
48+ readonly kind: SourceControlProviderKind | null;
49+} {
50+ const identity = project?.repositoryIdentity;
51+ const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null;
52+ if (!identity || kind === null) return { host: null, kind: null };
53+ return {
54+ host: pullRequestHostOf(identity, kind),
55+ kind,
56+ };
57+}
58+
59+/**
60+ * Turns whichever shape the agent passed into one host-level identity. A URL
61+ * wins outright; otherwise the repository and number are completed with the
62+ * thread's project host, which is where an agent working in that checkout
63+ * almost always opened the pull request.
64+ */
65+const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
66+ input: PullRequestTargetInput,
67+ project: OrchestrationProjectShell | undefined,
68+) {
69+ if (input.url !== undefined) {
70+ const parsed = parseChangeRequestUrl(input.url);
71+ if (parsed === null) {
72+ return yield* new PullRequestUrlInvalidError({});
73+ }
74+ return { ...parsed, url: input.url } satisfies ResolvedTarget;
75+ }
76+ if (input.repository === undefined || input.number === undefined) {
77+ return yield* new PullRequestTargetIncompleteError({});
78+ }
79+ const projectHost = projectHostAndProvider(project);
80+ const host = (input.host ?? projectHost.host)?.toLowerCase();
81+ if (host === undefined) {
82+ return yield* new PullRequestHostRequiredError({});
83+ }
84+ const repository = input.repository.toLowerCase();
85+ const url =
86+ changeRequestUrlFor(
87+ // The project's kind only describes its own host; another host gets no URL guess.
88+ host === projectHost.host ? projectHost.kind : null,
89+ host,
90+ repository,
91+ input.number,
92+ ) ?? `https://${host}/${repository}/pull/${input.number}`;
93+ return { host, repository, number: input.number, url } satisfies ResolvedTarget;
94+});
95+
96+function entryOf(
97+ link: ThreadPullRequestLink,
98+ chains: ReturnType<typeof resolveThreadPullRequestChains>,
99+): ThreadPullRequestEntry {
100+ const key = threadPullRequestKeyOf(link);
101+ let stack: ThreadPullRequestEntry["stack"] = null;
102+ for (const chain of chains) {
103+ if (chain.layers.length < 2) continue;
104+ const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key);
105+ if (index !== -1) {
106+ stack = { kind: chain.kind, position: index + 1, size: chain.layers.length };
107+ break;
108+ }
109+ }
110+ return {
111+ host: link.host,
112+ repository: link.repository,
113+ number: link.number,
114+ url: link.url,
115+ source: link.source,
116+ state: link.snapshot?.state ?? null,
117+ title: link.snapshot?.title ?? null,
118+ headBranch: link.snapshot?.headBranch ?? null,
119+ baseBranch: link.snapshot?.baseBranch ?? null,
120+ isDraft: link.snapshot?.isDraft ?? null,
121+ stack,
122+ };
123+}
124+
125+/** What the tools report from a thread shell; exported so the shape is testable without a layer. */
126+export function listThreadPullRequests(
127+ thread: Pick<OrchestrationThreadShell, "pullRequests">,
128+): ListThreadPullRequestsResult {
129+ const chains = resolveThreadPullRequestChains(thread.pullRequests);
130+ return {
131+ pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) =>
132+ entryOf(link, chains),
133+ ),
134+ chains: chains.map((chain) => ({
135+ kind: chain.kind,
136+ numbers: chain.layers.map((layer) => layer.number),
137+ })),
138+ };
139+}
140+
141+const make = Effect.gen(function* () {
142+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
143+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
144+ const crypto = yield* Crypto.Crypto;
145+
146+ const commandId = (tag: string, threadId: ThreadId) =>
147+ crypto.randomUUIDv4.pipe(
148+ Effect.orDie,
149+ Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)),
150+ );
151+
152+ const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* (
153+ Failure:
154+ | typeof PullRequestLinkFailedError
155+ | typeof PullRequestUnlinkFailedError
156+ | typeof PullRequestListFailedError,
157+ ) {
158+ const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
159+ const thread = yield* snapshots
160+ .getThreadShellById(scope.threadId)
161+ .pipe(Effect.mapError((cause) => new Failure({ cause })));
162+ if (Option.isNone(thread)) {
163+ return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId });
164+ }
165+ return thread.value;
166+ });
167+
168+ const projectOf = (
169+ thread: OrchestrationThreadShell,
170+ Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError,
171+ ) =>
172+ snapshots.getProjectShellById(thread.projectId).pipe(
173+ Effect.map(Option.getOrUndefined),
174+ Effect.mapError((cause) => new Failure({ cause })),
175+ );
176+
177+ const dispatchFailure =
178+ (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) =>
179+ <E>(
180+ cause: Cause.Cause<E>,
181+ ): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> =>
182+ Cause.hasInterruptsOnly(cause)
183+ ? Effect.failCause(cause as Cause.Cause<never>)
184+ : Effect.fail(new Failure({ cause }));
185+
186+ return PullRequestsToolkit.of({
187+ link_pull_request: (input) =>
188+ Effect.gen(function* () {
189+ const thread = yield* requireThread(PullRequestLinkFailedError);
190+ const project = yield* projectOf(thread, PullRequestLinkFailedError);
191+ const target = yield* resolveTarget(input, project);
192+ const alreadyLinked = yield* engine
193+ .dispatch({
194+ type: "thread.pull-request.link",
195+ commandId: yield* commandId("mcp-pr-link", thread.id),
196+ threadId: thread.id,
197+ host: target.host,
198+ repository: target.repository,
199+ number: target.number,
200+ url: target.url,
201+ source: "agent",
202+ })
203+ .pipe(
204+ Effect.as(false),
205+ // The decider rejects a second link of the same PR; for the agent that is
206+ // the outcome it asked for, not an error.
207+ Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }),
208+ Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)),
209+ );
210+ return { ...target, alreadyLinked };
211+ }),
212+ unlink_pull_request: (input) =>
213+ Effect.gen(function* () {
214+ const thread = yield* requireThread(PullRequestUnlinkFailedError);
215+ const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
216+ const target = yield* resolveTarget(input, project);
217+ const wasLinked = yield* engine
218+ .dispatch({
219+ type: "thread.pull-request.unlink",
220+ commandId: yield* commandId("mcp-pr-unlink", thread.id),
221+ threadId: thread.id,
222+ host: target.host,
223+ repository: target.repository,
224+ number: target.number,
225+ })
226+ .pipe(
227+ Effect.as(true),
228+ Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }),
229+ Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)),
230+ );
231+ return {
232+ host: target.host,
233+ repository: target.repository,
234+ number: target.number,
235+ wasLinked,
236+ };
237+ }),
238+ list_thread_pull_requests: () =>
239+ requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),
240+ });
241+});
242+
243+export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make);
apps/server/src/mcp/toolkits/pullRequests/tools.ts+231−0
@@ -0,0 +1,231 @@
1+import {
2+ McpCapabilityUnavailableError,
3+ PositiveInt,
4+ PullRequestState,
5+ ThreadPullRequestLinkSource,
6+ TrimmedNonEmptyString,
7+} from "@t3tools/contracts";
8+import * as Schema from "effect/Schema";
9+import * as Tool from "effect/unstable/ai/Tool";
10+import * as Toolkit from "effect/unstable/ai/Toolkit";
11+
12+import * as McpInvocationContext from "../../McpInvocationContext.ts";
13+import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts";
14+import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
15+
16+const dependencies = [
17+ McpInvocationContext.McpInvocationContext,
18+ OrchestrationEngine.OrchestrationEngineService,
19+ ProjectionSnapshotQuery.ProjectionSnapshotQuery,
20+];
21+
22+const REGISTER_EVERY_PR =
23+ "Register every pull request you open for this thread, including each layer of a stack, right after creating it.";
24+
25+/**
26+ * Either the pull request's URL or its repository and number. Both forms
27+ * resolve to the same host-level identity, so the agent can pass whichever
28+ * the host CLI handed back.
29+ */
30+export const PullRequestTargetInput = Schema.Struct({
31+ url: Schema.optional(
32+ TrimmedNonEmptyString.annotate({
33+ description:
34+ "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.",
35+ }),
36+ ),
37+ repository: Schema.optional(
38+ TrimmedNonEmptyString.annotate({
39+ description:
40+ "Repository path below the host, for example owner/repo. Required with number when url is omitted.",
41+ }),
42+ ),
43+ number: Schema.optional(
44+ PositiveInt.annotate({
45+ description: "Pull request number. Required with repository when url is omitted.",
46+ }),
47+ ),
48+ host: Schema.optional(
49+ TrimmedNonEmptyString.annotate({
50+ description:
51+ "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.",
52+ }),
53+ ),
54+});
55+export type PullRequestTargetInput = typeof PullRequestTargetInput.Type;
56+
57+export class PullRequestUrlInvalidError extends Schema.TaggedError<PullRequestUrlInvalidError>()(
58+ "PullRequestUrlInvalidError",
59+ {},
60+) {
61+ override get message(): string {
62+ return "This is not a recognised pull request URL. Pass repository and number instead.";
63+ }
64+}
65+
66+export class PullRequestTargetIncompleteError extends Schema.TaggedError<PullRequestTargetIncompleteError>()(
67+ "PullRequestTargetIncompleteError",
68+ {},
69+) {
70+ override get message(): string {
71+ return "Pass either url, or both repository and number.";
72+ }
73+}
74+
75+export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequestHostRequiredError>()(
76+ "PullRequestHostRequiredError",
77+ {},
78+) {
79+ override get message(): string {
80+ return "This thread's project has no recognised remote. Pass host or url.";
81+ }
82+}
83+
84+export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
85+ "PullRequestThreadNotFoundError",
86+ { threadId: Schema.String },
87+) {
88+ override get message(): string {
89+ return `Thread ${this.threadId} was not found.`;
90+ }
91+}
92+
93+export class PullRequestLinkFailedError extends Schema.TaggedError<PullRequestLinkFailedError>()(
94+ "PullRequestLinkFailedError",
95+ { cause: Schema.Defect() },
96+) {
97+ override get message(): string {
98+ return "Could not link the pull request.";
99+ }
100+}
101+
102+export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequestUnlinkFailedError>()(
103+ "PullRequestUnlinkFailedError",
104+ { cause: Schema.Defect() },
105+) {
106+ override get message(): string {
107+ return "Could not unlink the pull request.";
108+ }
109+}
110+
111+export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
112+ "PullRequestListFailedError",
113+ { cause: Schema.Defect() },
114+) {
115+ override get message(): string {
116+ return "Could not list the pull request.";
117+ }
118+}
119+
120+export const PullRequestToolError = Schema.Union([
121+ McpCapabilityUnavailableError,
122+ PullRequestUrlInvalidError,
123+ PullRequestTargetIncompleteError,
124+ PullRequestHostRequiredError,
125+ PullRequestThreadNotFoundError,
126+ PullRequestLinkFailedError,
127+ PullRequestUnlinkFailedError,
128+ PullRequestListFailedError,
129+]);
130+export type PullRequestToolError = typeof PullRequestToolError.Type;
131+
132+const PullRequestIdentity = {
133+ host: Schema.String,
134+ repository: Schema.String,
135+ number: Schema.Int,
136+ url: Schema.String,
137+};
138+
139+export const LinkPullRequestResult = Schema.Struct({
140+ ...PullRequestIdentity,
141+ alreadyLinked: Schema.Boolean.annotate({
142+ description: "True when the pull request was linked to this thread before the call.",
143+ }),
144+});
145+export type LinkPullRequestResult = typeof LinkPullRequestResult.Type;
146+
147+export const UnlinkPullRequestResult = Schema.Struct({
148+ host: Schema.String,
149+ repository: Schema.String,
150+ number: Schema.Int,
151+ wasLinked: Schema.Boolean.annotate({
152+ description: "False when the pull request was not linked to this thread to begin with.",
153+ }),
154+});
155+export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;
156+
157+export const ThreadPullRequestEntry = Schema.Struct({
158+ ...PullRequestIdentity,
159+ source: ThreadPullRequestLinkSource,
160+ state: Schema.NullOr(PullRequestState),
161+ title: Schema.NullOr(Schema.String),
162+ headBranch: Schema.NullOr(Schema.String),
163+ baseBranch: Schema.NullOr(Schema.String),
164+ isDraft: Schema.NullOr(Schema.Boolean),
165+ stack: Schema.NullOr(
166+ Schema.Struct({
167+ kind: Schema.Literals(["native", "derived"]),
168+ /** 1-based, bottom of the stack first. */
169+ position: Schema.Int,
170+ size: Schema.Int,
171+ }),
172+ ),
173+});
174+export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type;
175+
176+export const ListThreadPullRequestsResult = Schema.Struct({
177+ pullRequests: Schema.Array(ThreadPullRequestEntry),
178+ chains: Schema.Array(
179+ Schema.Struct({
180+ kind: Schema.Literals(["native", "derived"]),
181+ /** Bottom to top. */
182+ numbers: Schema.Array(Schema.Int),
183+ }),
184+ ),
185+});
186+export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;
187+
188+const LinkPullRequestTool = Tool.make("link_pull_request", {
189+ description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`,
190+ parameters: PullRequestTargetInput,
191+ success: LinkPullRequestResult,
192+ failure: PullRequestToolError,
193+ dependencies,
194+})
195+ .annotate(Tool.Title, "Link pull request to thread")
196+ .annotate(Tool.Readonly, false)
197+ .annotate(Tool.Destructive, false)
198+ .annotate(Tool.Idempotent, true)
199+ .annotate(Tool.OpenWorld, false);
200+
201+const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
202+ description:
203+ "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.",
204+ parameters: PullRequestTargetInput,
205+ success: UnlinkPullRequestResult,
206+ failure: PullRequestToolError,
207+ dependencies,
208+})
209+ .annotate(Tool.Title, "Unlink pull request from thread")
210+ .annotate(Tool.Readonly, false)
211+ .annotate(Tool.Destructive, true)
212+ .annotate(Tool.Idempotent, true)
213+ .annotate(Tool.OpenWorld, false);
214+
215+const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
216+ description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`,
217+ success: ListThreadPullRequestsResult,
218+ failure: PullRequestToolError,
219+ dependencies,
220+})
221+ .annotate(Tool.Title, "List thread pull requests")
222+ .annotate(Tool.Readonly, true)
223+ .annotate(Tool.Destructive, false)
224+ .annotate(Tool.Idempotent, true)
225+ .annotate(Tool.OpenWorld, false);
226+
227+export const PullRequestsToolkit = Toolkit.make(
228+ LinkPullRequestTool,
229+ UnlinkPullRequestTool,
230+ ListThreadPullRequestsTool,
231+);
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+35−5
@@ -60,7 +60,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); · 59 unmodified lines
6060 const asTurnId = (value: string): TurnId => TurnId.make(value);
6161 const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
6262
63−function makeOrchestrationLayer(databasePath?: string) {
63+function makeOrchestrationLayer(
64+ databasePath?: string,
65+ repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
66+) {
6467 const persistence = databasePath
6568 ? makeSqlitePersistenceLive(databasePath)
6669 : SqlitePersistenceMemory;
@@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) { · 11 unmodified lines
7881 Layer.provide(ThreadPlanProgress.layer),
7982 Layer.provide(OrchestrationEventStoreLive),
8083 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
81− Layer.provide(RepositoryIdentityResolver.layer),
84+ Layer.provide(
85+ repositoryIdentityResolver
86+ ? Layer.succeed(
87+ RepositoryIdentityResolver.RepositoryIdentityResolver,
88+ repositoryIdentityResolver,
89+ )
90+ : RepositoryIdentityResolver.layer,
91+ ),
8292 Layer.provide(persistence),
8393 Layer.provideMerge(ServerConfigLayer),
8494 Layer.provideMerge(NodeServices.layer),
8595 );
8696 }
8797
88−async function createOrchestrationSystem(databasePath?: string) {
89− const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath));
98+async function createOrchestrationSystem(
99+ databasePath?: string,
100+ repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
101+) {
102+ const runtime = ManagedRuntime.make(
103+ makeOrchestrationLayer(databasePath, repositoryIdentityResolver),
104+ );
90105 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
91106 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
92107 return {
@@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => { · 280 unmodified lines
373388 runtimeMode: "full-access" as const,
374389 branch: null,
375390 worktreePath: null,
391+ pullRequests: [],
376392 latestTurn: null,
377393 createdAt: "2026-03-03T00:00:02.000Z",
378394 updatedAt: "2026-03-03T00:00:03.000Z",
@@ -1009,7 +1025,20 @@ describe("OrchestrationEngine", () => { · 630 unmodified lines
10091025 it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)(
10101026 "rejects PR discovery completed after a newer %s command",
10111027 async (change) => {
1012− const system = await createOrchestrationSystem();
1028+ const system = await createOrchestrationSystem(undefined, {
1029+ resolve: (workspaceRoot) =>
1030+ Effect.succeed({
1031+ canonicalKey: "example.test/owner/repository",
1032+ provider: "github",
1033+ displayName: "owner/repository",
1034+ rootPath: workspaceRoot,
1035+ locator: {
1036+ source: "git-remote",
1037+ remoteName: "origin",
1038+ remoteUrl: "https://example.test/owner/repository.git",
1039+ },
1040+ }),
1041+ });
10131042 try {
10141043 const projectId = ProjectId.make("pr-race-project");
10151044 const threadId = ThreadId.make("pr-race-thread");
@@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => { · 42 unmodified lines
10581087 linkedPullRequest: previous,
10591088 }),
10601089 );
1090+ expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous);
10611091 const metadataChanges = {
10621092 unlink: { linkedPullRequest: null },
10631093 relink: {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12−0
@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; · 9 unmodified lines
1010 import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111 import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212 import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13+import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
1314 import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1415 import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1516 import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
@@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => { · 68 unmodified lines
8485 drain: Effect.void,
8586 }),
8687 ),
88+ Layer.provideMerge(
89+ Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
90+ start: () => {
91+ started.push("pull-request-sync-reactor");
92+ return Effect.void;
93+ },
94+ drain: Effect.void,
95+ requestSync: () => Effect.void,
96+ }),
97+ ),
8798 Layer.provideMerge(
8899 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
89100 publishThread: () => Effect.void,
@@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => { · 17 unmodified lines
107118 "thread-deletion-reactor",
108119 "thread-pull-request-reactor",
109120 "thread-settlement-reactor",
121+ "pull-request-sync-reactor",
110122 "agent-awareness-relay",
111123 ]);
112124
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3−0
@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; · 9 unmodified lines
1010 import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111 import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212 import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13+import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
1314 import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1415 import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1516
@@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 3 unmodified lines
1920 const checkpointReactor = yield* CheckpointReactor;
2021 const threadDeletionReactor = yield* ThreadDeletionReactor;
2122 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
23+ const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
2224 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
2325 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2426
@@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 4 unmodified lines
2931 yield* threadDeletionReactor.start();
3032 yield* threadPullRequestReactor.start();
3133 yield* threadSettlementReactor.start();
34+ yield* pullRequestSyncReactor.start();
3235 yield* agentAwarenessRelay.start();
3336 });
3437
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+237−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 MessageId,
99 ProjectId,
1010 ThreadId,
11+ type ThreadPullRequestSnapshot,
1112 ThreadLinkedPullRequest,
1213 TurnId,
1314 ProviderInstanceId,
@@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( · 672 unmodified lines
686687 },
687688 );
688689
690+it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))(
691+ "OrchestrationProjectionPipeline pull request links",
692+ (it) => {
693+ it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () =>
694+ Effect.gen(function* () {
695+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
696+ const eventStore = yield* OrchestrationEventStore;
697+ const sql = yield* SqlClient.SqlClient;
698+ const threadId = ThreadId.make("thread-pr");
699+ const projectId = ProjectId.make("project-pr");
700+ const t0 = "2026-01-01T00:00:00.000Z";
701+ let counter = 0;
702+ const base = (occurredAt: string) => {
703+ counter += 1;
704+ return {
705+ eventId: EventId.make(`evt-pr-${counter}`),
706+ aggregateKind: "thread",
707+ aggregateId: threadId,
708+ occurredAt,
709+ commandId: CommandId.make(`cmd-pr-${counter}`),
710+ causationEventId: null,
711+ correlationId: CommandId.make(`cmd-pr-${counter}`),
712+ metadata: {},
713+ } as const;
714+ };
715+ const readLinks = () =>
716+ sql<{
717+ readonly host: string;
718+ readonly repository: string;
719+ readonly number: number;
720+ readonly source: string;
721+ readonly linkedAt: string;
722+ readonly snapshotJson: string | null;
723+ readonly stackJson: string | null;
724+ }>`
725+ SELECT
726+ host,
727+ repository,
728+ number,
729+ source,
730+ linked_at AS "linkedAt",
731+ snapshot_json AS "snapshotJson",
732+ stack_json AS "stackJson"
733+ FROM projection_thread_pull_requests
734+ WHERE thread_id = ${threadId}
735+ ORDER BY number ASC
736+ `;
737+ const readThreadUpdatedAt = () =>
738+ sql<{ readonly updatedAt: string }>`
739+ SELECT updated_at AS "updatedAt" FROM projection_threads WHERE thread_id = ${threadId}
740+ `;
741+
742+ yield* eventStore.append({
743+ ...base(t0),
744+ type: "thread.created",
745+ payload: {
746+ threadId,
747+ projectId,
748+ title: "Thread PR",
749+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
750+ runtimeMode: "full-access",
751+ branch: null,
752+ worktreePath: null,
753+ createdAt: t0,
754+ updatedAt: t0,
755+ },
756+ });
757+
758+ // Legacy single-link event replays into a manual row with the URL host.
759+ yield* eventStore.append({
760+ ...base("2026-01-01T00:00:01.000Z"),
761+ type: "thread.meta-updated",
762+ payload: {
763+ threadId,
764+ linkedPullRequest: {
765+ projectId,
766+ repository: "web",
767+ number: 41,
768+ url: "https://org-a.visualstudio.com/DefaultCollection/project/_git/web/pullrequest/41",
769+ },
770+ updatedAt: "2026-01-01T00:00:01.000Z",
771+ },
772+ });
773+ yield* eventStore.append({
774+ ...base("2026-01-01T00:00:02.000Z"),
775+ type: "thread.pull-request-linked",
776+ payload: {
777+ threadId,
778+ link: {
779+ host: "github.com",
780+ repository: "pingdotgg/t3code",
781+ number: 42,
782+ url: "https://github.com/pingdotgg/t3code/pull/42",
783+ source: "created",
784+ linkedAt: "2026-01-01T00:00:02.000Z",
785+ snapshot: null,
786+ stack: null,
787+ },
788+ updatedAt: "2026-01-01T00:00:02.000Z",
789+ },
790+ });
791+ yield* projectionPipeline.bootstrap;
792+
793+ assert.deepEqual(yield* readLinks(), [
794+ {
795+ host: "dev.azure.com",
796+ repository: "org-a/project/_git/web",
797+ number: 41,
798+ source: "manual",
799+ linkedAt: "2026-01-01T00:00:01.000Z",
800+ snapshotJson: null,
801+ stackJson: null,
802+ },
803+ {
804+ host: "github.com",
805+ repository: "pingdotgg/t3code",
806+ number: 42,
807+ source: "created",
808+ linkedAt: "2026-01-01T00:00:02.000Z",
809+ snapshotJson: null,
810+ stackJson: null,
811+ },
812+ ]);
813+ assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:02.000Z" }]);
814+
815+ // Sync fills snapshot/stack on the matching row; a sync for an unknown
816+ // link is ignored.
817+ const snapshot: ThreadPullRequestSnapshot = {
818+ state: "open",
819+ title: "Add links",
820+ headBranch: "feat/links",
821+ baseBranch: "main",
822+ isDraft: false,
823+ updatedAt: "2026-01-01T00:00:02.500Z",
824+ syncedAt: "2026-01-01T00:00:03.000Z",
825+ };
826+ yield* eventStore.append({
827+ ...base("2026-01-01T00:00:03.000Z"),
828+ type: "thread.pull-request-synced",
829+ payload: {
830+ threadId,
831+ host: "github.com",
832+ repository: "pingdotgg/t3code",
833+ number: 42,
834+ snapshot,
835+ stack: null,
836+ updatedAt: "2026-01-01T00:00:03.000Z",
837+ },
838+ });
839+ yield* eventStore.append({
840+ ...base("2026-01-01T00:00:03.500Z"),
841+ type: "thread.pull-request-synced",
842+ payload: {
843+ threadId,
844+ host: "github.com",
845+ repository: "pingdotgg/t3code",
846+ number: 99,
847+ snapshot,
848+ stack: null,
849+ updatedAt: "2026-01-01T00:00:03.500Z",
850+ },
851+ });
852+ yield* projectionPipeline.bootstrap;
853+
854+ const synced = yield* readLinks();
855+ assert.equal(synced.length, 2);
856+ assert.equal(synced[0]?.snapshotJson, null);
857+ // @effect-diagnostics-next-line preferSchemaOverJson:off
858+ assert.deepEqual(JSON.parse(synced[1]?.snapshotJson ?? "null"), snapshot);
859+ assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:03.000Z" }]);
860+
861+ // A legacy null clears only the manual row; created/agent/stack rows stay.
862+ yield* eventStore.append({
863+ ...base("2026-01-01T00:00:04.000Z"),
864+ type: "thread.meta-updated",
865+ payload: {
866+ threadId,
867+ linkedPullRequest: null,
868+ updatedAt: "2026-01-01T00:00:04.000Z",
869+ },
870+ });
871+ yield* projectionPipeline.bootstrap;
872+ assert.deepEqual(
873+ (yield* readLinks()).map((row) => row.number),
874+ [42],
875+ );
876+
877+ yield* eventStore.append({
878+ ...base("2026-01-01T00:00:05.000Z"),
879+ type: "thread.pull-request-unlinked",
880+ payload: {
881+ threadId,
882+ host: "GitHub.COM",
883+ repository: "PingDotGG/T3Code",
884+ number: 42,
885+ updatedAt: "2026-01-01T00:00:05.000Z",
886+ },
887+ });
888+ yield* projectionPipeline.bootstrap;
889+ assert.deepEqual(yield* readLinks(), []);
890+ assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]);
891+
892+ // Deleting the thread clears whatever links it still had.
893+ yield* eventStore.append({
894+ ...base("2026-01-01T00:00:06.000Z"),
895+ type: "thread.pull-request-linked",
896+ payload: {
897+ threadId,
898+ link: {
899+ host: "github.com",
900+ repository: "pingdotgg/t3code",
901+ number: 43,
902+ url: "https://github.com/pingdotgg/t3code/pull/43",
903+ source: "agent",
904+ linkedAt: "2026-01-01T00:00:06.000Z",
905+ snapshot: null,
906+ stack: null,
907+ },
908+ updatedAt: "2026-01-01T00:00:06.000Z",
909+ },
910+ });
911+ yield* eventStore.append({
912+ ...base("2026-01-01T00:00:07.000Z"),
913+ type: "thread.deleted",
914+ payload: {
915+ threadId,
916+ deletedAt: "2026-01-01T00:00:07.000Z",
917+ },
918+ });
919+ yield* projectionPipeline.bootstrap;
920+ assert.deepEqual(yield* readLinks(), []);
921+ }),
922+ );
923+ },
924+);
925+
689926 it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(
690927 "OrchestrationProjectionPipeline",
691928 (it) => {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+104−0
@@ -16,6 +16,10 @@ import * as Path from "effect/Path"; · 15 unmodified lines
1616 import * as Schema from "effect/Schema";
1717 import * as Stream from "effect/Stream";
1818 import * as SqlClient from "effect/unstable/sql/SqlClient";
19+import {
20+ legacyThreadPullRequestKey,
21+ threadPullRequestKeysEqual,
22+} from "@t3tools/shared/threadPullRequests";
1923
2024 import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
2125 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
@@ -32,6 +36,7 @@ import { · 10 unmodified lines
3236 type ProjectionThreadProposedPlan,
3337 ProjectionThreadProposedPlanRepository,
3438 } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
39+import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts";
3540 import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
3641 import {
3742 type ProjectionTurn,
@@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 442 unmodified lines
480485 const projectionThreadRepository = yield* ProjectionThreadRepository;
481486 const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;
482487 const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository;
488+ const projectionThreadPullRequestRepository =
489+ yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository;
483490 const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;
484491 const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;
485492 const projectionTurnRepository = yield* ProjectionTurnRepository;
@@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 113 unmodified lines
599606 )(function* (event, attachmentSideEffects) {
600607 switch (event.type) {
601608 case "thread.created":
609+ // A draft retry can re-create this id; links belong to the old incarnation.
610+ yield* projectionThreadPullRequestRepository.deleteByThreadId({
611+ threadId: event.payload.threadId,
612+ });
602613 yield* projectionThreadRepository.upsert({
603614 threadId: event.payload.threadId,
604615 projectId: event.payload.projectId,
@@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 215 unmodified lines
820831 : {}),
821832 updatedAt: event.payload.updatedAt,
822833 });
834+ // Legacy single-link events replay into the link table. The old
835+ // field held one user-chosen link, so it only ever owns the manual
836+ // rows; created/agent/stack links are left alone.
837+ if (event.payload.linkedPullRequest !== undefined) {
838+ yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({
839+ threadId: event.payload.threadId,
840+ source: "manual",
841+ });
842+ if (event.payload.linkedPullRequest !== null) {
843+ const linked = event.payload.linkedPullRequest;
844+ yield* projectionThreadPullRequestRepository.upsert({
845+ threadId: event.payload.threadId,
846+ ...legacyThreadPullRequestKey(linked),
847+ url: linked.url,
848+ source: "manual",
849+ linkedAt: event.payload.updatedAt,
850+ snapshot: null,
851+ stack: null,
852+ });
853+ }
854+ }
855+ return;
856+ }
857+
858+ case "thread.pull-request-linked": {
859+ const existingRow = yield* projectionThreadRepository.getById({
860+ threadId: event.payload.threadId,
861+ });
862+ if (Option.isNone(existingRow)) {
863+ return;
864+ }
865+ yield* projectionThreadPullRequestRepository.upsert({
866+ threadId: event.payload.threadId,
867+ ...event.payload.link,
868+ });
869+ yield* projectionThreadRepository.upsert({
870+ ...existingRow.value,
871+ updatedAt: event.payload.updatedAt,
872+ });
873+ return;
874+ }
875+
876+ case "thread.pull-request-unlinked": {
877+ const existingRow = yield* projectionThreadRepository.getById({
878+ threadId: event.payload.threadId,
879+ });
880+ if (Option.isNone(existingRow)) {
881+ return;
882+ }
883+ yield* projectionThreadPullRequestRepository.delete({
884+ threadId: event.payload.threadId,
885+ host: event.payload.host.toLowerCase(),
886+ repository: event.payload.repository.toLowerCase(),
887+ number: event.payload.number,
888+ });
889+ yield* projectionThreadRepository.upsert({
890+ ...existingRow.value,
891+ updatedAt: event.payload.updatedAt,
892+ });
893+ return;
894+ }
895+
896+ case "thread.pull-request-synced": {
897+ const existingRow = yield* projectionThreadRepository.getById({
898+ threadId: event.payload.threadId,
899+ });
900+ if (Option.isNone(existingRow)) {
901+ return;
902+ }
903+ // A sync for a link the user removed in the meantime is stale; drop it.
904+ const links = yield* projectionThreadPullRequestRepository.listByThreadId({
905+ threadId: event.payload.threadId,
906+ });
907+ const link = links.find((candidate) =>
908+ threadPullRequestKeysEqual(candidate, event.payload),
909+ );
910+ if (link === undefined) {
911+ return;
912+ }
913+ yield* projectionThreadPullRequestRepository.upsert({
914+ ...link,
915+ snapshot: event.payload.snapshot,
916+ stack: event.payload.stack,
917+ });
918+ yield* projectionThreadRepository.upsert({
919+ ...existingRow.value,
920+ updatedAt: event.payload.updatedAt,
921+ });
823922 return;
824923 }
825924
@@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 40 unmodified lines
866965 if (!recreatedLater) {
867966 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
868967 }
968+ // A tombstoned thread must not show up as linked to a pull request.
969+ yield* projectionThreadPullRequestRepository.deleteByThreadId({
970+ threadId: event.payload.threadId,
971+ });
869972 const existingRow = yield* projectionThreadRepository.getById({
870973 threadId: event.payload.threadId,
871974 });
@@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( · 1196 unmodified lines
20682171 Layer.provideMerge(ProjectionThreadRepositoryLive),
20692172 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
20702173 Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive),
2174+ Layer.provideMerge(ProjectionThreadPullRequests.layer),
20712175 Layer.provideMerge(ProjectionThreadActivityRepositoryLive),
20722176 Layer.provideMerge(ProjectionThreadSessionRepositoryLive),
20732177 Layer.provideMerge(ProjectionTurnRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+146−17
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 MessageId,
77 ProjectId,
88 ThreadId,
9+ type ThreadPullRequestLink,
910 ThreadLinkedPullRequest,
1011 TurnId,
1112 ProviderInstanceId,
@@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 54 unmodified lines
6667 yield* sql`DELETE FROM projection_projects`;
6768 yield* sql`DELETE FROM projection_state`;
6869 yield* sql`DELETE FROM projection_thread_proposed_plans`;
70+ yield* sql`DELETE FROM projection_thread_pull_requests`;
6971 yield* sql`DELETE FROM projection_turns`;
7072
7173 yield* sql`
@@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 19 unmodified lines
9193 )
9294 `;
9395
96+ // A merged link plus a newer open one: the multi-link projection must
97+ // resolve to the open pull request, not the stale JSON column below.
98+ yield* sql`
99+ INSERT INTO projection_thread_pull_requests (
100+ thread_id,
101+ host,
102+ repository,
103+ number,
104+ url,
105+ source,
106+ linked_at,
107+ snapshot_json,
108+ stack_json
109+ )
110+ VALUES
111+ (
112+ 'thread-1',
113+ 'github.com',
114+ 'pingdotgg/t3code',
115+ 41,
116+ 'https://github.com/pingdotgg/t3code/pull/41',
117+ 'created',
118+ '2026-02-24T00:00:02.500Z',
119+ '{"state":"merged","title":"Groundwork","headBranch":"feat/groundwork","baseBranch":"main","isDraft":false,"updatedAt":"2026-02-24T00:00:02.600Z","syncedAt":"2026-02-24T00:00:02.700Z"}',
120+ NULL
121+ ),
122+ (
123+ 'thread-1',
124+ 'github.com',
125+ 'pingdotgg/t3code',
126+ 42,
127+ 'https://github.com/pingdotgg/t3code/pull/42',
128+ 'manual',
129+ '2026-02-24T00:00:03.000Z',
130+ NULL,
131+ NULL
132+ )
133+ `;
134+
94135 yield* sql`
95136 INSERT INTO projection_threads (
96137 thread_id,
@@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 27 unmodified lines
124165 'default',
125166 NULL,
126167 NULL,
127− '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
168+ '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}',
128169 ${encodeThreadLinkedPullRequest(branchPullRequest)},
129170 'turn-1',
130171 '2026-02-24T00:00:04.000Z',
@@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 155 unmodified lines
286327 sequence += 1;
287328 }
288329
330+ const expectedPullRequests: ReadonlyArray<ThreadPullRequestLink> = [
331+ {
332+ host: "github.com",
333+ repository: "pingdotgg/t3code",
334+ number: 41,
335+ url: "https://github.com/pingdotgg/t3code/pull/41",
336+ source: "created",
337+ linkedAt: "2026-02-24T00:00:02.500Z",
338+ snapshot: {
339+ state: "merged",
340+ title: "Groundwork",
341+ headBranch: "feat/groundwork",
342+ baseBranch: "main",
343+ isDraft: false,
344+ updatedAt: "2026-02-24T00:00:02.600Z",
345+ syncedAt: "2026-02-24T00:00:02.700Z",
346+ },
347+ stack: null,
348+ },
349+ {
350+ host: "github.com",
351+ repository: "pingdotgg/t3code",
352+ number: 42,
353+ url: "https://github.com/pingdotgg/t3code/pull/42",
354+ source: "manual",
355+ linkedAt: "2026-02-24T00:00:03.000Z",
356+ snapshot: null,
357+ stack: null,
358+ },
359+ ];
360+
289361 const snapshot = yield* snapshotQuery.getSnapshot();
290362
291363 assert.equal(snapshot.snapshotSequence, 5);
@@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 39 unmodified lines
331403 runtimeMode: "full-access",
332404 branch: null,
333405 worktreePath: null,
334− linkedPullRequest: {
335− projectId: asProjectId("project-1"),
336− repository: "pingdotgg/t3code",
337− number: 42,
338− url: "https://github.com/pingdotgg/t3code/pull/42",
339− },
406+ pullRequests: expectedPullRequests,
340407 branchPullRequest,
341408 latestTurn: {
342409 turnId: asTurnId("turn-1"),
@@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 118 unmodified lines
461528 runtimeMode: "full-access",
462529 branch: null,
463530 worktreePath: null,
464− linkedPullRequest: {
465− projectId: asProjectId("project-1"),
466− repository: "pingdotgg/t3code",
467− number: 42,
468− url: "https://github.com/pingdotgg/t3code/pull/42",
469− },
531+ pullRequests: expectedPullRequests,
470532 branchPullRequest,
471533 latestTurn: {
472534 turnId: asTurnId("turn-1"),
@@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 43 unmodified lines
516578 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
517579 }
518580
519− const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
520− assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
521− assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
522581 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
523582 assert.equal(threadShell._tag, "Some");
524583 if (threadShell._tag === "Some") {
525− assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest);
584+ assert.deepEqual(threadShell.value, shellSnapshot.threads[0]);
585+ }
586+
587+ const commandReadModel = yield* snapshotQuery.getCommandReadModel();
588+ assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests);
589+ assert.deepEqual(
590+ commandReadModel.threads[0]?.linkedPullRequest,
591+ snapshot.threads[0]?.linkedPullRequest,
592+ );
593+
594+ // Without link rows the legacy field is omitted, whatever the old JSON
595+ // column still holds.
596+ yield* sql`DELETE FROM projection_thread_pull_requests`;
597+ const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
598+ assert.equal(unlinkedShell._tag, "Some");
599+ if (unlinkedShell._tag === "Some") {
600+ assert.deepEqual(unlinkedShell.value.pullRequests, []);
601+ assert.equal("linkedPullRequest" in unlinkedShell.value, false);
526602 }
527603
528604 yield* sql`
@@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { · 2730 unmodified lines
32593335 }),
32603336 );
32613337 });
3338+
3339+it.effect("omits foreign-host PRs from legacy snapshots while preserving native links", () => {
3340+ const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
3341+ Layer.provide(ThreadBackgroundLiveness.layer),
3342+ Layer.provide(ThreadPlanProgress.layer),
3343+ Layer.provide(
3344+ Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
3345+ resolve: () =>
3346+ Effect.succeed({
3347+ canonicalKey: "github.com/acme/web",
3348+ provider: "github",
3349+ displayName: "acme/web",
3350+ locator: {
3351+ source: "git-remote" as const,
3352+ remoteName: "origin",
3353+ remoteUrl: "https://github.com/acme/web.git",
3354+ },
3355+ }),
3356+ }),
3357+ ),
3358+ Layer.provideMerge(SqlitePersistenceMemory),
3359+ );
3360+ return Effect.gen(function* () {
3361+ const sql = yield* SqlClient.SqlClient;
3362+ const query = yield* ProjectionSnapshotQuery;
3363+ yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
3364+ VALUES ('project-1', 'Project', '/repo', '[]', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`;
3365+ yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at)
3366+ VALUES ('thread-1', 'project-1', 'Thread', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`;
3367+ yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
3368+ VALUES ('thread-1', 'github.enterprise.test', 'acme/web', 42, 'https://github.enterprise.test/acme/web/pull/42', 'manual', '2026-09-09T00:00:00Z')`;
3369+ const readThreads = Effect.gen(function* () {
3370+ const full = yield* query.getSnapshot();
3371+ const shell = yield* query.getShellSnapshot();
3372+ const detail = yield* query.getThreadDetailById(ThreadId.make("thread-1"));
3373+ const individual = yield* query.getThreadShellById(ThreadId.make("thread-1"));
3374+ return [
3375+ full.threads[0]!,
3376+ shell.threads[0]!,
3377+ Option.getOrThrow(detail),
3378+ Option.getOrThrow(individual),
3379+ ];
3380+ });
3381+ for (const thread of yield* readThreads) {
3382+ assert.equal(thread.linkedPullRequest ?? null, null);
3383+ assert.equal(thread.pullRequests[0]?.host, "github.enterprise.test");
3384+ }
3385+ yield* sql`UPDATE projection_thread_pull_requests SET host = 'github.com', url = 'https://github.com/acme/web/pull/42'`;
3386+ for (const thread of yield* readThreads) {
3387+ assert.equal(thread.linkedPullRequest?.url, "https://github.com/acme/web/pull/42");
3388+ }
3389+ }).pipe(Effect.provide(layer));
3390+});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+423−187
@@ -29,7 +29,11 @@ import { · 28 unmodified lines
2929 ProjectId,
3030 ThreadLinkedPullRequest,
3131 ThreadId,
32+ ThreadPullRequestSnapshot,
33+ ThreadPullRequestStack,
34+ type ThreadPullRequestLink,
3235 } from "@t3tools/contracts";
36+import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests";
3337 import * as Arr from "effect/Array";
3438 import * as Effect from "effect/Effect";
3539 import * as Layer from "effect/Layer";
@@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; · 18 unmodified lines
5458 import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
5559 import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
5660 import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
61+import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts";
5762 import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
5863 import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
5964 import {
@@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema · 52 unmodified lines
112117 Struct.assign({ hasOtherUserMessages: Schema.Number }),
113118 );
114119 const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
120+const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields(
121+ Struct.assign({
122+ snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)),
123+ stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)),
124+ }),
125+);
115126 const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
116127 Struct.assign({
117128 modelSelection: Schema.fromJsonString(ModelSelection),
@@ -403,6 +414,49 @@ function mapProposedPlanRow( · 285 unmodified lines
403414 };
404415 }
405416
417+function mapPullRequestRow(
418+ row: Schema.Schema.Type<typeof ProjectionThreadPullRequestDbRowSchema>,
419+): ThreadPullRequestLink {
420+ return {
421+ host: row.host,
422+ repository: row.repository,
423+ number: row.number,
424+ url: row.url,
425+ source: row.source,
426+ linkedAt: row.linkedAt,
427+ snapshot: row.snapshot,
428+ stack: row.stack,
429+ };
430+}
431+
432+function groupPullRequestRowsByThread(
433+ rows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadPullRequestDbRowSchema>>,
434+): Map<string, Array<ThreadPullRequestLink>> {
435+ const byThread = new Map<string, Array<ThreadPullRequestLink>>();
436+ for (const row of rows) {
437+ const links = byThread.get(row.threadId) ?? [];
438+ links.push(mapPullRequestRow(row));
439+ byThread.set(row.threadId, links);
440+ }
441+ return byThread;
442+}
443+
444+/**
445+ * The link array plus the legacy single-link field derived from it, so clients
446+ * from before `pullRequests` keep seeing the thread's current pull request.
447+ */
448+function mapThreadPullRequests(
449+ pullRequests: ReadonlyArray<ThreadPullRequestLink>,
450+ projectId: ProjectId,
451+ identity?: OrchestrationProject["repositoryIdentity"],
452+): Pick<OrchestrationThread, "pullRequests" | "linkedPullRequest"> {
453+ const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity);
454+ return {
455+ pullRequests,
456+ ...(linkedPullRequest === null ? {} : { linkedPullRequest }),
457+ };
458+}
459+
406460 function mapThreadActivityRow(
407461 row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
408462 ): OrchestrationThreadActivity {
@@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 240 unmodified lines
649703 `,
650704 });
651705
706+ const listThreadPullRequestRows = SqlSchema.findAll({
707+ Request: Schema.Void,
708+ Result: ProjectionThreadPullRequestDbRowSchema,
709+ execute: () =>
710+ sql`
711+ SELECT
712+ thread_id AS "threadId",
713+ host,
714+ repository,
715+ number,
716+ url,
717+ source,
718+ linked_at AS "linkedAt",
719+ snapshot_json AS "snapshot",
720+ stack_json AS "stack"
721+ FROM projection_thread_pull_requests
722+ ORDER BY thread_id ASC, linked_at ASC, number ASC
723+ `,
724+ });
725+
726+ const listActiveThreadPullRequestRows = SqlSchema.findAll({
727+ Request: Schema.Void,
728+ Result: ProjectionThreadPullRequestDbRowSchema,
729+ execute: () =>
730+ sql`
731+ SELECT
732+ links.thread_id AS "threadId",
733+ links.host,
734+ links.repository,
735+ links.number,
736+ links.url,
737+ links.source,
738+ links.linked_at AS "linkedAt",
739+ links.snapshot_json AS "snapshot",
740+ links.stack_json AS "stack"
741+ FROM projection_thread_pull_requests links
742+ INNER JOIN projection_threads threads
743+ ON threads.thread_id = links.thread_id
744+ WHERE threads.deleted_at IS NULL
745+ AND threads.archived_at IS NULL
746+ ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC
747+ `,
748+ });
749+
750+ const listArchivedThreadPullRequestRows = SqlSchema.findAll({
751+ Request: Schema.Void,
752+ Result: ProjectionThreadPullRequestDbRowSchema,
753+ execute: () =>
754+ sql`
755+ SELECT
756+ links.thread_id AS "threadId",
757+ links.host,
758+ links.repository,
759+ links.number,
760+ links.url,
761+ links.source,
762+ links.linked_at AS "linkedAt",
763+ links.snapshot_json AS "snapshot",
764+ links.stack_json AS "stack"
765+ FROM projection_thread_pull_requests links
766+ INNER JOIN projection_threads threads
767+ ON threads.thread_id = links.thread_id
768+ WHERE threads.deleted_at IS NULL
769+ AND threads.archived_at IS NOT NULL
770+ ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC
771+ `,
772+ });
773+
652774 const listThreadActivityRows = SqlSchema.findAll({
653775 Request: Schema.Void,
654776 Result: ProjectionThreadActivityDbRowSchema,
@@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 553 unmodified lines
12081330 `,
12091331 });
12101332
1333+ const listThreadPullRequestRowsByThread = SqlSchema.findAll({
1334+ Request: ThreadIdLookupInput,
1335+ Result: ProjectionThreadPullRequestDbRowSchema,
1336+ execute: ({ threadId }) =>
1337+ sql`
1338+ SELECT
1339+ thread_id AS "threadId",
1340+ host,
1341+ repository,
1342+ number,
1343+ url,
1344+ source,
1345+ linked_at AS "linkedAt",
1346+ snapshot_json AS "snapshot",
1347+ stack_json AS "stack"
1348+ FROM projection_thread_pull_requests
1349+ WHERE thread_id = ${threadId}
1350+ ORDER BY linked_at ASC, number ASC
1351+ `,
1352+ });
1353+
12111354 const listThreadActivityRowsByThread = SqlSchema.findAll({
12121355 Request: ThreadIdLookupInput,
12131356 Result: ProjectionThreadActivityDbRowSchema,
@@ -1852,6 +1995,14 @@ pending_approval_requests AS ( · 638 unmodified lines
18521995 ),
18531996 ),
18541997 ),
1998+ listThreadPullRequestRows(undefined).pipe(
1999+ Effect.mapError(
2000+ toPersistenceSqlOrDecodeError(
2001+ "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query",
2002+ "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows",
2003+ ),
2004+ ),
2005+ ),
18552006 listThreadActivityRows(undefined).pipe(
18562007 Effect.mapError(
18572008 toPersistenceSqlOrDecodeError(
@@ -1901,6 +2052,7 @@ pending_approval_requests AS ( · 43 unmodified lines
19012052 threadRows,
19022053 messageRows,
19032054 proposedPlanRows,
2055+ pullRequestRows,
19042056 activityRows,
19052057 sessionRows,
19062058 checkpointRows,
@@ -1910,6 +2062,7 @@ pending_approval_requests AS ( · 3 unmodified lines
19102062 Effect.gen(function* () {
19112063 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
19122064 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2065+ const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
19132066 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
19142067 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
19152068 const sessionsByThread = new Map<string, OrchestrationSession>();
@@ -2071,10 +2224,12 @@ pending_approval_requests AS ( · 155 unmodified lines
20712224 interactionMode: row.interactionMode,
20722225 branch: row.branch,
20732226 worktreePath: row.worktreePath,
2227+ ...mapThreadPullRequests(
2228+ pullRequestsByThread.get(row.threadId) ?? [],
2229+ row.projectId,
2230+ repositoryIdentities.get(row.projectId),
2231+ ),
20742232 branchPullRequest: row.branchPullRequest,
2075− ...(row.linkedPullRequest === null
2076− ? {}
2077− : { linkedPullRequest: row.linkedPullRequest }),
20782233 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
20792234 createdAt: row.createdAt,
20802235 updatedAt: row.updatedAt,
@@ -2146,6 +2301,14 @@ pending_approval_requests AS ( · 65 unmodified lines
21462301 ),
21472302 ),
21482303 ),
2304+ listThreadPullRequestRows(undefined).pipe(
2305+ Effect.mapError(
2306+ toPersistenceSqlOrDecodeError(
2307+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query",
2308+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows",
2309+ ),
2310+ ),
2311+ ),
21492312 listThreadSessionRows(undefined).pipe(
21502313 Effect.mapError(
21512314 toPersistenceSqlOrDecodeError(
@@ -2174,8 +2337,25 @@ pending_approval_requests AS ( · 22 unmodified lines
21742337 )
21752338 .pipe(
21762339 Effect.flatMap(
2177− ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) =>
2178− Effect.sync(() => {
2340+ ([
2341+ projectRows,
2342+ threadRows,
2343+ proposedPlanRows,
2344+ pullRequestRows,
2345+ sessionRows,
2346+ latestTurnRows,
2347+ stateRows,
2348+ ]) =>
2349+ Effect.gen(function* () {
2350+ const linkedThreadIds = new Set(pullRequestRows.map((row) => row.threadId));
2351+ const linkedProjectIds = new Set(
2352+ threadRows
2353+ .filter((row) => linkedThreadIds.has(row.threadId))
2354+ .map((row) => row.projectId),
2355+ );
2356+ const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2357+ projectRows.filter((row) => linkedProjectIds.has(row.projectId)),
2358+ );
21792359 let updatedAt: string | null = null;
21802360 const projects: OrchestrationProject[] = [];
21812361 const threads: OrchestrationThread[] = [];
@@ -2190,6 +2370,7 @@ pending_approval_requests AS ( · 8 unmodified lines
21902370 id: row.projectId,
21912371 title: row.title,
21922372 workspaceRoot: row.workspaceRoot,
2373+ repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
21932374 defaultModelSelection: row.defaultModelSelection,
21942375 defaultThreadEnvMode: row.defaultThreadEnvMode,
21952376 autoPull: row.autoPull === 1,
@@ -2252,6 +2433,7 @@ pending_approval_requests AS ( · 56 unmodified lines
22522433 latestTurnByThread.set(row.threadId, mapLatestTurn(row));
22532434 }
22542435 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2436+ const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
22552437 const sessionByThread = new Map<string, OrchestrationSession>();
22562438
22572439 for (let index = 0; index < sessionRows.length; index += 1) {
@@ -2286,10 +2468,12 @@ pending_approval_requests AS ( · 28 unmodified lines
22862468 interactionMode: row.interactionMode,
22872469 branch: row.branch,
22882470 worktreePath: row.worktreePath,
2471+ ...mapThreadPullRequests(
2472+ pullRequestsByThread.get(row.threadId) ?? [],
2473+ row.projectId,
2474+ repositoryIdentities.get(row.projectId),
2475+ ),
22892476 branchPullRequest: row.branchPullRequest,
2290− ...(row.linkedPullRequest === null
2291− ? {}
2292− : { linkedPullRequest: row.linkedPullRequest }),
22932477 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
22942478 createdAt: row.createdAt,
22952479 updatedAt: row.updatedAt,
@@ -2356,6 +2540,14 @@ pending_approval_requests AS ( · 60 unmodified lines
23562540 ),
23572541 ),
23582542 ),
2543+ listActiveThreadPullRequestRows(undefined).pipe(
2544+ Effect.mapError(
2545+ toPersistenceSqlOrDecodeError(
2546+ "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query",
2547+ "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows",
2548+ ),
2549+ ),
2550+ ),
23592551 listActiveLatestTurnRows(undefined).pipe(
23602552 Effect.mapError(
23612553 toPersistenceSqlOrDecodeError(
@@ -2375,99 +2567,104 @@ pending_approval_requests AS ( · 13 unmodified lines
23752567 ]),
23762568 )
23772569 .pipe(
2378− Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
2379− Effect.gen(function* () {
2380− let updatedAt: string | null = null;
2381− for (const row of projectRows) {
2382− updatedAt = maxIso(updatedAt, row.updatedAt);
2383− }
2384− for (const row of threadRows) {
2385− updatedAt = maxIso(updatedAt, row.updatedAt);
2386− }
2387− for (const row of sessionRows) {
2388− updatedAt = maxIso(updatedAt, row.updatedAt);
2389− }
2390− for (const row of latestTurnRows) {
2391− updatedAt = maxIso(updatedAt, row.requestedAt);
2392− if (row.startedAt !== null) {
2393− updatedAt = maxIso(updatedAt, row.startedAt);
2570+ Effect.flatMap(
2571+ ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2572+ Effect.gen(function* () {
2573+ let updatedAt: string | null = null;
2574+ for (const row of projectRows) {
2575+ updatedAt = maxIso(updatedAt, row.updatedAt);
23942576 }
2395− if (row.completedAt !== null) {
2396− updatedAt = maxIso(updatedAt, row.completedAt);
2577+ for (const row of threadRows) {
2578+ updatedAt = maxIso(updatedAt, row.updatedAt);
2579+ }
2580+ for (const row of sessionRows) {
2581+ updatedAt = maxIso(updatedAt, row.updatedAt);
2582+ }
2583+ for (const row of latestTurnRows) {
2584+ updatedAt = maxIso(updatedAt, row.requestedAt);
2585+ if (row.startedAt !== null) {
2586+ updatedAt = maxIso(updatedAt, row.startedAt);
2587+ }
2588+ if (row.completedAt !== null) {
2589+ updatedAt = maxIso(updatedAt, row.completedAt);
2590+ }
2591+ }
2592+ for (const row of stateRows) {
2593+ updatedAt = maxIso(updatedAt, row.updatedAt);
23972594 }
2398− }
2399− for (const row of stateRows) {
2400− updatedAt = maxIso(updatedAt, row.updatedAt);
2401− }
24022595
2403− const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows);
2404− const latestTurnByThread = new Map(
2405− latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2406− );
2407− const sessionByThread = new Map(
2408− sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2409− );
2596+ const repositoryIdentities =
2597+ yield* resolveRepositoryIdentitiesForProjects(projectRows);
2598+ const latestTurnByThread = new Map(
2599+ latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2600+ );
2601+ const sessionByThread = new Map(
2602+ sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2603+ );
2604+ const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
24102605
2411− const snapshot = {
2412− snapshotSequence: computeSnapshotSequence(stateRows),
2413− projects: Arr.filterMap(projectRows, (row) =>
2414− row.deletedAt === null
2415− ? Result.succeed(
2416− mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2417− )
2418− : Result.failVoid,
2419− ),
2420− threads: Arr.filterMap(threadRows, (row) =>
2421− row.deletedAt === null
2422− ? Result.succeed({
2423− id: row.threadId,
2424− projectId: row.projectId,
2425− title: row.title,
2426− modelSelection: row.modelSelection,
2427− runtimeMode: row.runtimeMode,
2428− interactionMode: row.interactionMode,
2429− branch: row.branch,
2430− worktreePath: row.worktreePath,
2431− branchPullRequest: row.branchPullRequest,
2432− ...(row.linkedPullRequest === null
2433− ? {}
2434− : { linkedPullRequest: row.linkedPullRequest }),
2435− latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2436− createdAt: row.createdAt,
2437− updatedAt: row.updatedAt,
2438− archivedAt: row.archivedAt,
2439− settledOverride: row.settledOverride,
2440− settledAt: row.settledAt,
2441− unsettledAt: row.unsettledAt,
2442− snoozedUntil: row.snoozedUntil,
2443− snoozedAt: row.snoozedAt,
2444− pinnedAt: row.pinnedAt,
2445− pinOrderKey: row.pinOrderKey ?? null,
2446− activeOrderKey: row.activeOrderKey ?? null,
2447− titleRegeneration: mapTitleRegeneration(row),
2448− session: sessionByThread.get(row.threadId) ?? null,
2449− latestUserMessageAt: row.latestUserMessageAt,
2450− hasPendingApprovals: row.pendingApprovalCount > 0,
2451− hasPendingUserInput: row.pendingUserInputCount > 0,
2452− hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2453− backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2454− row.threadId,
2455− ),
2456− planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2457− } satisfies OrchestrationThreadShell)
2458− : Result.failVoid,
2459− ),
2460− updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2461− };
2606+ const snapshot = {
2607+ snapshotSequence: computeSnapshotSequence(stateRows),
2608+ projects: Arr.filterMap(projectRows, (row) =>
2609+ row.deletedAt === null
2610+ ? Result.succeed(
2611+ mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2612+ )
2613+ : Result.failVoid,
2614+ ),
2615+ threads: Arr.filterMap(threadRows, (row) =>
2616+ row.deletedAt === null
2617+ ? Result.succeed({
2618+ id: row.threadId,
2619+ projectId: row.projectId,
2620+ title: row.title,
2621+ modelSelection: row.modelSelection,
2622+ runtimeMode: row.runtimeMode,
2623+ interactionMode: row.interactionMode,
2624+ branch: row.branch,
2625+ worktreePath: row.worktreePath,
2626+ branchPullRequest: row.branchPullRequest,
2627+ ...mapThreadPullRequests(
2628+ pullRequestsByThread.get(row.threadId) ?? [],
2629+ row.projectId,
2630+ repositoryIdentities.get(row.projectId),
2631+ ),
2632+ latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2633+ createdAt: row.createdAt,
2634+ updatedAt: row.updatedAt,
2635+ archivedAt: row.archivedAt,
2636+ settledOverride: row.settledOverride,
2637+ settledAt: row.settledAt,
2638+ unsettledAt: row.unsettledAt,
2639+ snoozedUntil: row.snoozedUntil,
2640+ snoozedAt: row.snoozedAt,
2641+ pinnedAt: row.pinnedAt,
2642+ pinOrderKey: row.pinOrderKey ?? null,
2643+ activeOrderKey: row.activeOrderKey ?? null,
2644+ titleRegeneration: mapTitleRegeneration(row),
2645+ session: sessionByThread.get(row.threadId) ?? null,
2646+ latestUserMessageAt: row.latestUserMessageAt,
2647+ hasPendingApprovals: row.pendingApprovalCount > 0,
2648+ hasPendingUserInput: row.pendingUserInputCount > 0,
2649+ hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2650+ backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2651+ row.threadId,
2652+ ),
2653+ planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2654+ } satisfies OrchestrationThreadShell)
2655+ : Result.failVoid,
2656+ ),
2657+ updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2658+ };
24622659
2463− return yield* decodeShellSnapshot(snapshot).pipe(
2464− Effect.mapError(
2465− toPersistenceDecodeError(
2466− "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
2660+ return yield* decodeShellSnapshot(snapshot).pipe(
2661+ Effect.mapError(
2662+ toPersistenceDecodeError(
2663+ "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
2664+ ),
24672665 ),
2468− ),
2469− );
2470− }),
2666+ );
2667+ }),
24712668 ),
24722669 Effect.mapError((error) => {
24732670 if (isPersistenceError(error)) {
@@ -2505,6 +2702,14 @@ pending_approval_requests AS ( · 31 unmodified lines
25052702 ),
25062703 ),
25072704 ),
2705+ listArchivedThreadPullRequestRows(undefined).pipe(
2706+ Effect.mapError(
2707+ toPersistenceSqlOrDecodeError(
2708+ "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query",
2709+ "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows",
2710+ ),
2711+ ),
2712+ ),
25082713 listArchivedLatestTurnRows(undefined).pipe(
25092714 Effect.mapError(
25102715 toPersistenceSqlOrDecodeError(
@@ -2524,98 +2729,102 @@ pending_approval_requests AS ( · 13 unmodified lines
25242729 ]),
25252730 )
25262731 .pipe(
2527− Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
2528− Effect.gen(function* () {
2529− let updatedAt: string | null = null;
2530− for (const row of projectRows) {
2531− updatedAt = maxIso(updatedAt, row.updatedAt);
2532− }
2533− for (const row of threadRows) {
2534− updatedAt = maxIso(updatedAt, row.updatedAt);
2535− }
2536− for (const row of sessionRows) {
2537− updatedAt = maxIso(updatedAt, row.updatedAt);
2538− }
2539− for (const row of latestTurnRows) {
2540− updatedAt = maxIso(updatedAt, row.requestedAt);
2541− if (row.startedAt !== null) {
2542− updatedAt = maxIso(updatedAt, row.startedAt);
2732+ Effect.flatMap(
2733+ ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2734+ Effect.gen(function* () {
2735+ let updatedAt: string | null = null;
2736+ for (const row of projectRows) {
2737+ updatedAt = maxIso(updatedAt, row.updatedAt);
25432738 }
2544− if (row.completedAt !== null) {
2545− updatedAt = maxIso(updatedAt, row.completedAt);
2739+ for (const row of threadRows) {
2740+ updatedAt = maxIso(updatedAt, row.updatedAt);
2741+ }
2742+ for (const row of sessionRows) {
2743+ updatedAt = maxIso(updatedAt, row.updatedAt);
2744+ }
2745+ for (const row of latestTurnRows) {
2746+ updatedAt = maxIso(updatedAt, row.requestedAt);
2747+ if (row.startedAt !== null) {
2748+ updatedAt = maxIso(updatedAt, row.startedAt);
2749+ }
2750+ if (row.completedAt !== null) {
2751+ updatedAt = maxIso(updatedAt, row.completedAt);
2752+ }
2753+ }
2754+ for (const row of stateRows) {
2755+ updatedAt = maxIso(updatedAt, row.updatedAt);
25462756 }
2547− }
2548− for (const row of stateRows) {
2549− updatedAt = maxIso(updatedAt, row.updatedAt);
2550− }
25512757
2552− const activeProjectIds = new Set(threadRows.map((row) => row.projectId));
2553− const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2554− projectRows.filter((row) => activeProjectIds.has(row.projectId)),
2555− );
2556− const latestTurnByThread = new Map(
2557− latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2558− );
2559− const sessionByThread = new Map(
2560− sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2561− );
2758+ const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
2759+ const activeProjectIds = new Set(threadRows.map((row) => row.projectId));
2760+ const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2761+ projectRows.filter((row) => activeProjectIds.has(row.projectId)),
2762+ );
2763+ const latestTurnByThread = new Map(
2764+ latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2765+ );
2766+ const sessionByThread = new Map(
2767+ sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2768+ );
25622769
2563− const snapshot = {
2564− snapshotSequence: computeSnapshotSequence(stateRows),
2565− projects: Arr.filterMap(projectRows, (row) =>
2566− row.deletedAt === null && activeProjectIds.has(row.projectId)
2567− ? Result.succeed(
2568− mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2569− )
2570− : Result.failVoid,
2571− ),
2572− threads: threadRows.map((row): OrchestrationThreadShell => ({
2573− id: row.threadId,
2574− projectId: row.projectId,
2575− title: row.title,
2576− modelSelection: row.modelSelection,
2577− runtimeMode: row.runtimeMode,
2578− interactionMode: row.interactionMode,
2579− branch: row.branch,
2580− worktreePath: row.worktreePath,
2581− branchPullRequest: row.branchPullRequest,
2582− ...(row.linkedPullRequest === null
2583− ? {}
2584− : { linkedPullRequest: row.linkedPullRequest }),
2585− latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2586− createdAt: row.createdAt,
2587− updatedAt: row.updatedAt,
2588− archivedAt: row.archivedAt,
2589− settledOverride: row.settledOverride,
2590− settledAt: row.settledAt,
2591− unsettledAt: row.unsettledAt,
2592− snoozedUntil: row.snoozedUntil,
2593− snoozedAt: row.snoozedAt,
2594− pinnedAt: row.pinnedAt,
2595− pinOrderKey: row.pinOrderKey ?? null,
2596− activeOrderKey: row.activeOrderKey ?? null,
2597− titleRegeneration: mapTitleRegeneration(row),
2598− session: sessionByThread.get(row.threadId) ?? null,
2599− latestUserMessageAt: row.latestUserMessageAt,
2600− hasPendingApprovals: row.pendingApprovalCount > 0,
2601− hasPendingUserInput: row.pendingUserInputCount > 0,
2602− hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2603− backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2604− row.threadId,
2770+ const snapshot = {
2771+ snapshotSequence: computeSnapshotSequence(stateRows),
2772+ projects: Arr.filterMap(projectRows, (row) =>
2773+ row.deletedAt === null && activeProjectIds.has(row.projectId)
2774+ ? Result.succeed(
2775+ mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2776+ )
2777+ : Result.failVoid,
26052778 ),
2606− planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2607− })),
2608− updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2609− };
2779+ threads: threadRows.map((row): OrchestrationThreadShell => ({
2780+ id: row.threadId,
2781+ projectId: row.projectId,
2782+ title: row.title,
2783+ modelSelection: row.modelSelection,
2784+ runtimeMode: row.runtimeMode,
2785+ interactionMode: row.interactionMode,
2786+ branch: row.branch,
2787+ worktreePath: row.worktreePath,
2788+ branchPullRequest: row.branchPullRequest,
2789+ ...mapThreadPullRequests(
2790+ pullRequestsByThread.get(row.threadId) ?? [],
2791+ row.projectId,
2792+ repositoryIdentities.get(row.projectId),
2793+ ),
2794+ latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2795+ createdAt: row.createdAt,
2796+ updatedAt: row.updatedAt,
2797+ archivedAt: row.archivedAt,
2798+ settledOverride: row.settledOverride,
2799+ settledAt: row.settledAt,
2800+ unsettledAt: row.unsettledAt,
2801+ snoozedUntil: row.snoozedUntil,
2802+ snoozedAt: row.snoozedAt,
2803+ pinnedAt: row.pinnedAt,
2804+ pinOrderKey: row.pinOrderKey ?? null,
2805+ activeOrderKey: row.activeOrderKey ?? null,
2806+ titleRegeneration: mapTitleRegeneration(row),
2807+ session: sessionByThread.get(row.threadId) ?? null,
2808+ latestUserMessageAt: row.latestUserMessageAt,
2809+ hasPendingApprovals: row.pendingApprovalCount > 0,
2810+ hasPendingUserInput: row.pendingUserInputCount > 0,
2811+ hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2812+ backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2813+ row.threadId,
2814+ ),
2815+ planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2816+ })),
2817+ updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2818+ };
26102819
2611− return yield* decodeShellSnapshot(snapshot).pipe(
2612− Effect.mapError(
2613− toPersistenceDecodeError(
2614− "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
2820+ return yield* decodeShellSnapshot(snapshot).pipe(
2821+ Effect.mapError(
2822+ toPersistenceDecodeError(
2823+ "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
2824+ ),
26152825 ),
2616− ),
2617− );
2618− }),
2826+ );
2827+ }),
26192828 ),
26202829 Effect.mapError((error) => {
26212830 if (isPersistenceError(error)) {
@@ -2861,7 +3070,7 @@ pending_approval_requests AS ( · 239 unmodified lines
28613070
28623071 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
28633072 Effect.gen(function* () {
2864− const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
3073+ const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([
28653074 getActiveThreadRowById({ threadId }).pipe(
28663075 Effect.mapError(
28673076 toPersistenceSqlOrDecodeError(
@@ -2886,6 +3095,14 @@ pending_approval_requests AS ( · 18 unmodified lines
28863095 ),
28873096 ),
28883097 ),
3098+ listThreadPullRequestRowsByThread({ threadId }).pipe(
3099+ Effect.mapError(
3100+ toPersistenceSqlOrDecodeError(
3101+ "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query",
3102+ "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows",
3103+ ),
3104+ ),
3105+ ),
28893106 ]);
28903107
28913108 if (Option.isNone(threadRow)) {
@@ -2901,10 +3118,15 @@ pending_approval_requests AS ( · 9 unmodified lines
29013118 interactionMode: threadRow.value.interactionMode,
29023119 branch: threadRow.value.branch,
29033120 worktreePath: threadRow.value.worktreePath,
3121+ ...mapThreadPullRequests(
3122+ pullRequestRows.map(mapPullRequestRow),
3123+ threadRow.value.projectId,
3124+ pullRequestRows.length === 0
3125+ ? null
3126+ : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId))
3127+ ?.repositoryIdentity,
3128+ ),
29043129 branchPullRequest: threadRow.value.branchPullRequest,
2905− ...(threadRow.value.linkedPullRequest === null
2906− ? {}
2907− : { linkedPullRequest: threadRow.value.linkedPullRequest }),
29083130 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
29093131 createdAt: threadRow.value.createdAt,
29103132 updatedAt: threadRow.value.updatedAt,
@@ -3112,6 +3334,7 @@ pending_approval_requests AS ( · 201 unmodified lines
31123334 threadRow,
31133335 messageRows,
31143336 proposedPlanRows,
3337+ pullRequestRows,
31153338 activities,
31163339 checkpointRows,
31173340 latestTurnRow,
@@ -3144,6 +3367,14 @@ pending_approval_requests AS ( · 26 unmodified lines
31443367 ),
31453368 ),
31463369 ),
3370+ listThreadPullRequestRowsByThread({ threadId }).pipe(
3371+ Effect.mapError(
3372+ toPersistenceSqlOrDecodeError(
3373+ "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query",
3374+ "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows",
3375+ ),
3376+ ),
3377+ ),
31473378 activitiesEffect,
31483379 listCheckpointRowsByThread({ threadId }).pipe(
31493380 Effect.mapError(
@@ -3184,10 +3415,15 @@ pending_approval_requests AS ( · 34 unmodified lines
31843415 interactionMode: threadRow.value.interactionMode,
31853416 branch: threadRow.value.branch,
31863417 worktreePath: threadRow.value.worktreePath,
3418+ ...mapThreadPullRequests(
3419+ pullRequestRows.map(mapPullRequestRow),
3420+ threadRow.value.projectId,
3421+ pullRequestRows.length === 0
3422+ ? null
3423+ : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId))
3424+ ?.repositoryIdentity,
3425+ ),
31873426 branchPullRequest: threadRow.value.branchPullRequest,
3188− ...(threadRow.value.linkedPullRequest === null
3189− ? {}
3190− : { linkedPullRequest: threadRow.value.linkedPullRequest }),
31913427 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
31923428 createdAt: threadRow.value.createdAt,
31933429 updatedAt: threadRow.value.updatedAt,
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+704−0
@@ -0,0 +1,704 @@
1+import {
2+ ProjectId,
3+ ProviderInstanceId,
4+ PullRequestOperationError,
5+ ThreadId,
6+ type OrchestrationCommand,
7+ type OrchestrationProjectShell,
8+ type OrchestrationShellSnapshot,
9+ type OrchestrationThreadShell,
10+ type PullRequestRef,
11+ type PullRequestStack,
12+ type PullRequestSummary,
13+ type ThreadPullRequestLink,
14+ type ThreadPullRequestSnapshot,
15+} from "@t3tools/contracts";
16+import { assert, describe, 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 Layer from "effect/Layer";
21+import * as Queue from "effect/Queue";
22+import * as Ref from "effect/Ref";
23+import * as Stream from "effect/Stream";
24+import { TestClock } from "effect/testing";
25+
26+import { PullRequestService } from "../pullRequest/PullRequestService.ts";
27+import { ServerActivation } from "../serverActivation.ts";
28+import {
29+ OrchestrationEngineService,
30+ type OrchestrationEngineShape,
31+} from "./Services/OrchestrationEngine.ts";
32+import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
33+import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts";
34+
35+const NOW = "2026-08-28T12:00:00.000Z";
36+const PROJECT_ID = ProjectId.make("sync-project");
37+
38+type SyncCommand = Extract<
39+ OrchestrationCommand,
40+ { readonly type: "thread.pull-request-link.sync" }
41+>;
42+type LinkCommand = Extract<OrchestrationCommand, { readonly type: "thread.pull-request.link" }>;
43+
44+const testCrypto = Crypto.make({
45+ randomBytes: (size) => new Uint8Array(size).fill(1),
46+ digest: (_algorithm, data) => Effect.succeed(data),
47+});
48+
49+function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell {
50+ return {
51+ id,
52+ title: `Project ${id}`,
53+ workspaceRoot: "/workspace/project",
54+ defaultModelSelection: null,
55+ scripts: [],
56+ createdAt: "2026-08-01T00:00:00.000Z",
57+ updatedAt: NOW,
58+ };
59+}
60+
61+function makeThread(
62+ id: string,
63+ overrides: Partial<OrchestrationThreadShell> = {},
64+): OrchestrationThreadShell {
65+ return {
66+ id: ThreadId.make(id),
67+ projectId: PROJECT_ID,
68+ title: id,
69+ modelSelection: {
70+ instanceId: ProviderInstanceId.make("codex"),
71+ model: "gpt-5",
72+ },
73+ runtimeMode: "full-access",
74+ interactionMode: "default",
75+ branch: null,
76+ worktreePath: null,
77+ pullRequests: [],
78+ latestTurn: null,
79+ createdAt: "2026-08-01T00:00:00.000Z",
80+ updatedAt: "2026-08-20T00:00:00.000Z",
81+ archivedAt: null,
82+ settledOverride: null,
83+ settledAt: null,
84+ session: null,
85+ latestUserMessageAt: "2026-08-20T00:00:00.000Z",
86+ hasPendingApprovals: false,
87+ hasPendingUserInput: false,
88+ hasActionableProposedPlan: false,
89+ ...overrides,
90+ };
91+}
92+
93+function makeLink(
94+ number: number,
95+ snapshot: Partial<ThreadPullRequestSnapshot> | null = null,
96+ overrides: Partial<ThreadPullRequestLink> = {},
97+): ThreadPullRequestLink {
98+ return {
99+ host: "github.com",
100+ repository: "owner/repository",
101+ number,
102+ url: `https://github.com/owner/repository/pull/${number}`,
103+ source: "manual",
104+ linkedAt: "2026-08-10T00:00:00.000Z",
105+ snapshot:
106+ snapshot === null
107+ ? null
108+ : {
109+ state: "open",
110+ title: "Pull request",
111+ headBranch: "feature",
112+ baseBranch: "main",
113+ isDraft: false,
114+ updatedAt: "2026-08-27T00:00:00.000Z",
115+ syncedAt: "2026-08-27T00:00:00.000Z",
116+ ...snapshot,
117+ },
118+ stack: null,
119+ ...overrides,
120+ };
121+}
122+
123+function makeSnapshot(
124+ threads: ReadonlyArray<OrchestrationThreadShell>,
125+ snapshotSequence = 1,
126+): OrchestrationShellSnapshot {
127+ return {
128+ snapshotSequence,
129+ projects: [makeProject()],
130+ threads,
131+ updatedAt: NOW,
132+ };
133+}
134+
135+function makeSummary(
136+ input: PullRequestRef,
137+ overrides: Partial<PullRequestSummary> = {},
138+): PullRequestSummary {
139+ return {
140+ provider: "github",
141+ projectId: input.projectId,
142+ repository: input.repository,
143+ number: input.number,
144+ title: "Pull request",
145+ url: `https://github.com/${input.repository}/pull/${input.number}`,
146+ state: "open",
147+ headBranch: "feature",
148+ baseBranch: "main",
149+ updatedAt: "2026-08-27T00:00:00.000Z",
150+ ...overrides,
151+ };
152+}
153+
154+interface HarnessOptions {
155+ readonly invalidate?: PullRequestService["Service"]["invalidate"];
156+ readonly snapshot: OrchestrationShellSnapshot;
157+ readonly summary?: (
158+ input: PullRequestRef,
159+ ) => Effect.Effect<PullRequestSummary, PullRequestOperationError>;
160+ readonly stack?: (
161+ input: PullRequestRef,
162+ ) => Effect.Effect<PullRequestStack | null, PullRequestOperationError>;
163+}
164+
165+const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) {
166+ const activation = yield* Deferred.make<void>();
167+ const snapshots = yield* Ref.make(options.snapshot);
168+ const snapshotReads = yield* Queue.unbounded<void>();
169+ const syncCommands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
170+ const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]);
171+ const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
172+ const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
173+
174+ const summary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
175+ Effect.gen(function* () {
176+ assert.strictEqual(readOptions?.recoverTransientFailure, false);
177+ yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
178+ return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input));
179+ });
180+
181+ const stack: PullRequestService["Service"]["stack"] = (input) =>
182+ Effect.gen(function* () {
183+ yield* Ref.update(stackCalls, (calls) => [...calls, input]);
184+ return yield* options.stack?.(input) ?? Effect.succeed(null);
185+ });
186+
187+ const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
188+ if (command.type === "thread.pull-request-link.sync") {
189+ return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe(
190+ Effect.as({ sequence: 1 }),
191+ );
192+ }
193+ if (command.type === "thread.pull-request.link") {
194+ return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe(
195+ Effect.as({ sequence: 1 }),
196+ );
197+ }
198+ return Effect.die(new Error(`Unexpected command: ${command.type}`));
199+ };
200+
201+ const dependencies = Layer.mergeAll(
202+ Layer.mock(ProjectionSnapshotQuery)({
203+ getShellSnapshot: () =>
204+ Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))),
205+ }),
206+ Layer.mock(PullRequestService)({
207+ summary,
208+ stack,
209+ invalidate: options.invalidate ?? (() => Effect.void),
210+ }),
211+ Layer.mock(OrchestrationEngineService)({
212+ readEvents: () => Stream.empty,
213+ dispatch,
214+ streamDomainEvents: Stream.empty,
215+ latestSequence: Effect.succeed(0),
216+ }),
217+ Layer.succeed(ServerActivation, Deferred.await(activation)),
218+ Layer.succeed(Crypto.Crypto, testCrypto),
219+ );
220+
221+ return {
222+ activation,
223+ snapshots,
224+ snapshotReads,
225+ syncCommands,
226+ linkCommands,
227+ summaryCalls,
228+ stackCalls,
229+ layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)),
230+ };
231+});
232+
233+type Harness = Effect.Success<ReturnType<typeof makeHarness>>;
234+
235+const startAndSweep = Effect.fn("startPullRequestSyncHarness")(function* (fixture: Harness) {
236+ const reactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
237+ yield* reactor.start();
238+ yield* Deferred.succeed(fixture.activation, undefined);
239+ yield* Queue.take(fixture.snapshotReads);
240+ yield* reactor.drain;
241+ return reactor;
242+});
243+
244+const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* (
245+ fixture: Harness,
246+ reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"],
247+) {
248+ yield* TestClock.adjust("1 minute");
249+ yield* Queue.take(fixture.snapshotReads);
250+ yield* reactor.drain;
251+});
252+
253+/** What the reactor would have persisted, so the next sweep sees its own writes. */
254+function applySync(
255+ snapshot: OrchestrationShellSnapshot,
256+ commands: ReadonlyArray<SyncCommand>,
257+): OrchestrationShellSnapshot {
258+ return {
259+ ...snapshot,
260+ snapshotSequence: snapshot.snapshotSequence + 1,
261+ threads: snapshot.threads.map((thread) => ({
262+ ...thread,
263+ pullRequests: thread.pullRequests.map((link) => {
264+ const command = commands.findLast(
265+ (candidate) => candidate.threadId === thread.id && candidate.number === link.number,
266+ );
267+ return command === undefined
268+ ? link
269+ : { ...link, snapshot: command.snapshot, stack: command.stack };
270+ }),
271+ })),
272+ };
273+}
274+
275+describe("PullRequestSyncReactor", () => {
276+ it.effect("retries a failed stack read after the summary becomes terminal", () =>
277+ Effect.scoped(
278+ Effect.gen(function* () {
279+ yield* TestClock.setTime(Date.parse(NOW));
280+ let attempts = 0;
281+ const nativeStack: PullRequestStack = {
282+ id: "stack",
283+ number: 7,
284+ url: "https://github.com/owner/repository/stacks/7",
285+ base: "main",
286+ layers: [{ number: 7, headBranch: "feature", state: "merged" }],
287+ };
288+ const fixture = yield* makeHarness({
289+ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]),
290+ summary: (input) =>
291+ Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })),
292+ stack: () =>
293+ ++attempts === 1
294+ ? Effect.fail(
295+ new PullRequestOperationError({
296+ operation: "stack",
297+ detail: "temporary failure",
298+ }),
299+ )
300+ : Effect.succeed(nativeStack),
301+ });
302+ yield* Effect.gen(function* () {
303+ const reactor = yield* startAndSweep(fixture);
304+ const commands = yield* Ref.get(fixture.syncCommands);
305+ yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands));
306+ yield* sweepAgain(fixture, reactor);
307+ assert.strictEqual(attempts, 2);
308+ assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, {
309+ kind: "native",
310+ ...nativeStack,
311+ });
312+ }).pipe(Effect.provide(fixture.layer));
313+ }),
314+ ),
315+ );
316+
317+ it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () =>
318+ Effect.scoped(
319+ Effect.gen(function* () {
320+ yield* TestClock.setTime(Date.parse(NOW));
321+ const fixture = yield* makeHarness({
322+ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, {})] })]),
323+ });
324+ yield* Effect.gen(function* () {
325+ const reactor = yield* startAndSweep(fixture);
326+ assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 0);
327+ yield* reactor.requestSync({
328+ host: "github.com",
329+ repository: "owner/repository",
330+ number: 7,
331+ });
332+ yield* Queue.take(fixture.snapshotReads);
333+ yield* reactor.drain;
334+ assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
335+ }).pipe(Effect.provide(fixture.layer));
336+ }),
337+ ),
338+ );
339+ it.effect("snapshots an unsynced link once and writes it to the thread", () =>
340+ Effect.scoped(
341+ Effect.gen(function* () {
342+ yield* TestClock.setTime(Date.parse(NOW));
343+ const fixture = yield* makeHarness({
344+ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]),
345+ summary: (input) =>
346+ Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })),
347+ });
348+
349+ yield* Effect.gen(function* () {
350+ yield* startAndSweep(fixture);
351+
352+ assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
353+ {
354+ projectId: PROJECT_ID,
355+ host: "github.com",
356+ repository: "owner/repository",
357+ number: 42,
358+ },
359+ ]);
360+ assert.deepStrictEqual(
361+ (yield* Ref.get(fixture.syncCommands)).map(({ commandId: _, ...rest }) => rest),
362+ [
363+ {
364+ type: "thread.pull-request-link.sync",
365+ threadId: ThreadId.make("one"),
366+ host: "github.com",
367+ repository: "owner/repository",
368+ number: 42,
369+ snapshot: {
370+ state: "open",
371+ title: "Ship it",
372+ headBranch: "feature",
373+ baseBranch: "main",
374+ isDraft: true,
375+ updatedAt: "2026-08-27T00:00:00.000Z",
376+ syncedAt: NOW,
377+ closedAt: null,
378+ mergedAt: null,
379+ },
380+ stack: null,
381+ },
382+ ],
383+ );
384+ assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
385+ }).pipe(Effect.provide(fixture.layer));
386+ }),
387+ ),
388+ );
389+
390+ it.effect("asks the host once for a pull request shared by two threads", () =>
391+ Effect.scoped(
392+ Effect.gen(function* () {
393+ yield* TestClock.setTime(Date.parse(NOW));
394+ const fixture = yield* makeHarness({
395+ snapshot: makeSnapshot([
396+ makeThread("one", { pullRequests: [makeLink(42)] }),
397+ makeThread("two", {
398+ pullRequests: [makeLink(42, null, { repository: "Owner/Repository" })],
399+ }),
400+ ]),
401+ });
402+
403+ yield* Effect.gen(function* () {
404+ yield* startAndSweep(fixture);
405+
406+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
407+ const commands = yield* Ref.get(fixture.syncCommands);
408+ assert.deepStrictEqual(
409+ commands
410+ .map((command) => [command.threadId, command.repository] as const)
411+ .sort((left, right) => left[0].localeCompare(right[0])),
412+ [
413+ [ThreadId.make("one"), "owner/repository"],
414+ [ThreadId.make("two"), "Owner/Repository"],
415+ ],
416+ );
417+ }).pipe(Effect.provide(fixture.layer));
418+ }),
419+ ),
420+ );
421+
422+ it.effect("dispatches nothing when the host snapshot is unchanged", () =>
423+ Effect.scoped(
424+ Effect.gen(function* () {
425+ yield* TestClock.setTime(Date.parse(NOW));
426+ const fixture = yield* makeHarness({
427+ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]),
428+ });
429+
430+ yield* Effect.gen(function* () {
431+ const reactor = yield* startAndSweep(fixture);
432+ const firstSweep = yield* Ref.get(fixture.syncCommands);
433+ assert.strictEqual(firstSweep.length, 1);
434+ yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep));
435+
436+ yield* sweepAgain(fixture, reactor);
437+
438+ // Still open on an active thread, so the host was asked again, but nothing changed.
439+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
440+ assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
441+ assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1);
442+ }).pipe(Effect.provide(fixture.layer));
443+ }),
444+ ),
445+ );
446+
447+ it.effect("refreshes closed links through the reactor's project after reopening elsewhere", () =>
448+ Effect.scoped(
449+ Effect.gen(function* () {
450+ yield* TestClock.setTime(Date.parse(NOW));
451+ const stale = yield* Ref.make(true);
452+ const fixture = yield* makeHarness({
453+ snapshot: makeSnapshot([
454+ makeThread("first", { pullRequests: [makeLink(42, { state: "closed" })] }),
455+ makeThread("second", {
456+ projectId: ProjectId.make("second-project"),
457+ pullRequests: [makeLink(42, { state: "closed" })],
458+ }),
459+ ]),
460+ invalidate: ({ reference }) =>
461+ reference?.projectId === makeProject().id && reference.host === "github.com"
462+ ? Ref.set(stale, false)
463+ : Effect.void,
464+ summary: (input) =>
465+ Ref.get(stale).pipe(
466+ Effect.map((cached) => makeSummary(input, { state: cached ? "closed" : "open" })),
467+ ),
468+ });
469+ yield* Effect.gen(function* () {
470+ const reactor = yield* startAndSweep(fixture);
471+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
472+ yield* reactor.requestSync({
473+ host: "github.com",
474+ repository: "owner/repository",
475+ number: 42,
476+ });
477+ yield* Queue.take(fixture.snapshotReads);
478+ yield* reactor.drain;
479+ const commands = yield* Ref.get(fixture.syncCommands);
480+ yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands));
481+ const snapshot = yield* Ref.get(fixture.snapshots);
482+ assert.deepStrictEqual(
483+ snapshot.threads.map((thread) => thread.pullRequests[0]?.snapshot?.state),
484+ ["open", "open"],
485+ );
486+ }).pipe(Effect.provide(fixture.layer));
487+ }),
488+ ),
489+ );
490+
491+ it.effect("stops asking the host once a pull request is merged", () =>
492+ Effect.scoped(
493+ Effect.gen(function* () {
494+ yield* TestClock.setTime(Date.parse(NOW));
495+ const fixture = yield* makeHarness({
496+ snapshot: makeSnapshot([
497+ makeThread("merged", { pullRequests: [makeLink(1, { state: "merged" })] }),
498+ ]),
499+ });
500+
501+ yield* Effect.gen(function* () {
502+ const reactor = yield* startAndSweep(fixture);
503+ yield* sweepAgain(fixture, reactor);
504+
505+ assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
506+ assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []);
507+
508+ yield* reactor.requestSync({
509+ host: "github.com",
510+ repository: "owner/repository",
511+ number: 1,
512+ });
513+ yield* Queue.take(fixture.snapshotReads);
514+ yield* reactor.drain;
515+
516+ assert.deepStrictEqual(
517+ (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number),
518+ [1],
519+ );
520+ }).pipe(Effect.provide(fixture.layer));
521+ }),
522+ ),
523+ );
524+
525+ it.effect("discovers externally reopened pull requests after fifteen minutes", () =>
526+ Effect.scoped(
527+ Effect.gen(function* () {
528+ yield* TestClock.setTime(Date.parse(NOW));
529+ const state = yield* Ref.make<"closed" | "open">("closed");
530+ const fixture = yield* makeHarness({
531+ snapshot: makeSnapshot([
532+ makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }),
533+ ]),
534+ summary: (input) =>
535+ Ref.get(state).pipe(Effect.map((state) => makeSummary(input, { state }))),
536+ });
537+ yield* Effect.gen(function* () {
538+ const reactor = yield* startAndSweep(fixture);
539+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
540+ yield* Ref.set(state, "open");
541+ for (let index = 0; index < 14; index += 1) yield* sweepAgain(fixture, reactor);
542+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
543+ yield* sweepAgain(fixture, reactor);
544+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
545+ assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open");
546+ }).pipe(Effect.provide(fixture.layer));
547+ }),
548+ ),
549+ );
550+
551+ it.effect("preserves a refresh requested while an older host read is in flight", () =>
552+ Effect.scoped(
553+ Effect.gen(function* () {
554+ yield* TestClock.setTime(Date.parse(NOW));
555+ const reading = yield* Deferred.make<void>();
556+ const release = yield* Deferred.make<void>();
557+ let calls = 0;
558+ const fixture = yield* makeHarness({
559+ snapshot: makeSnapshot([]),
560+ summary: (input) =>
561+ Effect.gen(function* () {
562+ calls += 1;
563+ if (calls === 1) {
564+ yield* Deferred.succeed(reading, undefined);
565+ yield* Deferred.await(release);
566+ }
567+ return makeSummary(input, { state: calls === 1 ? "closed" : "open" });
568+ }),
569+ });
570+ yield* Effect.gen(function* () {
571+ const reactor = yield* startAndSweep(fixture);
572+ yield* Ref.set(
573+ fixture.snapshots,
574+ makeSnapshot([
575+ makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }),
576+ ]),
577+ );
578+ const key = { host: "github.com", repository: "owner/repository", number: 2 };
579+ yield* reactor.requestSync(key);
580+ yield* Deferred.await(reading);
581+ yield* reactor.requestSync(key);
582+ yield* Deferred.succeed(release, undefined);
583+ yield* reactor.drain;
584+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
585+ assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open");
586+ }).pipe(Effect.provide(fixture.layer));
587+ }),
588+ ),
589+ );
590+
591+ it.effect("polls open pull requests on settled threads every fifteen minutes", () =>
592+ Effect.scoped(
593+ Effect.gen(function* () {
594+ yield* TestClock.setTime(Date.parse(NOW));
595+ const fixture = yield* makeHarness({
596+ snapshot: makeSnapshot([
597+ makeThread("settled", {
598+ settledOverride: "settled",
599+ settledAt: "2026-08-21T00:00:00.000Z",
600+ pullRequests: [makeLink(5, { state: "open" })],
601+ }),
602+ ]),
603+ });
604+
605+ yield* Effect.gen(function* () {
606+ const reactor = yield* startAndSweep(fixture);
607+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
608+
609+ yield* sweepAgain(fixture, reactor);
610+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
611+
612+ for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor);
613+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
614+
615+ yield* sweepAgain(fixture, reactor);
616+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
617+ }).pipe(Effect.provide(fixture.layer));
618+ }),
619+ ),
620+ );
621+
622+ it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () =>
623+ Effect.scoped(
624+ Effect.gen(function* () {
625+ yield* TestClock.setTime(Date.parse(NOW));
626+ const stack: PullRequestStack = {
627+ id: "stack-1",
628+ number: 42,
629+ url: "https://github.com/owner/repository/stack/1",
630+ base: "main",
631+ layers: [
632+ { number: 41, headBranch: "layer-1", state: "merged" },
633+ { number: 42, headBranch: "layer-2", state: "open" },
634+ { number: 43, headBranch: "layer-3", state: "open" },
635+ ],
636+ };
637+ const fixture = yield* makeHarness({
638+ snapshot: makeSnapshot([
639+ makeThread("one", {
640+ pullRequests: [
641+ makeLink(42),
642+ makeLink(41, { state: "merged" }, { source: "stack-dismissed" }),
643+ ],
644+ }),
645+ ]),
646+ stack: () => Effect.succeed(stack),
647+ });
648+
649+ yield* Effect.gen(function* () {
650+ yield* startAndSweep(fixture);
651+
652+ const syncCommands = yield* Ref.get(fixture.syncCommands);
653+ assert.deepStrictEqual(
654+ syncCommands.map((command) => [command.number, command.stack] as const),
655+ [[42, { kind: "native", ...stack }]],
656+ );
657+ assert.deepStrictEqual(
658+ (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest),
659+ [
660+ {
661+ type: "thread.pull-request.link",
662+ threadId: ThreadId.make("one"),
663+ host: "github.com",
664+ repository: "owner/repository",
665+ number: 43,
666+ url: "https://github.com/owner/repository/pull/43",
667+ source: "stack",
668+ },
669+ ],
670+ );
671+ }).pipe(Effect.provide(fixture.layer));
672+ }),
673+ ),
674+ );
675+
676+ it.effect("keeps existing snapshots and continues when the host fails", () =>
677+ Effect.scoped(
678+ Effect.gen(function* () {
679+ yield* TestClock.setTime(Date.parse(NOW));
680+ const fixture = yield* makeHarness({
681+ snapshot: makeSnapshot([
682+ makeThread("failing", { pullRequests: [makeLink(7, { state: "open" })] }),
683+ makeThread("fine", { pullRequests: [makeLink(8)] }),
684+ ]),
685+ summary: (input) =>
686+ input.number === 7
687+ ? Effect.fail(
688+ new PullRequestOperationError({ operation: "summary", detail: "host down" }),
689+ )
690+ : Effect.succeed(makeSummary(input)),
691+ });
692+
693+ yield* Effect.gen(function* () {
694+ yield* startAndSweep(fixture);
695+
696+ assert.deepStrictEqual(
697+ (yield* Ref.get(fixture.syncCommands)).map((command) => command.number),
698+ [8],
699+ );
700+ }).pipe(Effect.provide(fixture.layer));
701+ }),
702+ ),
703+ );
704+});
apps/server/src/orchestration/PullRequestSyncReactor.ts+328−0
@@ -0,0 +1,328 @@
1+import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl";
2+import {
3+ CommandId,
4+ type OrchestrationThreadShell,
5+ type PullRequestSummary,
6+ type ThreadPullRequestKey,
7+ type ThreadPullRequestLink,
8+ type ThreadPullRequestSnapshot,
9+ type ThreadPullRequestStack,
10+} from "@t3tools/contracts";
11+import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
12+import {
13+ threadPullRequestKeyOf,
14+ threadPullRequestKeysEqual,
15+ visibleThreadPullRequests,
16+} from "@t3tools/shared/threadPullRequests";
17+import * as Cause from "effect/Cause";
18+import * as Context from "effect/Context";
19+import * as Crypto from "effect/Crypto";
20+import * as DateTime from "effect/DateTime";
21+import * as Effect from "effect/Effect";
22+import * as Layer from "effect/Layer";
23+import * as Schedule from "effect/Schedule";
24+import type * as Scope from "effect/Scope";
25+
26+import * as PullRequestService from "../pullRequest/PullRequestService.ts";
27+import { forkParked } from "../serverActivation.ts";
28+import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
29+import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
30+
31+const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000;
32+
33+type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">;
34+
35+interface LinkEntry {
36+ readonly thread: OrchestrationThreadShell;
37+ readonly link: ThreadPullRequestLink;
38+}
39+
40+function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
41+ return {
42+ state: summary.state,
43+ title: summary.title,
44+ headBranch: summary.headBranch,
45+ baseBranch: summary.baseBranch,
46+ isDraft: summary.isDraft ?? false,
47+ updatedAt: summary.updatedAt,
48+ closedAt: summary.closedAt ?? null,
49+ mergedAt: summary.mergedAt ?? null,
50+ ...(summary.author === undefined ? {} : { author: summary.author }),
51+ ...(summary.additions === undefined ? {} : { additions: summary.additions }),
52+ ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }),
53+ ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }),
54+ ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }),
55+ ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }),
56+ ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }),
57+ };
58+}
59+
60+function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean {
61+ return (
62+ left.state === right.state &&
63+ left.title === right.title &&
64+ left.headBranch === right.headBranch &&
65+ left.baseBranch === right.baseBranch &&
66+ left.isDraft === right.isDraft &&
67+ left.updatedAt === right.updatedAt &&
68+ (left.closedAt ?? null) === (right.closedAt ?? null) &&
69+ (left.mergedAt ?? null) === (right.mergedAt ?? null) &&
70+ (left.author?.login ?? null) === (right.author?.login ?? null) &&
71+ (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) &&
72+ left.additions === right.additions &&
73+ left.deletions === right.deletions &&
74+ left.changedFiles === right.changedFiles &&
75+ (left.reviewDecision ?? null) === (right.reviewDecision ?? null) &&
76+ (left.checksState ?? null) === (right.checksState ?? null) &&
77+ left.mergeability === right.mergeability
78+ );
79+}
80+
81+function stacksEqual(
82+ left: ThreadPullRequestStack | null,
83+ right: ThreadPullRequestStack | null,
84+): boolean {
85+ if (left === null || right === null) return left === right;
86+ return (
87+ left.kind === right.kind &&
88+ left.id === right.id &&
89+ left.number === right.number &&
90+ left.url === right.url &&
91+ left.base === right.base &&
92+ left.layers.length === right.layers.length &&
93+ left.layers.every((layer, index) => {
94+ const other = right.layers[index]!;
95+ return (
96+ layer.number === other.number &&
97+ layer.headBranch === other.headBranch &&
98+ layer.state === other.state
99+ );
100+ })
101+ );
102+}
103+
104+function isUnsettled(thread: OrchestrationThreadShell): boolean {
105+ return thread.settledOverride !== "settled" && thread.settledAt === null;
106+}
107+
108+/**
109+ * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads
110+ * the shell snapshot, groups visible links by pull request so the host is asked once per PR
111+ * no matter how many threads share it, and writes back only what changed. Native stacks the
112+ * host reports are auto-linked to the thread as `source: "stack"`.
113+ */
114+export class PullRequestSyncReactor extends Context.Service<
115+ PullRequestSyncReactor,
116+ {
117+ readonly start: () => Effect.Effect<void, never, Scope.Scope>;
118+ readonly drain: Effect.Effect<void>;
119+ /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */
120+ readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect<void>;
121+ }
122+>()("t3/orchestration/PullRequestSyncReactor") {}
123+
124+/** @public Service construction is part of the canonical Effect module API. */
125+export const make = Effect.gen(function* () {
126+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
127+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
128+ const pullRequests = yield* PullRequestService.PullRequestService;
129+ const crypto = yield* Crypto.Crypto;
130+
131+ const lastSyncedAt = new Map<string, number>();
132+ const requested = new Map<string, number>();
133+ let requestGeneration = 0;
134+ const retryStacks = new Set<string>();
135+
136+ const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => {
137+ if (requested.has(key) || retryStacks.has(key)) return true;
138+ if (entries.some((entry) => entry.link.snapshot === null)) return true;
139+ if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false;
140+ if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread)))
141+ return true;
142+ // Closed requests can reopen on the host, including after the thread settles.
143+ const last = lastSyncedAt.get(key);
144+ return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS;
145+ };
146+
147+ const logSkipped =
148+ (message: string, fields: Record<string, unknown>) =>
149+ <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> =>
150+ Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields);
151+
152+ const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () {
153+ const snapshot = yield* snapshots.getShellSnapshot();
154+ const now = yield* DateTime.now;
155+ const nowMs = DateTime.toEpochMillis(now);
156+ const nowIso = DateTime.formatIso(now);
157+
158+ const groups = new Map<string, Array<LinkEntry>>();
159+ for (const thread of snapshot.threads) {
160+ if (thread.archivedAt !== null) continue;
161+ for (const link of visibleThreadPullRequests(thread.pullRequests)) {
162+ const key = threadPullRequestKeyOf(link);
163+ const entries = groups.get(key) ?? [];
164+ entries.push({ thread, link });
165+ groups.set(key, entries);
166+ }
167+ }
168+
169+ for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key);
170+ for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key);
171+ for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key);
172+
173+ // Layers auto-linked this sweep, so two links of one thread that share a
174+ // stack do not both try to add the same sibling.
175+ const linkedThisSweep = new Set<string>();
176+
177+ const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* (
178+ entry: LinkEntry,
179+ fields: SnapshotFields,
180+ fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null,
181+ ) {
182+ const { thread, link } = entry;
183+ const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack;
184+ const changed =
185+ link.snapshot === null ||
186+ !snapshotFieldsEqual(link.snapshot, fields) ||
187+ !stacksEqual(link.stack, nextStack);
188+ if (changed) {
189+ const uuid = yield* crypto.randomUUIDv4;
190+ yield* engine.dispatch({
191+ type: "thread.pull-request-link.sync",
192+ commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),
193+ threadId: thread.id,
194+ host: link.host,
195+ repository: link.repository,
196+ number: link.number,
197+ snapshot: { ...fields, syncedAt: nowIso },
198+ stack: nextStack,
199+ });
200+ }
201+ if (fetchedStack === null || fetchedStack.stack === null) return;
202+ for (const layer of fetchedStack.stack.layers) {
203+ const layerKey = { host: link.host, repository: link.repository, number: layer.number };
204+ const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;
205+ if (linkedThisSweep.has(dedupeKey)) continue;
206+ // Tombstones count as present: a dismissed layer is never re-added.
207+ if (
208+ thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, layerKey))
209+ ) {
210+ continue;
211+ }
212+ const url = siblingPullRequestUrl(link.url, layer.number);
213+ if (url === null) continue;
214+ linkedThisSweep.add(dedupeKey);
215+ const uuid = yield* crypto.randomUUIDv4;
216+ yield* engine
217+ .dispatch({
218+ type: "thread.pull-request.link",
219+ commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`),
220+ threadId: thread.id,
221+ ...layerKey,
222+ url,
223+ source: "stack",
224+ })
225+ .pipe(
226+ Effect.catchCause(
227+ logSkipped("pull request stack layer link skipped", {
228+ threadId: thread.id,
229+ number: layer.number,
230+ }),
231+ ),
232+ );
233+ }
234+ });
235+
236+ const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* (
237+ key: string,
238+ entries: ReadonlyArray<LinkEntry>,
239+ ) {
240+ const first = entries[0]!;
241+ const ref = {
242+ projectId: first.thread.projectId,
243+ host: first.link.host,
244+ repository: first.link.repository,
245+ number: first.link.number,
246+ };
247+ const generation = requested.get(key);
248+ if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref });
249+ const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false });
250+ const fields = snapshotFieldsOf(summary);
251+ const needsStack =
252+ generation !== undefined ||
253+ retryStacks.has(key) ||
254+ entries.some(
255+ (entry) =>
256+ entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
257+ );
258+ const fetchedStack = needsStack
259+ ? yield* pullRequests.stack(ref).pipe(
260+ Effect.map((stack) => ({
261+ stack: stack === null ? null : ({ kind: "native", ...stack } as const),
262+ })),
263+ Effect.catchCause((cause) =>
264+ Cause.hasInterruptsOnly(cause)
265+ ? Effect.failCause(cause)
266+ : Effect.logWarning("pull request stack lookup failed", {
267+ key,
268+ }).pipe(Effect.as(null)),
269+ ),
270+ )
271+ : null;
272+ if (needsStack) {
273+ if (fetchedStack === null) retryStacks.add(key);
274+ else retryStacks.delete(key);
275+ }
276+ // The host answered, so the cadence clock ticks even if a dispatch below is rejected.
277+ lastSyncedAt.set(key, nowMs);
278+ // A refresh requested while the host read was in flight belongs to the next sweep.
279+ if (requested.get(key) === generation) requested.delete(key);
280+ yield* Effect.forEach(
281+ entries,
282+ (entry) =>
283+ syncEntry(entry, fields, fetchedStack).pipe(
284+ Effect.catchCause(
285+ logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }),
286+ ),
287+ ),
288+ { discard: true },
289+ );
290+ });
291+
292+ yield* Effect.forEach(
293+ groups,
294+ ([key, entries]) =>
295+ isDue(key, entries, nowMs)
296+ ? syncGroup(key, entries).pipe(
297+ Effect.catchCause(logSkipped("pull request sync skipped", { key })),
298+ )
299+ : Effect.void,
300+ { concurrency: 8, discard: true },
301+ );
302+ });
303+
304+ const worker = yield* makeDrainableWorker(() =>
305+ sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
306+ );
307+
308+ const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn(
309+ "PullRequestSyncReactor.start",
310+ )(function* () {
311+ yield* forkParked(
312+ Effect.gen(function* () {
313+ yield* worker.enqueue(undefined);
314+ yield* worker.drain;
315+ }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
316+ );
317+ });
318+
319+ const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) =>
320+ Effect.suspend(() => {
321+ requested.set(threadPullRequestKeyOf(key), ++requestGeneration);
322+ return worker.enqueue(undefined);
323+ });
324+
325+ return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"];
326+});
327+
328+export const layer = Layer.effect(PullRequestSyncReactor, make);
apps/server/src/orchestration/Schemas.ts+6−0
@@ -16,6 +16,9 @@ import { · 15 unmodified lines
1616 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
1717 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
1818 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
19+ ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,
20+ ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,
21+ ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,
1922 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
2023 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
2124 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
@@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; · 26 unmodified lines
4851 export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
4952 export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
5053 export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
54+export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;
55+export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;
56+export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;
5157
5258 export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
5359 export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+1−0
@@ -87,6 +87,7 @@ function thread( · 86 unmodified lines
8787 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
8888 runtimeMode: "full-access",
8989 interactionMode: "default",
90+ pullRequests: [],
9091 branch: "feature",
9192 worktreePath: null,
9293 latestTurn: null,
apps/server/src/orchestration/ThreadPullRequestReactor.ts+6−13
@@ -1,3 +1,7 @@
1+import {
2+ canonicalRepositoryKey,
3+ sourceControlRepositorySelector,
4+} from "@t3tools/shared/sourceControl";
15 import {
26 CommandId,
37 type OrchestrationEvent,
@@ -53,18 +57,6 @@ interface RefreshRequest { · 49 unmodified lines
5357 readonly backfill?: boolean;
5458 }
5559
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−
6860 export function pullRequestMatchesProject(
6961 pullRequest: GitManager.GitBranchPullRequest,
7062 project: OrchestrationProjectShell,
@@ -138,7 +130,7 @@ export const make = Effect.gen(function* () { · 67 unmodified lines
138130 const first = group[0]!;
139131 const project = projects.get(first.projectId);
140132 if (project === undefined) return finishBackfill(group);
141− const repository = PullRequestService.repositoryIdentityOf(project);
133+ const repository = sourceControlRepositorySelector(project.repositoryIdentity);
142134 if (first.branch !== null && repository === null) return finishBackfill(group);
143135 const worktreeExists =
144136 first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));
@@ -189,6 +181,7 @@ export const make = Effect.gen(function* () { · 44 unmodified lines
189181
190182 let replacement: ThreadLinkedPullRequest | undefined;
191183 if (
184+ thread.pullRequests.length === 0 &&
192185 thread.linkedPullRequest != null &&
193186 detected?.state === "open" &&
194187 detectedReference !== null &&
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+74−0
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 ProjectId,
66 TurnId,
77 type OrchestrationThreadShell,
8+ type ThreadPullRequestLink,
89 } from "@t3tools/contracts";
910 import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1011
@@ -18,6 +19,7 @@ const makeThread = ( · 7 unmodified lines
1819 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1920 runtimeMode: "full-access",
2021 interactionMode: "default",
22+ pullRequests: [],
2123 branch: "feature",
2224 worktreePath: "/repo",
2325 latestTurn: null,
@@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => { · 189 unmodified lines
213215 ).toBe(true);
214216 });
215217 });
218+
219+function linkedRequest(
220+ number: number,
221+ snapshot: ThreadPullRequestLink["snapshot"],
222+): ThreadPullRequestLink {
223+ return {
224+ host: "github.com",
225+ repository: "org/repo",
226+ number,
227+ url: `https://github.com/org/repo/pull/${number}`,
228+ source: "manual",
229+ linkedAt: NOW,
230+ stack: null,
231+ snapshot,
232+ };
233+}
234+
235+const terminalSnapshot = (
236+ state: "closed" | "merged",
237+ terminalAt: string,
238+ updatedAt = terminalAt,
239+) => ({
240+ state,
241+ title: "Change",
242+ headBranch: "feature",
243+ baseBranch: "main",
244+ isDraft: false,
245+ closedAt: terminalAt,
246+ mergedAt: state === "merged" ? terminalAt : null,
247+ updatedAt,
248+ syncedAt: NOW,
249+});
250+
251+describe("linked request settlement", () => {
252+ it.each(["closed", "merged"] as const)(
253+ "uses the latest actual %s transition despite later comments on another PR",
254+ (state) => {
255+ const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW));
256+ const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z"));
257+ expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true);
258+ expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true);
259+ expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false);
260+ },
261+ );
262+
263+ it("keeps unknown and open links active even after the inactivity window", () => {
264+ const merged = linkedRequest(1, terminalSnapshot("merged", NOW));
265+ const unknown = linkedRequest(2, null);
266+ const open = linkedRequest(3, {
267+ ...terminalSnapshot("closed", NOW),
268+ state: "open",
269+ closedAt: null,
270+ });
271+ expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false);
272+ expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false);
273+ expect(
274+ decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })),
275+ ).toBe(true);
276+ });
277+
278+ it("honors merge settings and ignores missing terminal timestamps", () => {
279+ const merged = linkedRequest(1, terminalSnapshot("merged", NOW));
280+ expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe(
281+ false,
282+ );
283+ const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null });
284+ expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false);
285+ expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe(
286+ true,
287+ );
288+ });
289+});
apps/server/src/orchestration/ThreadSettlementPolicy.ts+24−1
@@ -1,4 +1,5 @@
11 import type { OrchestrationThreadShell } from "@t3tools/contracts";
2+import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
23
34 export interface SettlementPullRequest {
45 readonly state: "open" | "closed" | "merged";
@@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: { · 67 unmodified lines
7273 readonly autoSettleAfterDays: number | null;
7374 readonly autoSettleOnMerge: boolean;
7475 }): string | null {
75− const { thread, pullRequest } = input;
76+ const { thread } = input;
77+ let pullRequest = input.pullRequest;
78+ const links = visibleThreadPullRequests(thread.pullRequests);
79+ if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null;
80+ if (links.length > 0) {
81+ const terminalTimestamp = (link: (typeof links)[number]) => {
82+ const snapshot = link.snapshot;
83+ const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;
84+ const timestamp = Date.parse(value ?? "");
85+ return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
86+ };
87+ const latest = links.reduce((current, candidate) =>
88+ terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current,
89+ );
90+ pullRequest =
91+ latest.snapshot === null
92+ ? null
93+ : {
94+ state: latest.snapshot.state,
95+ mergedAt: latest.snapshot.mergedAt ?? null,
96+ closedAt: latest.snapshot.closedAt ?? null,
97+ };
98+ }
7699 if (!isAutoSettlementCandidate(thread, input.now)) return null;
77100 const activityAt = latestTimestamp([
78101 thread.latestUserMessageAt,
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+52−0
@@ -80,6 +80,7 @@ function makeThread( · 79 unmodified lines
8080 },
8181 runtimeMode: "full-access",
8282 interactionMode: "default",
83+ pullRequests: [],
8384 branch: null,
8485 worktreePath: null,
8586 latestTurn: null,
@@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( · 215 unmodified lines
301302 });
302303
303304 describe("ThreadSettlementReactor", () => {
305+ it.effect(
306+ "settles all-terminal links from snapshots and keeps open or unsynced links active",
307+ () =>
308+ Effect.scoped(
309+ Effect.gen(function* () {
310+ yield* TestClock.setTime(Date.parse(NOW));
311+ const link = (number: number, state: "open" | "merged" | null) => ({
312+ host: "example.test",
313+ repository: "owner/repository",
314+ number,
315+ url: `https://example.test/owner/repository/pull/${number}`,
316+ source: "manual" as const,
317+ linkedAt: NOW,
318+ stack: null,
319+ snapshot:
320+ state === null
321+ ? null
322+ : {
323+ state,
324+ title: "Review",
325+ headBranch: "feature",
326+ baseBranch: "main",
327+ isDraft: false,
328+ updatedAt: NOW,
329+ syncedAt: NOW,
330+ mergedAt: state === "merged" ? NOW : null,
331+ },
332+ });
333+ const fixture = yield* makeHarness({
334+ snapshot: makeSnapshot([
335+ makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }),
336+ makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }),
337+ makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }),
338+ ]),
339+ settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true },
340+ branchPullRequest: () => Effect.die("linked threads must not query the branch"),
341+ pullRequestSummary: () => Effect.die("linked threads must use their snapshots"),
342+ });
343+ yield* Effect.gen(function* () {
344+ const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
345+ yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
346+ assert.deepStrictEqual(
347+ (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId),
348+ [ThreadId.make("merged")],
349+ );
350+ assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
351+ assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
352+ }).pipe(Effect.provide(fixture.layer));
353+ }),
354+ ),
355+ );
304356 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
305357 Effect.scoped(
306358 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+3−1
@@ -103,7 +103,9 @@ export const make = Effect.gen(function* () { · 102 unmodified lines
103103 {
104104 concurrency: 8,
105105 },
106− )).filter((thread) => thread !== null);
106+ ))
107+ .filter((thread) => thread !== null)
108+ .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed"));
107109
108110 // Use the same cwd as PR discovery so both paths share GitManager's cache.
109111 const lookupCwdByThreadId = new Map<string, string>();
apps/server/src/orchestration/commandInvariants.test.ts+2−0
@@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = { · 58 unmodified lines
5959 runtimeMode: "full-access",
6060 branch: null,
6161 worktreePath: null,
62+ pullRequests: [],
6263 createdAt: now,
6364 updatedAt: now,
6465 archivedAt: null,
@@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = { · 19 unmodified lines
8485 runtimeMode: "full-access",
8586 branch: null,
8687 worktreePath: null,
88+ pullRequests: [],
8789 createdAt: now,
8890 updatedAt: now,
8991 archivedAt: null,
apps/server/src/orchestration/decider.active-order.test.ts+1−0
@@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): Orchestrat · 32 unmodified lines
3333 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
3434 runtimeMode: "full-access",
3535 interactionMode: "default",
36+ pullRequests: [],
3637 branch: null,
3738 worktreePath: null,
3839 latestTurn: null,
apps/server/src/orchestration/decider.pinned.test.ts+1−0
@@ -36,6 +36,7 @@ function makeReadModel(input: { · 35 unmodified lines
3636 interactionMode: "default",
3737 branch: null,
3838 worktreePath: null,
39+ pullRequests: [],
3940 latestTurn: null,
4041 createdAt: NOW,
4142 updatedAt: NOW,
apps/server/src/orchestration/decider.pullRequests.test.ts+532−0
@@ -0,0 +1,532 @@
1+import {
2+ CommandId,
3+ ProjectId,
4+ ProviderInstanceId,
5+ ThreadId,
6+ OrchestrationEvent,
7+ OrchestrationCommand,
8+ type OrchestrationReadModel,
9+ type ThreadPullRequestLink,
10+ type ThreadPullRequestSnapshot,
11+} from "@t3tools/contracts";
12+import * as NodeServices from "@effect/platform-node/NodeServices";
13+import { expect, it } from "@effect/vitest";
14+import * as Effect from "effect/Effect";
15+import * as Schema from "effect/Schema";
16+
17+import { decideOrchestrationCommand } from "./decider.ts";
18+import { projectEvent } from "./projector.ts";
19+import { isThreadDetailEvent } from "../ws.ts";
20+
21+const decodeCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
22+
23+type PlannedEvent = Omit<OrchestrationEvent, "sequence">;
24+
25+function expectSingleEvent<Type extends OrchestrationEvent["type"]>(
26+ decided: PlannedEvent | ReadonlyArray<PlannedEvent>,
27+ type: Type,
28+): Omit<Extract<OrchestrationEvent, { type: Type }>, "sequence"> {
29+ const event = Array.isArray(decided) ? decided[0] : (decided as PlannedEvent);
30+ if (event === undefined || event.type !== type) {
31+ throw new Error(`expected ${type}, got ${String(event?.type)}`);
32+ }
33+ return event as Omit<Extract<OrchestrationEvent, { type: Type }>, "sequence">;
34+}
35+
36+const NOW = "2026-01-01T00:00:00.000Z";
37+const THREAD_ID = ThreadId.make("thread-1");
38+
39+function makeLink(overrides: Partial<ThreadPullRequestLink> = {}): ThreadPullRequestLink {
40+ return {
41+ host: "github.com",
42+ repository: "t3tools/t3code",
43+ number: 42,
44+ url: "https://github.com/t3tools/t3code/pull/42",
45+ source: "manual",
46+ linkedAt: NOW,
47+ snapshot: null,
48+ stack: null,
49+ ...overrides,
50+ };
51+}
52+
53+function makeReadModel(pullRequests: ReadonlyArray<ThreadPullRequestLink>): OrchestrationReadModel {
54+ return {
55+ snapshotSequence: 0,
56+ projects: [
57+ {
58+ id: ProjectId.make("project-1"),
59+ title: "Project",
60+ workspaceRoot: "/repo",
61+ defaultModelSelection: null,
62+ scripts: [],
63+ createdAt: NOW,
64+ updatedAt: NOW,
65+ deletedAt: null,
66+ repositoryIdentity: {
67+ canonicalKey: "github.com/t3tools/t3code",
68+ provider: "github",
69+ displayName: "t3tools/t3code",
70+ locator: {
71+ source: "git-remote",
72+ remoteName: "origin",
73+ remoteUrl: "https://github.com/t3tools/t3code.git",
74+ },
75+ },
76+ },
77+ ],
78+ threads: [
79+ {
80+ id: THREAD_ID,
81+ projectId: ProjectId.make("project-1"),
82+ title: "Thread",
83+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
84+ runtimeMode: "full-access",
85+ interactionMode: "default",
86+ branch: null,
87+ worktreePath: null,
88+ pullRequests,
89+ latestTurn: null,
90+ createdAt: NOW,
91+ updatedAt: NOW,
92+ archivedAt: null,
93+ settledOverride: null,
94+ settledAt: null,
95+ deletedAt: null,
96+ messages: [],
97+ proposedPlans: [],
98+ activities: [],
99+ checkpoints: [],
100+ session: null,
101+ },
102+ ],
103+ updatedAt: NOW,
104+ };
105+}
106+
107+const snapshot: ThreadPullRequestSnapshot = {
108+ state: "open",
109+ title: "Add links",
110+ headBranch: "feat/links",
111+ baseBranch: "main",
112+ isDraft: false,
113+ updatedAt: NOW,
114+ syncedAt: NOW,
115+};
116+
117+it.layer(NodeServices.layer)("pull request link decider", (it) => {
118+ it.effect("legacy unlink cannot remove a newer cross-host link", () =>
119+ Effect.gen(function* () {
120+ const own = makeLink();
121+ const foreign = makeLink({
122+ host: "github.enterprise.test",
123+ url: "https://github.enterprise.test/t3tools/t3code/pull/42",
124+ linkedAt: "2026-01-02T00:00:00Z",
125+ });
126+ const command = yield* decodeCommand({
127+ type: "thread.meta.update",
128+ commandId: "unlink",
129+ threadId: THREAD_ID,
130+ linkedPullRequest: null,
131+ });
132+ const decided = yield* decideOrchestrationCommand({
133+ readModel: makeReadModel([own, foreign]),
134+ command,
135+ });
136+ const event = expectSingleEvent(decided, "thread.pull-request-unlinked");
137+ expect(event.payload.host).toBe("github.com");
138+ }),
139+ );
140+
141+ it.effect("legacy replacement preserves unrelated manual links", () =>
142+ Effect.gen(function* () {
143+ const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } });
144+ const current = makeLink({ linkedAt: "2026-01-02T00:00:00Z" });
145+ let model = makeReadModel([other, current]);
146+ const command = yield* decodeCommand({
147+ type: "thread.meta.update",
148+ commandId: "replace",
149+ threadId: THREAD_ID,
150+ linkedPullRequest: {
151+ projectId: "project-1",
152+ repository: "t3tools/t3code",
153+ number: 99,
154+ url: "https://github.com/t3tools/t3code/pull/99",
155+ },
156+ });
157+ const decided = yield* decideOrchestrationCommand({ readModel: model, command });
158+ const events = Array.isArray(decided) ? decided : [decided];
159+ expect(events.map((event) => event.type)).toEqual([
160+ "thread.pull-request-unlinked",
161+ "thread.pull-request-linked",
162+ ]);
163+ for (const event of events)
164+ model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 });
165+ expect(model.threads[0]!.pullRequests.map((link) => link.number)).toEqual([7, 99]);
166+ }),
167+ );
168+ it.effect("round-trips an Azure legacy link and unlinks only its organization", () =>
169+ Effect.gen(function* () {
170+ const foreign = makeLink({
171+ host: "dev.azure.com",
172+ repository: "org-b/project/_git/web",
173+ number: 7,
174+ url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7",
175+ });
176+ let model = makeReadModel([foreign]);
177+ model = {
178+ ...model,
179+ projects: model.projects.map((project) => ({
180+ ...project,
181+ repositoryIdentity: {
182+ ...project.repositoryIdentity!,
183+ provider: "azure-devops",
184+ canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web",
185+ displayName: "v3/org-a/project/web",
186+ name: "web",
187+ },
188+ })),
189+ };
190+ const legacy = {
191+ projectId: "project-1",
192+ repository: "web",
193+ number: 7,
194+ url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
195+ };
196+ for (const linkedPullRequest of [legacy, null]) {
197+ const command = yield* decodeCommand({
198+ type: "thread.meta.update",
199+ commandId: linkedPullRequest === null ? "unlink-azure" : "link-azure",
200+ threadId: THREAD_ID,
201+ linkedPullRequest,
202+ });
203+ const decided = yield* decideOrchestrationCommand({ readModel: model, command });
204+ for (const event of Array.isArray(decided) ? decided : [decided]) {
205+ model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 });
206+ }
207+ if (linkedPullRequest !== null) {
208+ expect(model.threads[0]!.linkedPullRequest).toEqual(legacy);
209+ expect(model.threads[0]!.pullRequests.map((link) => link.repository)).toEqual([
210+ "org-b/project/_git/web",
211+ "org-a/project/_git/web",
212+ ]);
213+ }
214+ }
215+ expect(model.threads[0]!.pullRequests).toEqual([foreign]);
216+ expect(model.threads[0]!.linkedPullRequest).toBeNull();
217+ }),
218+ );
219+ it.effect("legacy unlink alone does not emit an empty metadata event", () =>
220+ Effect.gen(function* () {
221+ const command = yield* decodeCommand({
222+ type: "thread.meta.update",
223+ commandId: "unlink",
224+ threadId: THREAD_ID,
225+ linkedPullRequest: null,
226+ });
227+ const decided = yield* decideOrchestrationCommand({
228+ readModel: makeReadModel([makeLink()]),
229+ command,
230+ });
231+ const events = Array.isArray(decided) ? decided : [decided];
232+ expect(events.map((event) => event.type)).toEqual(["thread.pull-request-unlinked"]);
233+ }),
234+ );
235+
236+ for (const source of ["manual", "agent", "created", "stack"] as const) {
237+ it.effect(`legacy unlink removes the visible ${source} link and preserves other requests`, () =>
238+ Effect.gen(function* () {
239+ const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } });
240+ const current = makeLink({ source, linkedAt: "2026-01-02T00:00:00.000Z" });
241+ let model = makeReadModel([other, current]);
242+ // This is the pre-array command shape sent by older clients.
243+ const command = yield* decodeCommand({
244+ type: "thread.meta.update",
245+ commandId: "legacy-unlink",
246+ threadId: THREAD_ID,
247+ linkedPullRequest: null,
248+ title: "Renamed by old client",
249+ });
250+ const decided = yield* decideOrchestrationCommand({ readModel: model, command });
251+ const events = Array.isArray(decided) ? decided : [decided];
252+ for (const planned of events) {
253+ const event = { ...planned, sequence: model.snapshotSequence + 1 };
254+ const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event);
255+ const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded);
256+ // Older detail-event unions must never receive the new PR discriminants.
257+ expect(isThreadDetailEvent(decoded)).toBe(false);
258+ model = yield* projectEvent(model, decoded);
259+ }
260+ const thread = model.threads[0]!;
261+ expect(thread.title).toBe("Renamed by old client");
262+ expect(thread.pullRequests).toEqual(
263+ source === "stack" ? [other, { ...current, source: "stack-dismissed" }] : [other],
264+ );
265+ // The old single-link field continues to track the remaining visible request.
266+ expect(thread.linkedPullRequest?.number).toBe(7);
267+ }),
268+ );
269+ }
270+
271+ it.effect("links a pull request with a normalized key and empty host state", () =>
272+ Effect.gen(function* () {
273+ const decided = yield* decideOrchestrationCommand({
274+ command: {
275+ type: "thread.pull-request.link",
276+ commandId: CommandId.make("cmd-link"),
277+ threadId: THREAD_ID,
278+ host: " GitHub.com ",
279+ repository: "T3Tools/T3Code",
280+ number: 42,
281+ url: "https://github.com/t3tools/t3code/pull/42",
282+ source: "manual",
283+ },
284+ readModel: makeReadModel([]),
285+ });
286+ expect(Array.isArray(decided)).toBe(false);
287+ const event = expectSingleEvent(decided, "thread.pull-request-linked");
288+ expect(event.payload.link).toEqual({
289+ host: "github.com",
290+ repository: "t3tools/t3code",
291+ number: 42,
292+ url: "https://github.com/t3tools/t3code/pull/42",
293+ source: "manual",
294+ linkedAt: event.payload.updatedAt,
295+ snapshot: null,
296+ stack: null,
297+ });
298+ expect(event.payload.updatedAt).not.toBe(NOW);
299+ }),
300+ );
301+
302+ it.effect("rejects linking a pull request that is already linked", () =>
303+ Effect.gen(function* () {
304+ const error = yield* decideOrchestrationCommand({
305+ command: {
306+ type: "thread.pull-request.link",
307+ commandId: CommandId.make("cmd-link-dup"),
308+ threadId: THREAD_ID,
309+ host: "GITHUB.COM",
310+ repository: "t3tools/t3code",
311+ number: 42,
312+ url: "https://github.com/t3tools/t3code/pull/42",
313+ source: "agent",
314+ },
315+ readModel: makeReadModel([makeLink()]),
316+ }).pipe(Effect.flip);
317+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
318+ }),
319+ );
320+
321+ it.effect("re-linking a dismissed stack member un-dismisses it", () =>
322+ Effect.gen(function* () {
323+ const dismissed = makeLink({
324+ source: "stack-dismissed",
325+ snapshot,
326+ stack: {
327+ kind: "native",
328+ id: "stack-1",
329+ number: 1,
330+ url: "https://github.com/t3tools/t3code/stack/1",
331+ base: "main",
332+ layers: [{ number: 42, headBranch: "feat/links", state: "open" }],
333+ },
334+ });
335+ const decided = yield* decideOrchestrationCommand({
336+ command: {
337+ type: "thread.pull-request.link",
338+ commandId: CommandId.make("cmd-relink"),
339+ threadId: THREAD_ID,
340+ host: "github.com",
341+ repository: "t3tools/t3code",
342+ number: 42,
343+ url: "https://github.com/t3tools/t3code/pull/42",
344+ source: "manual",
345+ },
346+ readModel: makeReadModel([dismissed]),
347+ });
348+ const event = expectSingleEvent(decided, "thread.pull-request-linked");
349+ // Host state survives the flip; only the source changes.
350+ expect(event.payload.link).toEqual({ ...dismissed, source: "manual" });
351+ }),
352+ );
353+
354+ it.effect("rejects a stack sync re-adding a dismissed stack member", () =>
355+ Effect.gen(function* () {
356+ const error = yield* decideOrchestrationCommand({
357+ command: {
358+ type: "thread.pull-request.link",
359+ commandId: CommandId.make("cmd-stack-readd"),
360+ threadId: THREAD_ID,
361+ host: "github.com",
362+ repository: "t3tools/t3code",
363+ number: 42,
364+ url: "https://github.com/t3tools/t3code/pull/42",
365+ source: "stack",
366+ },
367+ readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]),
368+ }).pipe(Effect.flip);
369+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
370+ }),
371+ );
372+
373+ it.effect("unlinks a manual pull request", () =>
374+ Effect.gen(function* () {
375+ const decided = yield* decideOrchestrationCommand({
376+ command: {
377+ type: "thread.pull-request.unlink",
378+ commandId: CommandId.make("cmd-unlink"),
379+ threadId: THREAD_ID,
380+ host: "GitHub.com",
381+ repository: "t3tools/t3code",
382+ number: 42,
383+ },
384+ readModel: makeReadModel([makeLink()]),
385+ });
386+ const event = expectSingleEvent(decided, "thread.pull-request-unlinked");
387+ expect(event.payload).toMatchObject({
388+ threadId: THREAD_ID,
389+ host: "github.com",
390+ repository: "t3tools/t3code",
391+ number: 42,
392+ });
393+ }),
394+ );
395+
396+ it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () =>
397+ Effect.gen(function* () {
398+ const member = makeLink({ source: "stack", snapshot });
399+ const decided = yield* decideOrchestrationCommand({
400+ command: {
401+ type: "thread.pull-request.unlink",
402+ commandId: CommandId.make("cmd-unlink-stack"),
403+ threadId: THREAD_ID,
404+ host: "github.com",
405+ repository: "t3tools/t3code",
406+ number: 42,
407+ },
408+ readModel: makeReadModel([member]),
409+ });
410+ const event = expectSingleEvent(decided, "thread.pull-request-linked");
411+ expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" });
412+ }),
413+ );
414+
415+ for (const source of ["manual", "agent", "created"] as const) {
416+ it.effect(`unlinking a ${source} member prevents its sibling rediscovering it`, () =>
417+ Effect.gen(function* () {
418+ const member = makeLink({ source });
419+ const sibling = makeLink({
420+ number: 43,
421+ source: "stack",
422+ stack: {
423+ kind: "native",
424+ id: "stack-1",
425+ number: 1,
426+ url: "https://github.com/t3tools/t3code/stack/1",
427+ base: "main",
428+ layers: [
429+ { number: 42, headBranch: "first", state: "open" },
430+ { number: 43, headBranch: "second", state: "open" },
431+ ],
432+ },
433+ });
434+ let model = makeReadModel([member, sibling]);
435+ const decided = yield* decideOrchestrationCommand({
436+ readModel: model,
437+ command: {
438+ type: "thread.pull-request.unlink",
439+ commandId: CommandId.make("remove"),
440+ threadId: THREAD_ID,
441+ host: member.host,
442+ repository: member.repository,
443+ number: member.number,
444+ },
445+ });
446+ const event = expectSingleEvent(decided, "thread.pull-request-linked");
447+ model = yield* projectEvent(model, { ...event, sequence: 1 });
448+ expect(model.threads[0]!.pullRequests).toEqual([
449+ { ...member, source: "stack-dismissed" },
450+ sibling,
451+ ]);
452+ const rediscovered = yield* decideOrchestrationCommand({
453+ readModel: model,
454+ command: {
455+ type: "thread.pull-request.link",
456+ commandId: CommandId.make("rediscovered"),
457+ threadId: THREAD_ID,
458+ host: member.host,
459+ repository: member.repository,
460+ number: member.number,
461+ url: member.url,
462+ source: "stack",
463+ },
464+ }).pipe(Effect.result);
465+ expect(rediscovered._tag).toBe("Failure");
466+ }),
467+ );
468+ }
469+
470+ it.effect("rejects unlinking a pull request that is not linked", () =>
471+ Effect.gen(function* () {
472+ const error = yield* decideOrchestrationCommand({
473+ command: {
474+ type: "thread.pull-request.unlink",
475+ commandId: CommandId.make("cmd-unlink-missing"),
476+ threadId: THREAD_ID,
477+ host: "github.com",
478+ repository: "t3tools/t3code",
479+ number: 7,
480+ },
481+ readModel: makeReadModel([makeLink()]),
482+ }).pipe(Effect.flip);
483+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
484+ }),
485+ );
486+
487+ it.effect("rejects syncing a pull request that is not linked", () =>
488+ Effect.gen(function* () {
489+ const error = yield* decideOrchestrationCommand({
490+ command: {
491+ type: "thread.pull-request-link.sync",
492+ commandId: CommandId.make("cmd-sync-missing"),
493+ threadId: THREAD_ID,
494+ host: "github.com",
495+ repository: "t3tools/t3code",
496+ number: 42,
497+ snapshot,
498+ stack: null,
499+ },
500+ readModel: makeReadModel([]),
501+ }).pipe(Effect.flip);
502+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
503+ }),
504+ );
505+
506+ it.effect("sync emits the host snapshot for a linked pull request", () =>
507+ Effect.gen(function* () {
508+ const decided = yield* decideOrchestrationCommand({
509+ command: {
510+ type: "thread.pull-request-link.sync",
511+ commandId: CommandId.make("cmd-sync"),
512+ threadId: THREAD_ID,
513+ host: "GitHub.com",
514+ repository: "t3tools/t3code",
515+ number: 42,
516+ snapshot,
517+ stack: null,
518+ },
519+ readModel: makeReadModel([makeLink()]),
520+ });
521+ const event = expectSingleEvent(decided, "thread.pull-request-synced");
522+ expect(event.payload).toMatchObject({
523+ threadId: THREAD_ID,
524+ host: "github.com",
525+ repository: "t3tools/t3code",
526+ number: 42,
527+ snapshot,
528+ stack: null,
529+ });
530+ }),
531+ );
532+});
apps/server/src/orchestration/decider.questionAttachments.test.ts+1−0
@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { · 25 unmodified lines
2626 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
2727 runtimeMode: "full-access",
2828 interactionMode: "default",
29+ pullRequests: [],
2930 branch: null,
3031 worktreePath: null,
3132 latestTurn: null,
apps/server/src/orchestration/decider.settled.test.ts+1−0
@@ -47,6 +47,7 @@ function makeReadModel( · 46 unmodified lines
4747 interactionMode: "default",
4848 branch: null,
4949 worktreePath: null,
50+ pullRequests: [],
5051 latestTurn: null,
5152 createdAt: NOW,
5253 updatedAt: NOW,
apps/server/src/orchestration/decider.snoozed.test.ts+1−0
@@ -41,6 +41,7 @@ function makeReadModel(input: { · 40 unmodified lines
4141 interactionMode: "default",
4242 branch: null,
4343 worktreePath: null,
44+ pullRequests: [],
4445 latestTurn: null,
4546 createdAt: NOW,
4647 updatedAt: NOW,
apps/server/src/orchestration/decider.titleRegeneration.test.ts+1−0
@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { · 25 unmodified lines
2626 interactionMode: "default",
2727 branch: null,
2828 worktreePath: null,
29+ pullRequests: [],
2930 latestTurn: null,
3031 createdAt: UPDATED_AT,
3132 updatedAt: UPDATED_AT,
apps/server/src/orchestration/decider.ts+221−0
@@ -10,8 +10,16 @@ import { · 9 unmodified lines
1010 type OrchestrationEvent,
1111 type OrchestrationReadModel,
1212 type OrchestrationThread,
13+ type ThreadPullRequestKey,
14+ type ThreadPullRequestLink,
1315 type OrchestrationThreadActivity,
1416 } from "@t3tools/contracts";
17+import {
18+ legacyLinkedPullRequestOf,
19+ legacyThreadPullRequestKey,
20+ normalizeThreadPullRequestKey,
21+ threadPullRequestKeysEqual,
22+} from "@t3tools/shared/threadPullRequests";
1523 import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1624 import * as DateTime from "effect/DateTime";
1725 import * as Crypto from "effect/Crypto";
@@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread( · 103 unmodified lines
121129 );
122130 }
123131
132+function findPullRequestLink(
133+ thread: Pick<OrchestrationThread, "pullRequests">,
134+ key: ThreadPullRequestKey,
135+): ThreadPullRequestLink | undefined {
136+ return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key));
137+}
138+
124139 function withEventBase(
125140 input: Pick<OrchestrationCommand, "commandId"> & {
126141 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
@@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 749 unmodified lines
876891 command,
877892 threadId: command.threadId,
878893 });
894+ // Old clients only see the derived single link. Unlink that request through
895+ // the same command path as modern clients, including stack dismissal, while
896+ // retaining other links they cannot see. Historical metadata events still replay unchanged.
897+ const legacy = legacyLinkedPullRequestOf(
898+ thread.pullRequests,
899+ thread.projectId,
900+ readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity,
901+ );
902+ const currentPullRequest =
903+ legacy === null
904+ ? null
905+ : (thread.pullRequests.find(
906+ (link) => link.url === legacy.url && link.number === legacy.number,
907+ ) ?? null);
908+ if (command.linkedPullRequest != null) {
909+ const { linkedPullRequest: linked, ...metadata } = command;
910+ const project = readModel.projects.find((project) => project.id === thread.projectId);
911+ let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown";
912+ try {
913+ host = new URL(linked.url).hostname;
914+ } catch {
915+ // Historical clients can send links without a parseable URL.
916+ }
917+ const hasMetadata = Object.entries(metadata).some(
918+ ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined,
919+ );
920+ return yield* decideCommandSequence({
921+ readModel,
922+ commands: [
923+ ...(hasMetadata ? [metadata] : []),
924+ ...(currentPullRequest?.source === "manual"
925+ ? [
926+ {
927+ type: "thread.pull-request.unlink" as const,
928+ commandId: command.commandId,
929+ threadId: command.threadId,
930+ host: currentPullRequest.host,
931+ repository: currentPullRequest.repository,
932+ number: currentPullRequest.number,
933+ },
934+ ]
935+ : []),
936+ {
937+ type: "thread.pull-request.link",
938+ commandId: command.commandId,
939+ threadId: command.threadId,
940+ ...legacyThreadPullRequestKey(linked, host),
941+ url: linked.url,
942+ source: "manual",
943+ },
944+ ],
945+ });
946+ }
947+
948+ if (command.linkedPullRequest === null && currentPullRequest !== null) {
949+ const { linkedPullRequest: _linkedPullRequest, ...metadata } = command;
950+ const hasMetadata = Object.entries(metadata).some(
951+ ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined,
952+ );
953+ return yield* decideCommandSequence({
954+ readModel,
955+ commands: [
956+ ...(hasMetadata ? [metadata] : []),
957+ {
958+ type: "thread.pull-request.unlink",
959+ commandId: command.commandId,
960+ threadId: command.threadId,
961+ host: currentPullRequest.host,
962+ repository: currentPullRequest.repository,
963+ number: currentPullRequest.number,
964+ },
965+ ],
966+ });
967+ }
879968 const branch =
880969 command.branch !== undefined &&
881970 command.expectedBranch !== undefined &&
@@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 38 unmodified lines
9201009 };
9211010 }
9221011
1012+ case "thread.pull-request.link": {
1013+ const thread = yield* requireThread({
1014+ readModel,
1015+ command,
1016+ threadId: command.threadId,
1017+ });
1018+ const key = normalizeThreadPullRequestKey(command);
1019+ const existing = findPullRequestLink(thread, key);
1020+ // An explicit link on a dismissed stack member un-dismisses it; any
1021+ // other duplicate is a no-op the engine would reject as zero-event.
1022+ const undismisses =
1023+ existing?.source === "stack-dismissed" &&
1024+ (command.source === "manual" || command.source === "agent" || command.source === "created");
1025+ if (existing !== undefined && !undismisses) {
1026+ return yield* new OrchestrationCommandInvariantError({
1027+ commandType: command.type,
1028+ detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`,
1029+ });
1030+ }
1031+ const occurredAt = yield* nowIso;
1032+ return {
1033+ ...(yield* withEventBase({
1034+ aggregateKind: "thread",
1035+ aggregateId: command.threadId,
1036+ occurredAt,
1037+ commandId: command.commandId,
1038+ })),
1039+ type: "thread.pull-request-linked",
1040+ payload: {
1041+ threadId: command.threadId,
1042+ link:
1043+ existing !== undefined
1044+ ? { ...existing, url: command.url, source: command.source }
1045+ : {
1046+ ...key,
1047+ url: command.url,
1048+ source: command.source,
1049+ linkedAt: occurredAt,
1050+ snapshot: null,
1051+ stack: null,
1052+ },
1053+ updatedAt: occurredAt,
1054+ },
1055+ };
1056+ }
1057+
1058+ case "thread.pull-request.unlink": {
1059+ const thread = yield* requireThread({
1060+ readModel,
1061+ command,
1062+ threadId: command.threadId,
1063+ });
1064+ const key = normalizeThreadPullRequestKey(command);
1065+ const existing = findPullRequestLink(thread, key);
1066+ if (existing === undefined) {
1067+ return yield* new OrchestrationCommandInvariantError({
1068+ commandType: command.type,
1069+ detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`,
1070+ });
1071+ }
1072+ const occurredAt = yield* nowIso;
1073+ const eventBase = yield* withEventBase({
1074+ aggregateKind: "thread",
1075+ aggregateId: command.threadId,
1076+ occurredAt,
1077+ commandId: command.commandId,
1078+ });
1079+ // Any known native-stack member needs a tombstone, regardless of who linked it.
1080+ // A sibling can rediscover it even before this link has its own stack snapshot.
1081+ const belongsToStack =
1082+ existing.source === "stack" ||
1083+ existing.stack !== null ||
1084+ thread.pullRequests.some(
1085+ (link) =>
1086+ link.host.toLowerCase() === key.host &&
1087+ link.repository.toLowerCase() === key.repository &&
1088+ link.stack?.layers.some((layer) => layer.number === key.number),
1089+ );
1090+ if (belongsToStack) {
1091+ return {
1092+ ...eventBase,
1093+ type: "thread.pull-request-linked",
1094+ payload: {
1095+ threadId: command.threadId,
1096+ link: { ...existing, source: "stack-dismissed" },
1097+ updatedAt: occurredAt,
1098+ },
1099+ };
1100+ }
1101+ return {
1102+ ...eventBase,
1103+ type: "thread.pull-request-unlinked",
1104+ payload: {
1105+ threadId: command.threadId,
1106+ ...key,
1107+ updatedAt: occurredAt,
1108+ },
1109+ };
1110+ }
1111+
1112+ case "thread.pull-request-link.sync": {
1113+ const thread = yield* requireThread({
1114+ readModel,
1115+ command,
1116+ threadId: command.threadId,
1117+ });
1118+ const key = normalizeThreadPullRequestKey(command);
1119+ if (findPullRequestLink(thread, key) === undefined) {
1120+ return yield* new OrchestrationCommandInvariantError({
1121+ commandType: command.type,
1122+ detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`,
1123+ });
1124+ }
1125+ const occurredAt = yield* nowIso;
1126+ return {
1127+ ...(yield* withEventBase({
1128+ aggregateKind: "thread",
1129+ aggregateId: command.threadId,
1130+ occurredAt,
1131+ commandId: command.commandId,
1132+ })),
1133+ type: "thread.pull-request-synced",
1134+ payload: {
1135+ threadId: command.threadId,
1136+ ...key,
1137+ snapshot: command.snapshot,
1138+ stack: command.stack,
1139+ updatedAt: occurredAt,
1140+ },
1141+ };
1142+ }
1143+
9231144 case "thread.pull-request.sync": {
9241145 const thread = yield* requireThreadNotArchived({
9251146 readModel,
apps/server/src/orchestration/decider.userInputDismiss.test.ts+1−0
@@ -49,6 +49,7 @@ function makeReadModel( · 48 unmodified lines
4949 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
5050 runtimeMode: "full-access",
5151 interactionMode: "default",
52+ pullRequests: [],
5253 branch: null,
5354 worktreePath: null,
5455 latestTurn: null,
apps/server/src/orchestration/projector.pullRequests.test.ts+417−0
@@ -0,0 +1,417 @@
1+import {
2+ CommandId,
3+ EventId,
4+ ProjectId,
5+ ThreadId,
6+ type OrchestrationEvent,
7+ type OrchestrationReadModel,
8+ type RepositoryIdentity,
9+ type ThreadPullRequestLink,
10+ type ThreadPullRequestSnapshot,
11+} from "@t3tools/contracts";
12+import { expect, it } from "@effect/vitest";
13+import * as Effect from "effect/Effect";
14+
15+import { createEmptyReadModel, projectEvent } from "./projector.ts";
16+
17+const NOW = "2026-01-01T00:00:00.000Z";
18+const LATER = "2026-01-02T00:00:00.000Z";
19+const THREAD_ID = ThreadId.make("thread-1");
20+const PROJECT_ID = ProjectId.make("project-1");
21+
22+function makeEvent(input: {
23+ readonly sequence: number;
24+ readonly type: OrchestrationEvent["type"];
25+ readonly payload: unknown;
26+}): OrchestrationEvent {
27+ return {
28+ sequence: input.sequence,
29+ eventId: EventId.make(`event-${input.sequence}`),
30+ type: input.type,
31+ aggregateKind: "thread",
32+ aggregateId: THREAD_ID,
33+ occurredAt: NOW,
34+ commandId: CommandId.make(`command-${input.sequence}`),
35+ causationEventId: null,
36+ correlationId: null,
37+ metadata: {},
38+ payload: input.payload as never,
39+ } as OrchestrationEvent;
40+}
41+
42+function makeLink(overrides: Partial<ThreadPullRequestLink> = {}): ThreadPullRequestLink {
43+ return {
44+ host: "github.com",
45+ repository: "t3tools/t3code",
46+ number: 42,
47+ url: "https://github.com/t3tools/t3code/pull/42",
48+ source: "manual",
49+ linkedAt: NOW,
50+ snapshot: null,
51+ stack: null,
52+ ...overrides,
53+ };
54+}
55+
56+const snapshot: ThreadPullRequestSnapshot = {
57+ state: "merged",
58+ title: "Add links",
59+ headBranch: "feat/links",
60+ baseBranch: "main",
61+ isDraft: false,
62+ updatedAt: LATER,
63+ syncedAt: LATER,
64+};
65+
66+const createThread = (model: OrchestrationReadModel) =>
67+ projectEvent(
68+ model,
69+ makeEvent({
70+ sequence: model.snapshotSequence + 1,
71+ type: "thread.created",
72+ payload: {
73+ threadId: THREAD_ID,
74+ projectId: PROJECT_ID,
75+ title: "Thread",
76+ modelSelection: { provider: "codex", model: "gpt-5.4" },
77+ runtimeMode: "full-access",
78+ interactionMode: "default",
79+ branch: null,
80+ worktreePath: null,
81+ createdAt: NOW,
82+ updatedAt: NOW,
83+ },
84+ }),
85+ );
86+
87+const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) =>
88+ projectEvent(model, {
89+ ...makeEvent({
90+ sequence: model.snapshotSequence + 1,
91+ type: "project.created",
92+ payload: {
93+ projectId: PROJECT_ID,
94+ title: "Project",
95+ workspaceRoot: "/workspace/project",
96+ defaultModelSelection: null,
97+ scripts: [],
98+ createdAt: NOW,
99+ updatedAt: NOW,
100+ },
101+ }),
102+ aggregateKind: "project",
103+ aggregateId: PROJECT_ID,
104+ }).pipe(
105+ Effect.map((next) => ({
106+ ...next,
107+ projects: next.projects.map((project) =>
108+ project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project,
109+ ),
110+ })),
111+ );
112+
113+it.effect("seeds threads with no pull requests", () =>
114+ Effect.gen(function* () {
115+ const created = yield* createThread(createEmptyReadModel(NOW));
116+ expect(created.threads[0]?.pullRequests).toEqual([]);
117+ expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull();
118+ }),
119+);
120+
121+it.effect("projects link, sync, and unlink onto the thread", () =>
122+ Effect.gen(function* () {
123+ const created = yield* createThread(
124+ yield* createProject(createEmptyReadModel(NOW), {
125+ canonicalKey: "github.com/t3tools/t3code",
126+ provider: "github",
127+ displayName: "t3tools/t3code",
128+ locator: {
129+ source: "git-remote",
130+ remoteName: "origin",
131+ remoteUrl: "https://github.com/t3tools/t3code.git",
132+ },
133+ }),
134+ );
135+ const link = makeLink();
136+
137+ const linked = yield* projectEvent(
138+ created,
139+ makeEvent({
140+ sequence: 2,
141+ type: "thread.pull-request-linked",
142+ payload: { threadId: THREAD_ID, link, updatedAt: LATER },
143+ }),
144+ );
145+ expect(linked.threads[0]?.pullRequests).toEqual([link]);
146+ expect(linked.threads[0]?.updatedAt).toBe(LATER);
147+ // The legacy field is derived from the array so old clients keep working.
148+ expect(linked.threads[0]?.linkedPullRequest).toEqual({
149+ projectId: PROJECT_ID,
150+ repository: "t3tools/t3code",
151+ number: 42,
152+ url: "https://github.com/t3tools/t3code/pull/42",
153+ });
154+
155+ // A second link for the same key replaces in place (used for un-dismiss
156+ // and stack tombstones), never duplicates.
157+ const relinked = yield* projectEvent(
158+ linked,
159+ makeEvent({
160+ sequence: 3,
161+ type: "thread.pull-request-linked",
162+ payload: {
163+ threadId: THREAD_ID,
164+ link: { ...link, host: "GITHUB.COM", source: "agent" },
165+ updatedAt: LATER,
166+ },
167+ }),
168+ );
169+ expect(relinked.threads[0]?.pullRequests).toHaveLength(1);
170+ expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent");
171+
172+ const synced = yield* projectEvent(
173+ relinked,
174+ makeEvent({
175+ sequence: 4,
176+ type: "thread.pull-request-synced",
177+ payload: {
178+ threadId: THREAD_ID,
179+ host: "github.com",
180+ repository: "t3tools/t3code",
181+ number: 42,
182+ snapshot,
183+ stack: null,
184+ updatedAt: LATER,
185+ },
186+ }),
187+ );
188+ expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot);
189+
190+ const unlinked = yield* projectEvent(
191+ synced,
192+ makeEvent({
193+ sequence: 5,
194+ type: "thread.pull-request-unlinked",
195+ payload: {
196+ threadId: THREAD_ID,
197+ host: "github.com",
198+ repository: "t3tools/t3code",
199+ number: 42,
200+ updatedAt: LATER,
201+ },
202+ }),
203+ );
204+ expect(unlinked.threads[0]?.pullRequests).toEqual([]);
205+ expect(unlinked.threads[0]?.linkedPullRequest).toBeNull();
206+ }),
207+);
208+
209+it.effect("ignores a sync for a pull request that is no longer linked", () =>
210+ Effect.gen(function* () {
211+ const created = yield* createThread(createEmptyReadModel(NOW));
212+ const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" });
213+ const linked = yield* projectEvent(
214+ created,
215+ makeEvent({
216+ sequence: 2,
217+ type: "thread.pull-request-linked",
218+ payload: { threadId: THREAD_ID, link: other, updatedAt: NOW },
219+ }),
220+ );
221+ const synced = yield* projectEvent(
222+ linked,
223+ makeEvent({
224+ sequence: 3,
225+ type: "thread.pull-request-synced",
226+ payload: {
227+ threadId: THREAD_ID,
228+ host: "github.com",
229+ repository: "t3tools/t3code",
230+ number: 42,
231+ snapshot,
232+ stack: null,
233+ updatedAt: LATER,
234+ },
235+ }),
236+ );
237+ expect(synced.threads[0]?.pullRequests).toEqual([other]);
238+ expect(synced.threads[0]?.updatedAt).toBe(NOW);
239+ }),
240+);
241+
242+it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () =>
243+ Effect.gen(function* () {
244+ const withProject = yield* createProject(createEmptyReadModel(NOW), {
245+ canonicalKey: "GitHub.com/t3tools/t3code",
246+ provider: "github",
247+ displayName: "t3tools/t3code",
248+ locator: {
249+ source: "git-remote",
250+ remoteName: "origin",
251+ remoteUrl: "git@github.com:t3tools/t3code.git",
252+ },
253+ });
254+ const created = yield* createThread(withProject);
255+ const agentLink = makeLink({
256+ number: 7,
257+ url: "https://github.com/t3tools/t3code/pull/7",
258+ source: "agent",
259+ });
260+ const withAgentLink = yield* projectEvent(
261+ created,
262+ makeEvent({
263+ sequence: 3,
264+ type: "thread.pull-request-linked",
265+ payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW },
266+ }),
267+ );
268+
269+ const legacyLinked = yield* projectEvent(
270+ withAgentLink,
271+ makeEvent({
272+ sequence: 4,
273+ type: "thread.meta-updated",
274+ payload: {
275+ threadId: THREAD_ID,
276+ linkedPullRequest: {
277+ projectId: PROJECT_ID,
278+ repository: "T3Tools/T3Code",
279+ number: 42,
280+ url: "https://github.com/t3tools/t3code/pull/42",
281+ },
282+ updatedAt: LATER,
283+ },
284+ }),
285+ );
286+ expect(legacyLinked.threads[0]?.pullRequests).toEqual([
287+ agentLink,
288+ {
289+ host: "github.com",
290+ repository: "t3tools/t3code",
291+ number: 42,
292+ url: "https://github.com/t3tools/t3code/pull/42",
293+ source: "manual",
294+ linkedAt: LATER,
295+ snapshot: null,
296+ stack: null,
297+ },
298+ ]);
299+ // Two open links read as a stack; the derived field points at the top.
300+ expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({
301+ projectId: PROJECT_ID,
302+ repository: "t3tools/t3code",
303+ number: 42,
304+ url: "https://github.com/t3tools/t3code/pull/42",
305+ });
306+
307+ // Null clears only the manual link; the agent's stays.
308+ const legacyCleared = yield* projectEvent(
309+ legacyLinked,
310+ makeEvent({
311+ sequence: 5,
312+ type: "thread.meta-updated",
313+ payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER },
314+ }),
315+ );
316+ expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]);
317+ expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({
318+ projectId: PROJECT_ID,
319+ repository: "t3tools/t3code",
320+ number: 7,
321+ url: "https://github.com/t3tools/t3code/pull/7",
322+ });
323+ }),
324+);
325+
326+it.effect("falls back to the link URL host when the project has no repository identity", () =>
327+ Effect.gen(function* () {
328+ const created = yield* createThread(createEmptyReadModel(NOW));
329+ const legacyLinked = yield* projectEvent(
330+ created,
331+ makeEvent({
332+ sequence: 2,
333+ type: "thread.meta-updated",
334+ payload: {
335+ threadId: THREAD_ID,
336+ linkedPullRequest: {
337+ projectId: PROJECT_ID,
338+ repository: "t3tools/t3code",
339+ number: 42,
340+ url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42",
341+ },
342+ updatedAt: LATER,
343+ },
344+ }),
345+ );
346+ expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com");
347+ }),
348+);
349+
350+it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () =>
351+ Effect.gen(function* () {
352+ const created = yield* createThread(createEmptyReadModel(NOW));
353+ const link = makeLink();
354+ const linked = yield* projectEvent(
355+ created,
356+ makeEvent({
357+ sequence: 2,
358+ type: "thread.pull-request-linked",
359+ payload: { threadId: THREAD_ID, link, updatedAt: NOW },
360+ }),
361+ );
362+ const retitled = yield* projectEvent(
363+ linked,
364+ makeEvent({
365+ sequence: 3,
366+ type: "thread.meta-updated",
367+ payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER },
368+ }),
369+ );
370+ expect(retitled.threads[0]?.title).toBe("Renamed");
371+ expect(retitled.threads[0]?.pullRequests).toEqual([link]);
372+ }),
373+);
374+
375+it.effect("replays Azure legacy selectors as full repository keys", () =>
376+ Effect.gen(function* () {
377+ const withProject = yield* createProject(createEmptyReadModel(NOW), {
378+ canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web",
379+ provider: "azure-devops",
380+ displayName: "v3/org-a/project/web",
381+ name: "web",
382+ locator: {
383+ source: "git-remote",
384+ remoteName: "origin",
385+ remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web",
386+ },
387+ });
388+ const created = yield* createThread(withProject);
389+ const legacy = {
390+ projectId: PROJECT_ID,
391+ repository: "web",
392+ number: 7,
393+ url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
394+ };
395+ const model = yield* projectEvent(
396+ created,
397+ makeEvent({
398+ sequence: 3,
399+ type: "thread.meta-updated",
400+ payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER },
401+ }),
402+ );
403+ expect(model.threads[0]?.pullRequests).toEqual([
404+ {
405+ host: "dev.azure.com",
406+ repository: "org-a/project/_git/web",
407+ number: 7,
408+ url: legacy.url,
409+ source: "manual",
410+ linkedAt: LATER,
411+ snapshot: null,
412+ stack: null,
413+ },
414+ ]);
415+ expect(model.threads[0]?.linkedPullRequest).toEqual(legacy);
416+ }),
417+);
apps/server/src/orchestration/projector.test.ts+26−1
@@ -86,6 +86,7 @@ describe("orchestration projector", () => { · 85 unmodified lines
8686 interactionMode: "default",
8787 branch: null,
8888 worktreePath: null,
89+ pullRequests: [],
8990 branchPullRequest: null,
9091 latestTurn: null,
9192 createdAt: now,
@@ -117,7 +118,31 @@ describe("orchestration projector", () => { · 25 unmodified lines
117118 commandId: null,
118119 };
119120 let model = yield* projectEvent(
120− createEmptyReadModel(now),
121+ {
122+ ...createEmptyReadModel(now),
123+ projects: [
124+ {
125+ id: ProjectId.make("project-1"),
126+ title: "T3 Code",
127+ workspaceRoot: "/repo",
128+ defaultModelSelection: null,
129+ scripts: [],
130+ createdAt: now,
131+ updatedAt: now,
132+ deletedAt: null,
133+ repositoryIdentity: {
134+ canonicalKey: "github.com/pingdotgg/t3code",
135+ provider: "github",
136+ displayName: "pingdotgg/t3code",
137+ locator: {
138+ source: "git-remote",
139+ remoteName: "origin",
140+ remoteUrl: "https://github.com/pingdotgg/t3code.git",
141+ },
142+ },
143+ },
144+ ],
145+ },
121146 makeEvent({
122147 ...eventFields,
123148 sequence: 1,
apps/server/src/orchestration/projector.ts+212−25
@@ -1,4 +1,12 @@
1−import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";
1+import type {
2+ OrchestrationEvent,
3+ OrchestrationProject,
4+ OrchestrationReadModel,
5+ ThreadId,
6+ ThreadLinkedPullRequest,
7+ ThreadPullRequestKey,
8+ ThreadPullRequestLink,
9+} from "@t3tools/contracts";
210 import {
311 isImportedAgentSessionMessageId,
412 OrchestrationCheckpointSummary,
@@ -6,6 +14,11 @@ import { · 1 unmodified line
614 OrchestrationSession,
715 OrchestrationThread,
816 } from "@t3tools/contracts";
17+import {
18+ legacyLinkedPullRequestOf,
19+ legacyThreadPullRequestKey,
20+ threadPullRequestKeysEqual,
21+} from "@t3tools/shared/threadPullRequests";
922 import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1023 import * as Effect from "effect/Effect";
1124 import * as Schema from "effect/Schema";
@@ -28,6 +41,9 @@ import { · 16 unmodified lines
2841 ThreadSettledPayload,
2942 ThreadPinnedPayload,
3043 ThreadPinReorderedPayload,
44+ ThreadPullRequestLinkedPayload,
45+ ThreadPullRequestSyncedPayload,
46+ ThreadPullRequestUnlinkedPayload,
3147 ThreadSnoozedPayload,
3248 ThreadUnpinnedPayload,
3349 ThreadUnarchivedPayload,
@@ -101,6 +117,77 @@ function updateThread( · 67 unmodified lines
101117 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
102118 }
103119
120+/** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */
121+function pullRequestsPatch(
122+ thread: Pick<OrchestrationThread, "projectId">,
123+ pullRequests: ReadonlyArray<ThreadPullRequestLink>,
124+ projects: OrchestrationReadModel["projects"],
125+): Pick<OrchestrationThread, "pullRequests" | "linkedPullRequest"> {
126+ return {
127+ pullRequests,
128+ linkedPullRequest: legacyLinkedPullRequestOf(
129+ pullRequests,
130+ thread.projectId,
131+ projects.find((project) => project.id === thread.projectId)?.repositoryIdentity,
132+ ),
133+ };
134+}
135+
136+function upsertPullRequestLink(
137+ pullRequests: ReadonlyArray<ThreadPullRequestLink>,
138+ link: ThreadPullRequestLink,
139+): ReadonlyArray<ThreadPullRequestLink> {
140+ const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link));
141+ return index === -1
142+ ? [...pullRequests, link]
143+ : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry));
144+}
145+
146+function removePullRequestLink(
147+ pullRequests: ReadonlyArray<ThreadPullRequestLink>,
148+ key: ThreadPullRequestKey,
149+): ReadonlyArray<ThreadPullRequestLink> {
150+ return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key));
151+}
152+
153+/**
154+ * Host for a legacy `linkedPullRequest` being replayed into the link array.
155+ * Legacy links never carried one; the project's canonical key
156+ * (`<host>/<owner>/<name>`) is the best witness, then the link URL.
157+ */
158+function legacyPullRequestHost(
159+ project: OrchestrationProject | undefined,
160+ linked: ThreadLinkedPullRequest,
161+): string {
162+ const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0];
163+ if (canonicalHost) return canonicalHost.toLowerCase();
164+ try {
165+ return new URL(linked.url).hostname.toLowerCase();
166+ } catch {
167+ return "unknown";
168+ }
169+}
170+
171+function legacyLinkToPullRequests(
172+ thread: Pick<OrchestrationThread, "pullRequests">,
173+ project: OrchestrationProject | undefined,
174+ linked: ThreadLinkedPullRequest | null,
175+ linkedAt: string,
176+): ReadonlyArray<ThreadPullRequestLink> {
177+ // The legacy field held one user-chosen link, so null clears exactly the
178+ // manual ones and leaves created/agent/stack links alone.
179+ const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual");
180+ if (linked === null) return withoutManual;
181+ return upsertPullRequestLink(withoutManual, {
182+ ...legacyThreadPullRequestKey(linked, legacyPullRequestHost(project, linked)),
183+ url: linked.url,
184+ source: "manual",
185+ linkedAt,
186+ snapshot: null,
187+ stack: null,
188+ });
189+}
190+
104191 function decodeForEvent<A>(
105192 schema: Schema.Decoder<A, never>,
106193 value: unknown,
@@ -336,6 +423,7 @@ export function projectEvent( · 229 unmodified lines
336423 interactionMode: payload.interactionMode,
337424 branch: payload.branch,
338425 worktreePath: payload.worktreePath,
426+ pullRequests: [],
339427 branchPullRequest: null,
340428 latestTurn: null,
341429 createdAt: payload.createdAt,
@@ -498,30 +586,129 @@ export function projectEvent( · 156 unmodified lines
498586
499587 case "thread.meta-updated":
500588 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
501− Effect.map((payload) => ({
502− ...nextBase,
503− threads: updateThread(nextBase.threads, payload.threadId, {
504− ...(payload.title !== undefined ? { title: payload.title } : {}),
505− ...(payload.activeOrderKey !== undefined
506− ? { activeOrderKey: payload.activeOrderKey }
507− : {}),
508− ...(payload.titleRegeneration !== undefined
509− ? { titleRegeneration: payload.titleRegeneration }
510− : {}),
511− ...(payload.modelSelection !== undefined
512− ? { modelSelection: payload.modelSelection }
513− : {}),
514− ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
515− ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
516− ...(payload.linkedPullRequest !== undefined
517− ? { linkedPullRequest: payload.linkedPullRequest }
518− : {}),
519− ...(payload.branchPullRequest !== undefined
520− ? { branchPullRequest: payload.branchPullRequest }
521− : {}),
522− updatedAt: payload.updatedAt,
523− }),
524− })),
589+ Effect.map((payload) => {
590+ const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
591+ // Legacy single-link events replay into the link array so the
592+ // derived linkedPullRequest and pullRequests never disagree.
593+ const legacyLinkPatch =
594+ thread !== undefined && payload.linkedPullRequest !== undefined
595+ ? pullRequestsPatch(
596+ thread,
597+ legacyLinkToPullRequests(
598+ thread,
599+ nextBase.projects.find((project) => project.id === thread.projectId),
600+ payload.linkedPullRequest,
601+ payload.updatedAt,
602+ ),
603+ nextBase.projects,
604+ )
605+ : {};
606+ return {
607+ ...nextBase,
608+ threads: updateThread(nextBase.threads, payload.threadId, {
609+ ...(payload.title !== undefined ? { title: payload.title } : {}),
610+ ...(payload.titleRegeneration !== undefined
611+ ? { titleRegeneration: payload.titleRegeneration }
612+ : {}),
613+ ...(payload.modelSelection !== undefined
614+ ? { modelSelection: payload.modelSelection }
615+ : {}),
616+ ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
617+ ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
618+ ...(payload.activeOrderKey !== undefined
619+ ? { activeOrderKey: payload.activeOrderKey }
620+ : {}),
621+ ...(payload.branchPullRequest !== undefined
622+ ? { branchPullRequest: payload.branchPullRequest }
623+ : {}),
624+ ...legacyLinkPatch,
625+ updatedAt: payload.updatedAt,
626+ }),
627+ };
628+ }),
629+ );
630+
631+ case "thread.pull-request-linked":
632+ return decodeForEvent(
633+ ThreadPullRequestLinkedPayload,
634+ event.payload,
635+ event.type,
636+ "payload",
637+ ).pipe(
638+ Effect.map((payload) => {
639+ const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
640+ if (!thread) {
641+ return nextBase;
642+ }
643+ return {
644+ ...nextBase,
645+ threads: updateThread(nextBase.threads, payload.threadId, {
646+ ...pullRequestsPatch(
647+ thread,
648+ upsertPullRequestLink(thread.pullRequests, payload.link),
649+ nextBase.projects,
650+ ),
651+ updatedAt: payload.updatedAt,
652+ }),
653+ };
654+ }),
655+ );
656+
657+ case "thread.pull-request-unlinked":
658+ return decodeForEvent(
659+ ThreadPullRequestUnlinkedPayload,
660+ event.payload,
661+ event.type,
662+ "payload",
663+ ).pipe(
664+ Effect.map((payload) => {
665+ const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
666+ if (!thread) {
667+ return nextBase;
668+ }
669+ return {
670+ ...nextBase,
671+ threads: updateThread(nextBase.threads, payload.threadId, {
672+ ...pullRequestsPatch(
673+ thread,
674+ removePullRequestLink(thread.pullRequests, payload),
675+ nextBase.projects,
676+ ),
677+ updatedAt: payload.updatedAt,
678+ }),
679+ };
680+ }),
681+ );
682+
683+ case "thread.pull-request-synced":
684+ return decodeForEvent(
685+ ThreadPullRequestSyncedPayload,
686+ event.payload,
687+ event.type,
688+ "payload",
689+ ).pipe(
690+ Effect.map((payload) => {
691+ const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
692+ // A sync for a link the user removed in the meantime is stale; drop it.
693+ if (
694+ !thread ||
695+ !thread.pullRequests.some((link) => threadPullRequestKeysEqual(link, payload))
696+ ) {
697+ return nextBase;
698+ }
699+ const pullRequests = thread.pullRequests.map((link) =>
700+ threadPullRequestKeysEqual(link, payload)
701+ ? { ...link, snapshot: payload.snapshot, stack: payload.stack }
702+ : link,
703+ );
704+ return {
705+ ...nextBase,
706+ threads: updateThread(nextBase.threads, payload.threadId, {
707+ ...pullRequestsPatch(thread, pullRequests, nextBase.projects),
708+ updatedAt: payload.updatedAt,
709+ }),
710+ };
711+ }),
525712 );
526713
527714 case "thread.runtime-mode-set":
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+167−1
@@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement"; · 14 unmodified lines
1515 import { SqlitePersistenceMemory } from "./Sqlite.ts";
1616 import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts";
1717 import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts";
18−import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";
18+import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts";
1919 import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts";
2020 import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts";
21+import {
22+ ProjectionThreadPullRequestRepository,
23+ type ProjectionThreadPullRequest,
24+} from "../ProjectionThreadPullRequests.ts";
25+import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";
2126 import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts";
2227
2328 const projectionRepositoriesLayer = it.layer(
2429 Layer.mergeAll(
2530 ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2631 ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
32+ ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2733 ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2834 SqlitePersistenceMemory,
2935 ),
@@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 499 unmodified lines
529535 assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);
530536 }),
531537 );
538+
539+ it.effect("uses one Azure identity for repository writes, lookups, and deletion", () =>
540+ Effect.gen(function* () {
541+ const pullRequests = yield* ProjectionThreadPullRequestRepository;
542+ const threadId = ThreadId.make("azure-alias-link");
543+ const row: ProjectionThreadPullRequest = {
544+ threadId,
545+ host: "org.visualstudio.com",
546+ repository: "project/_git/web",
547+ number: 7,
548+ url: "https://org.visualstudio.com/project/_git/web/pullrequest/7",
549+ source: "manual",
550+ linkedAt: "2026-09-09T00:00:00.000Z",
551+ snapshot: null,
552+ stack: null,
553+ };
554+ yield* pullRequests.upsert(row);
555+ yield* pullRequests.upsert({
556+ ...row,
557+ host: "dev.azure.com",
558+ repository: "org/project/_git/web",
559+ });
560+ const found = yield* pullRequests.listByPullRequest({
561+ host: "ssh.dev.azure.com",
562+ repository: "v3/org/project/web",
563+ number: 7,
564+ });
565+ assert.deepStrictEqual(found, [
566+ { ...row, host: "dev.azure.com", repository: "org/project/_git/web" },
567+ ]);
568+ assert.deepStrictEqual(
569+ yield* pullRequests.listByPullRequest({
570+ host: "dev.azure.com",
571+ repository: "other/project/_git/web",
572+ number: 7,
573+ }),
574+ [],
575+ );
576+ yield* pullRequests.delete({
577+ threadId,
578+ host: "vs-ssh.visualstudio.com",
579+ repository: "v3/org/project/web",
580+ number: 7,
581+ });
582+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []);
583+ }),
584+ );
585+
586+ it.effect("round-trips pull request links with JSON snapshot and stack columns", () =>
587+ Effect.gen(function* () {
588+ const pullRequests = yield* ProjectionThreadPullRequestRepository;
589+ const sql = yield* SqlClient.SqlClient;
590+ const threadId = ThreadId.make("thread-pr-links");
591+ const otherThreadId = ThreadId.make("thread-pr-links-other");
592+
593+ const unsynced: ProjectionThreadPullRequest = {
594+ threadId,
595+ host: "github.com",
596+ repository: "pingdotgg/t3code",
597+ number: 42,
598+ url: "https://github.com/pingdotgg/t3code/pull/42",
599+ source: "manual",
600+ linkedAt: "2026-03-24T00:00:00.000Z",
601+ snapshot: null,
602+ stack: null,
603+ };
604+ const synced: ProjectionThreadPullRequest = {
605+ threadId,
606+ host: "github.com",
607+ repository: "pingdotgg/t3code",
608+ number: 7,
609+ url: "https://github.com/pingdotgg/t3code/pull/7",
610+ source: "stack",
611+ linkedAt: "2026-03-23T00:00:00.000Z",
612+ snapshot: {
613+ state: "open",
614+ title: "Add links",
615+ headBranch: "feat/links",
616+ baseBranch: "main",
617+ isDraft: false,
618+ updatedAt: "2026-03-23T01:00:00.000Z",
619+ syncedAt: "2026-03-23T02:00:00.000Z",
620+ },
621+ stack: {
622+ kind: "native",
623+ id: "stack-1",
624+ number: 1,
625+ url: "https://github.com/pingdotgg/t3code/stack/1",
626+ base: "main",
627+ layers: [
628+ { number: 7, headBranch: "feat/links", state: "open" },
629+ { number: 42, headBranch: "feat/links-ui", state: "open" },
630+ ],
631+ },
632+ };
633+ const sharedOnOtherThread: ProjectionThreadPullRequest = {
634+ ...unsynced,
635+ threadId: otherThreadId,
636+ source: "agent",
637+ linkedAt: "2026-03-25T00:00:00.000Z",
638+ };
639+
640+ yield* pullRequests.upsert(unsynced);
641+ yield* pullRequests.upsert(synced);
642+ yield* pullRequests.upsert(sharedOnOtherThread);
643+
644+ const rawRows = yield* sql<{
645+ readonly number: number;
646+ readonly snapshotJson: string | null;
647+ readonly stackJson: string | null;
648+ }>`
649+ SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson"
650+ FROM projection_thread_pull_requests
651+ WHERE thread_id = ${threadId}
652+ ORDER BY number ASC
653+ `;
654+ assert.strictEqual(rawRows[0]?.number, 7);
655+ // @effect-diagnostics-next-line preferSchemaOverJson:off
656+ assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot);
657+ // @effect-diagnostics-next-line preferSchemaOverJson:off
658+ assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack);
659+ assert.strictEqual(rawRows[1]?.snapshotJson, null);
660+ assert.strictEqual(rawRows[1]?.stackJson, null);
661+
662+ // Ordered by linked_at, then number.
663+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]);
664+
665+ // One pull request across threads, ordered by linked_at.
666+ assert.deepStrictEqual(
667+ yield* pullRequests.listByPullRequest({
668+ host: "github.com",
669+ repository: "pingdotgg/t3code",
670+ number: 42,
671+ }),
672+ [unsynced, sharedOnOtherThread],
673+ );
674+
675+ // Upsert on the composite key replaces snapshot and stack in place.
676+ const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const;
677+ yield* pullRequests.upsert(resynced);
678+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]);
679+
680+ yield* pullRequests.delete({
681+ threadId,
682+ host: "github.com",
683+ repository: "pingdotgg/t3code",
684+ number: 7,
685+ });
686+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]);
687+
688+ yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" });
689+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []);
690+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [
691+ sharedOnOtherThread,
692+ ]);
693+
694+ yield* pullRequests.deleteByThreadId({ threadId: otherThreadId });
695+ assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []);
696+ }),
697+ );
532698 });
apps/server/src/persistence/Migrations.ts+2−0
@@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps. · 60 unmodified lines
6161 import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
6262 import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
6363 import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
64+import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
6465
6566 /**
6667 * Migration loader with all migrations defined inline.
@@ -122,6 +123,7 @@ const migrationEntries = [ · 55 unmodified lines
122123 [47, "ProjectionProjectIcon", Migration0047],
123124 [48, "ProjectionThreadBranchPullRequest", Migration0048],
124125 [49, "ProjectionThreadsActiveOrderKey", Migration0049],
126+ [50, "ProjectionThreadPullRequests", Migration0050],
125127 ] as const;
126128
127129 export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts+181−0
@@ -0,0 +1,181 @@
1+import { assert, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+import * as Layer from "effect/Layer";
5+import * as SqlClient from "effect/unstable/sql/SqlClient";
6+
7+import { runMigrations } from "../Migrations.ts";
8+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
9+
10+const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
11+
12+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
13+
14+interface PullRequestRow {
15+ readonly threadId: string;
16+ readonly host: string;
17+ readonly repository: string;
18+ readonly number: number;
19+ readonly url: string;
20+ readonly source: string;
21+ readonly linkedAt: string;
22+ readonly snapshotJson: string | null;
23+ readonly stackJson: string | null;
24+}
25+
26+layer("050_ProjectionThreadPullRequests", (it) => {
27+ it.effect("creates the link table and backfills legacy single links", () =>
28+ Effect.gen(function* () {
29+ const sql = yield* SqlClient.SqlClient;
30+
31+ yield* runMigrations({ toMigrationInclusive: 49 });
32+
33+ yield* sql`
34+ INSERT INTO projection_projects (
35+ project_id,
36+ title,
37+ workspace_root,
38+ scripts_json,
39+ created_at,
40+ updated_at,
41+ deleted_at
42+ )
43+ VALUES (
44+ 'project-1',
45+ 'Project 1',
46+ '/tmp/project-1',
47+ '[]',
48+ '2026-03-01T00:00:00.000Z',
49+ '2026-03-01T00:00:00.000Z',
50+ NULL
51+ )
52+ `;
53+
54+ yield* sql`
55+ INSERT INTO projection_threads (
56+ thread_id,
57+ project_id,
58+ title,
59+ model_selection_json,
60+ linked_pull_request_json,
61+ created_at,
62+ updated_at
63+ )
64+ VALUES
65+ (
66+ 'thread-github',
67+ 'project-1',
68+ 'GitHub link',
69+ '{"instanceId":"codex","model":"gpt-5.4"}',
70+ '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}',
71+ '2026-03-01T00:00:01.000Z',
72+ '2026-03-02T00:00:00.000Z'
73+ ),
74+ (
75+ 'thread-bad-url',
76+ 'project-1',
77+ 'Unparseable URL',
78+ '{"instanceId":"codex","model":"gpt-5.4"}',
79+ '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}',
80+ '2026-03-01T00:00:02.000Z',
81+ '2026-03-03T00:00:00.000Z'
82+ ),
83+ (
84+ 'thread-malformed',
85+ 'project-1',
86+ 'Malformed JSON',
87+ '{"instanceId":"codex","model":"gpt-5.4"}',
88+ '{"repository":"acme/widgets"}',
89+ '2026-03-01T00:00:03.000Z',
90+ '2026-03-04T00:00:00.000Z'
91+ ),
92+ (
93+ 'thread-unlinked',
94+ 'project-1',
95+ 'No link',
96+ '{"instanceId":"codex","model":"gpt-5.4"}',
97+ NULL,
98+ '2026-03-01T00:00:04.000Z',
99+ '2026-03-05T00:00:00.000Z'
100+ )
101+ `;
102+
103+ yield* runMigrations({ toMigrationInclusive: 50 });
104+
105+ const rows = yield* sql<PullRequestRow>`
106+ SELECT
107+ thread_id AS "threadId",
108+ host,
109+ repository,
110+ number,
111+ url,
112+ source,
113+ linked_at AS "linkedAt",
114+ snapshot_json AS "snapshotJson",
115+ stack_json AS "stackJson"
116+ FROM projection_thread_pull_requests
117+ ORDER BY thread_id ASC
118+ `;
119+
120+ assert.deepStrictEqual(rows, [
121+ {
122+ threadId: "thread-bad-url",
123+ host: "unknown",
124+ repository: "acme/widgets",
125+ number: 7,
126+ url: "not a url",
127+ source: "manual",
128+ linkedAt: "2026-03-03T00:00:00.000Z",
129+ snapshotJson: null,
130+ stackJson: null,
131+ },
132+ {
133+ threadId: "thread-github",
134+ host: "github.com",
135+ repository: "pingdotgg/t3code",
136+ number: 42,
137+ url: "https://GitHub.com/pingdotgg/t3code/pull/42",
138+ source: "manual",
139+ linkedAt: "2026-03-02T00:00:00.000Z",
140+ snapshotJson: null,
141+ stackJson: null,
142+ },
143+ ]);
144+
145+ // The legacy column stays so a rollback keeps its data.
146+ const columns = yield* sql<{ readonly name: string }>`
147+ PRAGMA table_info(projection_threads)
148+ `;
149+ assert.ok(columns.some((column) => column.name === "linked_pull_request_json"));
150+
151+ const indexes = yield* sql<{ readonly name: string }>`
152+ PRAGMA index_list(projection_thread_pull_requests)
153+ `;
154+ assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr"));
155+ }),
156+ );
157+});
158+
159+it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => {
160+ it.effect("keeps legacy Azure repositories distinct across organizations", () =>
161+ Effect.gen(function* () {
162+ const sql = yield* SqlClient.SqlClient;
163+ yield* runMigrations({ toMigrationInclusive: 49 });
164+ for (const organization of ["org-a", "org-b"]) {
165+ yield* sql`
166+ INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
167+ VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
168+ ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
169+ '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
170+ `;
171+ }
172+ yield* runMigrations({ toMigrationInclusive: 50 });
173+ const rows =
174+ yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
175+ assert.deepStrictEqual(rows, [
176+ { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
177+ { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
178+ ]);
179+ }),
180+ );
181+});
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts+92−0
@@ -0,0 +1,92 @@
1+import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2+import * as Effect from "effect/Effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+interface LegacyLinkedThreadRow {
6+ readonly threadId: string;
7+ readonly updatedAt: string;
8+ readonly linkedPullRequestJson: string;
9+}
10+
11+interface LegacyLinkedPullRequest {
12+ readonly repository: string;
13+ readonly number: number;
14+ readonly url: string;
15+}
16+
17+function parseLegacyLinkedPullRequest(json: string): LegacyLinkedPullRequest | null {
18+ try {
19+ const value: unknown = JSON.parse(json);
20+ if (typeof value !== "object" || value === null) return null;
21+ const { repository, number, url } = value as Record<string, unknown>;
22+ if (typeof repository !== "string" || repository.trim().length === 0) return null;
23+ if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return null;
24+ if (typeof url !== "string" || url.trim().length === 0) return null;
25+ return { repository, number, url };
26+ } catch {
27+ return null;
28+ }
29+}
30+
31+export default Effect.gen(function* () {
32+ const sql = yield* SqlClient.SqlClient;
33+
34+ yield* sql`
35+ CREATE TABLE IF NOT EXISTS projection_thread_pull_requests (
36+ thread_id TEXT NOT NULL,
37+ host TEXT NOT NULL,
38+ repository TEXT NOT NULL,
39+ number INTEGER NOT NULL,
40+ url TEXT NOT NULL,
41+ source TEXT NOT NULL,
42+ linked_at TEXT NOT NULL,
43+ snapshot_json TEXT,
44+ stack_json TEXT,
45+ PRIMARY KEY (thread_id, host, repository, number)
46+ )
47+ `;
48+
49+ yield* sql`
50+ CREATE INDEX IF NOT EXISTS idx_projection_thread_pull_requests_pr
51+ ON projection_thread_pull_requests(host, repository, number)
52+ `;
53+
54+ const legacyRows = yield* sql<LegacyLinkedThreadRow>`
55+ SELECT
56+ thread_id AS "threadId",
57+ updated_at AS "updatedAt",
58+ linked_pull_request_json AS "linkedPullRequestJson"
59+ FROM projection_threads
60+ WHERE linked_pull_request_json IS NOT NULL
61+ `;
62+
63+ for (const row of legacyRows) {
64+ const linked = parseLegacyLinkedPullRequest(row.linkedPullRequestJson);
65+ if (linked === null) continue;
66+ const key = legacyThreadPullRequestKey(linked);
67+ yield* sql`
68+ INSERT OR IGNORE INTO projection_thread_pull_requests (
69+ thread_id,
70+ host,
71+ repository,
72+ number,
73+ url,
74+ source,
75+ linked_at,
76+ snapshot_json,
77+ stack_json
78+ )
79+ VALUES (
80+ ${row.threadId},
81+ ${key.host},
82+ ${key.repository},
83+ ${linked.number},
84+ ${linked.url},
85+ 'manual',
86+ ${row.updatedAt},
87+ NULL,
88+ NULL
89+ )
90+ `;
91+ }
92+});
apps/server/src/persistence/ProjectionThreadPullRequests.ts+266−0
@@ -0,0 +1,266 @@
1+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2+import {
3+ IsoDateTime,
4+ PositiveInt,
5+ ThreadId,
6+ ThreadPullRequestKey,
7+ ThreadPullRequestLinkSource,
8+ ThreadPullRequestSnapshot,
9+ ThreadPullRequestStack,
10+ TrimmedNonEmptyString,
11+} from "@t3tools/contracts";
12+import * as Schema from "effect/Schema";
13+import * as Context from "effect/Context";
14+import * as Effect from "effect/Effect";
15+
16+import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts";
17+
18+import * as Layer from "effect/Layer";
19+import * as Struct from "effect/Struct";
20+import * as SqlClient from "effect/unstable/sql/SqlClient";
21+import * as SqlSchema from "effect/unstable/sql/SqlSchema";
22+
23+export const ProjectionThreadPullRequest = Schema.Struct({
24+ threadId: ThreadId,
25+ host: TrimmedNonEmptyString,
26+ repository: TrimmedNonEmptyString,
27+ number: PositiveInt,
28+ url: TrimmedNonEmptyString,
29+ source: ThreadPullRequestLinkSource,
30+ linkedAt: IsoDateTime,
31+ snapshot: Schema.NullOr(ThreadPullRequestSnapshot),
32+ stack: Schema.NullOr(ThreadPullRequestStack),
33+});
34+export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type;
35+
36+export const ListProjectionThreadPullRequestsInput = Schema.Struct({
37+ threadId: ThreadId,
38+});
39+export type ListProjectionThreadPullRequestsInput =
40+ typeof ListProjectionThreadPullRequestsInput.Type;
41+
42+export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey;
43+export type ListProjectionThreadPullRequestsByPullRequestInput =
44+ typeof ListProjectionThreadPullRequestsByPullRequestInput.Type;
45+
46+export const DeleteProjectionThreadPullRequestInput = Schema.Struct({
47+ threadId: ThreadId,
48+ ...ThreadPullRequestKey.fields,
49+});
50+export type DeleteProjectionThreadPullRequestInput =
51+ typeof DeleteProjectionThreadPullRequestInput.Type;
52+
53+export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({
54+ threadId: ThreadId,
55+});
56+export type DeleteProjectionThreadPullRequestsInput =
57+ typeof DeleteProjectionThreadPullRequestsInput.Type;
58+
59+export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({
60+ threadId: ThreadId,
61+ source: ThreadPullRequestLinkSource,
62+});
63+export type DeleteProjectionThreadPullRequestsBySourceInput =
64+ typeof DeleteProjectionThreadPullRequestsBySourceInput.Type;
65+
66+const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields(
67+ Struct.assign({
68+ snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)),
69+ stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)),
70+ }),
71+);
72+
73+export class ProjectionThreadPullRequestRepository extends Context.Service<
74+ ProjectionThreadPullRequestRepository,
75+ {
76+ readonly upsert: (
77+ row: ProjectionThreadPullRequest,
78+ ) => Effect.Effect<void, ProjectionRepositoryError>;
79+ readonly listByThreadId: (
80+ input: ListProjectionThreadPullRequestsInput,
81+ ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequest>, ProjectionRepositoryError>;
82+ readonly listByPullRequest: (
83+ input: ListProjectionThreadPullRequestsByPullRequestInput,
84+ ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequest>, ProjectionRepositoryError>;
85+ readonly delete: (
86+ input: DeleteProjectionThreadPullRequestInput,
87+ ) => Effect.Effect<void, ProjectionRepositoryError>;
88+ readonly deleteByThreadId: (
89+ input: DeleteProjectionThreadPullRequestsInput,
90+ ) => Effect.Effect<void, ProjectionRepositoryError>;
91+ readonly deleteByThreadIdAndSource: (
92+ input: DeleteProjectionThreadPullRequestsBySourceInput,
93+ ) => Effect.Effect<void, ProjectionRepositoryError>;
94+ }
95+>()("t3/persistence/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {}
96+
97+/** @public Service construction is part of the canonical Effect module API. */
98+export const make = Effect.gen(function* () {
99+ const sql = yield* SqlClient.SqlClient;
100+
101+ const upsertProjectionThreadPullRequestRow = SqlSchema.void({
102+ Request: ProjectionThreadPullRequest,
103+ execute: (row) => sql`
104+ INSERT INTO projection_thread_pull_requests (
105+ thread_id,
106+ host,
107+ repository,
108+ number,
109+ url,
110+ source,
111+ linked_at,
112+ snapshot_json,
113+ stack_json
114+ )
115+ VALUES (
116+ ${row.threadId},
117+ ${row.host},
118+ ${row.repository},
119+ ${row.number},
120+ ${row.url},
121+ ${row.source},
122+ ${row.linkedAt},
123+ ${row.snapshot === null ? null : JSON.stringify(row.snapshot)},
124+ ${row.stack === null ? null : JSON.stringify(row.stack)}
125+ )
126+ ON CONFLICT (thread_id, host, repository, number)
127+ DO UPDATE SET
128+ url = excluded.url,
129+ source = excluded.source,
130+ linked_at = excluded.linked_at,
131+ snapshot_json = excluded.snapshot_json,
132+ stack_json = excluded.stack_json
133+ `,
134+ });
135+
136+ const listProjectionThreadPullRequestRows = SqlSchema.findAll({
137+ Request: ListProjectionThreadPullRequestsInput,
138+ Result: ProjectionThreadPullRequestDbRow,
139+ execute: ({ threadId }) => sql`
140+ SELECT
141+ thread_id AS "threadId",
142+ host,
143+ repository,
144+ number,
145+ url,
146+ source,
147+ linked_at AS "linkedAt",
148+ snapshot_json AS "snapshot",
149+ stack_json AS "stack"
150+ FROM projection_thread_pull_requests
151+ WHERE thread_id = ${threadId}
152+ ORDER BY linked_at ASC, number ASC
153+ `,
154+ });
155+
156+ const listProjectionThreadPullRequestRowsByPullRequest = SqlSchema.findAll({
157+ Request: ListProjectionThreadPullRequestsByPullRequestInput,
158+ Result: ProjectionThreadPullRequestDbRow,
159+ execute: ({ host, repository, number }) => sql`
160+ SELECT
161+ thread_id AS "threadId",
162+ host,
163+ repository,
164+ number,
165+ url,
166+ source,
167+ linked_at AS "linkedAt",
168+ snapshot_json AS "snapshot",
169+ stack_json AS "stack"
170+ FROM projection_thread_pull_requests
171+ WHERE host = ${host}
172+ AND repository = ${repository}
173+ AND number = ${number}
174+ ORDER BY linked_at ASC, thread_id ASC
175+ `,
176+ });
177+
178+ const deleteProjectionThreadPullRequestRow = SqlSchema.void({
179+ Request: DeleteProjectionThreadPullRequestInput,
180+ execute: ({ threadId, host, repository, number }) => sql`
181+ DELETE FROM projection_thread_pull_requests
182+ WHERE thread_id = ${threadId}
183+ AND host = ${host}
184+ AND repository = ${repository}
185+ AND number = ${number}
186+ `,
187+ });
188+
189+ const deleteProjectionThreadPullRequestRows = SqlSchema.void({
190+ Request: DeleteProjectionThreadPullRequestsInput,
191+ execute: ({ threadId }) => sql`
192+ DELETE FROM projection_thread_pull_requests
193+ WHERE thread_id = ${threadId}
194+ `,
195+ });
196+
197+ const deleteProjectionThreadPullRequestRowsBySource = SqlSchema.void({
198+ Request: DeleteProjectionThreadPullRequestsBySourceInput,
199+ execute: ({ threadId, source }) => sql`
200+ DELETE FROM projection_thread_pull_requests
201+ WHERE thread_id = ${threadId}
202+ AND source = ${source}
203+ `,
204+ });
205+
206+ const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) =>
207+ upsertProjectionThreadPullRequestRow({ ...row, ...normalizeThreadPullRequestKey(row) }).pipe(
208+ Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")),
209+ );
210+
211+ const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = (
212+ input,
213+ ) =>
214+ listProjectionThreadPullRequestRows(input).pipe(
215+ Effect.mapError(
216+ toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"),
217+ ),
218+ );
219+
220+ const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = (
221+ input,
222+ ) =>
223+ listProjectionThreadPullRequestRowsByPullRequest(normalizeThreadPullRequestKey(input)).pipe(
224+ Effect.mapError(
225+ toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"),
226+ ),
227+ );
228+
229+ const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) =>
230+ deleteProjectionThreadPullRequestRow({
231+ ...input,
232+ ...normalizeThreadPullRequestKey(input),
233+ }).pipe(
234+ Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")),
235+ );
236+
237+ const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = (
238+ input,
239+ ) =>
240+ deleteProjectionThreadPullRequestRows(input).pipe(
241+ Effect.mapError(
242+ toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"),
243+ ),
244+ );
245+
246+ const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] =
247+ (input) =>
248+ deleteProjectionThreadPullRequestRowsBySource(input).pipe(
249+ Effect.mapError(
250+ toPersistenceSqlError(
251+ "ProjectionThreadPullRequestRepository.deleteByThreadIdAndSource:query",
252+ ),
253+ ),
254+ );
255+
256+ return {
257+ upsert,
258+ listByThreadId,
259+ listByPullRequest,
260+ delete: deleteLink,
261+ deleteByThreadId,
262+ deleteByThreadIdAndSource,
263+ } satisfies ProjectionThreadPullRequestRepository["Service"];
264+});
265+
266+export const layer = Layer.effect(ProjectionThreadPullRequestRepository, make);
apps/server/src/project/AgentSessionImporter.test.ts+1−0
@@ -124,6 +124,7 @@ const makeProjectedThread = (input: { · 123 unmodified lines
124124 modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },
125125 runtimeMode: "full-access",
126126 interactionMode: "default",
127+ pullRequests: [],
127128 branch: null,
128129 worktreePath: null,
129130 latestTurn: null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+2−2
@@ -1,3 +1,4 @@
1+import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
12 // @effect-diagnostics nodeBuiltinImport:off
23 import * as NodeFS from "node:fs";
34 import * as NodeOS from "node:os";
@@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => { · 388 unmodified lines
392393 assert.deepEqual(createInput?.options.systemPrompt, {
393394 type: "preset",
394395 preset: "claude_code",
395− append:
396− "<runtime_info>In case you're asked: you are running in T3 Code through the Claude Code harness. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>",
396+ append: buildRuntimeInstructions({ harness: "Claude Code" }),
397397 });
398398 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
399399 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
apps/server/src/provider/Layers/CodexAdapter.ts+1−0
@@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( · 2282 unmodified lines
22832283 "-c",
22842284 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
22852285 ],
2286+ browserToolsAvailable: mcpSession.preview,
22862287 }
22872288 : {}),
22882289 };
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11−2
@@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions { · 165 unmodified lines
166166 readonly serviceTier?: CodexServiceTier | undefined;
167167 readonly resumeCursor?: CodexResumeCursor;
168168 readonly appServerArgs?: ReadonlyArray<string>;
169+ /**
170+ * Whether the attached `t3-code` MCP server exposes the preview tools. The
171+ * server is attached for every session now (the pull request toolkit is
172+ * always on), so its presence in `appServerArgs` no longer implies browser
173+ * access; the credential's own capability decides the developer prompt.
174+ */
175+ readonly browserToolsAvailable?: boolean;
169176 }
170177
171178 export interface CodexSessionRuntimeSendTurnInput {
@@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = ( · 2177 unmodified lines
23492356 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
23502357 ...(input.effort ? { effort: input.effort } : {}),
23512358 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
2352− // Derived from the session's own MCP configuration rather than the
2359+ // Derived from the session's own credential rather than the
23532360 // setting, so the prompt describes the tools this turn actually
23542361 // has even if the setting changed after the session started.
2355− browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs),
2362+ browserToolsAvailable:
2363+ hasConfiguredMcpServer(options.appServerArgs) &&
2364+ (options.browserToolsAvailable ?? true),
23562365 });
23572366 const rawResponse = yield* client.raw.request("turn/start", params);
23582367 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
apps/server/src/provider/Layers/ProviderService.test.ts+14−30
@@ -4797,7 +4797,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { · 4796 unmodified lines
47974797 const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
47984798
47994799 describe("agent browser access", () => {
4800− const revokedThreads: Array<ThreadId> = [];
48014800 const projectId = ProjectId.make("project-browser-access");
48024801
48034802 const startSessionWith = (
@@ -4806,7 +4805,7 @@ describe("agent browser access", () => { · 2 unmodified lines
48064805 projectOverride?: boolean,
48074806 ) =>
48084807 Effect.gen(function* () {
4809− const issued: Array<ThreadId> = [];
4808+ const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = [];
48104809 const codex = makeFakeCodexAdapter();
48114810 const providerAdapterLayer = Layer.succeed(
48124811 ProviderAdapterRegistry.ProviderAdapterRegistry,
@@ -4865,10 +4864,9 @@ describe("agent browser access", () => { · 52 unmodified lines
48654864 const providerLayer = makeProviderServiceLive({
48664865 issueMcpCredential: (request) =>
48674866 Effect.sync(() => {
4868− issued.push(request.threadId);
4867+ issued.push({ threadId: request.threadId, preview: request.preview });
48694868 return undefined;
48704869 }),
4871− revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)),
48724870 }).pipe(
48734871 Layer.provide(providerAdapterLayer),
48744872 Layer.provide(directoryLayer),
@@ -4903,48 +4901,34 @@ describe("agent browser access", () => { · 28 unmodified lines
49034901 return issued;
49044902 });
49054903
4906− // Credential issuance is the observable that matters: it is the only place a
4907− // credential is minted, and `/mcp` accepts nothing else, so withholding it is
4908− // what actually denies every provider and external MCP client.
4909− it.effect("requests no MCP credential when agent browser access is off", () =>
4904+ // The capability on the credential is the observable that matters: a session
4905+ // always gets a credential (the pull request toolkit is never withheld), and
4906+ // `preview` on it is what actually grants or denies the browser tools.
4907+ it.effect("issues a credential without preview when agent browser access is off", () =>
49104908 Effect.gen(function* () {
4911− const issued = yield* startSessionWith(false, asThreadId("thread-browser-off"));
4909+ const threadId = asThreadId("thread-browser-off");
49124910
4913− assert.deepEqual(issued, []);
4914− }).pipe(Effect.provide(NodeServices.layer)),
4915− );
4916−
4917− it.effect("revokes an already-issued credential when access is off", () =>
4918− Effect.gen(function* () {
4919− const threadId = asThreadId("thread-browser-revoke");
4920− revokedThreads.length = 0;
4921−
4922− yield* startSessionWith(false, threadId);
4911+ const issued = yield* startSessionWith(false, threadId);
49234912
4924− // Clearing the in-memory map is not enough: a token issued before the
4925− // toggle flipped stays valid against `/mcp` for its whole liveness
4926− // window, and later turns refresh it.
4927− assert.deepEqual(revokedThreads, [threadId]);
4913+ assert.deepEqual(issued, [{ threadId, preview: false }]);
49284914 }).pipe(Effect.provide(NodeServices.layer)),
49294915 );
49304916
4931− it.effect("requests an MCP credential when agent browser access is on", () =>
4917+ it.effect("issues a credential with preview when agent browser access is on", () =>
49324918 Effect.gen(function* () {
49334919 const threadId = asThreadId("thread-browser-on");
49344920
49354921 const issued = yield* startSessionWith(true, threadId);
49364922
4937− assert.deepEqual(issued, [threadId]);
4923+ assert.deepEqual(issued, [{ threadId, preview: true }]);
49384924 }).pipe(Effect.provide(NodeServices.layer)),
49394925 );
49404926
4941− it.effect("withholds and revokes MCP credentials when the project disables browser access", () =>
4927+ it.effect("issues a credential without preview when the project disables browser access", () =>
49424928 Effect.gen(function* () {
49434929 const threadId = asThreadId("thread-project-browser-off");
4944− revokedThreads.length = 0;
49454930 const issued = yield* startSessionWith(true, threadId, false);
4946− assert.deepEqual(issued, []);
4947− assert.deepEqual(revokedThreads, [threadId]);
4931+ assert.deepEqual(issued, [{ threadId, preview: false }]);
49484932 }).pipe(Effect.provide(NodeServices.layer)),
49494933 );
49504934
@@ -4952,7 +4936,7 @@ describe("agent browser access", () => { · 1 unmodified line
49524936 Effect.gen(function* () {
49534937 const threadId = asThreadId("thread-project-browser-on");
49544938 const issued = yield* startSessionWith(false, threadId, true);
4955− assert.deepEqual(issued, [threadId]);
4939+ assert.deepEqual(issued, [{ threadId, preview: true }]);
49564940 }).pipe(Effect.provide(NodeServices.layer)),
49574941 );
49584942 });
apps/server/src/provider/Layers/ProviderService.ts+13−23
@@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions { · 248 unmodified lines
249249 * test see whether a credential was requested at all.
250250 */
251251 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
252− /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */
253− readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
254252 }
255253
256254 interface TurnAnalyticsMetadata {
@@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 222 unmodified lines
479477 );
480478 const issueMcpCredential =
481479 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
482− const revokeMcpCredential =
483− options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
484480 const fileSystem = yield* FileSystem.FileSystem;
485481 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
486482 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
@@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 366 unmodified lines
853849 yield* recordCompletedTurnProperties(properties);
854850 });
855851 /**
856− * Attach the `t3-code` MCP server to the session that is about to start.
852+ * Whether the credential minted below may drive the user's browser.
857853 *
858− * This is the only place a credential is minted, so withholding one here is
859− * what disables agent browser access everywhere: every adapter already
860− * treats a missing session as "no MCP server", and the `/mcp` endpoint
861− * accepts nothing but tokens issued from this path.
862− */
863− /**
864854 * Deny on an unreadable settings file rather than letting the read failure
865855 * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
866856 * a union every caller handles, for a branch that only decides whether one
@@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 22 unmodified lines
889879 ),
890880 );
891881
882+ /**
883+ * Attach the `t3-code` MCP server to the session that is about to start.
884+ *
885+ * Every session gets a credential: the pull request toolkit is always on,
886+ * since it only registers links on the session's own thread. Browser access
887+ * is a capability on that credential, so turning the setting off withholds
888+ * the preview tools without taking the server away. `issueActiveMcpCredential`
889+ * revokes the thread's previous token first, which matters because a session
890+ * restart (runtime mode, cwd, model) re-prepares without stopping.
891+ */
892892 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
893893 Effect.gen(function* () {
894− if (!(yield* agentBrowserAccessEnabled(threadId))) {
895− // Revoke as well as clear. Every other prepare path reaches
896− // `issueActiveMcpCredential`, which revokes the thread first, so
897− // skipping it here would leave a previously issued bearer token valid
898− // against `/mcp` for the rest of its liveness window — and later turns
899− // would keep refreshing it. A session restart (runtime mode, cwd,
900− // model) re-prepares without stopping, so it relies on this.
901− yield* revokeMcpCredential(threadId);
902− yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));
903− return undefined;
904− }
905− const credential = yield* issueMcpCredential({ threadId, providerInstanceId });
894+ const preview = yield* agentBrowserAccessEnabled(threadId);
895+ const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview });
906896 if (credential) {
907897 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));
908898 }
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1−0
@@ -98,6 +98,7 @@ function makeReadModel( · 97 unmodified lines
9898 runtimeMode: "full-access" as const,
9999 branch: null,
100100 worktreePath: null,
101+ pullRequests: [],
101102 createdAt: now,
102103 updatedAt: now,
103104 archivedAt: null,
apps/server/src/provider/RuntimeInstructions.test.ts+8−0
@@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line
22 import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
33
44 describe("buildRuntimeInstructions", () => {
5+ it("requires explicit registration of every PR and stack layer", () => {
6+ const instructions = buildRuntimeInstructions({ harness: "Codex" });
7+ expect(instructions).toContain("When the t3-code MCP server exposes link_pull_request");
8+ expect(instructions).toContain("with the full PR URL immediately after creating a PR");
9+ expect(instructions).toContain("For a stack, call it for every layer");
10+ expect(instructions).toContain("call list_thread_pull_requests and link any PR");
11+ });
12+
513 it("keeps known model and effort metadata on one line", () => {
614 expect(
715 buildRuntimeInstructions({
apps/server/src/provider/RuntimeInstructions.ts+5−1
@@ -1,3 +1,7 @@
1+const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
2+When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.
3+</pull_request_linking>`;
4+
15 /** Shared runtime context; omit model and effort when the harness manages them dynamically. */
26 export function buildRuntimeInstructions(runtime: {
37 readonly harness: string;
@@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: { · 5 unmodified lines
913 const effort = toSingleLine(runtime.reasoningEffort ?? "");
1014 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
1115 const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
12− return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>`;
16+ return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
1317 }
1418
1519 function toSingleLine(value: string): string {
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+227−26
@@ -183,20 +183,37 @@ afterEach(() => { · 182 unmodified lines
183183 });
184184
185185 layer("GitHubPullRequestCli.layer", (it) => {
186− it.effect("reads linked pull request status through one narrow request", () =>
186+ it.effect("reads linked pull request status with the overview fields in one request", () =>
187187 Effect.gen(function* () {
188− mockedGetPullRequest.mockReturnValueOnce(
189− Effect.succeed({
190− number: 7,
191− title: "Reuse the summary",
192− url: "https://github.com/acme/web/pull/7",
193− baseRefName: "main",
194− headRefName: "feat/summary",
195− state: "merged",
196− closedAt: "2026-08-23T10:00:00Z",
197− mergedAt: "2026-08-23T10:00:00Z",
198− updatedAt: "2026-08-24T12:34:56.000Z",
199− }),
188+ mockedExecute.mockReturnValueOnce(
189+ Effect.succeed(
190+ output(
191+ // @effect-diagnostics-next-line preferSchemaOverJson:off
192+ JSON.stringify({
193+ number: 7,
194+ title: "Reuse the summary",
195+ url: "https://github.com/acme/web/pull/7",
196+ author: { login: "octocat", name: "Octo Cat" },
197+ baseRefName: "main",
198+ headRefName: "feat/summary",
199+ state: "OPEN",
200+ isDraft: false,
201+ mergeable: "MERGEABLE",
202+ reviewDecision: "APPROVED",
203+ additions: 12,
204+ deletions: 3,
205+ changedFiles: 2,
206+ createdAt: "2026-08-20T00:00:00.000Z",
207+ updatedAt: "2026-08-24T12:34:56.000Z",
208+ reviewRequests: [],
209+ labels: [],
210+ statusCheckRollup: [
211+ { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" },
212+ ],
213+ body: "",
214+ }),
215+ ),
216+ ),
200217 );
201218 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
202219
@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => { · 4 unmodified lines
207224 number: 7,
208225 });
209226
210− assert.deepStrictEqual(summary, {
227+ assert.deepStrictEqual(
228+ {
229+ number: summary.number,
230+ state: summary.state,
231+ headBranch: summary.headBranch,
232+ isDraft: summary.isDraft,
233+ author: summary.author?.login,
234+ additions: summary.additions,
235+ deletions: summary.deletions,
236+ changedFiles: summary.changedFiles,
237+ reviewDecision: summary.reviewDecision,
238+ checksState: summary.checksState,
239+ mergeability: summary.mergeability,
240+ },
241+ {
242+ number: 7,
243+ state: "open",
244+ headBranch: "feat/summary",
245+ isDraft: false,
246+ author: "octocat",
247+ additions: 12,
248+ deletions: 3,
249+ changedFiles: 2,
250+ reviewDecision: "approved",
251+ checksState: "passing",
252+ mergeability: "mergeable",
253+ },
254+ );
255+ expect(mockedExecute).toHaveBeenCalledOnce();
256+ expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([
257+ "pr",
258+ "view",
259+ "7",
260+ "--repo",
261+ "github.com/acme/web",
262+ "--json",
263+ expect.stringContaining("statusCheckRollup"),
264+ ]);
265+ expect(mockedGetPullRequest).not.toHaveBeenCalled();
266+ }),
267+ );
268+
269+ it.effect("reads the stack a pull request is in through the stacks preview, on its host", () =>
270+ Effect.gen(function* () {
271+ mockedExecute.mockReturnValueOnce(
272+ Effect.succeed(
273+ output(
274+ // @effect-diagnostics-next-line preferSchemaOverJson:off
275+ JSON.stringify([
276+ {
277+ id: 42,
278+ number: 3,
279+ url: "https://api.github.com/repos/acme/web/stacks/3",
280+ base: { ref: "main" },
281+ pull_requests: [
282+ {
283+ number: 6,
284+ head: { ref: "feat/one" },
285+ state: "closed",
286+ merged_at: "2026-09-02",
287+ },
288+ { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null },
289+ ],
290+ },
291+ ]),
292+ ),
293+ ),
294+ );
295+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
296+
297+ const stack = yield* cli.getPullRequestStack({
298+ cwd: "/w",
299+ repository: "acme/web",
300+ host: "ghe.example.com",
211301 number: 7,
212− title: "Reuse the summary",
213− url: "https://github.com/acme/web/pull/7",
214− headBranch: "feat/summary",
215− baseBranch: "main",
216− state: "merged",
217− closedAt: "2026-08-23T10:00:00Z",
218− mergedAt: "2026-08-23T10:00:00Z",
219− updatedAt: "2026-08-24T12:34:56.000Z",
220302 });
221− expect(mockedGetPullRequest).toHaveBeenCalledOnce();
222− expect(mockedGetPullRequest).toHaveBeenCalledWith({
303+
304+ assert.deepStrictEqual(stack, {
305+ id: "42",
306+ number: 3,
307+ url: "https://api.github.com/repos/acme/web/stacks/3",
308+ base: "main",
309+ layers: [
310+ { number: 6, headBranch: "feat/one", state: "merged" },
311+ { number: 7, headBranch: "feat/two", state: "open" },
312+ ],
313+ });
314+ assert.deepStrictEqual(callAt(0).args, [
315+ "api",
316+ "--hostname",
317+ "ghe.example.com",
318+ "repos/acme/web/stacks?pull_request=7",
319+ ]);
320+ }),
321+ );
322+
323+ it.effect("reads an empty stacks listing as not stacked", () =>
324+ Effect.gen(function* () {
325+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
326+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
327+
328+ const stack = yield* cli.getPullRequestStack({
223329 cwd: "/w",
224− reference: "https://github.com/acme/web/pull/7",
330+ repository: "acme/web",
331+ host: "github.com",
332+ number: 7,
333+ });
334+
335+ assert.isNull(stack);
336+ }),
337+ );
338+
339+ it.effect("reads a host that refuses the stacks preview as not stacked", () =>
340+ Effect.gen(function* () {
341+ // The CLI classifies a missing preview endpoint as not found.
342+ mockedExecute.mockReturnValueOnce(
343+ Effect.fail(
344+ new GitHubCli.GitHubPullRequestNotFoundError({
345+ command: "gh",
346+ cwd: "/w",
347+ cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
348+ }),
349+ ),
350+ );
351+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
352+
353+ const stack = yield* cli.getPullRequestStack({
354+ cwd: "/w",
355+ repository: "acme/web",
356+ host: "github.com",
357+ number: 7,
358+ });
359+
360+ assert.isNull(stack);
361+ }),
362+ );
363+
364+ it.effect("does not read a signed-out gh as an unstacked pull request", () =>
365+ Effect.gen(function* () {
366+ mockedExecute.mockReturnValueOnce(
367+ Effect.fail(
368+ new GitHubCli.GitHubCliAuthenticationError({
369+ command: "gh",
370+ cwd: "/w",
371+ cause: new Error("gh auth login"),
372+ }),
373+ ),
374+ );
375+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
376+
377+ const error = yield* Effect.flip(
378+ cli.getPullRequestStack({
379+ cwd: "/w",
380+ repository: "acme/web",
381+ host: "github.com",
382+ number: 7,
383+ }),
384+ );
385+
386+ assert.strictEqual(error._tag, "GitHubCliAuthenticationError");
387+ }),
388+ );
389+
390+ it.effect("preserves transient stack failures instead of reporting no stack", () =>
391+ Effect.gen(function* () {
392+ const failure = new GitHubCli.GitHubCliCommandError({
393+ command: "gh",
394+ cwd: "/w",
395+ cause: new Error("HTTP 503"),
225396 });
226− expect(mockedExecute).not.toHaveBeenCalled();
397+ mockedExecute.mockReturnValueOnce(Effect.fail(failure));
398+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
399+ const error = yield* Effect.flip(
400+ cli.getPullRequestStack({
401+ cwd: "/w",
402+ repository: "acme/web",
403+ host: "github.com",
404+ number: 7,
405+ }),
406+ );
407+ assert.strictEqual(error, failure);
408+ }),
409+ );
410+
411+ it.effect("reports a stacks answer it cannot read against the stack read", () =>
412+ Effect.gen(function* () {
413+ mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]')));
414+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
415+
416+ const error = yield* Effect.flip(
417+ cli.getPullRequestStack({
418+ cwd: "/w",
419+ repository: "acme/web",
420+ host: "github.com",
421+ number: 7,
422+ }),
423+ );
424+
425+ assert.strictEqual(error._tag, "GitHubPullRequestReadError");
426+ if (error._tag !== "GitHubPullRequestReadError") return;
427+ assert.strictEqual(error.operation, "getPullRequestStack");
227428 }),
228429 );
229430
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94−42
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
3939 decodePullRequestListJson,
4040 decodePullRequestNodeIdJson,
4141 decodePullRequestSearchJson,
42+ decodePullRequestStacksJson,
4243 decodePullRequestStatsJson,
4344 decodeReactionSubjectScopeJson,
4445 decodeRepositoryAccessJson,
@@ -84,6 +85,7 @@ import { · 39 unmodified lines
8485 type GitHubPullRequestHead,
8586 type GitHubPullRequestListItem,
8687 type GitHubPullRequestSearchItem,
88+ type GitHubPullRequestStack,
8789 type GitHubReviewThreadComments,
8890 type GitHubRepositoryAccess,
8991 type GitHubWorkflowRunApproval,
@@ -91,7 +93,7 @@ import { · 1 unmodified line
9193 type GitHubReviewThreadPage,
9294 type GitHubViewerAccess,
9395 } from "./gitHubPullRequestJson.ts";
94−import type { ProviderListCursor } from "./PullRequestProvider.ts";
96+import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";
9597
9698 /**
9799 * Names the read that produced unusable output, so a failure reports the call it came from
@@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service< · 362 unmodified lines
460462 readonly repository: string;
461463 readonly host: string;
462464 readonly number: number;
463− }) => Effect.Effect<
464− {
465− readonly number: number;
466− readonly title: string;
467− readonly url: string;
468− readonly headBranch: string;
469− readonly baseBranch: string;
470− readonly state: "open" | "closed" | "merged";
471− readonly isDraft?: boolean;
472− readonly closedAt?: string | null;
473− readonly mergedAt?: string | null;
474− readonly updatedAt: string;
475− },
476− GitHubPullRequestCliError
477− >;
465+ }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>;
478466
479467 readonly getPullRequestDetail: (input: {
480468 readonly cwd: string;
@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service< · 13 unmodified lines
494482 readonly isCrossRepository: true;
495483 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;
496484
485+ /**
486+ * The host-native stack this pull request is in, or null when it is in none — which is also
487+ * the answer for a host that refuses the stacks preview altogether.
488+ */
489+ readonly getPullRequestStack: (input: {
490+ readonly cwd: string;
491+ readonly repository: string;
492+ readonly host: string;
493+ readonly number: number;
494+ }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;
495+
497496 /**
498497 * How far the branch trails its base, and whether this viewer may update it. Its own read
499498 * because the comparison needs the head ref the detail answers with — a fork's branch is not
@@ -807,8 +806,8 @@ function matchesFilters( · 307 unmodified lines
807806 viewer: string,
808807 ): boolean {
809808 if (filters === undefined) return true;
810− const labels = item.labels.map((label) => label.name.trim().toLowerCase());
811− const holds = (label: string) => labels.includes(label.trim().toLowerCase());
809+ const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
810+ const holds = (label: string) => labels.has(label.trim().toLowerCase());
812811 return (
813812 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
814813 (filters.review === undefined ||
@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () { · 815 unmodified lines
16301629 ).pipe(Effect.map((results) => results.flat()));
16311630 },
16321631
1632+ // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1633+ // the thread overview its author, diff stat, review decision and checks in the same read.
16331634 getPullRequestSummary: (input) =>
16341635 github
1635− .getPullRequest({
1636+ .execute({
16361637 cwd: input.cwd,
1637− reference: `https://${input.host}/${input.repository}/pull/${input.number}`,
1638+ args: [
1639+ "pr",
1640+ "view",
1641+ String(input.number),
1642+ ...repositoryArgs(input),
1643+ "--json",
1644+ PULL_REQUEST_DETAIL_JSON_FIELDS,
1645+ ],
16381646 })
16391647 .pipe(
1640− Effect.flatMap((summary) =>
1641− summary.updatedAt === undefined
1642− ? Effect.fail(
1643− new GitHubPullRequestUpdatedAtUnavailableError({
1644− command: "gh",
1645− cwd: input.cwd,
1646− repository: input.repository,
1647− number: input.number,
1648− }),
1649− )
1650− : Effect.succeed({
1651− number: summary.number,
1652− title: summary.title,
1653− url: summary.url,
1654− headBranch: summary.headRefName,
1655− baseBranch: summary.baseRefName,
1656− state: summary.state ?? "open",
1657− ...(summary.isDraft === true ? { isDraft: true } : {}),
1658− closedAt: summary.closedAt ?? null,
1659− mergedAt: summary.mergedAt ?? null,
1660− updatedAt: summary.updatedAt,
1648+ Effect.flatMap((result) => {
1649+ const decoded = decodePullRequestDetailJson(result.stdout.trim());
1650+ if (!Result.isSuccess(decoded)) {
1651+ return Effect.fail(
1652+ new GitHubPullRequestReadError({
1653+ command: "gh",
1654+ cwd: input.cwd,
1655+ operation: "getPullRequestSummary",
1656+ cause: decoded.failure,
16611657 }),
1662− ),
1658+ );
1659+ }
1660+ const detail = decoded.success;
1661+ return Effect.succeed({
1662+ number: detail.number,
1663+ title: detail.title,
1664+ url: detail.url,
1665+ headBranch: detail.headBranch,
1666+ baseBranch: detail.baseBranch,
1667+ state: detail.state,
1668+ updatedAt: detail.updatedAt,
1669+ closedAt: detail.closedAt ?? null,
1670+ mergedAt: detail.mergedAt ?? null,
1671+ isDraft: detail.isDraft,
1672+ author: detail.author,
1673+ additions: detail.additions,
1674+ deletions: detail.deletions,
1675+ changedFiles: detail.changedFiles,
1676+ reviewDecision: detail.reviewDecision,
1677+ checksState: detail.checksState,
1678+ mergeability: detail.mergeability,
1679+ });
1680+ }),
16631681 ),
16641682
16651683 getPullRequestDetail,
16661684 listWorkflowRunsRequiringApproval,
16671685
1686+ getPullRequestStack: (input) => {
1687+ const { owner, name } = parseRepositorySelector(input.repository);
1688+ return github
1689+ .execute({
1690+ cwd: input.cwd,
1691+ args: [
1692+ "api",
1693+ "--hostname",
1694+ input.host,
1695+ `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
1696+ ],
1697+ })
1698+ .pipe(
1699+ Effect.flatMap((result) => {
1700+ const decoded = decodePullRequestStacksJson(result.stdout.trim());
1701+ return Result.isSuccess(decoded)
1702+ ? Effect.succeed(decoded.success)
1703+ : Effect.fail(
1704+ new GitHubPullRequestReadError({
1705+ command: "gh",
1706+ cwd: input.cwd,
1707+ operation: "getPullRequestStack",
1708+ cause: decoded.failure,
1709+ }),
1710+ );
1711+ }),
1712+ // Hosts without the stacks preview return 404. Other failures must preserve the
1713+ // previously synced stack and let the caller retry.
1714+ Effect.catchTags({
1715+ GitHubPullRequestNotFoundError: () => Effect.succeed(null),
1716+ }),
1717+ );
1718+ },
1719+
16681720 getPullRequestBaseComparison: (input) => {
16691721 const { owner, name } = parseRepositorySelector(input.repository);
16701722 return graphqlRead({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+61−0
@@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 24 unmodified lines
2525 baseBranch: "main",
2626 state: "open" as const,
2727 updatedAt: "2026-08-24T12:34:56.000Z",
28+ author: { login: "octocat", name: null, avatarUrl: null },
2829 };
2930 }),
3031 }),
@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 11 unmodified lines
4243
4344 expect(summary.state).toBe("open");
4445 expect(summaryReads).toBe(1);
46+ // The author's avatar comes from the login-shaped URL, not a second request.
47+ expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80");
48+ }),
49+);
50+
51+it.effect("declares host-native stacks and passes the one the CLI reads through", () =>
52+ Effect.gen(function* () {
53+ const stack = {
54+ id: "42",
55+ number: 3,
56+ url: "https://github.com/acme/web/stacks/3",
57+ base: "main",
58+ layers: [
59+ { number: 6, headBranch: "feat/one", state: "merged" as const },
60+ { number: 7, headBranch: "feat/two", state: "open" as const },
61+ ],
62+ };
63+ const provider = yield* make.pipe(
64+ Effect.provide(
65+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
66+ getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null),
67+ }),
68+ ),
69+ );
70+
71+ expect(provider.capabilities.stacks).toBe(true);
72+ const readStack = provider.getChangeRequestStack;
73+ if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
74+ const ref = { cwd: "/w", repository: "acme/web", host: "github.com" };
75+ expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack);
76+ expect(yield* readStack({ ...ref, number: 8 })).toBeNull();
77+ }),
78+);
79+
80+it.effect("reports a failed stack read against its own operation", () =>
81+ Effect.gen(function* () {
82+ const provider = yield* make.pipe(
83+ Effect.provide(
84+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
85+ getPullRequestStack: () =>
86+ Effect.fail(
87+ new GitHubPullRequestCli.GitHubPullRequestReadError({
88+ command: "gh",
89+ cwd: "/w",
90+ operation: "getPullRequestStack",
91+ cause: new Error("unreadable"),
92+ }),
93+ ),
94+ }),
95+ ),
96+ );
97+
98+ const readStack = provider.getChangeRequestStack;
99+ if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
100+ const error = yield* Effect.flip(
101+ readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
102+ );
103+
104+ expect(error.operation).toBe("getChangeRequestStack");
105+ expect(error.reason).toBe("failed");
45106 }),
46107 );
47108
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15−1
@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 46 unmodified lines
4747 },
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
50+ stacks: true,
5051 labels: true,
5152 };
5253
@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () { · 239 unmodified lines
292293 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
293294
294295 getChangeRequestSummary: (input) =>
295− cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),
296+ cli.getPullRequestSummary(input).pipe(
297+ // `gh pr view` names the author without an avatar; the login-shaped URL every user
298+ // has stands in, without the second request the listing spends on it.
299+ Effect.map((summary) => ({
300+ ...summary,
301+ ...(summary.author === undefined
302+ ? {}
303+ : { author: withAvatar(summary.author, new Map(), input.host) }),
304+ })),
305+ Effect.mapError(fail("getChangeRequestSummary")),
306+ ),
307+
308+ getChangeRequestStack: (input) =>
309+ cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
296310
297311 getChangeRequest: (input) =>
298312 Effect.all(
apps/server/src/pullRequest/PullRequestProvider.ts+36−0
@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary { · 102 unmodified lines
103103 readonly closedAt?: string | null;
104104 readonly mergedAt?: string | null;
105105 readonly updatedAt: string;
106+ /** Overview fields, present where the host's single read returns them at no extra cost. */
107+ readonly author?: PullRequestActor | null | undefined;
108+ readonly additions?: number | undefined;
109+ readonly deletions?: number | undefined;
110+ readonly changedFiles?: number | undefined;
111+ readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
112+ readonly checksState?: PullRequestChecksState | null | undefined;
113+ readonly mergeability?: PullRequestMergeability | undefined;
114+}
115+
116+/** One layer of a host-native stack, bottom to top order is the array's. */
117+export interface ProviderChangeRequestStackLayer {
118+ readonly number: number;
119+ readonly headBranch: string;
120+ readonly state: PullRequestState;
121+}
122+
123+/**
124+ * A host-native stack: an ordered set of change requests the host itself merges and retargets as
125+ * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
126+ * ignorant of which host said so.
127+ */
128+export interface ProviderChangeRequestStack {
129+ readonly id: string;
130+ readonly number: number;
131+ readonly url: string;
132+ readonly base: string;
133+ readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
106134 }
107135
108136 export interface ProviderChangeRequestPage {
@@ -331,6 +359,14 @@ export interface PullRequestProviderApi { · 222 unmodified lines
331359 input: ProviderRepositoryRef & { readonly number: number },
332360 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
333361
362+ /**
363+ * The host-native stack a change request belongs to, or null when it is not stacked. Optional
364+ * because most hosts have no such object; the service derives chains from base branches there.
365+ */
366+ readonly getChangeRequestStack?: (
367+ input: ProviderRepositoryRef & { readonly number: number },
368+ ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
369+
334370 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
335371 readonly getChangeRequestActivity: (
336372 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+323−39
@@ -32,6 +32,7 @@ function project(input: { · 31 unmodified lines
3232 readonly repository?: string;
3333 readonly provider?: string;
3434 readonly host?: string;
35+ readonly remoteUrl?: string;
3536 }): OrchestrationProjectShell {
3637 // The host defaults from the provider, so a fixture only names one when the point of the
3738 // test is two hosts of the same kind.
@@ -47,7 +48,7 @@ function project(input: { · 9 unmodified lines
4748 locator: {
4849 source: "git-remote" as const,
4950 remoteName: "origin",
50− remoteUrl: `https://${host}/${input.repository}.git`,
51+ remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
5152 },
5253 provider: input.provider ?? "github",
5354 displayName: input.repository,
@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" · 416 unmodified lines
470471
471472 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
472473 assert.deepStrictEqual(result.nextCursors, {
473− "dev.azure.com web": "2026-07-02T00:00:00Z|4|7",
474+ "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7",
474475 });
475476 }),
476477 );
@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project", · 1086 unmodified lines
15631564 }),
15641565 );
15651566
1567+it.effect("reads a host-native stack through the provider and null where it has none", () =>
1568+ Effect.gen(function* () {
1569+ const service = yield* makeService({
1570+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1571+ providers: [
1572+ fakeProvider("github", {
1573+ getChangeRequestStack: () =>
1574+ Effect.succeed({
1575+ id: "9",
1576+ number: 3,
1577+ url: "https://github.com/acme/web/stacks/3",
1578+ base: "main",
1579+ layers: [
1580+ { number: 7, headBranch: "a", state: "open" as const },
1581+ { number: 8, headBranch: "b", state: "open" as const },
1582+ ],
1583+ }),
1584+ }),
1585+ ],
1586+ });
1587+
1588+ const stack = yield* service.stack({
1589+ projectId: "p1" as ProjectId,
1590+ repository: "acme/web",
1591+ number: 7,
1592+ });
1593+ assert.deepStrictEqual(
1594+ stack?.layers.map((layer) => layer.number),
1595+ [7, 8],
1596+ );
1597+
1598+ const withoutStacks = yield* makeService({
1599+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1600+ providers: [fakeProvider("github")],
1601+ });
1602+ assert.isNull(
1603+ yield* withoutStacks.stack({
1604+ projectId: "p1" as ProjectId,
1605+ repository: "acme/web",
1606+ number: 7,
1607+ }),
1608+ );
1609+ }),
1610+);
1611+
1612+it.effect("routes a hosted reference to another repository through a project on that host", () =>
1613+ Effect.gen(function* () {
1614+ const seen: Array<{ cwd: string; repository: string; host: string }> = [];
1615+ const service = yield* makeService({
1616+ projects: [
1617+ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1618+ ],
1619+ providers: [
1620+ fakeProvider("github", {
1621+ getChangeRequestSummary: (input) =>
1622+ Effect.sync(() => {
1623+ seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
1624+ return changeRequest(7, "2026-07-02T00:00:00Z");
1625+ }),
1626+ }),
1627+ ],
1628+ });
1629+
1630+ const summary = yield* service.summary(
1631+ { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 },
1632+ { recoverTransientFailure: false },
1633+ );
1634+
1635+ assert.strictEqual(summary.number, 7);
1636+ assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]);
1637+ }),
1638+);
1639+
1640+it.effect("routes Azure reads and writes through the requested organization's checkout", () =>
1641+ Effect.gen(function* () {
1642+ const seen: string[] = [];
1643+ const service = yield* makeService({
1644+ projects: ["org-a", "org-b"].map((organization) =>
1645+ project({
1646+ id: organization,
1647+ title: organization,
1648+ workspaceRoot: `/${organization}`,
1649+ repository: `${organization}/project/_git/web`,
1650+ provider: "azure-devops",
1651+ host: "dev.azure.com",
1652+ }),
1653+ ),
1654+ providers: [
1655+ fakeProvider("azure-devops", {
1656+ getChangeRequestSummary: (input) =>
1657+ Effect.sync(() => {
1658+ seen.push(`read ${input.cwd} ${input.repository}`);
1659+ return changeRequest(7, "2026-07-02T00:00:00Z");
1660+ }),
1661+ runAction: (input) =>
1662+ Effect.sync(() => {
1663+ seen.push(`write ${input.cwd} ${input.repository}`);
1664+ }),
1665+ }),
1666+ ],
1667+ });
1668+ const reference = {
1669+ projectId: "org-a" as ProjectId,
1670+ host: "dev.azure.com",
1671+ repository: "org-b/project/_git/web",
1672+ number: 7,
1673+ };
1674+ yield* service.summary(reference, { recoverTransientFailure: false });
1675+ yield* service.runAction({ ...reference, action: "merge" });
1676+ assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]);
1677+ }),
1678+);
1679+
1680+for (const checkout of [
1681+ {
1682+ host: "ssh.dev.azure.com",
1683+ repository: "v3/org-b/project/web",
1684+ remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web",
1685+ },
1686+ {
1687+ host: "vs-ssh.visualstudio.com",
1688+ repository: "v3/org-b/project/web",
1689+ remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web",
1690+ },
1691+ {
1692+ host: "org-b.visualstudio.com",
1693+ repository: "DefaultCollection/project/_git/web",
1694+ remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
1695+ },
1696+]) {
1697+ it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () =>
1698+ Effect.gen(function* () {
1699+ const seen: string[] = [];
1700+ const target = project({
1701+ id: "target",
1702+ title: "target",
1703+ workspaceRoot: "/target",
1704+ provider: "azure-devops",
1705+ ...checkout,
1706+ });
1707+ const service = yield* makeService({
1708+ projects: [
1709+ ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
1710+ project({
1711+ id: repository,
1712+ title: repository,
1713+ workspaceRoot: `/${repository}`,
1714+ provider: "azure-devops",
1715+ host: "dev.azure.com",
1716+ repository,
1717+ }),
1718+ ),
1719+ target,
1720+ ],
1721+ providers: [
1722+ fakeProvider("azure-devops", {
1723+ getChangeRequestSummary: (input) =>
1724+ Effect.sync(() => {
1725+ seen.push(`read ${input.cwd} ${input.repository}`);
1726+ return changeRequest(7, "2026-07-02T00:00:00Z");
1727+ }),
1728+ runAction: (input) =>
1729+ Effect.sync(() => {
1730+ seen.push(`write ${input.cwd} ${input.repository}`);
1731+ }),
1732+ }),
1733+ ],
1734+ });
1735+ const reference = {
1736+ projectId: "org-a/project/_git/web" as ProjectId,
1737+ host: "dev.azure.com",
1738+ repository: "org-b/project/_git/web",
1739+ number: 7,
1740+ };
1741+ yield* service.summary(reference, { recoverTransientFailure: false });
1742+ yield* service.runAction({ ...reference, action: "merge" });
1743+ assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
1744+ }),
1745+ );
1746+}
1747+
1748+it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
1749+ Effect.gen(function* () {
1750+ const service = yield* makeService({
1751+ projects: [
1752+ project({
1753+ id: "org-a",
1754+ title: "org-a",
1755+ workspaceRoot: "/org-a",
1756+ repository: "org-a/project/_git/web",
1757+ provider: "azure-devops",
1758+ host: "dev.azure.com",
1759+ }),
1760+ ],
1761+ providers: [
1762+ fakeProvider("azure-devops", {
1763+ getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
1764+ runAction: () => Effect.die("must not modify the wrong organization"),
1765+ }),
1766+ ],
1767+ });
1768+ const reference = {
1769+ projectId: "org-a" as ProjectId,
1770+ host: "dev.azure.com",
1771+ repository: "org-b/project/_git/web",
1772+ number: 7,
1773+ };
1774+ const readError = yield* Effect.flip(
1775+ service.summary(reference, { recoverTransientFailure: false }),
1776+ );
1777+ const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" }));
1778+ assert.strictEqual(readError._tag, "PullRequestUnavailableError");
1779+ assert.strictEqual(writeError._tag, "PullRequestUnavailableError");
1780+ }),
1781+);
1782+
1783+it.effect("refuses a hosted reference when nothing is checked out from that host", () =>
1784+ Effect.gen(function* () {
1785+ const service = yield* makeService({
1786+ projects: [
1787+ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1788+ ],
1789+ providers: [fakeProvider("github")],
1790+ });
1791+
1792+ const error = yield* service
1793+ .summary(
1794+ {
1795+ projectId: "frontend" as ProjectId,
1796+ host: "gitlab.com",
1797+ repository: "acme/api",
1798+ number: 7,
1799+ },
1800+ { recoverTransientFailure: false },
1801+ )
1802+ .pipe(Effect.flip);
1803+
1804+ assert.strictEqual(error._tag, "PullRequestUnavailableError");
1805+ }),
1806+);
1807+
15661808 it.effect("refuses a diff on a host that cannot produce one", () =>
15671809 Effect.gen(function* () {
15681810 const service = yield* makeService({
@@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( · 1808 unmodified lines
33773619 }),
33783620 );
33793621
3622+it.effect(
3623+ "opening detail preserves enriched linked summaries and updates draft and diff fields",
3624+ () =>
3625+ Effect.gen(function* () {
3626+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3627+ const service = yield* makeService({
3628+ projects: [
3629+ project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3630+ ],
3631+ providers: [
3632+ fakeProvider("github", {
3633+ getChangeRequestSummary: () =>
3634+ Effect.succeed({
3635+ ...changeRequest(1, "2026-07-02T00:00:00Z"),
3636+ isDraft: true,
3637+ reviewDecision: "approved",
3638+ checksState: "passing",
3639+ }),
3640+ getChangeRequest: () =>
3641+ Effect.succeed({
3642+ ...hostedChangeRequest("body", 14),
3643+ deletions: 3,
3644+ changedFiles: 5,
3645+ mergeability: "conflicting",
3646+ }),
3647+ }),
3648+ ],
3649+ });
3650+ yield* service.summary(reference);
3651+ const detail = yield* service.detail(reference);
3652+ const summary = yield* service.summary(reference);
3653+ assert.strictEqual(summary.isDraft, false);
3654+ assert.deepStrictEqual(summary.author, detail.author);
3655+ assert.strictEqual(summary.additions, 14);
3656+ assert.strictEqual(summary.deletions, 3);
3657+ assert.strictEqual(summary.changedFiles, 5);
3658+ assert.strictEqual(summary.mergeability, "conflicting");
3659+ assert.strictEqual(summary.reviewDecision, "approved");
3660+ assert.strictEqual(summary.checksState, "passing");
3661+ }),
3662+);
3663+
33803664 it.effect("reuses an observed merged state for strict settlement reads", () =>
33813665 Effect.gen(function* () {
33823666 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
@@ -3587,43 +3871,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil · 204 unmodified lines
35873871 }),
35883872 );
35893873
3590−it("names an Azure DevOps repository by its own name, not its project path", () => {
3591− // `az repos pr list --repository` takes a name and detects the organisation and project from
3592− // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
3593− // reads as unavailable on the page.
3594− const selector = PullRequestService.repositoryIdentityOf({
3595− repositoryIdentity: {
3596− provider: "azure-devops",
3597− displayName: "contoso/payments/_git/checkout",
3598− owner: "contoso",
3599− name: "checkout",
3600− },
3601− } as never);
3602− assert.strictEqual(selector, "checkout");
3603−});
3604−
3605−it("falls back to the path's last segment where an Azure identity has no name", () => {
3606− const selector = PullRequestService.repositoryIdentityOf({
3607− repositoryIdentity: {
3608− provider: "azure-devops",
3609− displayName: "contoso/payments/_git/checkout",
3610− },
3611− } as never);
3612− assert.strictEqual(selector, "checkout");
3613−});
3614−
3615−it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
3616− const selector = PullRequestService.repositoryIdentityOf({
3617− repositoryIdentity: {
3618− provider: "gitlab",
3619− displayName: "group/subgroup/service",
3620− owner: "group",
3621− name: "service",
3622− },
3623− } as never);
3624− assert.strictEqual(selector, "group/subgroup/service");
3625−});
3626−
36273874 it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
36283875 Effect.gen(function* () {
36293876 const service = yield* makeService({
@@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the · 507 unmodified lines
41374384 assert.strictEqual(unnamed.viewer, undefined);
41384385 }),
41394386 );
4387+
4388+it.effect("keeps Azure continuation cursors separate for repositories with the same name", () =>
4389+ Effect.gen(function* () {
4390+ const seen: string[] = [];
4391+ const service = yield* makeService({
4392+ projects: ["org-a", "org-b"].map((organization) =>
4393+ project({
4394+ id: organization,
4395+ title: organization,
4396+ workspaceRoot: `/${organization}`,
4397+ repository: `${organization}/project/_git/web`,
4398+ provider: "azure-devops",
4399+ host: "dev.azure.com",
4400+ }),
4401+ ),
4402+ providers: [
4403+ fakeProvider("azure-devops", {
4404+ listChangeRequests: (input) =>
4405+ Effect.sync(() => {
4406+ seen.push(input.cwd);
4407+ return {
4408+ items: [changeRequest(7, "2026-07-02T00:00:00Z")],
4409+ truncated: true,
4410+ continues: true,
4411+ };
4412+ }),
4413+ }),
4414+ ],
4415+ });
4416+ const first = yield* service.list({ state: "open" });
4417+ assert.lengthOf(Object.keys(first.nextCursors), 2);
4418+ const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!;
4419+ seen.length = 0;
4420+ yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } });
4421+ assert.deepStrictEqual(seen, ["/org-b"]);
4422+ }),
4423+);
apps/server/src/pullRequest/PullRequestService.ts+202−59
@@ -1,3 +1,7 @@
1+import {
2+ canonicalRepositoryKey,
3+ sourceControlRepositorySelector,
4+} from "@t3tools/shared/sourceControl";
15 import * as Cache from "effect/Cache";
26 import * as Clock from "effect/Clock";
37 import * as Context from "effect/Context";
@@ -46,6 +50,7 @@ import { · 42 unmodified lines
4650 type PullRequestLabelCandidateList,
4751 type PullRequestLabelChangeInput,
4852 type PullRequestSubmitReviewInput,
53+ type PullRequestStack,
4954 type PullRequestSummary,
5055 type PullRequestThreadReplyInput,
5156 type PullRequestThreadResolutionInput,
@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service< · 92 unmodified lines
144149 input: PullRequestRef,
145150 options?: { readonly recoverTransientFailure?: boolean },
146151 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
152+ /**
153+ * The host-native stack the pull request belongs to, or null when it is not in one or the
154+ * host keeps no such object. Cached like a summary; a stack changes about as often.
155+ */
156+ readonly stack: (
157+ input: PullRequestRef,
158+ ) => Effect.Effect<PullRequestStack | null, PullRequestError>;
147159 readonly subscribeMerges: Effect.Effect<
148160 Stream.Stream<PullRequestMergeEvent>,
149161 never,
@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang · 91 unmodified lines
241253
242254 /** A project this page can read: its remote is on a host with an implementation. */
243255 interface SupportedProject {
256+ readonly cursorKey: string;
244257 readonly project: OrchestrationProjectShell;
245258 readonly api: PullRequestProviderApi;
246259 readonly repository: string;
@@ -453,7 +466,7 @@ function withRateLimitBackoff( · 206 unmodified lines
453466 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
454467 ) => wrap(operation, call, true);
455468
456− return {
469+ const wrapped = {
457470 kind: api.kind,
458471 capabilities: api.capabilities,
459472 getViewer: wrap("getViewer", api.getViewer),
@@ -474,6 +487,9 @@ function withRateLimitBackoff( · 14 unmodified lines
474487 : {
475488 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
476489 }),
490+ ...(api.getChangeRequestStack === undefined
491+ ? {}
492+ : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }),
477493 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
478494 ...(api.getReviewThreadComments === undefined
479495 ? {}
@@ -506,30 +522,9 @@ function withRateLimitBackoff( · 26 unmodified lines
506522 setReaction: interactive("setReaction", api.setReaction),
507523 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
508524 };
509−}
510−
511−/**
512− * The provider-native repository selector. `displayName` is the full path below the host, which
513− * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
514− * recorded before that field existed.
515− *
516− * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
517− * takes the organisation and project from the checkout it detects — so the recorded
518− * `org/project/_git/repo` path is refused outright and the whole repository reads as
519− * unavailable. Its name is the last segment, which is what this hands over.
520− *
521− * One function because everything downstream is keyed by what it answers: the rows' own
522− * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
523− */
524−export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null {
525− const identity = project.repositoryIdentity;
526− if (!identity) return null;
527− if (identity.provider === "azure-devops") {
528− const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
529− return identity.name || segments.at(-1) || null;
530− }
531− if (identity.displayName) return identity.displayName;
532− return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
525+ // Optional provider methods must be forwarded too; returning the interface alone permits omissions.
526+ return wrapped satisfies PullRequestProviderApi &
527+ Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
533528 }
534529
535530 export const make = Effect.gen(function* () {
@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () { · 18 unmodified lines
554549 for (const project of projects) {
555550 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
556551 const identity = project.repositoryIdentity;
557− if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue;
552+ if (
553+ identity?.provider !== "unknown" ||
554+ sourceControlRepositorySelector(project.repositoryIdentity) === null
555+ )
556+ continue;
558557 const host = pullRequestHostOf(identity, "unknown");
559558 // A legacy identity has no canonical host until its provider is refined, so it must reach
560559 // the refinement before a host filter can decide whether it belongs in the result.
@@ -628,7 +627,7 @@ export const make = Effect.gen(function* () { · 67 unmodified lines
628627 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
629628 const identity = project.repositoryIdentity;
630629 let kind = identity?.provider as SourceControlProviderKind | undefined;
631− const repository = repositoryIdentityOf(project);
630+ const repository = sourceControlRepositorySelector(project.repositoryIdentity);
632631 if (!identity || kind === undefined || repository === null) continue;
633632 // Worktrees of one repository are separate projects; reading the remote once keeps
634633 // the page from repeating every change request per local checkout. The host is part
@@ -647,7 +646,10 @@ export const make = Effect.gen(function* () { · 12 unmodified lines
647646 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
648647 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);
649648 }
650− const key = listCursorKey(host, repository);
649+ const key = listCursorKey(
650+ host,
651+ kind === "azure-devops" ? identity.canonicalKey : repository,
652+ );
651653 if (seen.has(key)) continue;
652654 seen.add(key);
653655 if (api === null) {
@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
657659 continue;
658660 }
659661 supported.push({
662+ cursorKey: key,
660663 project,
661664 api: withRateLimitBackoff(api, host, rateLimits),
662665 repository,
@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
667670 }),
668671 );
669672
673+ /**
674+ * The project whose checkout and credentials serve a reference. The project's own
675+ * repository is the default; a reference that names a `host` may instead point at any
676+ * repository on that host. Prefer its own checkout; providers with explicit repository
677+ * targeting can fall back to another checkout on the host. Azure derives its organization
678+ * from the checkout, so it requires a matching repository.
679+ */
670680 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
671681 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
672682 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {
673− const match = supported[0];
674− if (!match) {
675− return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
683+ const own = supported[0];
684+ const repository = ref.repository.trim();
685+ const host = ref.host?.trim().toLowerCase();
686+ if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) {
687+ // Hostless references only ever meant the project's own repository, and a hosted one
688+ // naming it still is; either way the project serves itself.
689+ if (host === undefined || host === own.host) return Effect.succeed(own);
676690 }
677− // The repository travels through the client, so it is checked against the project's
678− // own remote rather than being handed to a provider verbatim.
679− if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) {
691+ if (host === undefined) {
692+ if (own === undefined) {
693+ return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
694+ }
695+ // The repository travels through the client, so it is checked against the project's
696+ // own remote rather than being handed to a provider verbatim.
680697 return Effect.fail(
681698 new PullRequestOperationError({
682699 operation: "resolveRepository",
@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () { · 1 unmodified line
684701 }),
685702 );
686703 }
687− return Effect.succeed(match);
704+ const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase());
705+ // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare
706+ // the complete repository identity before narrowing those checkouts by host.
707+ return listWorkspaceProjects(
708+ repositoryKey.startsWith("dev.azure.com/") ? {} : { host },
709+ ).pipe(
710+ Effect.flatMap(({ supported }) => {
711+ const onHost = supported.filter((candidate) => candidate.host === host);
712+ const route =
713+ supported.find(
714+ (candidate) =>
715+ candidate.api.kind === "azure-devops" &&
716+ candidate.project.repositoryIdentity != null &&
717+ canonicalRepositoryKey(
718+ candidate.project.repositoryIdentity.canonicalKey.toLowerCase(),
719+ ) === repositoryKey,
720+ ) ??
721+ onHost.find(
722+ (candidate) =>
723+ candidate.api.kind !== "azure-devops" &&
724+ candidate.repository.toLowerCase() === repository.toLowerCase(),
725+ ) ??
726+ onHost.find((candidate) => candidate.api.kind !== "azure-devops");
727+ if (route === undefined) {
728+ return Effect.fail(
729+ new PullRequestUnavailableError({ reason: "provider-unsupported" }),
730+ );
731+ }
732+ return Effect.succeed(
733+ route.api.kind === "azure-devops" ? route : { ...route, repository },
734+ );
735+ }),
736+ );
688737 }),
689738 );
690739
@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () { · 138 unmodified lines
829878 viewer: string,
830879 ): boolean => {
831880 if (filters === undefined) return true;
832− const labels = item.labels.map((label) => label.name.trim().toLowerCase());
833− const holds = (label: string) => labels.includes(label.trim().toLowerCase());
881+ const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
882+ const holds = (label: string) => labels.has(label.trim().toLowerCase());
834883 return (
835884 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
836885 // Judged on the provider row rather than the entry, because the two absences mean
@@ -949,9 +998,7 @@ export const make = Effect.gen(function* () { · 112 unmodified lines
949998 const selected =
950999 continuation === null
9511000 ? projects
952− : projects.filter(({ host, repository }) =>
953− continuation.has(listCursorKey(host, repository)),
954− );
1001+ : projects.filter(({ cursorKey }) => continuation.has(cursorKey));
9551002 const readable = selected.filter(({ host }) => viewers[host] !== undefined);
9561003 // A host that could not be read still has projects, and they are absent from the list.
9571004 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.
@@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () { · 35 unmodified lines
9931040
9941041 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;
9951042 const cursorOf = (project: SupportedProject): ListCursor | undefined =>
996− continuation?.get(listCursorKey(project.host, project.repository));
1043+ continuation?.get(project.cursorKey);
9971044
9981045 /**
9991046 * One repository asked on its own. What every host without a search across repositories
@@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
10021049 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {
10031050 {
10041051 const viewer = viewers[project.host]!;
1005− const key = listCursorKey(project.host, project.repository);
1052+ const key = project.cursorKey;
10061053 const cursor = cursorOf(project);
10071054 return project.api
10081055 .listChangeRequests({
@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () { · 152 unmodified lines
11611208 !cursorHere.seenAt.includes(item.number),
11621209 );
11631210 return Effect.succeed({
1164− key: listCursorKey(project.host, project.repository),
1211+ key: project.cursorKey,
11651212 entries: items
11661213 .filter((item) => matchesRowFilters(item, input.filters, viewer))
11671214 .map((item) => toEntry({ project, item, viewer })),
@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () { · 86 unmodified lines
12541301 title: changeRequest.title,
12551302 url: changeRequest.url,
12561303 state: changeRequest.state,
1257− ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
12581304 headBranch: changeRequest.headBranch,
12591305 baseBranch: changeRequest.baseBranch,
12601306 closedAt: changeRequest.closedAt ?? null,
12611307 mergedAt: changeRequest.mergedAt ?? null,
12621308 updatedAt: changeRequest.updatedAt,
1309+ ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }),
1310+ ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }),
1311+ ...(changeRequest.additions === undefined
1312+ ? {}
1313+ : { additions: changeRequest.additions }),
1314+ ...(changeRequest.deletions === undefined
1315+ ? {}
1316+ : { deletions: changeRequest.deletions }),
1317+ ...(changeRequest.changedFiles === undefined
1318+ ? {}
1319+ : { changedFiles: changeRequest.changedFiles }),
1320+ ...(changeRequest.reviewDecision === undefined
1321+ ? {}
1322+ : { reviewDecision: changeRequest.reviewDecision }),
1323+ ...(changeRequest.checksState === undefined
1324+ ? {}
1325+ : { checksState: changeRequest.checksState }),
1326+ ...(changeRequest.mergeability === undefined
1327+ ? {}
1328+ : { mergeability: changeRequest.mergeability }),
12631329 })),
12641330 );
12651331 }),
12661332 );
12671333
1334+ const stackUncached: PullRequestService["Service"]["stack"] = (input) =>
1335+ requireProject(input).pipe(
1336+ Effect.flatMap((project) => {
1337+ const read = project.api.getChangeRequestStack;
1338+ if (read === undefined) return Effect.succeed(null);
1339+ return read({
1340+ cwd: project.project.workspaceRoot,
1341+ repository: project.repository,
1342+ host: project.host,
1343+ number: input.number,
1344+ }).pipe(
1345+ Effect.mapError(toPullRequestError("stack")),
1346+ Effect.map((stack): PullRequestStack | null =>
1347+ stack === null
1348+ ? null
1349+ : {
1350+ id: stack.id,
1351+ number: stack.number,
1352+ url: stack.url,
1353+ base: stack.base,
1354+ layers: stack.layers.map((layer) => ({
1355+ number: layer.number,
1356+ headBranch: layer.headBranch,
1357+ state: layer.state,
1358+ })),
1359+ },
1360+ ),
1361+ );
1362+ }),
1363+ );
1364+
12681365 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
12691366 requireProject(input).pipe(
12701367 Effect.flatMap((project) =>
@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { · 236 unmodified lines
15071604 })
15081605 .pipe(
15091606 Effect.mapError(toPullRequestError("runAction")),
1510− Effect.as(project.repository),
1607+ Effect.as(
1608+ project.api.kind === "azure-devops"
1609+ ? input.repository.trim()
1610+ : project.repository,
1611+ ),
15111612 );
15121613 }),
15131614 );
@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () { · 619 unmodified lines
21332234 let turnRefreshEpoch = 0;
21342235 const refEpochs = new Map<string, number>();
21352236 const REF_EPOCH_CAPACITY = 2_048;
2136− const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
2237+ const refScope = (ref: PullRequestRef) =>
2238+ `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`;
21372239 const refEpoch = (ref: PullRequestRef) =>
21382240 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2241+ // Keys carry the reference back out of the cache loader, so the slot layout is shared with
2242+ // `refOfCacheKey` rather than read positionally at every loader.
21392243 const refCacheKey = (ref: PullRequestRef) =>
2140− JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);
2244+ JSON.stringify([
2245+ refEpoch(ref),
2246+ ref.projectId,
2247+ ref.host?.toLowerCase() ?? null,
2248+ ref.repository.toLowerCase(),
2249+ ref.number,
2250+ ]);
2251+ const refOfCacheKey = (key: string): PullRequestRef => {
2252+ const [, projectId, host, repository, number] = JSON.parse(key) as [
2253+ number,
2254+ string,
2255+ string | null,
2256+ string,
2257+ number,
2258+ ];
2259+ return {
2260+ projectId,
2261+ ...(host === null ? {} : { host }),
2262+ repository,
2263+ number,
2264+ } as PullRequestRef;
2265+ };
21412266 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse
21422267 // them; explicit refreshes, mutations, and turns strand old and in-flight results.
21432268 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);
@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () { · 37 unmodified lines
21812306
21822307 const summaryCache = yield* Cache.makeWith(
21832308 (key: string) => {
2184− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2185− return summaryUncached({ projectId, repository, number } as PullRequestRef);
2309+ return summaryUncached(refOfCacheKey(key));
21862310 },
21872311 {
21882312 capacity: DETAIL_CACHE_CAPACITY,
@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () { · 14 unmodified lines
22032327 );
22042328 };
22052329
2330+ const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), {
2331+ capacity: DETAIL_CACHE_CAPACITY,
2332+ timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2333+ });
2334+ const stack: PullRequestService["Service"]["stack"] = (input) =>
2335+ Cache.get(stackCache, refCacheKey(input));
2336+
22062337 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
22072338 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
22082339 // The continuation cursors are part of the key, entries sorted so one continuation is one
@@ -2283,8 +2414,7 @@ export const make = Effect.gen(function* () { · 74 unmodified lines
22832414 const detailCache = yield* Cache.makeWith(
22842415 (key: string) => {
22852416 const statsKey = statsCacheKey(key);
2286− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2287− return detailUncached({ projectId, repository, number } as PullRequestRef).pipe(
2417+ return detailUncached(refOfCacheKey(key)).pipe(
22882418 Effect.tap(
22892419 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {
22902420 recordStats(
@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
23072437 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
23082438 },
23092439 );
2310− const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({
2440+ const summaryFromDetail = (
2441+ detail: PullRequestDetail,
2442+ previous: PullRequestSummary | undefined,
2443+ ): PullRequestSummary => ({
2444+ // Detail does not carry review/check summaries. Keep the last summary observation.
2445+ ...previous,
23112446 provider: detail.provider,
23122447 projectId: detail.projectId,
23132448 repository: detail.repository,
@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line
23152450 title: detail.title,
23162451 url: detail.url,
23172452 state: detail.state,
2318− ...(detail.isDraft === true ? { isDraft: true } : {}),
2453+ isDraft: detail.isDraft,
2454+ author: detail.author,
2455+ additions: detail.additions,
2456+ deletions: detail.deletions,
2457+ changedFiles: detail.changedFiles,
2458+ mergeability: detail.mergeability,
23192459 headBranch: detail.headBranch,
23202460 baseBranch: detail.baseBranch,
23212461 closedAt: detail.closedAt,
@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
23382478 key,
23392479 Cache.get(detailCache, key).pipe(
23402480 Effect.tap((value) => {
2341− const summary = summaryFromDetail(value);
2481+ const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
23422482 return shouldReplaceHeldSummary(key, summary)
23432483 ? lastGoodSummary.record(key, summary)
23442484 : Effect.void;
@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
23502490
23512491 const activityCache = yield* Cache.makeWith(
23522492 (key: string) => {
2353− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2354− return activityUncached({ projectId, repository, number } as PullRequestRef);
2493+ return activityUncached(refOfCacheKey(key));
23552494 },
23562495 {
23572496 capacity: DETAIL_CACHE_CAPACITY,
@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
23652504
23662505 const diffCache = yield* Cache.makeWith(
23672506 (key: string) => {
2368− const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [
2507+ const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [
23692508 number,
23702509 string,
2510+ string | null,
23712511 string,
23722512 number,
23732513 string | null,
@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
23752515 ];
23762516 return diffUncached({
23772517 projectId,
2518+ ...(host === null ? {} : { host }),
23782519 repository,
23792520 number,
23802521 ...(cursor === null ? {} : { cursor }),
@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
23852526 capacity: DIFF_CACHE_CAPACITY,
23862527 timeToLive: (exit, key) => {
23872528 if (!Exit.isSuccess(exit)) return Duration.zero;
2388− const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[5];
2529+ const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[6];
23892530 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
23902531 },
23912532 },
@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
23942535 const key = JSON.stringify([
23952536 refEpoch(input),
23962537 input.projectId,
2397− input.repository,
2538+ input.host?.toLowerCase() ?? null,
2539+ input.repository.toLowerCase(),
23982540 input.number,
23992541 input.cursor ?? null,
24002542 input.commit ?? null,
@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () { · 122 unmodified lines
25232665 list,
25242666 listStats,
25252667 summary,
2668+ stack,
25262669 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
25272670 Effect.map((subscription) => Stream.fromSubscription(subscription)),
25282671 ),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 decodePullRequestListJson,
1212 decodePullRequestNodeIdJson,
1313 decodePullRequestSearchJson,
14+ decodePullRequestStacksJson,
1415 decodeLabelCandidatesJson,
1516 decodeRepositoryAccessJson,
1617 decodeReviewerCandidatesJson,
@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => { · 1483 unmodified lines
15001501 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
15011502 });
15021503 });
1504+
1505+describe("host-native stack decoding", () => {
1506+ /** A stack as the preview lists it, bottom to top, with the fields it answers today. */
1507+ function stack(overrides: Record<string, unknown> = {}) {
1508+ return {
1509+ id: 42,
1510+ number: 3,
1511+ node_id: "STK_kwDO",
1512+ url: "https://api.github.com/repos/acme/web/stacks/3",
1513+ base: { ref: "main", sha: "abc" },
1514+ open: true,
1515+ created_at: "2026-09-01T00:00:00Z",
1516+ pull_requests: [
1517+ {
1518+ number: 10,
1519+ head: { ref: "feat/one" },
1520+ state: "closed",
1521+ merged_at: "2026-09-02T00:00:00Z",
1522+ },
1523+ { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null },
1524+ { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null },
1525+ ],
1526+ ...overrides,
1527+ };
1528+ }
1529+
1530+ /** The one stack a listing answered with, which these reads all expect to find. */
1531+ function expectStack(overrides: Record<string, unknown> = {}) {
1532+ const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)])));
1533+ if (decoded === null) throw new Error("expected a stack");
1534+ return decoded;
1535+ }
1536+
1537+ it("reads the first stack, bottom to top, with merged_at outranking state", () => {
1538+ expect(expectStack()).toEqual({
1539+ id: "42",
1540+ number: 3,
1541+ url: "https://api.github.com/repos/acme/web/stacks/3",
1542+ base: "main",
1543+ layers: [
1544+ { number: 10, headBranch: "feat/one", state: "merged" },
1545+ { number: 11, headBranch: "feat/two", state: "open" },
1546+ { number: 12, headBranch: "feat/three", state: "closed" },
1547+ ],
1548+ });
1549+ });
1550+
1551+ it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
1552+ expect(expectStack({ base: "develop" }).base).toBe("develop");
1553+ });
1554+
1555+ it("prefers the page a person opens over the API URL, where the host reports one", () => {
1556+ expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe(
1557+ "https://github.com/acme/web/stacks/3",
1558+ );
1559+ });
1560+
1561+ it("falls back to the node id, then the number, for a stack without an id", () => {
1562+ expect(expectStack({ id: undefined }).id).toBe("STK_kwDO");
1563+ expect(expectStack({ id: null, node_id: null }).id).toBe("3");
1564+ });
1565+
1566+ it("reads an empty listing as not stacked", () => {
1567+ expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull();
1568+ });
1569+
1570+ it("refuses a stack without a number or without its pull requests", () => {
1571+ expect(
1572+ Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))),
1573+ ).toBe(false);
1574+ expect(
1575+ Result.isSuccess(
1576+ decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])),
1577+ ),
1578+ ).toBe(false);
1579+ expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
1580+ });
1581+});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+66−0
@@ -2445,3 +2445,69 @@ export function decodePullRequestFilesJson( · 2444 unmodified lines
24452445 omittedFileStats,
24462446 });
24472447 }
2448+
2449+/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
2450+const RawStackPullRequestSchema = Schema.Struct({
2451+ number: Schema.Int,
2452+ head: Schema.Struct({ ref: Schema.String }),
2453+ state: Schema.optional(Schema.NullOr(Schema.String)),
2454+ merged_at: Schema.optional(Schema.NullOr(Schema.String)),
2455+});
2456+
2457+/**
2458+ * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may
2459+ * still move. Only what a stack is made of is required — where it lives, what it stands on, and
2460+ * its pull requests — and `base` is accepted both as the ref object the preview sends today and
2461+ * as the bare branch name it started out as.
2462+ */
2463+const RawStackSchema = Schema.Struct({
2464+ id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))),
2465+ number: Schema.Int,
2466+ node_id: Schema.optional(Schema.NullOr(Schema.String)),
2467+ url: Schema.String,
2468+ html_url: Schema.optional(Schema.NullOr(Schema.String)),
2469+ base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]),
2470+ pull_requests: Schema.Array(RawStackPullRequestSchema),
2471+});
2472+
2473+const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
2474+
2475+export interface GitHubPullRequestStackLayer {
2476+ readonly number: number;
2477+ readonly headBranch: string;
2478+ readonly state: PullRequestState;
2479+}
2480+
2481+export interface GitHubPullRequestStack {
2482+ readonly id: string;
2483+ readonly number: number;
2484+ readonly url: string;
2485+ readonly base: string;
2486+ /** Bottom to top, which is the order GitHub lists them in. */
2487+ readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>;
2488+}
2489+
2490+/**
2491+ * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in
2492+ * at most one stack, so the array is GitHub's way of saying "none" rather than a page.
2493+ */
2494+export function decodePullRequestStacksJson(
2495+ raw: string,
2496+): Result.Result<GitHubPullRequestStack | null, DecodeFailure> {
2497+ const decoded = decodeStacks(raw);
2498+ if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2499+ const stack = decoded.success[0];
2500+ if (stack === undefined) return Result.succeed(null);
2501+ return Result.succeed({
2502+ id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id),
2503+ number: stack.number,
2504+ // The page a person opens where the preview reports one; the API URL is what it always has.
2505+ url: trimmed(stack.html_url) ?? stack.url,
2506+ base: typeof stack.base === "string" ? stack.base : stack.base.ref,
2507+ layers: stack.pull_requests.map((pullRequest) => ({
2508+ number: pullRequest.number,
2509+ headBranch: pullRequest.head.ref,
2510+ state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }),
2511+ })),
2512+ });
2513+}
apps/server/src/pullRequest/linkedThreads.test.ts+117−0
@@ -0,0 +1,117 @@
1+import { assert, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
6+import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
7+
8+it.effect(
9+ "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links",
10+ () =>
11+ Effect.gen(function* () {
12+ const sql = yield* SqlClient.SqlClient;
13+ const createdAt = "2026-09-01T00:00:00.000Z";
14+ const archivedAt = "2026-09-03T00:00:00.000Z";
15+ yield* sql`
16+ INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
17+ VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
18+ `;
19+ const fixtures = [
20+ {
21+ id: "azure",
22+ host: "dev.azure.com",
23+ repository: "org/project/_git/web",
24+ number: 7,
25+ source: "manual",
26+ },
27+ {
28+ id: "other-org",
29+ host: "dev.azure.com",
30+ repository: "other/project/_git/web",
31+ number: 7,
32+ source: "manual",
33+ },
34+ { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
35+ {
36+ id: "archived",
37+ host: "github.com",
38+ repository: "acme/web",
39+ number: 7,
40+ source: "created",
41+ },
42+ { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
43+ {
44+ id: "dismissed",
45+ host: "github.com",
46+ repository: "acme/web",
47+ number: 7,
48+ source: "stack-dismissed",
49+ },
50+ {
51+ id: "other-host",
52+ host: "github.example.com",
53+ repository: "acme/web",
54+ number: 7,
55+ source: "manual",
56+ },
57+ {
58+ id: "other-repository",
59+ host: "github.com",
60+ repository: "acme/api",
61+ number: 7,
62+ source: "manual",
63+ },
64+ {
65+ id: "other-number",
66+ host: "github.com",
67+ repository: "acme/web",
68+ number: 8,
69+ source: "manual",
70+ },
71+ ];
72+ for (const fixture of fixtures) {
73+ yield* sql`
74+ INSERT INTO projection_threads (
75+ thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at
76+ ) VALUES (
77+ ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}',
78+ ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt},
79+ ${fixture.id === "archived" ? archivedAt : null},
80+ ${fixture.id === "deleted" ? archivedAt : null}
81+ )
82+ `;
83+ yield* sql`
84+ INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
85+ VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number},
86+ 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt})
87+ `;
88+ }
89+
90+ expect(
91+ (yield* listLinkedPullRequestThreads({
92+ host: "org.visualstudio.com",
93+ repository: "project/_git/web",
94+ number: 7,
95+ })).threads.map((thread) => thread.id),
96+ ).toEqual(["azure"]);
97+ const result = yield* listLinkedPullRequestThreads({
98+ host: "GitHub.Com",
99+ repository: "ACME/WEB",
100+ number: 7,
101+ });
102+ expect(result).toEqual({
103+ threads: [
104+ { id: "archived", projectId: "project-1", title: "archived", archivedAt },
105+ { id: "active", projectId: "project-1", title: "active", archivedAt: null },
106+ ],
107+ });
108+ assert.deepStrictEqual(
109+ yield* listLinkedPullRequestThreads({
110+ host: "github.com",
111+ repository: "acme/web",
112+ number: 99,
113+ }),
114+ { threads: [] },
115+ );
116+ }).pipe(Effect.provide(SqlitePersistenceMemory)),
117+);
apps/server/src/pullRequest/linkedThreads.ts+39−0
@@ -0,0 +1,39 @@
1+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2+import {
3+ PullRequestLinkedThreadsResult,
4+ PullRequestOperationError,
5+ type ThreadPullRequestKey,
6+} from "@t3tools/contracts";
7+import * as Effect from "effect/Effect";
8+import * as Schema from "effect/Schema";
9+import * as SqlClient from "effect/unstable/sql/SqlClient";
10+
11+const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult);
12+
13+export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(
14+ function* (input: ThreadPullRequestKey) {
15+ const key = normalizeThreadPullRequestKey(input);
16+ const sql = yield* SqlClient.SqlClient;
17+ const threads = yield* sql`
18+ SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,
19+ t.archived_at AS "archivedAt"
20+ FROM projection_thread_pull_requests AS link
21+ JOIN projection_threads AS t ON t.thread_id = link.thread_id
22+ WHERE link.host = ${key.host.toLowerCase()}
23+ AND link.repository = ${key.repository.toLowerCase()}
24+ AND link.number = ${key.number}
25+ AND link.source != 'stack-dismissed'
26+ AND t.deleted_at IS NULL
27+ ORDER BY t.updated_at DESC, t.thread_id ASC
28+ `;
29+ return yield* decodeLinkedThreads({ threads });
30+ },
31+ Effect.mapError(
32+ (cause) =>
33+ new PullRequestOperationError({
34+ operation: "linkedThreads",
35+ detail: "Could not load linked threads.",
36+ cause,
37+ }),
38+ ),
39+);
apps/server/src/pullRequest/pullRequestSyncKey.test.ts+46−0
@@ -0,0 +1,46 @@
1+import { ProjectId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
5+
6+describe("pullRequestSyncKey", () => {
7+ const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 };
8+ it.each([
9+ "dev.azure.com/org/project/_git/web",
10+ "ssh.dev.azure.com/v3/org/project/web",
11+ "vs-ssh.visualstudio.com/v3/org/project/web",
12+ "org.visualstudio.com/defaultcollection/project/_git/web",
13+ ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => {
14+ const identity = {
15+ canonicalKey,
16+ provider: "azure-devops",
17+ name: "web",
18+ displayName: canonicalKey.split("/").slice(1).join("/"),
19+ locator: {
20+ source: "git-remote" as const,
21+ remoteName: "origin",
22+ remoteUrl: `https://${canonicalKey}`,
23+ },
24+ };
25+ const expected = { host: "dev.azure.com", repository: "org/project/_git/web", number: 7 };
26+ expect(pullRequestSyncKey(reference, identity)).toEqual(expected);
27+ expect(
28+ pullRequestSyncKey({ ...reference, host: canonicalKey.split("/")[0]! }, identity),
29+ ).toEqual(expected);
30+ expect(pullRequestSyncKey({ ...reference, repository: "other" }, identity)).toBeNull();
31+ expect(pullRequestSyncKey({ ...reference, host: "unrelated.test" }, identity)).toBeNull();
32+ });
33+ it("normalizes complete hosted aliases without requiring a checkout", () => {
34+ expect(
35+ pullRequestSyncKey({
36+ ...reference,
37+ host: "org.visualstudio.com",
38+ repository: "project/_git/web",
39+ }),
40+ ).toEqual({ host: "dev.azure.com", repository: "org/project/_git/web", number: 7 });
41+ expect(
42+ pullRequestSyncKey({ ...reference, host: "github.com", repository: "acme/web" }),
43+ ).toEqual({ host: "github.com", repository: "acme/web", number: 7 });
44+ expect(pullRequestSyncKey(reference)).toBeNull();
45+ });
46+});
apps/server/src/pullRequest/pullRequestSyncKey.ts+44−0
@@ -0,0 +1,44 @@
1+import {
2+ pullRequestHostOf,
3+ type PullRequestRef,
4+ type RepositoryIdentity,
5+ type SourceControlProviderKind,
6+ type ThreadPullRequestKey,
7+} from "@t3tools/contracts";
8+import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
9+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
10+
11+/** Convert checkout-scoped references to the host-level identity used by linked threads. */
12+export function pullRequestSyncKey(
13+ reference: PullRequestRef,
14+ identity?: RepositoryIdentity | null,
15+): ThreadPullRequestKey | null {
16+ if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) {
17+ if (
18+ reference.repository.toLowerCase() !==
19+ sourceControlRepositorySelector(identity)?.toLowerCase() ||
20+ (reference.host !== undefined &&
21+ reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops"))
22+ )
23+ return null;
24+ const [host, ...repository] = identity.canonicalKey.split("/");
25+ if (!host || repository.length === 0) return null;
26+ return normalizeThreadPullRequestKey({
27+ host,
28+ repository: repository.join("/"),
29+ number: reference.number,
30+ });
31+ }
32+ const host =
33+ reference.host ??
34+ (identity
35+ ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
36+ : undefined);
37+ return host === undefined
38+ ? null
39+ : normalizeThreadPullRequestKey({
40+ host,
41+ repository: reference.repository,
42+ number: reference.number,
43+ });
44+}
apps/server/src/relay/AgentAwarenessRelay.test.ts+3−0
@@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 333 unmodified lines
334334 interactionMode: "default",
335335 branch: null,
336336 worktreePath: null,
337+ pullRequests: [],
337338 latestTurn: null,
338339 createdAt: now,
339340 updatedAt: now,
@@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 137 unmodified lines
477478 interactionMode: "default",
478479 branch: null,
479480 worktreePath: null,
481+ pullRequests: [],
480482 latestTurn: {
481483 turnId: "turn-1" as TurnId,
482484 state: "running",
@@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 185 unmodified lines
668670 interactionMode: "default",
669671 branch: null,
670672 worktreePath: null,
673+ pullRequests: [],
671674 latestTurn: {
672675 turnId: "turn-1" as TurnId,
673676 state: "running",
apps/server/src/server.test.ts+10−1
@@ -117,6 +117,7 @@ import { · 116 unmodified lines
117117 } from "./orchestration/Errors.ts";
118118 import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
119119 import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
120+import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
120121 import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
121122 import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";
122123 import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
@@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => { · 208 unmodified lines
331332 runtimeMode: "full-access" as const,
332333 branch: null,
333334 worktreePath: null,
335+ pullRequests: [],
334336 createdAt: now,
335337 updatedAt: now,
336338 archivedAt: null,
@@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = ( · 24 unmodified lines
361363 interactionMode: "default",
362364 branch: null,
363365 worktreePath: null,
366+ pullRequests: [],
364367 latestTurn: null,
365368 createdAt: now,
366369 updatedAt: now,
@@ -384,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll( · 17 unmodified lines
384387
385388 const makeAuthTestLayer = () =>
386389 EnvironmentAuth.layer.pipe(
387− Layer.provide(SqlitePersistenceMemory),
390+ Layer.provideMerge(SqlitePersistenceMemory),
388391 Layer.provide(ServerSecretStore.layer),
389392 Layer.provide(
390393 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({
@@ -956,6 +959,11 @@ const buildAppUnderTest = (options?: { · 565 unmodified lines
956959 drainThrough: () => Effect.void,
957960 ...options?.layers?.threadDeletionReactor,
958961 }),
962+ Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({
963+ start: () => Effect.void,
964+ drain: Effect.void,
965+ requestSync: () => Effect.void,
966+ }),
959967 ),
960968 ),
961969 Layer.provide(
@@ -8104,6 +8112,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 7142 unmodified lines
81048112 runtimeMode: "full-access" as const,
81058113 branch: null,
81068114 worktreePath: null,
8115+ pullRequests: [],
81078116 createdAt: now,
81088117 updatedAt: now,
81098118 archivedAt: null,
apps/server/src/server.ts+2−0
@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma · 70 unmodified lines
7171 import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
7272 import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
7373 import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
74+import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
7475 import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
7576 import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
7677 import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
@@ -280,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 203 unmodified lines
280281 Layer.provideMerge(CheckpointReactorLive),
281282 Layer.provideMerge(ThreadDeletionReactorLive),
282283 Layer.provideMerge(ThreadSettlementReactor.layer),
284+ Layer.provideMerge(PullRequestSyncReactor.layer),
283285 Layer.provideMerge(ThreadPullRequestReactor.layer),
284286 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
285287 Layer.provideMerge(RuntimeReceiptBusLive),
apps/server/src/ws.ts+89−8
@@ -70,6 +70,7 @@ import { · 69 unmodified lines
7070 type TerminalError,
7171 type TerminalEvent,
7272 type TerminalMetadataStreamEvent,
73+ type PullRequestRef,
7374 WS_METHODS,
7475 WsRpcGroup,
7576 } from "@t3tools/contracts";
@@ -124,6 +125,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; · 48 unmodified lines
124125 import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
125126 import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
126127 import * as GitWorkflowService from "./git/GitWorkflowService.ts";
128+import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
127129 import * as ReviewService from "./review/ReviewService.ts";
128130 import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
129131 import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
@@ -142,6 +144,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; · 12 unmodified lines
142144 import * as UsageService from "./usage/UsageService.ts";
143145 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
144146 import * as PullRequestService from "./pullRequest/PullRequestService.ts";
147+import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts";
148+import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts";
149+import * as SqlClient from "effect/unstable/sql/SqlClient";
150+import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
145151 import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
146152 import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
147153 import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
@@ -476,7 +482,18 @@ const makeWsRpcLayer = ( · 328 unmodified lines
476482 Effect.gen(function* () {
477483 const currentSessionId = currentSession.sessionId;
478484 const crypto = yield* Crypto.Crypto;
485+ const sql = yield* SqlClient.SqlClient;
479486 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
487+ /** A reference's host-level link key; the project's own host where the ref names none. */
488+ const resolvePullRequestSyncKey = (reference: PullRequestRef) =>
489+ reference.host !== undefined && reference.repository.includes("/")
490+ ? Effect.succeed(pullRequestSyncKey(reference))
491+ : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe(
492+ Effect.map((project) =>
493+ pullRequestSyncKey(reference, Option.getOrUndefined(project)?.repositoryIdentity),
494+ ),
495+ Effect.orElseSucceed(() => null),
496+ );
480497 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
481498 const threadDeletionReactor = yield* ThreadDeletionReactor;
482499 const analytics = yield* AnalyticsService.AnalyticsService;
@@ -604,6 +621,7 @@ const makeWsRpcLayer = ( · 121 unmodified lines
604621 const sourceControlRepositories =
605622 yield* SourceControlRepositoryService.SourceControlRepositoryService;
606623 const pullRequests = yield* PullRequestService.PullRequestService;
624+ const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
607625 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
608626 const sessions = yield* SessionStore.SessionStore;
609627 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
@@ -2133,6 +2151,24 @@ const makeWsRpcLayer = ( · 1523 unmodified lines
21332151 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
21342152 "rpc.aggregate": "pull-requests",
21352153 }),
2154+ [WS_METHODS.pullRequestsStack]: (input) =>
2155+ observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), {
2156+ "rpc.aggregate": "pull-requests",
2157+ }),
2158+ [WS_METHODS.pullRequestsLinkedThreads]: (input) =>
2159+ observeRpcEffect(
2160+ WS_METHODS.pullRequestsLinkedThreads,
2161+ resolvePullRequestSyncKey(input).pipe(
2162+ Effect.flatMap((key) =>
2163+ key === null
2164+ ? Effect.succeed({ threads: [] })
2165+ : listLinkedPullRequestThreads(key).pipe(
2166+ Effect.provideService(SqlClient.SqlClient, sql),
2167+ ),
2168+ ),
2169+ ),
2170+ { "rpc.aggregate": "pull-requests" },
2171+ ),
21362172 [WS_METHODS.pullRequestsDetail]: (input) =>
21372173 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
21382174 "rpc.aggregate": "pull-requests",
@@ -2156,9 +2192,21 @@ const makeWsRpcLayer = ( · 17 unmodified lines
21562192 { "rpc.aggregate": "pull-requests" },
21572193 ),
21582194 [WS_METHODS.pullRequestsRunAction]: (input) =>
2159− observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), {
2160− "rpc.aggregate": "pull-requests",
2161− }),
2195+ observeRpcEffect(
2196+ WS_METHODS.pullRequestsRunAction,
2197+ pullRequests
2198+ .runAction(input)
2199+ .pipe(
2200+ Effect.tap(() =>
2201+ resolvePullRequestSyncKey(input).pipe(
2202+ Effect.flatMap((key) =>
2203+ key === null ? Effect.void : pullRequestSync.requestSync(key),
2204+ ),
2205+ ),
2206+ ),
2207+ ),
2208+ { "rpc.aggregate": "pull-requests" },
2209+ ),
21622210 [WS_METHODS.pullRequestsUpdate]: (input) =>
21632211 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {
21642212 "rpc.aggregate": "pull-requests",
@@ -2196,9 +2244,23 @@ const makeWsRpcLayer = ( · 31 unmodified lines
21962244 "rpc.aggregate": "pull-requests",
21972245 }),
21982246 [WS_METHODS.pullRequestsInvalidate]: (input) =>
2199− observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {
2200− "rpc.aggregate": "pull-requests",
2201− }),
2247+ observeRpcEffect(
2248+ WS_METHODS.pullRequestsInvalidate,
2249+ pullRequests.invalidate(input).pipe(
2250+ // A reader asking for fresh host state also wants the thread badges it feeds to
2251+ // catch up, including a merged link the sweep would otherwise never revisit.
2252+ Effect.andThen(
2253+ input.reference === undefined
2254+ ? Effect.void
2255+ : resolvePullRequestSyncKey(input.reference).pipe(
2256+ Effect.flatMap((key) =>
2257+ key === null ? Effect.void : pullRequestSync.requestSync(key),
2258+ ),
2259+ ),
2260+ ),
2261+ ),
2262+ { "rpc.aggregate": "pull-requests" },
2263+ ),
22022264 [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
22032265 observeRpcStream(
22042266 WS_METHODS.pullRequestsSubscribeRefreshes,
@@ -2507,8 +2569,25 @@ const makeWsRpcLayer = ( · 302 unmodified lines
25072569 .pipe(
25082570 Effect.matchCauseEffect({
25092571 onFailure: (cause) => Queue.failCause(queue, cause),
2510− onSuccess: () =>
2511− refreshGitStatus(input.cwd).pipe(
2572+ onSuccess: (result) =>
2573+ (input.threadId === undefined
2574+ ? Effect.void
2575+ : linkCreatedPullRequest({
2576+ threadId: input.threadId,
2577+ result,
2578+ commandId: serverCommandId("pr-created-link"),
2579+ }).pipe(
2580+ Effect.provideService(
2581+ OrchestrationEngine.OrchestrationEngineService,
2582+ orchestrationEngine,
2583+ ),
2584+ Effect.provideService(
2585+ ProjectionSnapshotQuery.ProjectionSnapshotQuery,
2586+ projectionSnapshotQuery,
2587+ ),
2588+ )
2589+ ).pipe(
2590+ Effect.andThen(refreshGitStatus(input.cwd)),
25122591 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
25132592 ),
25142593 }),
@@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 417 unmodified lines
29323011 ),
29333012 });
29343013 const pullRequests = yield* PullRequestService.PullRequestService;
3014+ const sql = yield* SqlClient.SqlClient;
29353015 return HttpRouter.add(
29363016 "GET",
29373017 "/ws",
@@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 28 unmodified lines
29663046 previewAutomationBroker,
29673047 ).pipe(
29683048 Layer.provideMerge(RpcSerialization.layerJson),
3049+ Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
29693050 Layer.provide(AgentSessionScanner.layer),
29703051 Layer.provide(ProviderMaintenanceRunner.layer),
29713052 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
apps/web/src/components/ChatMarkdown.test.tsx+2−2
@@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({ · 42 unmodified lines
4343 vi.mock("../state/entities", () => ({
4444 readThreadShell: () => null,
4545 useProjects: () => [],
46+ useServerConfigs: () => new Map(),
4647 }));
4748 vi.mock("../remoteOpen", () => ({
4849 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
@@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({ · 3 unmodified lines
5253 usePreferredEditor: () => [null, vi.fn()],
5354 }));
5455 vi.mock("~/lib/openPullRequestLink", () => ({
55− findProjectForChangeRequest: () => undefined,
56− matchesLinkedPullRequestUrl: () => false,
56+ findProjectOnChangeRequestHost: () => undefined,
5757 parseChangeRequestUrl: () => null,
5858 useOpenChangeRequestLink: () => vi.fn(),
5959 }));
apps/web/src/components/ChatMarkdown.tsx+25−50
@@ -1,3 +1,4 @@
1+import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
12 import { useAtomValue } from "@effect/atom-react";
23 import {
34 CheckIcon,
@@ -26,7 +27,7 @@ import type { · 22 unmodified lines
2627 EnvironmentId,
2728 ScopedThreadRef,
2829 ServerProviderSkill,
29− ThreadLinkedPullRequest,
30+ ThreadPullRequestKey,
3031 } from "@t3tools/contracts";
3132 import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
3233 import {
@@ -155,7 +156,6 @@ import { previewEnvironment } from "../state/preview"; · 122 unmodified lines
155156 import { useAtomCommand } from "../state/use-atom-command";
156157 import { useAtomQueryRunner } from "../state/use-atom-query-runner";
157158 import { projectEnvironment } from "../state/projects";
158−import { threadEnvironment } from "../state/threads";
159159 import {
160160 claimWorkspaceBasenameLookup,
161161 needsWorkspaceBasenameLookup,
@@ -164,7 +164,6 @@ import { · 2 unmodified lines
164164 } from "../workspaceBasenameLookup";
165165 import {
166166 findProjectForChangeRequest,
167− matchesLinkedPullRequestUrl,
168167 parseChangeRequestUrl,
169168 pullRequestCandidateUrlFromReferenceAutolink,
170169 useOpenChangeRequestLink,
@@ -2185,9 +2184,7 @@ function useChatMarkdownState({ · 2014 unmodified lines
21852184 const openPreview = useAtomCommand(previewEnvironment.open, {
21862185 reportFailure: false,
21872186 });
2188− const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
2189− reportFailure: false,
2190− });
2187+ const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
21912188 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
21922189 const remoteOpen = useRemoteOpenResolution(environmentId);
21932190 const canUseShellActions = canUseMarkdownFileShellActions(
@@ -2239,9 +2236,6 @@ function useChatMarkdownState({ · 45 unmodified lines
22392236 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],
22402237 );
22412238 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
2242− const threadServerConfig = useAtomValue(
2243− serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
2244− );
22452239 const projects = useProjects();
22462240 const availableEditors = serverConfig?.availableEditors ?? [];
22472241 const [preferredEditor] = usePreferredEditor(availableEditors);
@@ -2331,52 +2325,33 @@ function useChatMarkdownState({ · 83 unmodified lines
23312325 // makes a persisted "app" apply once settings hydrate after launch.
23322326 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
23332327 const resolveThreadPullRequest = useCallback(
2334− (href: string): ThreadLinkedPullRequest | null => {
2328+ (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => {
23352329 if (
23362330 threadRef === undefined ||
23372331 readThreadShell(threadRef) === null ||
2338− threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true
2339− ) {
2332+ !pullRequestLinking.canLink(href)
2333+ )
23402334 return null;
2341− }
23422335 const parsed = parseChangeRequestUrl(href);
2343− if (parsed === null) return null;
2344− const project = findProjectForChangeRequest(
2345− projects.filter((candidate) => candidate.environmentId === threadRef.environmentId),
2346− parsed,
2347− );
2348− if (project === undefined) return null;
2349− return {
2350− projectId: project.id,
2351− repository: project.repositoryIdentity?.displayName ?? parsed.repository,
2352− number: parsed.number,
2353− url: href,
2354− };
2336+ return parsed === null ? null : { ...parsed, url: href };
23552337 },
2356− [projects, threadRef, threadServerConfig],
2338+ [pullRequestLinking, threadRef],
2339+ );
2340+ const linkedThreadPullRequestFor = useCallback(
2341+ (href: string) => {
2342+ if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href))
2343+ return null;
2344+ const parsed = parseChangeRequestUrl(href);
2345+ return parsed === null ? null : { ...parsed, url: href };
2346+ },
2347+ [pullRequestLinking, threadRef],
23572348 );
23582349 const updateThreadPullRequestLink = useCallback(
23592350 async (href: string, linked: boolean) => {
2360− if (threadRef === undefined) return;
2361− const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null;
2362− if (linked && linkedPullRequest === null) {
2363− throw new Error("The pull request is not available in this environment.");
2364− }
2365− if (!linked) {
2366− const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest;
2367− if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) {
2368− return;
2369− }
2370− }
2371− const result = await updateThreadMetadata({
2372− environmentId: threadRef.environmentId,
2373− input: { threadId: threadRef.threadId, linkedPullRequest },
2374− });
2375− if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2376− throw squashAtomCommandFailure(result);
2377− }
2351+ if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2352+ await pullRequestLinking.changeLink(threadRef, href, linked);
23782353 },
2379− [resolveThreadPullRequest, threadRef, updateThreadMetadata],
2354+ [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
23802355 );
23812356 const openExternalLinkInPreview = useCallback(
23822357 (url: string) => {
@@ -2588,6 +2563,7 @@ function useChatMarkdownState({ · 205 unmodified lines
25882563 openExternalLinkInPreview,
25892564 openMarkdownMedia,
25902565 projects,
2566+ linkedThreadPullRequestFor,
25912567 resolveThreadPullRequest,
25922568 resolvedTheme,
25932569 serverConfig,
@@ -2614,6 +2590,7 @@ function useChatMarkdownState({ · 20 unmodified lines
26142590 openExternalLinkInPreview,
26152591 openMarkdownMedia,
26162592 projects,
2593+ linkedThreadPullRequestFor,
26172594 resolveThreadPullRequest,
26182595 resolvedTheme,
26192596 serverConfig,
@@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = { · 112 unmodified lines
27322709 linkTargetPreference,
27332710 openExternalLinkInPreview,
27342711 projects,
2712+ linkedThreadPullRequestFor,
27352713 resolveThreadPullRequest,
27362714 serverConfig,
27372715 updateThreadPullRequestLink,
@@ -2866,13 +2844,10 @@ const CHAT_MARKDOWN_COMPONENTS = { · 128 unmodified lines
28662844 event.stopPropagation();
28672845 const api = readLocalApi();
28682846 if (!api) return;
2869− const pullRequest = resolveThreadPullRequest(href);
2870− const currentPullRequest =
2871− threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
28722847 const threadLinkAction =
2873− currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href)
2848+ linkedThreadPullRequestFor(href) !== null
28742849 ? "unlink-from-thread"
2875− : pullRequest === null
2850+ : resolveThreadPullRequest(href) === null
28762851 ? undefined
28772852 : "link-to-thread";
28782853 void showExternalLinkContextMenu({
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+2−2
@@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({ · 31 unmodified lines
3232 vi.mock("../state/entities", () => ({
3333 readThreadShell: () => null,
3434 useProjects: () => [],
35+ useServerConfigs: () => new Map(),
3536 }));
3637 vi.mock("../remoteOpen", () => ({
3738 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
@@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({ · 3 unmodified lines
4142 usePreferredEditor: () => [null, vi.fn()],
4243 }));
4344 vi.mock("~/lib/openPullRequestLink", () => ({
44− findProjectForChangeRequest: () => undefined,
45− matchesLinkedPullRequestUrl: () => false,
45+ findProjectOnChangeRequestHost: () => undefined,
4646 parseChangeRequestUrl: () => null,
4747 useOpenChangeRequestLink: () => vi.fn(),
4848 }));
apps/web/src/components/ChatView.logic.test.ts+2−0
@@ -726,6 +726,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 725 unmodified lines
726726 proposedPlans: [],
727727 activities: [],
728728 checkpoints: [],
729+ pullRequests: [],
729730 createdAt: now,
730731 updatedAt: now,
731732 archivedAt: null,
@@ -843,6 +844,7 @@ describe("buildLoadingThreadFromShell", () => { · 111 unmodified lines
843844 snoozedUntil: null,
844845 snoozedAt: null,
845846 session: null,
847+ pullRequests: [],
846848 latestUserMessageAt: now,
847849 hasPendingApprovals: false,
848850 hasPendingUserInput: false,
apps/web/src/components/ChatView.logic.ts+1−0
@@ -353,6 +353,7 @@ export function buildLocalDraftThread( · 352 unmodified lines
353353 branch: draftThread.branch,
354354 worktreePath: draftThread.worktreePath,
355355 checkpoints: [],
356+ pullRequests: [],
356357 activities: [],
357358 proposedPlans: [],
358359 };
apps/web/src/components/ChatView.tsx+30−2
@@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; · 202 unmodified lines
203203 import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
204204 import { RightPanelTabs } from "./RightPanelTabs";
205205 import { AgentsPanel } from "./AgentsPanel";
206+import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
207+import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
206208 import {
207209 deriveAgentPanelModel,
208210 foldSubagentActivities,
@@ -4127,6 +4129,12 @@ export default function ChatView(props: ChatViewProps) { · 3918 unmodified lines
41274129 if (!activeThreadRef) return;
41284130 useRightPanelStore.getState().open(activeThreadRef, "agents");
41294131 }, [activeThreadRef]);
4132+ const supportsThreadPullRequests =
4133+ serverConfig?.environment.capabilities.threadPullRequests === true;
4134+ const addPullRequestsSurface = useCallback(() => {
4135+ if (!activeThreadRef || !supportsThreadPullRequests) return;
4136+ useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
4137+ }, [activeThreadRef, supportsThreadPullRequests]);
41304138 const openFileSurface = useCallback(
41314139 (relativePath: string) => {
41324140 if (!activeThreadRef || !activeProject) return;
@@ -5375,9 +5383,16 @@ export default function ChatView(props: ChatViewProps) { · 1242 unmodified lines
53755383 : resolveThreadReferenceCopyTarget({
53765384 threadId: activeThreadId,
53775385 openPanelPullRequestUrl,
5386+ pullRequests: activeThreadMetadata?.pullRequests,
53785387 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
53795388 }),
5380− [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl],
5389+ [
5390+ activeThreadId,
5391+ isServerThread,
5392+ activeThreadMetadata?.pullRequests,
5393+ linkedThreadPullRequest?.url,
5394+ openPanelPullRequestUrl,
5395+ ],
53815396 );
53825397 const copyActiveThreadReference = useCallback(() => {
53835398 const target = activeThreadReferenceCopyTarget;
@@ -8037,11 +8052,12 @@ export default function ChatView(props: ChatViewProps) { · 2653 unmodified lines
80378052 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
80388053 // checkable out like it is anywhere else.
80398054 <PullRequestDetailPanel
8040− key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
8055+ key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
80418056 environmentId={activeThread.environmentId}
80428057 threadRef={activeThreadRef}
80438058 reference={{
80448059 projectId: renderedRightPanelSurface.projectId as ProjectId,
8060+ ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}),
80458061 repository: renderedRightPanelSurface.repository,
80468062 number: renderedRightPanelSurface.number,
80478063 }}
@@ -8062,7 +8078,14 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
80628078 : "page"
80638079 }
80648080 composerDraftTarget={composerDraftTarget}
8081+ onBack={
8082+ activeThreadRef !== null && supportsThreadPullRequests
8083+ ? addPullRequestsSurface
8084+ : undefined
8085+ }
80658086 />
8087+ ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
8088+ <ThreadPullRequestsPanel threadRef={activeThreadRef} />
80668089 ) : renderedRightPanelSurface?.kind === "agents" ? (
80678090 <AgentsPanel
80688091 model={agentPanelModel}
@@ -8620,12 +8643,14 @@ export default function ChatView(props: ChatViewProps) { · 551 unmodified lines
86208643 onAddDiff={addDiffSurface}
86218644 onAddFiles={addFilesSurface}
86228645 onAddPullRequest={addPullRequestSurface}
8646+ onAddPullRequests={addPullRequestsSurface}
86238647 onAddAgents={addAgentsSurface}
86248648 browserAvailable={isPreviewSupportedInRuntime()}
86258649 terminalAvailable={activeProject !== null}
86268650 diffAvailable={isServerThread && isGitRepo}
86278651 filesAvailable={activeProject !== null}
86288652 pullRequestAvailable={pullRequestSurfaceAvailable}
8653+ pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
86298654 agentsAvailable
86308655 liveAgentCount={agentPanelModel.liveCount}
86318656 >
@@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) { · 38 unmodified lines
86708695 onAddDiff={addDiffSurface}
86718696 onAddFiles={addFilesSurface}
86728697 onAddPullRequest={addPullRequestSurface}
8698+ onAddPullRequests={addPullRequestsSurface}
86738699 onAddAgents={addAgentsSurface}
86748700 browserAvailable={isPreviewSupportedInRuntime()}
86758701 terminalAvailable={activeProject !== null}
86768702 diffAvailable={isServerThread && isGitRepo}
86778703 filesAvailable={activeProject !== null}
86788704 pullRequestAvailable={pullRequestSurfaceAvailable}
8705+ pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
86798706 agentsAvailable
86808707 liveAgentCount={agentPanelModel.liveCount}
86818708 >
@@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) { · 2 unmodified lines
86848711 </RightPanelSheet>
86858712 ) : null}
86868713
8714+ <LinkPullRequestDialogHost />
86878715 {expandedImage && (
86888716 <ExpandedImageDialog
86898717 key={expandedImageKey(expandedImage)}
apps/web/src/components/CommandPalette.logic.test.ts+1−0
@@ -149,6 +149,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 148 unmodified lines
149149 branch: null,
150150 worktreePath: null,
151151 checkpoints: [],
152+ pullRequests: [],
152153 activities: [],
153154 ...overrides,
154155 };
apps/web/src/components/CommandPalette.tsx+38−1
@@ -1,5 +1,7 @@
11 "use client";
22
3+import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
4+
35 import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
46 import {
57 canCreateProjectInEnvironment,
@@ -43,6 +45,7 @@ import { · 37 unmodified lines
4345 FileSearchIcon,
4446 FolderIcon,
4547 FolderPlusIcon,
48+ GitPullRequestArrowIcon,
4649 LinkIcon,
4750 MessageSquareIcon,
4851 PaletteIcon,
@@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; · 31 unmodified lines
8083 import { useAtomCommand } from "../state/use-atom-command";
8184 import { useAtomQueryRunner } from "../state/use-atom-query-runner";
8285 import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
83−import { useProjects, useThreadShells } from "../state/entities";
86+import { useProjects, useServerConfigs, useThreadShells } from "../state/entities";
8487 import { useThreadSearch } from "../state/queries";
8588 import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
8689 import {
@@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" · 61 unmodified lines
148151 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
149152 import { ProjectFavicon } from "./ProjectFavicon";
150153 import { ProjectFilePicker } from "./files/ProjectFilePicker";
154+import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
151155 import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
152156 import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
153157 import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
@@ -605,12 +609,16 @@ function OpenCommandPaletteDialog(props: { · 451 unmodified lines
605609 ? scopeThreadRef(activeThread.environmentId, activeThread.id)
606610 : null;
607611 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
612+ const activeThreadServerConfig = useServerConfigs().get(
613+ activeThread?.environmentId ?? ("" as EnvironmentId),
614+ );
608615 const activeThreadReferenceCopyTarget =
609616 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
610617 ? null
611618 : resolveThreadReferenceCopyTarget({
612619 threadId: referenceThreadRef.threadId,
613620 openPanelPullRequestUrl,
621+ pullRequests: activeThread?.pullRequests,
614622 linkedPullRequestUrl:
615623 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
616624 });
@@ -1591,6 +1599,35 @@ function OpenCommandPaletteDialog(props: { · 974 unmodified lines
15911599 });
15921600 }
15931601
1602+ if (
1603+ activeThread !== null &&
1604+ threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported"
1605+ ) {
1606+ const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
1607+ actionItems.push({
1608+ kind: "action",
1609+ value: "action:link-pull-request",
1610+ searchTerms: ["link", "pull request", "pr", "attach", "stack"],
1611+ title: "Link pull request to thread",
1612+ icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1613+ run: async () => {
1614+ openLinkPullRequestDialog(threadRef);
1615+ },
1616+ });
1617+ if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) {
1618+ actionItems.push({
1619+ kind: "action",
1620+ value: "action:open-thread-pull-requests",
1621+ searchTerms: ["pull requests", "linked", "stack", "prs"],
1622+ title: "Show linked pull requests",
1623+ icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1624+ run: async () => {
1625+ useRightPanelStore.getState().open(threadRef, "pull-requests");
1626+ },
1627+ });
1628+ }
1629+ }
1630+
15941631 actionItems.push({
15951632 kind: "action",
15961633 value: "action:open-file-picker",
apps/web/src/components/GitActionsControl.tsx+3−0
@@ -1351,6 +1351,9 @@ export default function GitActionsControl({ · 1350 unmodified lines
13511351 ...(commitMessage ? { commitMessage } : {}),
13521352 ...(featureBranch ? { featureBranch } : {}),
13531353 ...(filePaths ? { filePaths } : {}),
1354+ // A pull request the action opens is linked to the thread it ran beside. Drafts
1355+ // have no server thread yet, so there is nothing to link to.
1356+ ...(activeServerThread ? { threadId: activeServerThread.id } : {}),
13541357 onProgress: applyProgressEvent,
13551358 });
13561359
apps/web/src/components/LegacySidebar.tsx+45−4
@@ -1,3 +1,10 @@
1+import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2+import { GitPullRequestIcon } from "lucide-react";
3+import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton";
4+import {
5+ resolveThreadCurrentPullRequestLink,
6+ visibleThreadPullRequests,
7+} from "@t3tools/shared/threadPullRequests";
18 import { Spinner } from "~/components/ui/spinner";
29 import {
310 ArchiveIcon,
@@ -465,10 +472,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 461 unmodified lines
465472 });
466473 const linkedPullRequestStatus = useLinkedThreadPullRequest(
467474 thread.environmentId,
468− thread.linkedPullRequest ?? thread.branchPullRequest,
475+ thread.linkedPullRequest,
469476 leaseLiveStatus,
477+ thread.pullRequests,
478+ thread.branchPullRequest,
470479 );
471480 const pr = linkedPullRequestStatus?.pr ?? null;
481+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
482+ const currentLinkedPr = supportsMultiplePullRequests
483+ ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
484+ : null;
472485 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
473486 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
474487 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
@@ -579,17 +592,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 104 unmodified lines
579592 );
580593 const handlePrClick = useCallback(
581594 (event: React.MouseEvent<HTMLAnchorElement>) => {
582− if (!prStatus) return;
595+ const url = prStatus?.url ?? currentLinkedPr?.url;
596+ if (!url) return;
583597 const openedInRightPanel = openPrLink(
584598 event,
585− prStatus.url,
599+ url,
586600 openPullRequestsInRightPanel ? threadRef : undefined,
587601 );
588602 if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) {
589603 navigateToThread(threadRef);
590604 }
591605 },
592− [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef],
606+ [
607+ isActive,
608+ navigateToThread,
609+ openPrLink,
610+ openPullRequestsInRightPanel,
611+ prStatus,
612+ currentLinkedPr,
613+ threadRef,
614+ ],
593615 );
594616 const handleRenameInputRef = useCallback(
595617 (element: HTMLInputElement | null) => {
@@ -729,6 +751,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 133 unmodified lines
729751 </TooltipPopup>
730752 </Tooltip>
731753 )}
754+ {!pr && currentLinkedPr ? (
755+ <a
756+ href={currentLinkedPr.url}
757+ target="_blank"
758+ rel="noopener noreferrer"
759+ onPointerDown={(event) => event.stopPropagation()}
760+ onClick={handlePrClick}
761+ className="text-muted-foreground"
762+ aria-label={`PR #${currentLinkedPr.number}, status pending`}
763+ >
764+ <GitPullRequestIcon className="size-3" />
765+ </a>
766+ ) : null}
767+ {pr &&
768+ (supportsMultiplePullRequests
769+ ? visibleThreadPullRequests(thread.pullRequests).length === 0
770+ : thread.linkedPullRequest == null) ? (
771+ <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
772+ ) : null}
732773 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
733774 {renamingThreadKey === threadKey ? (
734775 <input
apps/web/src/components/RightPanelTabs.test.tsx+48−0
@@ -1,9 +1,11 @@
1+import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
12 import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
23 import { renderToStaticMarkup } from "react-dom/server";
34 import { describe, expect, it } from "vite-plus/test";
45
56 import {
67 RightPanelTabs,
8+ resolvePullRequestTabLink,
79 shouldOpenDefaultBrowserProfileFromMenuClick,
810 surfaceShortcutActionForKey,
911 surfaceShortcutTargetsTypingContext,
@@ -117,6 +119,7 @@ function renderTabs( · 107 unmodified lines
117119 onAddBrowserInProfile={() => undefined}
118120 onAddTerminal={() => undefined}
119121 onAddPullRequest={() => undefined}
122+ onAddPullRequests={() => undefined}
120123 onAddDiff={() => undefined}
121124 onAddFiles={() => undefined}
122125 onAddAgents={() => undefined}
@@ -126,6 +129,7 @@ function renderTabs( · 3 unmodified lines
126129 diffAvailable={false}
127130 filesAvailable={false}
128131 pullRequestAvailable={false}
132+ pullRequestsAvailable={false}
129133 agentsAvailable={false}
130134 >
131135 <div>content</div>
@@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => { · 146 unmodified lines
278282 });
279283 });
280284 });
285+
286+describe("pull request tab snapshots", () => {
287+ const environmentId = EnvironmentId.make("local");
288+ const link: ThreadPullRequestLink = {
289+ host: "github.com",
290+ repository: "acme/api",
291+ number: 7,
292+ url: "https://github.com/acme/api/pull/7",
293+ source: "manual",
294+ linkedAt: "2026-01-01T00:00:00Z",
295+ stack: null,
296+ snapshot: null,
297+ };
298+ it("keeps unknown linked state authoritative and scopes matches to environment and host", () => {
299+ const threads = [{ environmentId, pullRequests: [link] }];
300+ expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link);
301+ expect(
302+ resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link),
303+ ).toBeUndefined();
304+ expect(
305+ resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link),
306+ ).toBeUndefined();
307+ });
308+ it("uses the newest snapshot when several threads link the same PR", () => {
309+ const snapshot = {
310+ state: "merged" as const,
311+ title: "API",
312+ headBranch: "api",
313+ baseBranch: "main",
314+ isDraft: false,
315+ updatedAt: null,
316+ syncedAt: "2026-02-01T00:00:00Z",
317+ };
318+ const newer = { ...link, snapshot };
319+ expect(
320+ resolvePullRequestTabLink(
321+ [{ environmentId, pullRequests: [link, newer] }],
322+ environmentId,
323+ "github.com",
324+ link,
325+ ),
326+ ).toBe(newer);
327+ });
328+});
apps/web/src/components/RightPanelTabs.tsx+97−3
@@ -1,3 +1,10 @@
1+import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
2+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3+import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";
4+import {
5+ threadPullRequestKeysEqual,
6+ visibleThreadPullRequests,
7+} from "@t3tools/shared/threadPullRequests";
18 import type {
29 ContextMenuItem,
310 EnvironmentId,
@@ -14,6 +21,7 @@ import { · 10 unmodified lines
1421 FileDiff,
1522 Files,
1623 GitPullRequest,
24+ GitPullRequestArrow,
1725 Globe2,
1826 Plus,
1927 TerminalSquare,
@@ -104,12 +112,14 @@ interface RightPanelTabsProps { · 84 unmodified lines
104112 onAddDiff: () => void;
105113 onAddFiles: () => void;
106114 onAddPullRequest: () => void;
115+ onAddPullRequests: () => void;
107116 onAddAgents: () => void;
108117 browserAvailable: boolean;
109118 terminalAvailable: boolean;
110119 diffAvailable: boolean;
111120 filesAvailable: boolean;
112121 pullRequestAvailable: boolean;
122+ pullRequestsAvailable: boolean;
113123 agentsAvailable: boolean;
114124 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
115125 /** Running + waiting subagents; badges the Agents card in the empty state. */
@@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = { · 23 unmodified lines
139149 files: "Files are only available when a project is open.",
140150 diff: "Diff is only available for server threads in Git repositories.",
141151 pullRequest: "This thread's branch has no pull request yet.",
152+ pullRequests: "Linked pull requests are only available for server threads.",
142153 agents: "Agents are only available from a thread.",
143154 } as const;
144155
@@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = { · 16 unmodified lines
161172 files: "Available when a project is open.",
162173 diff: "Available for Git repositories.",
163174 pullRequest: "No pull request on this branch yet.",
175+ pullRequests: "Available for server threads.",
164176 agents: "Available from a thread.",
165177 } as const;
166178
@@ -298,12 +310,14 @@ function RightPanelEmptyState(props: { · 131 unmodified lines
298310 onAddDiff: () => void;
299311 onAddFiles: () => void;
300312 onAddPullRequest: () => void;
313+ onAddPullRequests: () => void;
301314 onAddAgents: () => void;
302315 browserAvailable: boolean;
303316 terminalAvailable: boolean;
304317 diffAvailable: boolean;
305318 filesAvailable: boolean;
306319 pullRequestAvailable: boolean;
320+ pullRequestsAvailable: boolean;
307321 agentsAvailable: boolean;
308322 liveAgentCount: number;
309323 }) {
@@ -361,6 +375,16 @@ function RightPanelEmptyState(props: { · 51 unmodified lines
361375 onClick: props.onAddPullRequest,
362376 badgeCount: 0,
363377 },
378+ {
379+ label: "Linked pull requests",
380+ description: "Every pull request this thread has linked, stacks included.",
381+ icon: GitPullRequestArrow,
382+ shortcut: "L",
383+ available: props.pullRequestsAvailable,
384+ disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,
385+ onClick: props.onAddPullRequests,
386+ badgeCount: 0,
387+ },
364388 {
365389 label: "Agents",
366390 description: "Follow subagents and workflows.",
@@ -602,6 +626,8 @@ function surfaceTitle( · 235 unmodified lines
602626 );
603627 case "pull-request":
604628 return `#${surface.number}`;
629+ case "pull-requests":
630+ return "Pull requests";
605631 case "agents":
606632 return "Agents";
607633 case "preview": {
@@ -683,11 +709,42 @@ function SurfaceIcon({ · 75 unmodified lines
683709 seed={pullRequestStatusSeeds?.[surface.id]}
684710 />
685711 );
712+ case "pull-requests":
713+ return <GitPullRequestArrow className="size-3 shrink-0" />;
686714 case "agents":
687715 return <Bot className="size-3 shrink-0" />;
688716 }
689717 }
690718
719+export function resolvePullRequestTabLink(
720+ threads: readonly Pick<EnvironmentThreadShell, "environmentId" | "pullRequests">[],
721+ environmentId: EnvironmentId | null,
722+ host: string | null,
723+ reference: { repository: string; number: number },
724+) {
725+ if (environmentId === null || host === null) return undefined;
726+ let newest: EnvironmentThreadShell["pullRequests"][number] | undefined;
727+ for (const thread of threads) {
728+ if (thread.environmentId !== environmentId) continue;
729+ for (const link of visibleThreadPullRequests(thread.pullRequests)) {
730+ if (
731+ !threadPullRequestKeysEqual(link, {
732+ host,
733+ repository: reference.repository,
734+ number: reference.number,
735+ })
736+ )
737+ continue;
738+ if (
739+ newest === undefined ||
740+ (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "")
741+ )
742+ newest = link;
743+ }
744+ }
745+ return newest;
746+}
747+
691748 function PullRequestSurfaceIcon({
692749 surface,
693750 environmentId,
@@ -699,13 +756,36 @@ function PullRequestSurfaceIcon({ · 5 unmodified lines
699756 }) {
700757 const resolvedEnvironmentId =
701758 (surface.environmentId as EnvironmentId | undefined) ?? environmentId;
702− const detail = useEnvironmentQuery(
759+ const projects = useProjects();
760+ const threads = useThreadShells();
761+ const project = projects.find(
762+ (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId,
763+ );
764+ const identity = project?.repositoryIdentity;
765+ const host =
766+ surface.host ??
767+ (identity?.provider
768+ ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
769+ : null);
770+ const configs = useServerConfigs();
771+ const capabilities =
703772 resolvedEnvironmentId === null
773+ ? undefined
774+ : configs.get(resolvedEnvironmentId)?.environment.capabilities;
775+ const linkedSnapshot =
776+ capabilities?.threadPullRequests === true
777+ ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null)
778+ : null;
779+ const detail = useEnvironmentQuery(
780+ resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null
704781 ? null
705782 : pullRequestEnvironment.detail({
706783 environmentId: resolvedEnvironmentId,
707784 input: {
708785 projectId: surface.projectId as ProjectId,
786+ ...(capabilities?.threadPullRequests === true && surface.host !== undefined
787+ ? { host: surface.host }
788+ : {}),
709789 repository: surface.repository,
710790 number: surface.number,
711791 },
@@ -714,11 +794,15 @@ function PullRequestSurfaceIcon({ · 2 unmodified lines
714794 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the
715795 // full detail would flip an open tab to the conflict glyph the moment its read lands.
716796 const status =
717− detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft };
797+ linkedSnapshot !== null
798+ ? linkedSnapshot
799+ : detail === null
800+ ? (seed ?? null)
801+ : { state: detail.state, isDraft: detail.isDraft };
718802 if (status === null) {
719803 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;
720804 }
721− const presentation = resolvePullRequestState(status);
805+ const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
722806 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
723807 }
724808
@@ -806,6 +890,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 81 unmodified lines
806890 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
807891 onClick: props.onAddPullRequest,
808892 },
893+ {
894+ label: "Linked pull requests",
895+ icon: GitPullRequestArrow,
896+ shortcut: "L",
897+ available: props.pullRequestsAvailable,
898+ disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
899+ onClick: props.onAddPullRequests,
900+ },
809901 {
810902 label: "Agents",
811903 icon: Bot,
@@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 438 unmodified lines
12501342 onAddDiff={props.onAddDiff}
12511343 onAddFiles={props.onAddFiles}
12521344 onAddPullRequest={props.onAddPullRequest}
1345+ onAddPullRequests={props.onAddPullRequests}
12531346 onAddAgents={props.onAddAgents}
12541347 browserAvailable={props.browserAvailable}
12551348 terminalAvailable={props.terminalAvailable}
12561349 diffAvailable={props.diffAvailable}
12571350 filesAvailable={props.filesAvailable}
12581351 pullRequestAvailable={props.pullRequestAvailable}
1352+ pullRequestsAvailable={props.pullRequestsAvailable}
12591353 agentsAvailable={props.agentsAvailable}
12601354 liveAgentCount={props.liveAgentCount}
12611355 />
apps/web/src/components/Sidebar.logic.test.ts+1−0
@@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 2132 unmodified lines
21332133 branch: null,
21342134 worktreePath: null,
21352135 checkpoints: [],
2136+ pullRequests: [],
21362137 activities: [],
21372138 ...overrides,
21382139 };
apps/web/src/components/Sidebar.tsx+114−11
@@ -1,3 +1,9 @@
1+import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2+import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton";
3+import {
4+ resolveThreadCurrentPullRequestLink,
5+ visibleThreadPullRequests,
6+} from "@t3tools/shared/threadPullRequests";
17 import { useAtomValue } from "@effect/atom-react";
28 import * as Schema from "effect/Schema";
39 import {
@@ -70,6 +76,7 @@ import { · 66 unmodified lines
7076 } from "react";
7177 import { useParams, useRouter } from "@tanstack/react-router";
7278
79+import { useRightPanelStore } from "../rightPanelStore";
7380 import {
7481 isAtomCommandInterrupted,
7582 settlePromise,
@@ -185,8 +192,12 @@ import { · 109 unmodified lines
185192 import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
186193 import { createSidebarListMotion } from "./Sidebar.motion";
187194 import {
195+ PR_STATE_COLOR_CLASS,
196+ ThreadPullRequestBadgeIcon,
197+ ThreadPullRequestsMiniList,
188198 ThreadWorktreeIndicator,
189199 prStatusIndicator,
200+ resolveThreadPullRequestBadge,
190201 settledPrHoverColorClass,
191202 terminalStatusFromRunningIds,
192203 type TerminalStatusIndicator,
@@ -209,7 +220,7 @@ import { · 16 unmodified lines
209220 } from "../providerInstances";
210221 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
211222 import { stackedThreadToast, toastManager } from "./ui/toast";
212−import { Button } from "./ui/button";
223+import { Button, InlineButton } from "./ui/button";
213224 import { Input } from "./ui/input";
214225 import {
215226 Combobox,
@@ -330,6 +341,7 @@ function SidebarThreadTooltip({ · 114 unmodified lines
330341 terminalProcessCount: number;
331342 }) {
332343 const driverKind = providerEntry?.driverKind ?? null;
344+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
333345 return (
334346 <TooltipPopup
335347 side="right"
@@ -411,6 +423,11 @@ function SidebarThreadTooltip({ · 75 unmodified lines
411423 </div>
412424 ) : null}
413425 </div>
426+ {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? (
427+ <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground">
428+ <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} />
429+ </div>
430+ ) : null}
414431 </div>
415432 </TooltipPopup>
416433 );
@@ -1047,8 +1064,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 630 unmodified lines
10471064 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
10481065 const linkedPullRequestStatus = useLinkedThreadPullRequest(
10491066 thread.environmentId,
1050− thread.linkedPullRequest ?? thread.branchPullRequest,
1067+ thread.linkedPullRequest,
10511068 leaseLiveStatus,
1069+ thread.pullRequests,
1070+ thread.branchPullRequest,
10521071 );
10531072 const gitStatus = useEnvironmentQuery(
10541073 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
@@ -1063,6 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 8 unmodified lines
10631082 gitStatus.data,
10641083 );
10651084 const pr = linkedPullRequestStatus?.pr ?? null;
1085+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
1086+ const currentLinkedPr = supportsMultiplePullRequests
1087+ ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
1088+ : null;
10661089
10671090 // Same semantics as the legacy sidebar (never-visited counts as read):
10681091 // switching sidebars must not light up every historical thread as unread.
@@ -1339,17 +1362,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 270 unmodified lines
13391362 }, [showSnoozeButton]);
13401363 const handlePrClick = useCallback(
13411364 (event: ReactMouseEvent<HTMLAnchorElement>) => {
1342− if (!pr?.url) return;
1365+ const url = pr?.url ?? currentLinkedPr?.url;
1366+ if (!url) return;
13431367 const openedInRightPanel = openPrLink(
13441368 event,
1345− pr.url,
1369+ url,
13461370 openPullRequestsInRightPanel ? threadRef : undefined,
13471371 );
13481372 if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {
13491373 onThreadActivate(threadRef);
13501374 }
13511375 },
1352− [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef],
1376+ [
1377+ onThreadActivate,
1378+ openPrLink,
1379+ openPullRequestsInRightPanel,
1380+ pr,
1381+ currentLinkedPr,
1382+ props.isActive,
1383+ threadRef,
1384+ ],
13531385 );
13541386
13551387 // All sidebar rows share one surface model. Live threads used to look
@@ -1456,10 +1488,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 100 unmodified lines
14561488 </span>
14571489 );
14581490
1459− // A real link so cmd/ctrl+click and middle-click open the host in the
1460− // browser. A plain click still opens T3's pull request view.
1491+ // One badge shape for every thread: the glyph says stack or not, the number is the current
1492+ // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and
1493+ // middle-click open the host in the browser; a plain click opens T3's pull request view.
1494+ const prBadgeShape = supportsMultiplePullRequests
1495+ ? resolveThreadPullRequestBadge(thread.pullRequests)
1496+ : null;
1497+ const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) =>
1498+ cn(
1499+ // Sidebar chrome follows the interface font; tabular digits keep the number from
1500+ // reflowing as PR states stream in. A border rather than text-decoration, so the line
1501+ // runs under the glyph as well as the number.
1502+ "text-xs tabular-nums",
1503+ variant === "slim" && variantAction === "unsettle"
1504+ ? props.isActive
1505+ ? "text-secondary-label"
1506+ : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state))
1507+ : colorClass,
1508+ );
1509+ const handlePrStackClick = useCallback(() => {
1510+ useRightPanelStore.getState().open(threadRef, "pull-requests");
1511+ if (!props.isActive) onThreadActivate(threadRef);
1512+ }, [onThreadActivate, props.isActive, threadRef]);
14611513 const prBadge =
1462− prStatus && pr ? (
1514+ prBadgeShape?.kind === "stack" ? (
1515+ // A stack is one thing with N layers; naming one of them would misrepresent it, so the
1516+ // badge counts layers and opens the thread's pull-requests surface.
1517+ <InlineButton
1518+ underline
1519+ onPointerDown={(event) => event.stopPropagation()}
1520+ onClick={handlePrStackClick}
1521+ className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])}
1522+ aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`}
1523+ >
1524+ <ThreadPullRequestBadgeIcon icon="stack" />
1525+ {prBadgeShape.layers}
1526+ </InlineButton>
1527+ ) : prStatus && pr ? (
14631528 <a
14641529 href={pr.url}
14651530 target="_blank"
@@ -1469,14 +1534,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 3 unmodified lines
14691534 className={cn(
14701535 // Sidebar chrome follows the interface font; tabular digits keep the
14711536 // number from reflowing as PR states stream in.
1472− "shrink-0 text-xs tabular-nums hover:underline",
1537+ "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline",
14731538 variant === "slim" && variantAction === "unsettle"
14741539 ? cn("text-secondary-label transition-colors", settledPrHoverClass)
14751540 : prStatus.colorClass,
14761541 )}
1477− aria-label={prStatus.tooltip}
1542+ aria-label={
1543+ prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0
1544+ ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked`
1545+ : prStatus.tooltip
1546+ }
14781547 >
1479− #{pr.number}
1548+ <ThreadPullRequestBadgeIcon icon="pull-request" />
1549+ {pr.number}
1550+ {prBadgeShape && prBadgeShape.others > 0 ? (
1551+ <span className="opacity-70">+{prBadgeShape.others}</span>
1552+ ) : null}
1553+ </a>
1554+ ) : currentLinkedPr ? (
1555+ <a
1556+ href={currentLinkedPr.url}
1557+ target="_blank"
1558+ rel="noopener noreferrer"
1559+ onPointerDown={(event) => event.stopPropagation()}
1560+ onClick={handlePrClick}
1561+ className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline"
1562+ aria-label={`PR #${currentLinkedPr.number}, status pending`}
1563+ >
1564+ <ThreadPullRequestBadgeIcon icon="pull-request" />
1565+ {currentLinkedPr.number}
1566+ {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? (
1567+ <span>+{prBadgeShape.others}</span>
1568+ ) : null}
14801569 </a>
14811570 ) : null;
14821571 const terminalStatusIcon = terminalStatus ? (
@@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 106 unmodified lines
15891678 remain visible AND clickable while the row is hovered. Only
15901679 the time/jump label yields to the settle affordance. */}
15911680 {prBadge}
1681+ {prBadge &&
1682+ pr &&
1683+ (supportsMultiplePullRequests
1684+ ? visibleThreadPullRequests(thread.pullRequests).length === 0
1685+ : thread.linkedPullRequest == null) ? (
1686+ <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
1687+ ) : null}
15921688 {sortable?.isDragging ? (
15931689 dragDestination
15941690 ) : (
@@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 294 unmodified lines
18891985 )}
18901986 {terminalStatusIcon}
18911987 {prBadge}
1988+ {prBadge &&
1989+ pr &&
1990+ (supportsMultiplePullRequests
1991+ ? visibleThreadPullRequests(thread.pullRequests).length === 0
1992+ : thread.linkedPullRequest == null) ? (
1993+ <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
1994+ ) : null}
18921995 {diff ? (
18931996 <span className="shrink-0 font-mono">
18941997 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
apps/web/src/components/ThreadStatusIndicators.test.tsx+45−2
@@ -1,8 +1,8 @@
1−import { ThreadId } from "@t3tools/contracts";
1+import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
22 import { renderToStaticMarkup } from "react-dom/server";
33 import { describe, expect, it } from "vite-plus/test";
44
5−import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators";
5+import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators";
66
77 describe("ThreadWorktreeIndicator", () => {
88 it("renders the worktree folder and branch in an accessible label", () => {
@@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => { · 28 unmodified lines
3737 expect(markup).toBe("");
3838 });
3939 });
40+
41+describe("linked pull request snapshots", () => {
42+ const link: ThreadPullRequestLink = {
43+ host: "gitlab.example.com",
44+ repository: "acme/web",
45+ number: 42,
46+ url: "https://gitlab.example.com/acme/web/-/merge_requests/42",
47+ source: "manual",
48+ linkedAt: "2026-01-01T00:00:00Z",
49+ stack: null,
50+ snapshot: null,
51+ };
52+ it("keeps unsynced links unknown", () => {
53+ expect(linkedPullRequestSnapshotStatus(link)).toBeNull();
54+ });
55+ it("uses the snapshot state and branches with the linked identity", () => {
56+ const result = linkedPullRequestSnapshotStatus({
57+ ...link,
58+ snapshot: {
59+ state: "merged",
60+ title: "Change",
61+ headBranch: "feature",
62+ baseBranch: "main",
63+ isDraft: false,
64+ updatedAt: "2026-01-02T00:00:00Z",
65+ syncedAt: "2026-01-03T00:00:00Z",
66+ },
67+ });
68+ expect(result).toEqual({
69+ pr: {
70+ number: 42,
71+ url: link.url,
72+ title: "Change",
73+ state: "merged",
74+ isDraft: false,
75+ headRef: "feature",
76+ baseRef: "main",
77+ updatedAt: "2026-01-02T00:00:00Z",
78+ },
79+ sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" },
80+ });
81+ });
82+});
apps/web/src/components/ThreadStatusIndicators.tsx+161−28
@@ -1,25 +1,35 @@
1+import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
12 import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
23 import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
34 import {
4− type EnvironmentId,
55 resolveEnvironmentMachineKind,
6+ type EnvironmentId,
67 type ThreadLinkedPullRequest,
8+ type ThreadPullRequestLink,
79 type VcsStatusResult,
810 } from "@t3tools/contracts";
9−import { FolderGit2Icon, TerminalIcon } from "lucide-react";
11+import {
12+ resolveThreadCurrentPullRequestLink,
13+ resolveThreadPullRequestChains,
14+ visibleThreadPullRequests,
15+} from "@t3tools/shared/threadPullRequests";
16+import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";
1017 import { useMemo } from "react";
18+import { cn } from "../lib/utils";
1119 import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1220 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
21+import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
1322 import { useEnvironmentQuery } from "../state/query";
1423 import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
1524 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
1625 import { useUiStateStore } from "../uiStateStore";
1726 import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
1827 import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
19−import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
2028 import type { SidebarThreadSummary } from "../types";
2129 import { formatWorktreePathForDisplay } from "../worktreeCleanup";
2230 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31+import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
32+import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
2333
2434 export interface PrStatusIndicator {
2535 label: string;
@@ -43,42 +53,151 @@ export interface LinkedThreadPullRequestStatus { · 17 unmodified lines
4353 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
4454 }
4555
46−/** Keep cached summaries visible when an offscreen row stops live queries. */
56+/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */
4757 export function useLinkedThreadPullRequest(
4858 environmentId: EnvironmentId | null,
4959 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
5060 enabled = true,
61+ pullRequests?: ReadonlyArray<ThreadPullRequestLink>,
62+ branchPullRequest?: ThreadLinkedPullRequest | null,
5163 ): LinkedThreadPullRequestStatus | null {
64+ const supportsLinks = useSupportsMultiplePullRequests(environmentId);
65+ const current = useMemo(
66+ () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null),
67+ [pullRequests, supportsLinks],
68+ );
69+ const fallback =
70+ current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;
71+ const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host;
72+ const reference =
73+ fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) };
5274 const queried = useEnvironmentQuery(
53− !enabled || environmentId === null || linkedPullRequest == null
75+ !enabled || environmentId === null || reference === null
5476 ? null
55− : linkedPullRequestDetailAtom({
56− environmentId,
57− input: {
58− projectId: linkedPullRequest.projectId,
59− repository: linkedPullRequest.repository,
60− number: linkedPullRequest.number,
61− },
62− }),
77+ : linkedPullRequestDetailAtom({ environmentId, input: reference }),
6378 ).data;
64− const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried);
79+ const detail = useSharedPullRequestSummary(environmentId, reference, queried);
80+
81+ return useMemo(() => {
82+ if (current !== null) return linkedPullRequestSnapshotStatus(current);
83+ return detail === null
84+ ? null
85+ : {
86+ pr: pullRequestDetailToVcsStatus(detail),
87+ sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" },
88+ };
89+ }, [current, detail]);
90+}
6591
66− return useMemo(
92+export function linkedPullRequestSnapshotStatus(
93+ link: ThreadPullRequestLink,
94+): LinkedThreadPullRequestStatus | null {
95+ const snapshot = link.snapshot;
96+ if (snapshot === null) return null;
97+ const kind = link.url.includes("/-/merge_requests/")
98+ ? "gitlab"
99+ : link.url.includes("/pullrequest/")
100+ ? "azure-devops"
101+ : link.url.includes("/pull-requests/")
102+ ? "bitbucket"
103+ : "github";
104+ return {
105+ pr: {
106+ number: link.number,
107+ url: link.url,
108+ title: snapshot.title,
109+ state: snapshot.state,
110+ isDraft: snapshot.isDraft,
111+ headRef: snapshot.headBranch,
112+ baseRef: snapshot.baseBranch,
113+ ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
114+ },
115+ sourceControlProvider: { kind, name: kind, baseUrl: "" },
116+ };
117+}
118+
119+export {
120+ resolveThreadPullRequestBadge,
121+ type ThreadPullRequestBadge,
122+} from "@t3tools/shared/threadPullRequests";
123+
124+/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */
125+export function ThreadPullRequestBadgeIcon({
126+ icon,
127+ className,
128+}: {
129+ icon: "stack" | "pull-request";
130+ className?: string | undefined;
131+}) {
132+ const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon;
133+ return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
134+}
135+
136+/**
137+ * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
138+ * so the hover answers "what is in here" without opening the surface.
139+ */
140+export function ThreadPullRequestsMiniList({
141+ pullRequests,
142+}: {
143+ pullRequests: ReadonlyArray<ThreadPullRequestLink>;
144+}) {
145+ const lines = useMemo(
67146 () =>
68− detail === null
69− ? null
70− : {
71− pr: pullRequestDetailToVcsStatus(detail),
72− sourceControlProvider: {
73− kind: detail.provider,
74− name: detail.provider,
75− baseUrl: "",
76− },
77− },
78− [detail],
147+ pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))),
148+ [pullRequests],
149+ );
150+ if (lines.length === 0) return null;
151+ return (
152+ <ul className="flex flex-col gap-1">
153+ {lines.map((line) => {
154+ const snapshot = line.link.snapshot;
155+ const presentation =
156+ snapshot === null
157+ ? null
158+ : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });
159+ return (
160+ <li
161+ key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
162+ className="flex min-w-0 items-center gap-2"
163+ // Capped like the panel: past a few layers the indent only repeats "still in the
164+ // stack", and sixteen of them would walk the titles off the popover.
165+ style={{ paddingLeft: `${Math.min(line.depth, 3) * 0.75}rem` }}
166+ >
167+ {presentation ? (
168+ <presentation.Icon
169+ aria-hidden
170+ className={cn("size-3 shrink-0", presentation.toneClassName)}
171+ />
172+ ) : (
173+ <GitPullRequestArrowIcon
174+ aria-hidden
175+ className="size-3 shrink-0 stroke-muted-foreground"
176+ />
177+ )}
178+ <span className="shrink-0 font-mono tabular-nums">#{line.link.number}</span>
179+ <span className="min-w-0 truncate text-foreground/75">
180+ {snapshot?.title ?? line.link.repository}
181+ </span>
182+ {line.stack ? (
183+ <span className="ml-auto shrink-0 pl-1 text-[10px]">
184+ {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size}
185+ </span>
186+ ) : null}
187+ </li>
188+ );
189+ })}
190+ </ul>
79191 );
80192 }
81193
194+/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
195+export const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = {
196+ open: "text-emerald-600 dark:text-emerald-300/90",
197+ merged: "text-violet-600 dark:text-violet-300/90",
198+ closed: "text-red-600 dark:text-red-300/90",
199+};
200+
82201 export function settledPrHoverColorClass(
83202 state: NonNullable<ThreadPr>["state"],
84203 isDraft = false,
@@ -282,7 +401,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 197 unmodified lines
282401 );
283402 const pullRequest = useLinkedThreadPullRequest(
284403 thread.environmentId,
285− thread.linkedPullRequest ?? thread.branchPullRequest,
404+ thread.linkedPullRequest,
405+ true,
406+ thread.pullRequests,
407+ thread.branchPullRequest,
286408 );
287409 const pr = pullRequest?.pr ?? null;
288410 const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);
@@ -293,7 +415,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 4 unmodified lines
293415 },
294416 });
295417
296− if (!prStatus && !threadStatus) {
418+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
419+ const pendingLink =
420+ pr === null && supportsMultiplePullRequests
421+ ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
422+ : null;
423+ if (!prStatus && !threadStatus && !pendingLink) {
297424 return null;
298425 }
299426
@@ -316,6 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 16 unmodified lines
316443 </TooltipPopup>
317444 </Tooltip>
318445 ) : null}
446+ {pendingLink ? (
447+ <GitPullRequestArrowIcon
448+ className="size-3 text-muted-foreground"
449+ aria-label={`PR #${pendingLink.number}, status pending`}
450+ />
451+ ) : null}
319452 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
320453 </span>
321454 );
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+1−0
@@ -390,6 +390,7 @@ describe("streaming row projection", () => { · 389 unmodified lines
390390 runtimeMode: "full-access",
391391 interactionMode: "default",
392392 branch: null,
393+ pullRequests: [],
393394 worktreePath: null,
394395 latestTurn: {
395396 ...initial.input.latestTurn,
apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx+54−0
@@ -0,0 +1,54 @@
1+import type { ScopedThreadRef } from "@t3tools/contracts";
2+import { Link2 } from "lucide-react";
3+import { useState } from "react";
4+import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
5+import { Button } from "../ui/button";
6+import { toastManager } from "../ui/toast";
7+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8+
9+/** Adopts a branch discovery as a durable link, even after the thread changes branches. */
10+export function LinkBranchPullRequestButton({
11+ threadRef,
12+ url,
13+}: {
14+ threadRef: ScopedThreadRef;
15+ url: string;
16+}) {
17+ const linking = usePullRequestLinking(threadRef.environmentId);
18+ const [pending, setPending] = useState(false);
19+ if (!linking.canLink(url)) return null;
20+ return (
21+ <Tooltip>
22+ <TooltipTrigger
23+ render={
24+ <Button
25+ size="icon-tiny"
26+ variant="ghost-muted"
27+ aria-label="Link this PR"
28+ disabled={pending}
29+ onPointerDown={(event) => event.stopPropagation()}
30+ onClick={async (event) => {
31+ event.preventDefault();
32+ event.stopPropagation();
33+ setPending(true);
34+ try {
35+ await linking.changeLink(threadRef, url, true);
36+ } catch (error) {
37+ toastManager.add({
38+ type: "error",
39+ title: "Could not link pull request",
40+ description: error instanceof Error ? error.message : String(error),
41+ });
42+ } finally {
43+ setPending(false);
44+ }
45+ }}
46+ >
47+ <Link2 className="size-3" />
48+ </Button>
49+ }
50+ />
51+ <TooltipPopup>Link this PR to keep it with this thread</TooltipPopup>
52+ </Tooltip>
53+ );
54+}
apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts+136−0
@@ -0,0 +1,136 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog";
4+
5+const project = {
6+ host: "github.com",
7+ repository: "acme/web",
8+ webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number),
9+};
10+
11+describe("resolveLinkPullRequestInput", () => {
12+ it.each([
13+ ["https://bitbucket.org/acme/web/pull-requests/42", "bitbucket.org"],
14+ ["https://github.acme.test/acme/web/pull/42", "github.acme.test"],
15+ ["https://git.acme.test/acme/web/-/merge_requests/42", "git.acme.test"],
16+ ])("links supported host URL %s without a thread project", (url, host) => {
17+ expect(
18+ resolveLinkPullRequestInput({
19+ reference: ` ${url} `,
20+ project: null,
21+ hasProject: (candidate) => candidate.host === host,
22+ }),
23+ ).toEqual({ link: { host, repository: "acme/web", number: 42, url } });
24+ });
25+
26+ it("validates the full Azure repository when resolving a browser URL", () => {
27+ const hasProject = (reference: { host: string; repository: string }) =>
28+ reference.host === "dev.azure.com" && reference.repository === "org-a/project/_git/web";
29+ expect(
30+ resolveLinkPullRequestInput({
31+ reference: "https://dev.azure.com/org-a/project/_git/web/pullrequest/42",
32+ project: null,
33+ hasProject,
34+ }),
35+ ).toMatchObject({ link: { repository: "org-a/project/_git/web", number: 42 } });
36+ expect(
37+ resolveLinkPullRequestInput({
38+ reference: "https://dev.azure.com/org-b/project/_git/web/pullrequest/42",
39+ project: null,
40+ hasProject,
41+ }),
42+ ).toMatchObject({ error: expect.stringContaining("org-b/project/_git/web") });
43+ });
44+
45+ it("resolves bare Azure numbers into canonical browser URLs", () => {
46+ expect(
47+ resolveLinkPullRequestInput({
48+ reference: "#42",
49+ project: {
50+ host: "ssh.dev.azure.com",
51+ repository: "v3/org/project/web",
52+ webUrl: (number) =>
53+ changeRequestWebUrl("azure-devops", "ssh.dev.azure.com", "v3/org/project/web", number),
54+ },
55+ hasProject: () => true,
56+ }),
57+ ).toMatchObject({
58+ link: {
59+ host: "dev.azure.com",
60+ repository: "org/project/_git/web",
61+ number: 42,
62+ url: "https://dev.azure.com/org/project/_git/web/pullrequest/42",
63+ },
64+ });
65+ });
66+
67+ it("returns null for input that is not a reference", () => {
68+ expect(
69+ resolveLinkPullRequestInput({ reference: "hello", project, hasProject: () => true }),
70+ ).toBeNull();
71+ });
72+
73+ it("resolves a bare number against the thread's own repository", () => {
74+ expect(
75+ resolveLinkPullRequestInput({ reference: "#42", project, hasProject: () => true }),
76+ ).toEqual({
77+ link: {
78+ host: "github.com",
79+ repository: "acme/web",
80+ number: 42,
81+ url: "https://github.com/acme/web/pull/42",
82+ },
83+ });
84+ });
85+
86+ it("links a URL from another repository on a host with a project", () => {
87+ expect(
88+ resolveLinkPullRequestInput({
89+ reference: "https://github.com/acme/api/pull/7",
90+ project,
91+ hasProject: (reference) => reference.host === "github.com",
92+ }),
93+ ).toEqual({
94+ link: {
95+ host: "github.com",
96+ repository: "acme/api",
97+ number: 7,
98+ url: "https://github.com/acme/api/pull/7",
99+ },
100+ });
101+ });
102+
103+ it("refuses a URL on a host nothing is checked out from", () => {
104+ const result = resolveLinkPullRequestInput({
105+ reference: "https://gitlab.com/acme/api/-/merge_requests/7",
106+ project,
107+ hasProject: () => false,
108+ });
109+ expect(result).toMatchObject({ error: expect.stringContaining("gitlab.com") });
110+ });
111+
112+ it("asks for a URL when a bare number has no project to resolve against", () => {
113+ expect(
114+ resolveLinkPullRequestInput({ reference: "12", project: null, hasProject: () => true }),
115+ ).toMatchObject({ error: expect.stringContaining("full URL") });
116+ });
117+
118+ it("accepts a checkout command as a reference", () => {
119+ expect(
120+ resolveLinkPullRequestInput({
121+ reference: "gh pr checkout https://github.com/acme/web/pull/3",
122+ project,
123+ hasProject: () => true,
124+ }),
125+ ).toMatchObject({ link: { number: 3, repository: "acme/web" } });
126+ });
127+});
128+
129+describe("changeRequestWebUrl", () => {
130+ it("knows the four hosts and nothing else", () => {
131+ expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe(
132+ "https://gitlab.com/g/sub/repo/-/merge_requests/5",
133+ );
134+ expect(changeRequestWebUrl("unknown", "x", "a/b", 1)).toBeNull();
135+ });
136+});
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+254−0
@@ -0,0 +1,254 @@
1+import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
2+export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
3+import {
4+ pullRequestHostOf,
5+ type ScopedThreadRef,
6+ type SourceControlProviderKind,
7+} from "@t3tools/contracts";
8+import { useAtomValue } from "@effect/atom-react";
9+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10+
11+import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
12+import { parsePullRequestReference } from "~/pullRequestReference";
13+import { useProjects, useThreadShell } from "~/state/entities";
14+import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
15+import { appAtomRegistry } from "~/rpc/atomRegistry";
16+import { Atom } from "effect/unstable/reactivity";
17+import { Button } from "../ui/button";
18+import {
19+ Dialog,
20+ DialogDescription,
21+ DialogFooter,
22+ DialogHeader,
23+ DialogPanel,
24+ DialogPopup,
25+ DialogTitle,
26+} from "../ui/dialog";
27+import { Input } from "../ui/input";
28+
29+/**
30+ * Which thread has the link dialog open, set by whichever entry point asked (command palette,
31+ * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives
32+ * a palette that closes the moment its command runs.
33+ */
34+const linkPullRequestDialogThreadAtom = Atom.make<ScopedThreadRef | null>(null).pipe(
35+ Atom.keepAlive,
36+ Atom.withLabel("pull-requests:link-dialog-thread"),
37+);
38+
39+export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void {
40+ appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef);
41+}
42+
43+interface LinkPullRequestDialogProps {
44+ open: boolean;
45+ threadRef: ScopedThreadRef;
46+ /** The thread's own project: bare numbers resolve against its repository. */
47+ projectId: string | null;
48+ onOpenChange: (open: boolean) => void;
49+}
50+
51+/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */
52+export function LinkPullRequestDialogHost() {
53+ const threadRef = useAtomValue(linkPullRequestDialogThreadAtom);
54+ const thread = useThreadShell(threadRef);
55+ const linking = usePullRequestLinking(threadRef?.environmentId);
56+ if (threadRef === null || linking.mode === "unsupported") return null;
57+ return (
58+ <LinkPullRequestDialog
59+ open
60+ threadRef={threadRef}
61+ projectId={thread?.projectId ?? null}
62+ onOpenChange={(open) => {
63+ if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null);
64+ }}
65+ />
66+ );
67+}
68+
69+interface ResolvedLink {
70+ readonly host: string;
71+ readonly repository: string;
72+ readonly number: number;
73+ readonly url: string;
74+}
75+
76+/**
77+ * Which pull request an input names, or why it cannot. A URL carries its own host and
78+ * repository and may point at any repository on a host this environment has a project for; a
79+ * bare `#123` can only mean the thread's own repository.
80+ */
81+export function resolveLinkPullRequestInput(input: {
82+ readonly reference: string;
83+ readonly project: {
84+ readonly host: string;
85+ readonly repository: string;
86+ readonly webUrl: (number: number) => string | null;
87+ } | null;
88+ readonly hasProject: (reference: ResolvedLink) => boolean;
89+}): { link: ResolvedLink } | { error: string } | null {
90+ const parsed =
91+ parseChangeRequestUrl(input.reference.trim()) !== null
92+ ? input.reference.trim()
93+ : parsePullRequestReference(input.reference);
94+ if (parsed === null) return null;
95+ const url = parseChangeRequestUrl(parsed);
96+ if (url !== null) {
97+ if (!input.hasProject({ ...url, url: parsed })) {
98+ return { error: `No project in this environment can read ${url.host}/${url.repository}.` };
99+ }
100+ return {
101+ link: { host: url.host, repository: url.repository, number: url.number, url: parsed },
102+ };
103+ }
104+ const number = Number(parsed);
105+ if (!Number.isSafeInteger(number) || number < 1) return null;
106+ if (input.project === null) {
107+ return { error: "Paste a full URL to link a pull request from another repository." };
108+ }
109+ const webUrl = input.project.webUrl(number);
110+ const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl);
111+ if (webUrl === null || webReference === null) {
112+ return { error: "Paste a full URL; this project's host has no known pull request URL." };
113+ }
114+ return {
115+ link: { ...webReference, url: webUrl },
116+ };
117+}
118+
119+function LinkPullRequestDialog({
120+ open,
121+ threadRef,
122+ projectId,
123+ onOpenChange,
124+}: LinkPullRequestDialogProps) {
125+ const inputRef = useRef<HTMLInputElement>(null);
126+ const [reference, setReference] = useState("");
127+ const [dirty, setDirty] = useState(false);
128+ const [submitError, setSubmitError] = useState<string | null>(null);
129+ const projects = useProjects();
130+ const environmentProjects = useMemo(
131+ () => projects.filter((project) => project.environmentId === threadRef.environmentId),
132+ [projects, threadRef.environmentId],
133+ );
134+ const ownProject = useMemo(() => {
135+ const project = environmentProjects.find((candidate) => candidate.id === projectId);
136+ const identity = project?.repositoryIdentity;
137+ if (!project || !identity) return null;
138+ const repository =
139+ identity.displayName ??
140+ (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
141+ if (repository === null) return null;
142+ const kind = identity.provider as SourceControlProviderKind;
143+ const host = pullRequestHostOf(identity, kind);
144+ return {
145+ host,
146+ repository,
147+ webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number),
148+ };
149+ }, [environmentProjects, projectId]);
150+ const linking = usePullRequestLinking(threadRef.environmentId);
151+ const [pending, setPending] = useState(false);
152+
153+ useEffect(() => {
154+ if (!open) return;
155+ setReference("");
156+ setDirty(false);
157+ setSubmitError(null);
158+ const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
159+ return () => window.cancelAnimationFrame(frame);
160+ }, [open]);
161+
162+ const resolved = useMemo(
163+ () =>
164+ resolveLinkPullRequestInput({
165+ reference,
166+ project: ownProject,
167+ hasProject: (reference) => linking.canLink(reference.url),
168+ }),
169+ [linking, ownProject, reference],
170+ );
171+
172+ const submit = useCallback(async () => {
173+ setDirty(true);
174+ if (resolved === null || "error" in resolved) return;
175+ setSubmitError(null);
176+ setPending(true);
177+ try {
178+ await linking.changeLink(threadRef, resolved.link.url, true);
179+ } catch (error) {
180+ setSubmitError(error instanceof Error ? error.message : "Could not link the pull request.");
181+ return;
182+ } finally {
183+ setPending(false);
184+ }
185+ onOpenChange(false);
186+ }, [linking, onOpenChange, resolved, threadRef]);
187+
188+ const validation = !dirty
189+ ? null
190+ : reference.trim().length === 0
191+ ? "Paste a pull request URL or enter 123 / #123."
192+ : resolved === null
193+ ? "Use a pull request URL, 123, or #123."
194+ : "error" in resolved
195+ ? resolved.error
196+ : null;
197+
198+ return (
199+ <Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}>
200+ <DialogPopup className="max-w-xl">
201+ <DialogHeader>
202+ <DialogTitle>Link pull request</DialogTitle>
203+ <DialogDescription>
204+ Attach a pull request to this thread. A full URL can point at any repository on a host
205+ this environment has a project for.
206+ </DialogDescription>
207+ </DialogHeader>
208+ <DialogPanel className="space-y-3">
209+ <Input
210+ ref={inputRef}
211+ placeholder="Pull request URL or #42"
212+ value={reference}
213+ onChange={(event) => {
214+ setDirty(true);
215+ setReference(event.target.value);
216+ }}
217+ onKeyDown={(event) => {
218+ if (event.key !== "Enter") return;
219+ event.preventDefault();
220+ void submit();
221+ }}
222+ />
223+ {resolved !== null && "link" in resolved ? (
224+ <p className="truncate text-muted-foreground text-xs">
225+ {resolved.link.host}/{resolved.link.repository} #{resolved.link.number}
226+ </p>
227+ ) : null}
228+ {(validation ?? submitError) ? (
229+ <p className="text-destructive text-xs">{validation ?? submitError}</p>
230+ ) : null}
231+ </DialogPanel>
232+ <DialogFooter>
233+ <Button
234+ type="button"
235+ variant="outline"
236+ size="sm"
237+ onClick={() => onOpenChange(false)}
238+ disabled={pending}
239+ >
240+ Cancel
241+ </Button>
242+ <Button
243+ type="button"
244+ size="sm"
245+ onClick={() => void submit()}
246+ disabled={pending || resolved === null || "error" in resolved}
247+ >
248+ {pending ? "Linking..." : "Link"}
249+ </Button>
250+ </DialogFooter>
251+ </DialogPopup>
252+ </Dialog>
253+ );
254+}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+116−8
@@ -66,16 +66,15 @@ import { cn } from "~/lib/utils"; · 65 unmodified lines
6666 import { readLocalApi } from "~/localApi";
6767 import type { ReviewCommentContext } from "~/reviewCommentContext";
6868 import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
69−import { useProjects } from "~/state/entities";
69+import { useProjects, useServerConfigs } from "~/state/entities";
7070 import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
7171 import { useEnvironmentQuery } from "~/state/query";
7272 import { useLiveRefresh } from "~/hooks/useLiveRefresh";
73−import {
74− pullRequestEnvironment,
75− usePullRequestTurnRefresh,
76− useSharedPullRequestSummary,
77−} from "~/state/pullRequests";
73+import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
74+import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";
7875 import { useAtomCommand } from "~/state/use-atom-command";
76+import { PullRequestStackMap } from "./PullRequestStackMap";
77+import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
7978 import { vcsEnvironment } from "~/state/vcs";
8079 import { formatRelativeTimeLabel } from "~/timestampFormat";
8180 import { useUiStateStore } from "~/uiStateStore";
@@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({ · 370 unmodified lines
452451 export function PullRequestDetailPanel({
453452 environmentId,
454453 threadRef = null,
455− reference,
454+ reference: requestedReference,
456455 listEntry = null,
457456 refreshToken: forcedRefreshToken = 0,
458457 onActed,
459458 onClose,
460459 context = "page",
461460 composerDraftTarget,
461+ onBack,
462462 }: {
463463 environmentId: EnvironmentId;
464464 /**
@@ -495,14 +495,35 @@ export function PullRequestDetailPanel({ · 30 unmodified lines
495495 * land here instead of opening a new thread — the branch is already under the reader's feet.
496496 */
497497 composerDraftTarget?: ScopedThreadRef | DraftId;
498+ /**
499+ * Beside a thread, the way back to that thread's list of pull requests. The tab strip can
500+ * close this surface, but closing is not going back: the reader came from the list and
501+ * expects to land on it, with this one still open behind.
502+ */
503+ onBack?: (() => void) | undefined;
498504 }) {
499− const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;
505+ const environmentConfigs = useServerConfigs();
506+ const supportsThreadPullRequests =
507+ environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;
508+ const reference = useMemo(
509+ () =>
510+ supportsThreadPullRequests
511+ ? requestedReference
512+ : {
513+ projectId: requestedReference.projectId,
514+ repository: requestedReference.repository,
515+ number: requestedReference.number,
516+ },
517+ [requestedReference, supportsThreadPullRequests],
518+ );
519+ const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;
500520 const matchingListEntry =
501521 listEntry?.projectId === reference.projectId &&
502522 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&
503523 listEntry.number === reference.number
504524 ? listEntry
505525 : null;
526+ const [threadPickerOpen, setThreadPickerOpen] = useState(false);
506527 const [tab, setTab] = useState<DetailTab>("summary");
507528 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");
508529 const [codeCommitScope, setCodeCommitScope] = useState<{
@@ -631,6 +652,11 @@ export function PullRequestDetailPanel({ · 122 unmodified lines
631652 : {
632653 ...resolvedCoreDetail,
633654 ...sharedSummary,
655+ author: sharedSummary.author ?? resolvedCoreDetail.author,
656+ additions: sharedSummary.additions ?? resolvedCoreDetail.additions,
657+ deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions,
658+ changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles,
659+ mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability,
634660 closedAt:
635661 sharedSummary.closedAt === undefined
636662 ? resolvedCoreDetail.closedAt
@@ -702,6 +728,13 @@ export function PullRequestDetailPanel({ · 65 unmodified lines
702728 const isStackedPullRequest =
703729 detail !== null &&
704730 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
731+ // The host's own stack, where it keeps one. Only asked for once the detail has landed so a
732+ // pull request nobody can read costs one request rather than two.
733+ const nativeStack = useEnvironmentQuery(
734+ detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
735+ ? null
736+ : pullRequestStackAtom({ environmentId, input: reference }),
737+ ).data;
705738 const activityPending = activityQuery.isPending && activity === null;
706739 const activityError = activity === null ? activityQuery.error : null;
707740 const refreshDetail = useCallback(() => {
@@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({ · 671 unmodified lines
13791412
13801413 return (
13811414 <div className="flex h-full min-h-0 w-full flex-col bg-background">
1415+ {threadPickerOpen && detail ? (
1416+ <PullRequestThreadLinks
1417+ key={`${environmentId}:${detail.url}`}
1418+ display="picker"
1419+ environmentId={environmentId}
1420+ reference={reference}
1421+ url={detail.url}
1422+ threadRef={null}
1423+ onPickerOpenChange={setThreadPickerOpen}
1424+ />
1425+ ) : null}
13821426 <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
13831427 <div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden">
13841428 <div
@@ -1393,6 +1437,24 @@ export function PullRequestDetailPanel({ · 8 unmodified lines
13931437 >
13941438 {detail && statePresentation ? (
13951439 <>
1440+ {onBack ? (
1441+ <Tooltip>
1442+ <TooltipTrigger
1443+ render={
1444+ <Button
1445+ size="icon-micro"
1446+ variant="ghost-muted"
1447+ onClick={onBack}
1448+ className="-ml-1.5"
1449+ aria-label="Back to this thread's pull requests"
1450+ >
1451+ <ArrowLeftIcon aria-hidden className="size-3.5" />
1452+ </Button>
1453+ }
1454+ />
1455+ <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1456+ </Tooltip>
1457+ ) : null}
13961458 <Tooltip>
13971459 <TooltipTrigger
13981460 render={
@@ -1450,6 +1512,25 @@ export function PullRequestDetailPanel({ · 51 unmodified lines
14501512 >
14511513 {detail && statePresentation ? (
14521514 <>
1515+ {onBack ? (
1516+ <Tooltip>
1517+ <TooltipTrigger
1518+ render={
1519+ <Button
1520+ size="icon-micro"
1521+ variant="ghost-muted"
1522+ tabIndex={condensed ? 0 : -1}
1523+ onClick={onBack}
1524+ className="-ml-1.5"
1525+ aria-label="Back to this thread's pull requests"
1526+ >
1527+ <ArrowLeftIcon aria-hidden className="size-3.5" />
1528+ </Button>
1529+ }
1530+ />
1531+ <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1532+ </Tooltip>
1533+ ) : null}
14531534 <Tooltip>
14541535 <TooltipTrigger
14551536 render={
@@ -1488,6 +1569,15 @@ export function PullRequestDetailPanel({ · 32 unmodified lines
14881569 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
14891570 {detail ? (
14901571 <>
1572+ {context === "page" ? (
1573+ <PullRequestThreadLinks
1574+ display="count"
1575+ environmentId={environmentId}
1576+ reference={reference}
1577+ url={detail.url}
1578+ threadRef={null}
1579+ />
1580+ ) : null}
14911581 {/* Checking a pull request out is the reason to open one here at all, so it is a
14921582 button of its own rather than a side effect of asking an agent for something.
14931583 It asks where, because the two answers are not interchangeable: one leaves your
@@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({ · 220 unmodified lines
17141804 <MoreHorizontalIcon className="size-4" />
17151805 </MenuTrigger>
17161806 <MenuPopup align="end" side="bottom" className="min-w-72">
1807+ <PullRequestThreadLinks
1808+ display="menu-item"
1809+ environmentId={environmentId}
1810+ reference={reference}
1811+ url={detail.url}
1812+ threadRef={
1813+ threadRef ??
1814+ (typeof composerDraftTarget === "object" ? composerDraftTarget : null)
1815+ }
1816+ onPickerOpenChange={setThreadPickerOpen}
1817+ />
17171818 <MenuItem
17181819 disabled={isInvalidating || detailQuery.isPending}
17191820 onClick={() => void refreshFromHost()}
@@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({ · 281 unmodified lines
20012102 />
20022103 </span>
20032104 </div>
2105+ {nativeStack ? (
2106+ <PullRequestStackMap
2107+ stack={nativeStack}
2108+ currentNumber={detail.number}
2109+ className="mt-1"
2110+ />
2111+ ) : null}
20042112 </div>
20052113 ) : null}
20062114 </div>
apps/web/src/components/pullRequest/PullRequestStackMap.tsx+87−0
@@ -0,0 +1,87 @@
1+import type { PullRequestStack } from "@t3tools/contracts";
2+import { GitPullRequestArrowIcon } from "lucide-react";
3+
4+import { InlineButton } from "../ui/button";
5+import { cn } from "~/lib/utils";
6+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7+import { resolvePullRequestState } from "./pullRequestPresentation";
8+
9+/**
10+ * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors
11+ * the map GitHub draws above a stacked pull request so a reader who came from there finds the
12+ * same shape here.
13+ */
14+export function PullRequestStackMap({
15+ stack,
16+ currentNumber,
17+ onSelect,
18+ className,
19+}: {
20+ stack: PullRequestStack;
21+ currentNumber: number;
22+ /** Opens another layer in the same panel; absent where the panel cannot swap references. */
23+ onSelect?: ((number: number) => void) | undefined;
24+ className?: string;
25+}) {
26+ return (
27+ <div
28+ className={cn(
29+ "flex min-w-0 items-center gap-1 overflow-x-auto text-[11px] text-muted-foreground",
30+ className,
31+ )}
32+ aria-label={`Stack ${stack.number} on ${stack.base}, ${stack.layers.length} layers`}
33+ >
34+ <Tooltip>
35+ <TooltipTrigger
36+ render={<span className="inline-flex shrink-0 items-center gap-1 font-mono" />}
37+ >
38+ <GitPullRequestArrowIcon aria-hidden className="size-3" />
39+ <code className="truncate">{stack.base}</code>
40+ </TooltipTrigger>
41+ <TooltipPopup side="top">
42+ Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it.
43+ </TooltipPopup>
44+ </Tooltip>
45+ {stack.layers.map((layer) => {
46+ const presentation = resolvePullRequestState({ state: layer.state, isDraft: false });
47+ const isCurrent = layer.number === currentNumber;
48+ const chip = (
49+ <span
50+ className={cn(
51+ "inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono tabular-nums",
52+ isCurrent
53+ ? "border-foreground/40 bg-accent text-foreground"
54+ : "border-border/70 hover:bg-accent/60",
55+ )}
56+ >
57+ <presentation.Icon aria-hidden className={cn("size-3", presentation.toneClassName)} />#
58+ {layer.number}
59+ </span>
60+ );
61+ return (
62+ <span key={layer.number} className="inline-flex shrink-0 items-center gap-1">
63+ <span aria-hidden className="text-muted-foreground/50">
64+ →
65+ </span>
66+ <Tooltip>
67+ <TooltipTrigger
68+ render={
69+ onSelect && !isCurrent ? (
70+ <InlineButton onClick={() => onSelect(layer.number)} />
71+ ) : (
72+ <span aria-current={isCurrent ? "true" : undefined} />
73+ )
74+ }
75+ >
76+ {chip}
77+ </TooltipTrigger>
78+ <TooltipPopup side="top">
79+ #{layer.number} · {layer.headBranch} · {presentation.label}
80+ </TooltipPopup>
81+ </Tooltip>
82+ </span>
83+ );
84+ })}
85+ </div>
86+ );
87+}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11−4
@@ -314,12 +314,14 @@ function Section({ · 313 unmodified lines
314314
315315 function CommentComposer({
316316 environmentId,
317+ reference,
317318 detail,
318319 actionPending,
319320 onCommentAction,
320321 onCommented,
321322 }: {
322323 environmentId: EnvironmentId;
324+ reference: PullRequestRef;
323325 detail: PullRequestDetailView;
324326 actionPending: boolean;
325327 onCommentAction: (
@@ -355,9 +357,7 @@ function CommentComposer({ · 29 unmodified lines
355357 const result = await postComment({
356358 environmentId,
357359 input: {
358− projectId: detail.projectId,
359− repository: detail.repository,
360− number: detail.number,
360+ ...reference,
361361 body: trimmed,
362362 },
363363 });
@@ -996,7 +996,14 @@ export function PullRequestSummaryTab({ · 632 unmodified lines
996996 {/* Posting is a core capability and remains usable even if the activity read failed. */}
997997 {detail.capabilities.comment && detail.viewerPermissions.comment ? (
998998 <CommentComposer
999− key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`}
999+ reference={reference}
1000+ key={JSON.stringify([
1001+ environmentId,
1002+ reference.projectId,
1003+ reference.host,
1004+ reference.repository,
1005+ reference.number,
1006+ ])}
10001007 environmentId={environmentId}
10011008 detail={detail}
10021009 actionPending={actionPending}
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+255−0
@@ -0,0 +1,255 @@
1+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2+import { Link } from "@tanstack/react-router";
3+import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
4+import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";
5+import { useState } from "react";
6+import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
7+import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
8+
9+import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
10+import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
11+import { pullRequestEnvironment } from "~/state/pullRequests";
12+import { useEnvironmentQuery } from "~/state/query";
13+import { appAtomRegistry } from "~/rpc/atomRegistry";
14+import { buildThreadRouteParams } from "~/threadRoutes";
15+import { Button } from "../ui/button";
16+import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
17+import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
18+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
19+import { toastManager } from "../ui/toast";
20+
21+interface PullRequestThreadLinksProps {
22+ environmentId: EnvironmentId;
23+ reference: PullRequestRef;
24+ url: string;
25+ threadRef: ScopedThreadRef | null;
26+ display: "count" | "menu-item" | "picker";
27+ onPickerOpenChange?: (open: boolean) => void;
28+}
29+
30+/** Thread relations belong to the detail environment, including when another environment is active. */
31+export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) {
32+ const configs = useServerConfigs();
33+ if (
34+ threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) ===
35+ "unsupported"
36+ ) {
37+ return null;
38+ }
39+ return <EnabledPullRequestThreadLinks key={`${props.environmentId}:${props.url}`} {...props} />;
40+}
41+
42+function EnabledPullRequestThreadLinks({
43+ environmentId,
44+ reference,
45+ url,
46+ threadRef,
47+ display,
48+ onPickerOpenChange,
49+}: PullRequestThreadLinksProps) {
50+ const parsed = parseChangeRequestUrl(url);
51+ const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null;
52+ const thread = useThreadShell(currentThreadRef);
53+ const linking = usePullRequestLinking(environmentId);
54+ const linkedHere = linking.isLinked(thread, url);
55+ const relations = useEnvironmentQuery(
56+ linking.mode === "multiple" && display !== "menu-item"
57+ ? pullRequestEnvironment.linkedThreads({
58+ environmentId,
59+ input: parsed === null ? reference : { ...reference, ...parsed },
60+ })
61+ : null,
62+ );
63+ // Refreshes can briefly clear the query value. Keep the last response so polling
64+ // does not unmount an open menu or move its highlighted thread.
65+ const [lastRelations, setLastRelations] = useState(relations.data);
66+ if (relations.data !== null && relations.data !== lastRelations) {
67+ setLastRelations(relations.data);
68+ }
69+ const [pending, setPending] = useState(false);
70+
71+ const changeLink = async (threadId: ThreadId, remove: boolean) => {
72+ if (parsed === null || pending) return;
73+ setPending(true);
74+ try {
75+ await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove);
76+ } catch (error) {
77+ toastManager.add({
78+ type: "error",
79+ title: remove ? "Could not unlink the pull request" : "Could not link the pull request",
80+ description: error instanceof Error ? error.message : String(error),
81+ });
82+ return;
83+ } finally {
84+ setPending(false);
85+ }
86+ if (linking.mode === "multiple") {
87+ appAtomRegistry.refresh(
88+ pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),
89+ );
90+ }
91+ onPickerOpenChange?.(false);
92+ };
93+
94+ if (parsed === null || (!linkedHere && !linking.canLink(url))) return null;
95+ const linkedThreads =
96+ linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : [];
97+ const linkedThreadsLabel =
98+ linkedThreads.length > 0
99+ ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}`
100+ : "Linked threads";
101+ return (
102+ <>
103+ {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (
104+ <Menu>
105+ <MenuTrigger
106+ render={
107+ <Button
108+ size="xs"
109+ variant="ghost"
110+ aria-label={linkedThreadsLabel}
111+ title={linkedThreadsLabel}
112+ />
113+ }
114+ >
115+ <MessageSquareIcon aria-hidden className="size-3.5" />
116+ <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span>
117+ <span aria-hidden className="hidden @max-[40rem]/pr-header:inline">
118+ {linkedThreads.length || "?"}
119+ </span>
120+ </MenuTrigger>
121+ <MenuPopup align="end" className="max-h-72 max-w-80 overflow-y-auto">
122+ {relations.error !== null ? (
123+ <MenuItem onClick={relations.refresh}>Could not load linked threads. Retry</MenuItem>
124+ ) : null}
125+ {linkedThreads.map((linkedThread) => (
126+ <MenuItem
127+ key={linkedThread.id}
128+ render={
129+ <Link
130+ to="/$environmentId/$threadId"
131+ params={buildThreadRouteParams(scopeThreadRef(environmentId, linkedThread.id))}
132+ />
133+ }
134+ >
135+ <span className="min-w-0 flex-1 truncate">
136+ {linkedThread.title || "Untitled thread"}
137+ </span>
138+ {linkedThread.archivedAt !== null ? (
139+ <span className="text-xs text-muted-foreground">Archived</span>
140+ ) : null}
141+ </MenuItem>
142+ ))}
143+ </MenuPopup>
144+ </Menu>
145+ ) : null}
146+ {display === "menu-item" ? (
147+ <MenuItem
148+ disabled={pending}
149+ onClick={() => {
150+ if (currentThreadRef !== null) {
151+ void changeLink(currentThreadRef.threadId, linkedHere);
152+ } else {
153+ onPickerOpenChange?.(true);
154+ }
155+ }}
156+ >
157+ {linkedHere ? (
158+ <UnlinkIcon aria-hidden className="size-3.5" />
159+ ) : (
160+ <LinkIcon aria-hidden className="size-3.5" />
161+ )}
162+ {linkedHere
163+ ? "Unlink from this thread"
164+ : currentThreadRef
165+ ? "Link to this thread"
166+ : "Link to thread"}
167+ </MenuItem>
168+ ) : null}
169+ {display === "picker" ? (
170+ <Dialog open onOpenChange={onPickerOpenChange}>
171+ <DialogPopup className="max-w-md" showCloseButton={false}>
172+ <DialogTitle className="sr-only">Link pull request to a thread</DialogTitle>
173+ <ThreadPicker
174+ environmentId={environmentId}
175+ url={url}
176+ pending={pending}
177+ onSelect={(threadId) => void changeLink(threadId, false)}
178+ />
179+ </DialogPopup>
180+ </Dialog>
181+ ) : null}
182+ </>
183+ );
184+}
185+
186+function ThreadPicker({
187+ environmentId,
188+ url,
189+ pending,
190+ onSelect,
191+}: {
192+ environmentId: EnvironmentId;
193+ url: string;
194+ pending: boolean;
195+ onSelect: (threadId: ThreadId) => void;
196+}) {
197+ const threads = useThreadShells();
198+ const linking = usePullRequestLinking(environmentId);
199+ const projects = useProjects();
200+ const [query, setQuery] = useState("");
201+ const projectNames = new Map(
202+ projects
203+ .filter((project) => project.environmentId === environmentId)
204+ .map((project) => [project.id, project.title]),
205+ );
206+ const search = query.trim().toLocaleLowerCase();
207+ const candidates = threads
208+ .filter(
209+ (thread) =>
210+ thread.environmentId === environmentId &&
211+ thread.archivedAt === null &&
212+ `${thread.title} ${projectNames.get(thread.projectId) ?? ""}`
213+ .toLocaleLowerCase()
214+ .includes(search),
215+ )
216+ .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt));
217+ return (
218+ <Command mode="none" value={query} onValueChange={setQuery} aria-label="Choose a thread">
219+ <CommandInput placeholder="Search threads or projects..." disabled={pending} />
220+ <CommandList className="max-h-80 overflow-y-auto">
221+ {candidates.length === 0 ? (
222+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
223+ No active threads found.
224+ </div>
225+ ) : (
226+ candidates.map((thread) => {
227+ const linked = linking.isLinked(thread, url);
228+ return (
229+ <CommandItem
230+ key={thread.id}
231+ value={thread.id}
232+ disabled={pending || linked}
233+ onClick={() => onSelect(thread.id)}
234+ >
235+ <MessageSquareIcon aria-hidden className="size-4 shrink-0" />
236+ <span className="flex min-w-0 flex-1 flex-col">
237+ <span className="truncate">{thread.title || "Untitled thread"}</span>
238+ <span className="truncate text-xs text-muted-foreground">
239+ {projectNames.get(thread.projectId)}
240+ </span>
241+ </span>
242+ {linked ? (
243+ <>
244+ <CheckIcon aria-hidden className="size-3.5" />
245+ <span className="text-xs text-muted-foreground">Linked</span>
246+ </>
247+ ) : null}
248+ </CommandItem>
249+ );
250+ })
251+ )}
252+ </CommandList>
253+ </Command>
254+ );
255+}
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+296−0
@@ -0,0 +1,296 @@
1+import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2+import {
3+ resolveThreadPullRequestChains,
4+ visibleThreadPullRequests,
5+} from "@t3tools/shared/threadPullRequests";
6+import {
7+ GitPullRequestArrow,
8+ LayersIcon,
9+ LinkIcon,
10+ MoreHorizontalIcon,
11+ PlusIcon,
12+} from "lucide-react";
13+import { useCallback, useMemo } from "react";
14+
15+import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
16+import { useOpenPrLink } from "~/lib/openPullRequestLink";
17+import { cn } from "~/lib/utils";
18+import { useServerConfigs, useThreadShell } from "~/state/entities";
19+import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
20+import { threadEnvironment } from "~/state/threads";
21+import { useAtomCommand } from "~/state/use-atom-command";
22+import { formatRelativeTimeLabel } from "~/timestampFormat";
23+import { Button } from "../ui/button";
24+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
25+import { ScrollArea } from "../ui/scroll-area";
26+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27+import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";
28+import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";
29+import {
30+ PullRequestActorAvatar,
31+ PullRequestDiffStat,
32+ PullRequestStateGlyph,
33+ pullRequestChecksStatePresentation,
34+} from "./pullRequestPresentation";
35+
36+const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
37+ manual: "Linked by you",
38+ created: "Created from this thread",
39+ agent: "Linked by the agent",
40+ stack: "Found in the stack",
41+ "stack-dismissed": "Dismissed",
42+};
43+
44+function ChecksGlyph({
45+ state,
46+}: {
47+ state: NonNullable<ThreadPullRequestLink["snapshot"]>["checksState"] & string;
48+}) {
49+ const presentation = pullRequestChecksStatePresentation(state);
50+ return (
51+ <Tooltip>
52+ <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
53+ <presentation.Icon
54+ role="img"
55+ aria-label={presentation.label}
56+ className={cn("size-3.5", presentation.toneClassName)}
57+ />
58+ </TooltipTrigger>
59+ <TooltipPopup>{presentation.label}</TooltipPopup>
60+ </Tooltip>
61+ );
62+}
63+
64+function LinkRow({
65+ line,
66+ threadRef,
67+ onUnlink,
68+}: {
69+ line: PullRequestListLine;
70+ threadRef: ScopedThreadRef;
71+ onUnlink: (link: ThreadPullRequestLink) => void;
72+}) {
73+ const openPrLink = useOpenPrLink(threadRef);
74+ const { link, depth, stack } = line;
75+ const snapshot = link.snapshot;
76+ return (
77+ <div
78+ className="group/pr-row flex items-center gap-2 rounded-md py-1 pr-1 hover:bg-accent/60"
79+ // Each layer steps in under the one it targets. The step is capped: beyond a few layers
80+ // the indent only says "still in the stack", which the connector line already does, and
81+ // a sixteen-layer stack would otherwise stair-step off the right edge.
82+ style={{ paddingLeft: `${0.5 + Math.min(depth, 3) * 1.25}rem` }}
83+ >
84+ {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}
85+ {snapshot === null ? (
86+ <GitPullRequestArrow
87+ aria-label="Waiting for host state"
88+ className="size-4 shrink-0 text-muted-foreground"
89+ />
90+ ) : (
91+ <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />
92+ )}
93+ <a
94+ href={link.url}
95+ onClick={(event) => openPrLink(event, link.url, threadRef)}
96+ className="min-w-0 flex-1"
97+ >
98+ <span className="flex min-w-0 items-center gap-1.5">
99+ <Tooltip>
100+ <TooltipTrigger
101+ render={
102+ <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground" />
103+ }
104+ >
105+ #{link.number}
106+ </TooltipTrigger>
107+ <TooltipPopup>
108+ {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)}
109+ </TooltipPopup>
110+ </Tooltip>
111+ <span className="min-w-0 flex-1 truncate text-sm">
112+ {snapshot?.title ?? link.repository}
113+ </span>
114+ {/* Right-aligned signals, in the order a reviewer scans them: are checks green,
115+ has someone ruled, how big is it. Each is absent rather than neutral when the
116+ host said nothing, so a row without them reads as unknown, not as fine. */}
117+ <span className="ml-auto flex shrink-0 items-center gap-1.5 text-[11px]">
118+ {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
119+ {snapshot?.state === "open" &&
120+ (snapshot.reviewDecision === "approved" ||
121+ snapshot.reviewDecision === "changes-requested") ? (
122+ <span
123+ className={cn(
124+ snapshot.reviewDecision === "approved"
125+ ? "text-emerald-600/90 dark:text-emerald-400/80"
126+ : "text-amber-600/90 dark:text-amber-400/80",
127+ )}
128+ >
129+ {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"}
130+ </span>
131+ ) : null}
132+ {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? (
133+ <span className="text-destructive">Conflicts</span>
134+ ) : null}
135+ <PullRequestDiffStat
136+ additions={snapshot?.additions ?? 0}
137+ deletions={snapshot?.deletions ?? 0}
138+ className="font-mono"
139+ />
140+ </span>
141+ </span>
142+ <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
143+ {stack ? (
144+ <Tooltip>
145+ <TooltipTrigger
146+ render={
147+ <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />
148+ }
149+ >
150+ {stack.kind === "native" ? (
151+ <LayersIcon aria-hidden className="size-3" />
152+ ) : (
153+ <GitPullRequestArrow aria-hidden className="size-3" />
154+ )}
155+ {stack.size}
156+ </TooltipTrigger>
157+ <TooltipPopup>
158+ {stack.kind === "native"
159+ ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.`
160+ : `${stack.size} pull requests chained by base branch.`}
161+ </TooltipPopup>
162+ </Tooltip>
163+ ) : null}
164+ {snapshot?.author ? (
165+ <span className="inline-flex shrink-0 items-center gap-1">
166+ <PullRequestActorAvatar actor={snapshot.author} className="size-3.5" />
167+ <span className="max-w-28 truncate">{snapshot.author.login}</span>
168+ </span>
169+ ) : null}
170+ <span className="truncate font-mono">
171+ {snapshot !== null
172+ ? `${snapshot.headBranch} → ${snapshot.baseBranch}`
173+ : `${link.host}/${link.repository}`}
174+ </span>
175+ {snapshot?.updatedAt ? (
176+ <span className="shrink-0">· {formatRelativeTimeLabel(snapshot.updatedAt)}</span>
177+ ) : null}
178+ </span>
179+ </a>
180+ <Menu>
181+ <MenuTrigger
182+ render={
183+ <Button
184+ variant="ghost"
185+ size="icon-xs"
186+ aria-label={`Actions for #${link.number}`}
187+ className={cn(
188+ "opacity-0 group-hover/pr-row:opacity-100 data-[popup-open]:opacity-100",
189+ )}
190+ >
191+ <MoreHorizontalIcon className="size-3.5" />
192+ </Button>
193+ }
194+ />
195+ <MenuPopup align="end" side="bottom">
196+ <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>Copy link</MenuItem>
197+ <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>Open</MenuItem>
198+ <MenuItem onClick={() => onUnlink(link)}>
199+ {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
200+ </MenuItem>
201+ </MenuPopup>
202+ </Menu>
203+ </div>
204+ );
205+}
206+
207+export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
208+ const configs = useServerConfigs();
209+ if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) {
210+ return (
211+ <PullRequestsUnavailableState
212+ title="Linked pull requests unavailable"
213+ error="This environment does not support multiple linked pull requests."
214+ />
215+ );
216+ }
217+ return <EnabledThreadPullRequestsPanel threadRef={threadRef} />;
218+}
219+
220+function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
221+ const thread = useThreadShell(threadRef);
222+ const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
223+ const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
224+ const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
225+ const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
226+ const handleUnlink = useCallback(
227+ (link: ThreadPullRequestLink) => {
228+ void unlink({
229+ environmentId: threadRef.environmentId,
230+ input: {
231+ threadId: threadRef.threadId,
232+ host: link.host,
233+ repository: link.repository,
234+ number: link.number,
235+ },
236+ });
237+ },
238+ [threadRef, unlink],
239+ );
240+ const openCount = useMemo(
241+ () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,
242+ [links],
243+ );
244+ const lastSynced = useMemo(() => {
245+ let latest: string | null = null;
246+ for (const link of links) {
247+ const at = link.snapshot?.syncedAt;
248+ if (at !== undefined && (latest === null || at > latest)) latest = at;
249+ }
250+ return latest;
251+ }, [links]);
252+
253+ if (links.length === 0) {
254+ return (
255+ <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
256+ <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" />
257+ <p className="text-sm font-medium">No linked pull requests</p>
258+ <p className="max-w-60 text-xs text-muted-foreground">
259+ Pull requests the agent opens from this thread land here. Link one yourself from a URL or
260+ a number.
261+ </p>
262+ <Button size="sm" variant="outline" onClick={openLinkDialog}>
263+ <PlusIcon className="size-3.5" />
264+ Link pull request
265+ </Button>
266+ </div>
267+ );
268+ }
269+
270+ return (
271+ <div className="flex h-full min-h-0 flex-col">
272+ <ScrollArea className="min-h-0 flex-1">
273+ <div className="flex flex-col p-1.5">
274+ {lines.map((line) => (
275+ <LinkRow
276+ key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
277+ line={line}
278+ threadRef={threadRef}
279+ onUnlink={handleUnlink}
280+ />
281+ ))}
282+ </div>
283+ </ScrollArea>
284+ <footer className="flex items-center justify-between border-t border-border/60 px-2 py-1.5 text-[.7rem] text-muted-foreground">
285+ <span>
286+ {openCount} open · {links.length} linked
287+ {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""}
288+ </span>
289+ <Button size="xs" variant="ghost" onClick={openLinkDialog}>
290+ <PlusIcon className="size-3.5" />
291+ Link
292+ </Button>
293+ </footer>
294+ </div>
295+ );
296+}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+36−0
@@ -1420,6 +1420,42 @@ describe("cached pull request detail", () => { · 1419 unmodified lines
14201420 expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull();
14211421 });
14221422
1423+ it("isolates stored and displayed details between hosts with the same repository and number", () => {
1424+ const storage = makeStorage();
1425+ const publicRef = { ...reference, host: "github.com" };
1426+ const enterpriseRef = { ...reference, host: "github.example.com" };
1427+ const publicDetail = detail();
1428+ const enterpriseDetail = detail({
1429+ title: "Enterprise change",
1430+ url: "https://github.example.com/acme/web/pull/7",
1431+ });
1432+ writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);
1433+ expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
1434+ writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail);
1435+ expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe(
1436+ publicDetail.title,
1437+ );
1438+ expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe(
1439+ enterpriseDetail.title,
1440+ );
1441+ expect(
1442+ resolveDisplayedPullRequestDetail({
1443+ live: null,
1444+ cached: publicDetail,
1445+ reference: enterpriseRef,
1446+ }),
1447+ ).toBeNull();
1448+ expect(
1449+ resolveDisplayedPullRequestDetail({
1450+ live: null,
1451+ cached: enterpriseDetail,
1452+ reference: enterpriseRef,
1453+ }),
1454+ ).toBe(enterpriseDetail);
1455+ writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail);
1456+ expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
1457+ });
1458+
14231459 it("shrugs off corrupt storage and no storage at all", () => {
14241460 const storage = makeStorage();
14251461 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+11−3
@@ -1,4 +1,5 @@
11 import * as Schema from "effect/Schema";
2+import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
23
34 import {
45 PullRequestDetail,
@@ -1028,6 +1029,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo · 1023 unmodified lines
10281029 type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;
10291030
10301031 export interface PullRequestDetailSnapshotRef {
1032+ readonly host?: string | undefined;
10311033 readonly projectId: string;
10321034 readonly repository: string;
10331035 readonly number: number;
@@ -1037,7 +1039,9 @@ const pullRequestDetailSnapshotKey = ( · 3 unmodified lines
10371039 environmentId: string,
10381040 reference: PullRequestDetailSnapshotRef,
10391041 ) =>
1040− `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`;
1042+ reference.host
1043+ ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}`
1044+ : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`;
10411045
10421046 const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail);
10431047
@@ -1056,7 +1060,9 @@ export function readPullRequestDetailSnapshot( · 12 unmodified lines
10561060 const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference));
10571061 if (!raw) return null;
10581062 const decoded = decodeDetailSnapshot(JSON.parse(raw));
1059− return decoded._tag === "Some" ? decoded.value : null;
1063+ return decoded._tag === "Some"
1064+ ? resolveDisplayedPullRequestDetail({ live: null, cached: decoded.value, reference })
1065+ : null;
10601066 } catch {
10611067 return null;
10621068 }
@@ -1090,7 +1096,9 @@ export function resolveDisplayedPullRequestDetail(input: { · 27 unmodified lines
10901096 input.cached !== null &&
10911097 input.cached.projectId === input.reference.projectId &&
10921098 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&
1093− input.cached.number === input.reference.number
1099+ input.cached.number === input.reference.number &&
1100+ (input.reference.host === undefined ||
1101+ parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase())
10941102 ) {
10951103 return input.cached;
10961104 }
apps/web/src/components/pullRequest/pullRequestListLines.test.ts+76−0
@@ -0,0 +1,76 @@
1+import type { ThreadPullRequestLink } from "@t3tools/contracts";
2+import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests";
3+import { describe, expect, it } from "vite-plus/test";
4+
5+import { pullRequestListLines } from "./pullRequestListLines";
6+
7+function link(
8+ number: number,
9+ head: string,
10+ base: string,
11+ updatedAt: string,
12+ stack: ThreadPullRequestLink["stack"] = null,
13+): ThreadPullRequestLink {
14+ return {
15+ host: "github.com",
16+ repository: "acme/web",
17+ number,
18+ url: `https://github.com/acme/web/pull/${number}`,
19+ source: "manual",
20+ linkedAt: "2026-01-01T00:00:00.000Z",
21+ snapshot: {
22+ state: "open",
23+ title: `PR ${number}`,
24+ headBranch: head,
25+ baseBranch: base,
26+ isDraft: false,
27+ updatedAt,
28+ syncedAt: updatedAt,
29+ },
30+ stack,
31+ };
32+}
33+
34+describe("pullRequestListLines", () => {
35+ it("orders newest first and keeps a stack together under its base layer", () => {
36+ const lines = pullRequestListLines(
37+ resolveThreadPullRequestChains([
38+ link(1, "a", "main", "2026-01-01T10:00:00Z"),
39+ link(2, "b", "a", "2026-01-01T12:00:00Z"),
40+ link(9, "solo", "main", "2026-01-01T11:00:00Z"),
41+ link(5, "old", "main", "2026-01-01T09:00:00Z"),
42+ ]),
43+ );
44+ expect(lines.map((line) => [line.link.number, line.depth, line.stack?.size ?? null])).toEqual([
45+ // The stack's newest layer is #2 at 12:00, so the whole stack outranks #9 at 11:00.
46+ [1, 0, 2],
47+ [2, 1, null],
48+ [9, 0, null],
49+ [5, 0, null],
50+ ]);
51+ });
52+
53+ it("marks native stacks on their base layer", () => {
54+ const stack = {
55+ kind: "native" as const,
56+ id: "1",
57+ number: 1,
58+ url: "https://github.com/acme/web/stacks/1",
59+ base: "main",
60+ layers: [
61+ { number: 3, headBranch: "x", state: "open" as const },
62+ { number: 4, headBranch: "y", state: "open" as const },
63+ ],
64+ };
65+ const lines = pullRequestListLines(
66+ resolveThreadPullRequestChains([
67+ link(4, "y", "x", "2026-01-01T10:00:00Z", stack),
68+ link(3, "x", "main", "2026-01-01T10:00:00Z", stack),
69+ ]),
70+ );
71+ expect(lines.map((line) => [line.link.number, line.depth, line.stack?.kind ?? null])).toEqual([
72+ [3, 0, "native"],
73+ [4, 1, null],
74+ ]);
75+ });
76+});
apps/web/src/components/pullRequest/pullRequestListLines.ts+49−0
@@ -0,0 +1,49 @@
1+import type { ThreadPullRequestLink } from "@t3tools/contracts";
2+import type { ThreadPullRequestChain } from "@t3tools/shared/threadPullRequests";
3+
4+/** One line of a thread's pull-request list: a link plus how deep it sits in its stack. */
5+export interface PullRequestListLine {
6+ readonly link: ThreadPullRequestLink;
7+ /** 0 for a pull request on the base branch; each layer above steps in by one. */
8+ readonly depth: number;
9+ /** Which chain the line belongs to, so callers can tell one stack's lines from another's. */
10+ readonly chainKey: string;
11+ /** Set on the bottom layer of a multi-layer stack, so that row can name the whole stack. */
12+ readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null;
13+}
14+
15+function activityAt(link: ThreadPullRequestLink): number {
16+ const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt);
17+ return Number.isNaN(ms) ? 0 : ms;
18+}
19+
20+function chainKeyOf(chain: ThreadPullRequestChain): string {
21+ const bottom = chain.layers[0]!;
22+ return `${bottom.host}/${bottom.repository}#${bottom.number}`;
23+}
24+
25+/**
26+ * Flattens chains into indented lines, newest first. A stack sorts by its most recent layer and
27+ * then reads bottom to top beneath that slot, so the layer you would review first is at the
28+ * bottom of the indent and a fresh push anywhere in the stack floats the whole stack up.
29+ */
30+export function pullRequestListLines(
31+ chains: ReadonlyArray<ThreadPullRequestChain>,
32+): ReadonlyArray<PullRequestListLine> {
33+ const ordered = [...chains].sort(
34+ (left, right) =>
35+ Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)),
36+ );
37+ return ordered.flatMap((chain) => {
38+ const chainKey = chainKeyOf(chain);
39+ return chain.layers.map((link, depth) => ({
40+ link,
41+ depth,
42+ chainKey,
43+ stack:
44+ depth === 0 && chain.layers.length > 1
45+ ? { kind: chain.kind, size: chain.layers.length }
46+ : null,
47+ }));
48+ });
49+}
apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts+30−1
@@ -1,6 +1,11 @@
11 import { beforeEach, describe, expect, it } from "vite-plus/test";
2+import { ProjectId } from "@t3tools/contracts";
23
3−import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore";
4+import {
5+ type PendingReviewComment,
6+ pullRequestReviewKey,
7+ usePullRequestReviewStore,
8+} from "./pullRequestReviewStore";
49
510 function comment(id: string, body = id): PendingReviewComment {
611 return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } };
@@ -36,6 +41,30 @@ describe("pull request review drafts", () => { · 29 unmodified lines
3641 });
3742 });
3843
44+ it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => {
45+ const reference = {
46+ projectId: ProjectId.make("project-a"),
47+ repository: "owner/repo",
48+ number: 7,
49+ };
50+ const publicKey = pullRequestReviewKey({ ...reference, host: "github.com" });
51+ const enterpriseKey = pullRequestReviewKey({ ...reference, host: "github.example.com" });
52+ const store = usePullRequestReviewStore.getState();
53+ store.addComment(publicKey, comment("public"));
54+ store.setSummary(publicKey, "Public review");
55+
56+ expect(usePullRequestReviewStore.getState().drafts[enterpriseKey]).toBeUndefined();
57+ expect(usePullRequestReviewStore.getState().summaries[enterpriseKey]).toBeUndefined();
58+
59+ store.addComment(enterpriseKey, comment("enterprise"));
60+ store.setSummary(enterpriseKey, "Enterprise review");
61+ store.clear(enterpriseKey);
62+ store.clearSummary(enterpriseKey, "Enterprise review");
63+
64+ expect(usePullRequestReviewStore.getState().drafts[publicKey]).toEqual([comment("public")]);
65+ expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review");
66+ });
67+
3968 it("does not clear a summary revised while submission is in flight", () => {
4069 const store = usePullRequestReviewStore.getState();
4170 store.setSummary("review-a", "Submitted body");
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+11−8
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 * hosts that have no pending review of their own. That also means a draft lives only as long
77 * as the tab does, which is why this is deliberately not persisted.
88 */
9−import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
9+import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
1010 import { create } from "zustand";
1111
1212 export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };
@@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string { · 11 unmodified lines
2424 return `pending-review-comment-${pendingCommentSequence}`;
2525 }
2626
27−/** One pull request's draft, scoped by project as well as repository: a repository can be checked out twice. */
27+/** A project's thread can review the same repository path and number on different hosts. */
2828 export function pullRequestReviewKey(reference: PullRequestRef): string {
29− return `${reference.projectId}/${reference.repository}#${reference.number}`;
29+ return JSON.stringify([
30+ reference.projectId,
31+ reference.host?.toLowerCase() ?? null,
32+ reference.repository.toLowerCase(),
33+ reference.number,
34+ ]);
3035 }
3136
3237 interface PullRequestReviewStoreState {
@@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()(( · 48 unmodified lines
8186 }));
8287
8388 /** The comments a pull request's draft holds, stable across renders while it is empty. */
84−export function usePendingReviewComments(reference: {
85− readonly projectId: ProjectId;
86− readonly repository: string;
87− readonly number: number;
88−}): ReadonlyArray<PendingReviewComment> {
89+export function usePendingReviewComments(
90+ reference: PullRequestRef,
91+): ReadonlyArray<PendingReviewComment> {
8992 return usePullRequestReviewStore(
9093 (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY,
9194 );
apps/web/src/components/ui/button.tsx+21−0
@@ -23,6 +23,7 @@ const buttonVariants = cva( · 22 unmodified lines
2323 "icon-lg": "size-10 sm:size-9",
2424 "icon-micro":
2525 "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",
26+ "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3",
2627 "icon-sm": "size-8 sm:size-7",
2728 "icon-xl":
2829 "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
@@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { · 56 unmodified lines
8586 }
8687
8788 export { Button, buttonVariants };
89+
90+/** An inline action that keeps the geometry of surrounding text or a graph node. */
91+export function InlineButton({
92+ className,
93+ underline = false,
94+ ...props
95+}: React.ComponentProps<"button"> & { underline?: boolean }) {
96+ return (
97+ <button
98+ type="button"
99+ data-slot="inline-button"
100+ className={cn(
101+ "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
102+ underline && "border-b border-transparent hover:border-current",
103+ className,
104+ )}
105+ {...props}
106+ />
107+ );
108+}
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+5−0
@@ -11,6 +11,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanel · 10 unmodified lines
1111 import { useProject } from "../state/entities";
1212 import { pullRequestEnvironment } from "../state/pullRequests";
1313 import { useEnvironmentQuery } from "../state/query";
14+import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests";
1415
1516 export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
1617 const surface = useRightPanelStore((state) =>
@@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { · 3 unmodified lines
2021 const environmentId = reference?.environmentId
2122 ? EnvironmentId.make(reference.environmentId)
2223 : threadRef?.environmentId;
24+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);
2325 const project = useProject(
2426 reference && environmentId
2527 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))
@@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { · 5 unmodified lines
3133 environmentId,
3234 input: {
3335 projectId: ProjectId.make(reference.projectId),
36+ ...(supportsMultiplePullRequests && reference.host !== undefined
37+ ? { host: reference.host }
38+ : {}),
3439 repository: reference.repository,
3540 number: reference.number,
3641 },
apps/web/src/hooks/usePullRequestLinking.ts+104−0
@@ -0,0 +1,104 @@
1+import { useMemo } from "react";
2+import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
3+import type {
4+ EnvironmentId,
5+ ScopedThreadRef,
6+ ThreadLinkedPullRequest,
7+ ThreadPullRequestLink,
8+} from "@t3tools/contracts";
9+import {
10+ isAtomCommandInterrupted,
11+ squashAtomCommandFailure,
12+} from "@t3tools/client-runtime/state/runtime";
13+import {
14+ planThreadPullRequestMutation,
15+ threadPullRequestLinkMode,
16+} from "@t3tools/client-runtime/thread-pull-request-compatibility";
17+import {
18+ threadPullRequestKeysEqual,
19+ visibleThreadPullRequests,
20+} from "@t3tools/shared/threadPullRequests";
21+import {
22+ findProjectForChangeRequest,
23+ findProjectOnChangeRequestHost,
24+ matchesLinkedPullRequestUrl,
25+ parseChangeRequestUrl,
26+} from "~/lib/openPullRequestLink";
27+import { useProjects, useServerConfigs } from "~/state/entities";
28+import { threadEnvironment } from "~/state/threads";
29+import { useAtomCommand } from "~/state/use-atom-command";
30+
31+/** Routes link actions through the command advertised by this environment. */
32+export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
33+ const configs = useServerConfigs();
34+ const projects = useProjects();
35+ const capabilities =
36+ environmentId == null ? undefined : configs.get(environmentId)?.environment.capabilities;
37+ const mode = threadPullRequestLinkMode(capabilities);
38+ const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false });
39+ const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false });
40+ const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
41+ return useMemo(() => {
42+ const environmentProjects = projects.filter(
43+ (project) => project.environmentId === environmentId,
44+ );
45+ const canLink = (url: string) => {
46+ const parsed = parseChangeRequestUrl(url);
47+ if (parsed === null || mode === "unsupported") return false;
48+ return (
49+ (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
50+ environmentProjects,
51+ parsed,
52+ ) !== undefined
53+ );
54+ };
55+ const isLinked = (
56+ thread: {
57+ readonly pullRequests?: readonly ThreadPullRequestLink[];
58+ readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
59+ } | null,
60+ url: string,
61+ ) => {
62+ if (thread === null || mode === "unsupported") return false;
63+ if (mode !== "multiple")
64+ return (
65+ thread.linkedPullRequest != null &&
66+ matchesLinkedPullRequestUrl(thread.linkedPullRequest, url)
67+ );
68+ const parsed = parseChangeRequestUrl(url);
69+ return (
70+ parsed !== null &&
71+ visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) =>
72+ threadPullRequestKeysEqual(entry, parsed),
73+ )
74+ );
75+ };
76+ const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => {
77+ const parsed = parseChangeRequestUrl(url);
78+ if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url)))
79+ throw new Error("The pull request is not available in this environment.");
80+ const legacyProject = findProjectForChangeRequest(environmentProjects, parsed);
81+ const mutation = planThreadPullRequestMutation({
82+ capabilities,
83+ threadId: threadRef.threadId,
84+ reference: { ...parsed, url },
85+ legacyProjectId: legacyProject?.id ?? null,
86+ legacyRepository:
87+ sourceControlRepositorySelector(legacyProject?.repositoryIdentity) ?? undefined,
88+ linked,
89+ });
90+ if (mutation === null)
91+ throw new Error("This environment does not support linking this pull request.");
92+ const result = await (mutation.type === "thread.meta.update"
93+ ? updateMetadata({ environmentId: threadRef.environmentId, input: mutation.input })
94+ : mutation.type === "thread.pull-request.link"
95+ ? link({ environmentId: threadRef.environmentId, input: mutation.input })
96+ : unlink({ environmentId: threadRef.environmentId, input: mutation.input }));
97+ if (result._tag === "Failure") {
98+ if (isAtomCommandInterrupted(result)) throw new Error("Link update interrupted.");
99+ throw squashAtomCommandFailure(result);
100+ }
101+ };
102+ return { mode, canLink, isLinked, changeLink };
103+ }, [capabilities, environmentId, link, mode, projects, unlink, updateMetadata]);
104+}
apps/web/src/hooks/useSupportsMultiplePullRequests.ts+10−0
@@ -0,0 +1,10 @@
1+import type { EnvironmentId } from "@t3tools/contracts";
2+import { useServerConfigs } from "~/state/entities";
3+
4+export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean {
5+ const configs = useServerConfigs();
6+ return (
7+ environmentId !== null &&
8+ configs.get(environmentId)?.environment.capabilities.threadPullRequests === true
9+ );
10+}
apps/web/src/lib/openPullRequestLink.test.ts+76−0
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
33 import {
44 changeRequestRepositoryUrl,
55 findProjectForChangeRequest,
6+ findProjectOnChangeRequestHost,
67 gitHubPullRequestBrowserUrl,
78 matchesLinkedPullRequestUrl,
89 parseChangeRequestUrl,
@@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => { · 286 unmodified lines
295296 });
296297 });
297298
299+describe("findProjectOnChangeRequestHost", () => {
300+ const project = (id: string, identity: Record<string, unknown>) =>
301+ ({ id, repositoryIdentity: identity }) as never;
302+ const frontend = project("frontend", {
303+ canonicalKey: "github.com/acme/frontend",
304+ provider: "github",
305+ owner: "acme",
306+ name: "frontend",
307+ });
308+ const backend = project("backend", {
309+ canonicalKey: "github.com/acme/backend",
310+ provider: "github",
311+ owner: "acme",
312+ name: "backend",
313+ });
314+
315+ it.each([
316+ "ssh.dev.azure.com/v3/org-a/project/web",
317+ "vs-ssh.visualstudio.com/v3/org-a/project/web",
318+ "org-a.visualstudio.com/defaultcollection/project/_git/web",
319+ "dev.azure.com/org-a/project/_git/web",
320+ ])("matches Azure browser references against %s", (canonicalKey) => {
321+ const checkout = project("azure", {
322+ canonicalKey,
323+ provider: "azure-devops",
324+ displayName: canonicalKey.split("/").slice(1).join("/"),
325+ });
326+ const reference = { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 42 };
327+ expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
328+ expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout);
329+ expect(
330+ findProjectOnChangeRequestHost([checkout], {
331+ ...reference,
332+ repository: "org-b/project/_git/web",
333+ }),
334+ ).toBeUndefined();
335+ expect(
336+ findProjectOnChangeRequestHost([checkout], {
337+ ...reference,
338+ repository: "org-a/other-project/_git/web",
339+ }),
340+ ).toBeUndefined();
341+ });
342+
343+ it("prefers the project checked out from the link's own repository", () => {
344+ expect(
345+ findProjectOnChangeRequestHost([frontend, backend], {
346+ host: "github.com",
347+ repository: "acme/backend",
348+ number: 7,
349+ }),
350+ ).toBe(backend);
351+ });
352+
353+ it("lends any project on the host to a repository nobody has checked out", () => {
354+ expect(
355+ findProjectOnChangeRequestHost([frontend], {
356+ host: "github.com",
357+ repository: "acme/backend",
358+ number: 7,
359+ }),
360+ ).toBe(frontend);
361+ });
362+
363+ it("finds nothing on a host nothing is checked out from", () => {
364+ expect(
365+ findProjectOnChangeRequestHost([frontend], {
366+ host: "gitlab.com",
367+ repository: "acme/backend",
368+ number: 7,
369+ }),
370+ ).toBeUndefined();
371+ });
372+});
373+
298374 describe("findProjectForChangeRequest", () => {
299375 const project = (identity: Record<string, unknown>) =>
300376 ({ id: "p1", repositoryIdentity: identity }) as never;
apps/web/src/lib/openPullRequestLink.ts+76−182
@@ -1,13 +1,13 @@
1−import type {
2− EnvironmentId,
3− RepositoryIdentity,
4− ScopedThreadRef,
5− ThreadLinkedPullRequest,
6−} from "@t3tools/contracts";
1+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
72 import { useNavigate } from "@tanstack/react-router";
83 import { type MouseEvent, useCallback } from "react";
94
105 import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
6+import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
7+import {
8+ canonicalRepositoryKey,
9+ sourceControlRepositorySelector,
10+} from "@t3tools/shared/sourceControl";
1111
1212 import { useOpenLink } from "../browser/useOpenLink";
1313 import { stackedThreadToast, toastManager } from "../components/ui/toast";
@@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; · 3 unmodified lines
1717 import { useProjects, useServerConfigs } from "../state/entities";
1818 import { usePrimaryEnvironmentId } from "../state/environments";
1919
20−/** Builds a GitHub URL that remains available when the pull request API cannot be read. */
21−export function gitHubPullRequestBrowserUrl(
22− identity: RepositoryIdentity | null | undefined,
23− repository: string,
24− number: number,
25−): string | null {
26− if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;
27− const repositoryPath = repository.split("/");
28− if (
29− repositoryPath.length !== 2 ||
30− repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..")
31− ) {
32− return null;
33− }
34−
35− let origin: string | null = null;
36− try {
37− const remoteUrl = new URL(identity.locator.remoteUrl.trim());
38− if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") {
39− origin = remoteUrl.origin;
40− }
41− } catch {
42− // SCP-style remotes are read from their normalized identity below.
43− }
44− const hostname = identity.canonicalKey.split("/")[0];
45− if (origin === null && !hostname) return null;
46−
47− try {
48− const url = new URL(origin ?? `https://${hostname}`);
49− url.pathname = `/${repositoryPath.join("/")}/pull/${number}`;
50− return url.toString();
51− } catch {
52− return null;
53− }
54−}
55−
56−/**
57− * A change request the page can open, named the way the page names one: the host below which the
58− * repository is addressed, the repository path as that host writes it, and the number.
59− *
60− * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce
61− * from a git remote — lower case, no port, the full path below the host — because the page matches
62− * a link against those. Anything else opens nothing.
63− */
64−export interface ChangeRequestLink {
65− readonly host: string;
66− readonly repository: string;
67− readonly number: number;
68−}
69−
70−/** The host itself, one of its subdomains, or an install named after the provider. */
71−function isHostOf(hostname: string, apex: string, label?: string): boolean {
72− if (hostname === apex || hostname.endsWith(`.${apex}`)) return true;
73− return label !== undefined && hostname.startsWith(`${label}.`);
74−}
75−
76−/**
77− * The repository and number behind a change request URL on a host the page can read, or null for
78− * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an
79− * ordinary link. Null means the system browser, so a doubtful match is worse than no match: it
80− * takes the reader out of their browser and into a page that cannot find the change request.
81− *
82− * Each host is recognised by the path shape it alone uses, guarded by a hostname it could
83− * plausibly be served from, since self-hosted installs are named whatever their admin chose:
84− * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic
85− * enough that it is only believed from a GitHub-ish host.
86− */
87−export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
88− let url: URL;
89− try {
90− url = new URL(targetUrl);
91− } catch {
92− return null;
93− }
94− // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open.
95− if (url.protocol !== "https:" && url.protocol !== "http:") return null;
96− // Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test`,
97− // `github.com-evil.test` and the rest are an open set, and blocking spellings of it costs real
98− // hosts (`gitlab.com.br` is a registrable domain, not a disguise). What a claim is worth is
99− // decided where it is used: only a link matching a repository this workspace has checked out
100− // opens the page, and everything else stays the ordinary link it was.
101− const host = url.hostname.toLowerCase();
102−
103− // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
104− if (isHostOf(host, "github.com", "github")) {
105− const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
106− return claim(host, match);
107− }
108− // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
109− // separator is GitLab's own, so the hostname is not asked about.
110− const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
111− if (gitlab) return claim(host, gitlab);
112− // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
113− if (isHostOf(host, "bitbucket.org", "bitbucket")) {
114− const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
115− return claim(host, match);
116− }
117− // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
118− // of the repository path there, as it is in the remote URL the identity is read from.
119− if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) {
120− const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
121− return claim(host, match);
122− }
123− return null;
124−}
125−
126−/**
127− * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
128− * reference through `/issues/`, including pull requests, so this only builds a candidate: the
129− * caller must successfully read it as a pull request before treating it as one.
130− */
131−export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
132− let url: URL;
133− try {
134− url = new URL(targetUrl);
135− } catch {
136− return null;
137− }
138− if (
139− (url.protocol !== "https:" && url.protocol !== "http:") ||
140− !isHostOf(url.hostname.toLowerCase(), "github.com", "github")
141− ) {
142− return null;
143− }
144− const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
145− if (match?.[1] === undefined || match[2] === undefined) return null;
146− url.pathname = `/${match[1]}/pull/${match[2]}`;
147− return url.toString();
148−}
149−
150−/** Match a stored PR without requiring its project to remain available. */
151−export function matchesLinkedPullRequestUrl(
152− linkedPullRequest: ThreadLinkedPullRequest,
153− targetUrl: string,
154−): boolean {
155− const linked = parseChangeRequestUrl(linkedPullRequest.url);
156− const target = parseChangeRequestUrl(targetUrl);
157− return (
158− linked !== null &&
159− target !== null &&
160− linked.host === target.host &&
161− linked.repository === target.repository &&
162− linked.number === target.number
163− );
164−}
165−
166−/** The repository root behind a recognised change-request URL, without PR-specific state. */
167−export function changeRequestRepositoryUrl(targetUrl: string): string | null {
168− const changeRequest = parseChangeRequestUrl(targetUrl);
169− if (changeRequest === null) return null;
170− const url = new URL(targetUrl);
171− const repositoryPath =
172− /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
173− /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
174− url.pathname,
175− )?.[1];
176− if (!repositoryPath) return null;
177− url.pathname = repositoryPath;
178− url.search = "";
179− url.hash = "";
180− return url.toString();
181−}
182−
183−function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
184− const repository = match?.[1];
185− const number = Number(match?.[2]);
186− return repository && Number.isSafeInteger(number) && number > 0
187− ? { host, repository: repository.toLowerCase(), number }
188− : null;
189−}
20+export {
21+ parseChangeRequestUrl,
22+ type ChangeRequestLink,
23+ gitHubPullRequestBrowserUrl,
24+ pullRequestCandidateUrlFromReferenceAutolink,
25+ matchesLinkedPullRequestUrl,
26+ changeRequestRepositoryUrl,
27+} from "@t3tools/shared/changeRequestUrl";
19028
19129 /**
19230 * Returns a click handler that opens a pull request URL in the system browser.
@@ -210,6 +48,12 @@ export function findProjectForChangeRequest( · 17 unmodified lines
21048 if (!identity) return false;
21149 const kind = identity.provider as SourceControlProviderKind | undefined;
21250 if (kind === undefined) return false;
51+ if (kind === "azure-devops") {
52+ return (
53+ canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
54+ canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase())
55+ );
56+ }
21357 const repository =
21458 identity.displayName ??
21559 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
@@ -221,6 +65,36 @@ export function findProjectForChangeRequest( · 5 unmodified lines
22165 });
22266 }
22367
68+/**
69+ * Any project checked out from the link's host. Thread links are host-level, so a pull request
70+ * from a repository nobody has checked out is still linkable as long as one project on that
71+ * host can lend the server its credentials. The link's own project, when it exists, comes first.
72+ */
73+export function findProjectOnChangeRequestHost(
74+ projects: ReadonlyArray<EnvironmentProject>,
75+ link: ChangeRequestLink,
76+): EnvironmentProject | undefined {
77+ const own = findProjectForChangeRequest(projects, link);
78+ if (own !== undefined) return own;
79+ // Azure CLI reads use the checkout's organization and project, not host-wide credentials.
80+ if (
81+ canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith(
82+ "dev.azure.com/",
83+ )
84+ )
85+ return undefined;
86+ return projects.find((project) => {
87+ const identity = project.repositoryIdentity;
88+ const kind = identity?.provider as SourceControlProviderKind | undefined;
89+ return (
90+ identity != null &&
91+ kind !== undefined &&
92+ kind !== "azure-devops" &&
93+ pullRequestHostOf(identity, kind) === link.host.toLowerCase()
94+ );
95+ });
96+}
97+
22498 /**
22599 * Opens a change request link on the page, and says whether it did. Anything else — another
226100 * organisation's repository, a host nothing here is checked out from, a link that merely looks
@@ -284,8 +158,25 @@ export function useOpenChangeRequestLink( · 57 unmodified lines
284158 Number(right.environmentId === primaryEnvironmentId) -
285159 Number(left.environmentId === primaryEnvironmentId),
286160 );
287− const project = findProjectForChangeRequest(projects, parsed);
161+ const exactProject = findProjectForChangeRequest(projects, parsed);
162+ const project =
163+ exactProject ??
164+ (resolvedPanelRef
165+ ? findProjectOnChangeRequestHost(
166+ projects.filter(
167+ (candidate) =>
168+ serverConfigs.get(candidate.environmentId)?.environment.capabilities
169+ .threadPullRequests === true,
170+ ),
171+ parsed,
172+ )
173+ : undefined);
288174 if (project === undefined || !reads(project.environmentId)) return false;
175+ const repository =
176+ serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests ===
177+ true
178+ ? parsed.repository
179+ : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository);
289180 event.preventDefault();
290181 event.stopPropagation();
291182 if (resolvedPanelRef) {
@@ -295,9 +186,12 @@ export function useOpenChangeRequestLink( · 3 unmodified lines
295186 ? {}
296187 : { environmentId: project.environmentId }),
297188 projectId: project.id,
298− // The identity's own spelling, not the one read out of the URL: the panel asks the
299− // provider for this repository, while matching a link only ever compares lower case.
300− repository: project.repositoryIdentity?.displayName ?? parsed.repository,
189+ ...(serverConfigs.get(project.environmentId)?.environment.capabilities
190+ .threadPullRequests === true
191+ ? { host: parsed.host }
192+ : {}),
193+ repository,
194+ url: targetUrl,
301195 number: parsed.number,
302196 });
303197 if (!resolvedThreadRef) {
@@ -307,7 +201,7 @@ export function useOpenChangeRequestLink( · 3 unmodified lines
307201 ...previous,
308202 involvement: previous.involvement ?? "all",
309203 state: previous.state ?? "all",
310− repository: project.repositoryIdentity?.displayName ?? parsed.repository,
204+ repository,
311205 number: parsed.number,
312206 selectedProjectId: project.id,
313207 selectedEnvironmentId: project.environmentId,
@@ -324,7 +218,7 @@ export function useOpenChangeRequestLink( · 10 unmodified lines
324218 // Every state, so the pull request being opened is also in the list behind it whether
325219 // it is open, merged or closed.
326220 state: "all",
327− repository: parsed.repository,
221+ repository,
328222 number: parsed.number,
329223 selectedProjectId: project.id,
330224 // Named so the page opens the right one of two servers holding this project.
apps/web/src/lib/threadSort.test.ts+1−0
@@ -34,6 +34,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 33 unmodified lines
3434 branch: null,
3535 worktreePath: null,
3636 checkpoints: [],
37+ pullRequests: [],
3738 activities: [],
3839 ...overrides,
3940 };
apps/web/src/rightPanelStore.test.ts+12−0
@@ -605,6 +605,18 @@ describe("rightPanelStore", () => { · 604 unmodified lines
605605 expect(state.surfaces[1]).not.toHaveProperty("url");
606606 });
607607
608+ it("keeps matching repository and number on different hosts as separate tabs", () => {
609+ const first = { projectId: "project-a", repository: "acme/api", number: 7, host: "github.com" };
610+ const second = { ...first, host: "github.example.com" };
611+ useRightPanelStore.getState().openPullRequest(refA, first);
612+ useRightPanelStore.getState().openPullRequest(refA, second);
613+ const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
614+ expect(state.surfaces).toEqual([pullRequestSurface(first), pullRequestSurface(second)]);
615+ expect(pullRequestSurfaceId({ ...first, host: "GITHUB.COM" })).toBe(
616+ pullRequestSurfaceId(first),
617+ );
618+ });
619+
608620 it("keeps one pull request read from two servers as two tabs", () => {
609621 const local = {
610622 environmentId: "local",
apps/web/src/rightPanelStore.ts+12−1
@@ -26,6 +26,7 @@ const RIGHT_PANEL_KINDS = [ · 25 unmodified lines
2626 "preview",
2727 "terminal",
2828 "pull-request",
29+ "pull-requests",
2930 "agents",
3031 ] as const;
3132 export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
@@ -68,10 +69,13 @@ export type RightPanelSurface = · 36 unmodified lines
6869 */
6970 environmentId?: string;
7071 projectId: string;
72+ host?: string;
7173 repository: string;
7274 number: number;
7375 url?: string;
7476 }
77+ /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */
78+ | { id: "pull-requests"; kind: "pull-requests" }
7579 | { id: "agents"; kind: "agents" };
7680
7781 const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
@@ -124,6 +128,7 @@ interface RightPanelStoreState { · 46 unmodified lines
124128 target: {
125129 environmentId?: string;
126130 projectId: string;
131+ host?: string;
127132 repository: string;
128133 number: number;
129134 url?: string;
@@ -169,6 +174,8 @@ const singletonSurface = ( · 39 unmodified lines
169174 return { id: "diff", kind };
170175 case "files":
171176 return { id: "files", kind };
177+ case "pull-requests":
178+ return { id: "pull-requests", kind };
172179 case "agents":
173180 return { id: "agents", kind };
174181 }
@@ -213,6 +220,7 @@ export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-reques · 38 unmodified lines
213220 export function pullRequestSurfaceId(target: {
214221 environmentId?: string;
215222 projectId: string;
223+ host?: string;
216224 repository: string;
217225 number: number;
218226 }): PullRequestSurface["id"] {
@@ -220,12 +228,14 @@ export function pullRequestSurfaceId(target: { · 1 unmodified line
220228 // servers is two tabs rather than one tab that changes its mind about which server it is on.
221229 const scope =
222230 target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;
223− return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;
231+ const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`;
232+ return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`;
224233 }
225234
226235 export function pullRequestSurface(target: {
227236 environmentId?: string;
228237 projectId: string;
238+ host?: string;
229239 repository: string;
230240 number: number;
231241 url?: string;
@@ -235,6 +245,7 @@ export function pullRequestSurface(target: { · 3 unmodified lines
235245 kind: "pull-request",
236246 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),
237247 projectId: target.projectId,
248+ ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}),
238249 repository: target.repository,
239250 number: target.number,
240251 ...(typeof target.url === "string" ? { url: target.url } : {}),
apps/web/src/routes/_chat.pull-requests.tsx+2−0
@@ -1923,12 +1923,14 @@ function PullRequestsRouteView() { · 1922 unmodified lines
19231923 onAddDiff={() => undefined}
19241924 onAddFiles={() => undefined}
19251925 onAddPullRequest={() => undefined}
1926+ onAddPullRequests={() => undefined}
19261927 onAddAgents={() => undefined}
19271928 browserAvailable={false}
19281929 terminalAvailable={false}
19291930 diffAvailable={false}
19301931 filesAvailable={false}
19311932 pullRequestAvailable={false}
1933+ pullRequestsAvailable={false}
19321934 agentsAvailable={false}
19331935 liveAgentCount={0}
19341936 pullRequestStatusSeeds={listedPullRequestTabStatuses}
apps/web/src/state/pullRequests.ts+3−0
@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; · 1 unmodified line
22 import {
33 createLinkedPullRequestSummaryAtomFamily,
44 createPullRequestEnvironmentAtoms,
5+ createPullRequestStackAtomFamily,
56 } from "@t3tools/client-runtime/state/pull-requests";
67 import type {
78 EnvironmentId,
@@ -58,6 +59,7 @@ export function useSharedPullRequestSummary( · 50 unmodified lines
5859 : JSON.stringify([
5960 environmentId,
6061 reference.projectId,
62+ reference.host?.toLowerCase() ?? null,
6163 reference.repository.toLowerCase(),
6264 reference.number,
6365 ]);
@@ -72,6 +74,7 @@ export function useSharedPullRequestSummary( · 8 unmodified lines
7274 }, [atom, current, environmentId]);
7375 return newestPullRequestSummary(current, observed);
7476 }
77+export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime);
7578
7679 export interface EnvironmentQueryTarget<Input> {
7780 readonly environmentId: EnvironmentId;
apps/web/src/state/sourceControlActions.ts+2−0
@@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { · 217 unmodified lines
218218 commitMessage?: string;
219219 featureBranch?: boolean;
220220 filePaths?: string[];
221+ threadId?: ThreadId;
221222 onProgress?: (event: GitActionProgressEvent) => void;
222223 }) => {
223224 if (resolveScope(scope) === null) {
@@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { · 13 unmodified lines
237238 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
238239 ...(input.featureBranch ? { featureBranch: true } : {}),
239240 ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}),
241+ ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
240242 ...(input.onProgress ? { onProgress: input.onProgress } : {}),
241243 });
242244 },
apps/web/src/worktreeCleanup.test.ts+1−0
@@ -21,6 +21,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 20 unmodified lines
2121 session: null,
2222 messages: [],
2323 checkpoints: [],
24+ pullRequests: [],
2425 activities: [],
2526 proposedPlans: [],
2627 createdAt: "2026-02-13T00:00:00.000Z",
docs/internals/glossary.md+8−0
@@ -45,3 +45,11 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain · 44 unmodified lines
4545 | Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. |
4646 | Checkpoint baseline | The workspace state captured before the work being compared. |
4747 | Turn diff | The workspace changes attributed to one turn. |
48+
49+## Pull requests
50+
51+| Term | Meaning |
52+| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53+| Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. |
54+| Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. |
55+| Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. |
docs/internals/overview.md+19−0
@@ -18,6 +18,25 @@ versioned clients and servers. Subscriptions send the state a client needs, so a · 17 unmodified lines
1818 thread does not pay for every thread's history. Authentication of a socket does not authorize every
1919 method on it. See [environment auth](./environment-auth.md).
2020
21+### Pull request linking compatibility
22+
23+Web, desktop, mobile, and environments upgrade independently. Negotiate linking through the
24+environment descriptor, never through a client version or an assumed coordinated release:
25+
26+| Environment capability | Client behavior |
27+| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
28+| `threadPullRequests: true` | Use persisted `pullRequests[]`, multi-link commands, stack UI, and reverse thread lookup. |
29+| Only `threadPullRequestLinking: true` | Use `linkedPullRequest` and the existing `thread.meta.update` single-link operation. Do not call multi-link RPCs. |
30+| Neither flag | Hide linking actions; existing branch-discovered PR display remains available. |
31+
32+New environments continue advertising the legacy flag, accepting legacy metadata commands, and
33+emitting the derived `linkedPullRequest` field for older clients. That hostless field includes only
34+links in the thread project's own repository; cross-host and cross-repository links require the
35+multi-link protocol. New clients accept snapshots that
36+omit `pullRequests`. Retain the legacy wire fields, projection column, and replay support; this feature
37+does not schedule their removal. Missing new capabilities must also override cached multi-link data
38+after an environment downgrade.
39+
2140 Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands
2241 and events, so adding a provider should not require branches throughout the domain or clients.
2342 See [provider constraints](./providers.md).
docs/user/source-control.md+24−0
@@ -91,3 +91,27 @@ reopening a declined pull request. · 90 unmodified lines
9191 remotes can require separate setup from the hosting provider's API access.
9292 - **A review cannot load:** open it on the host website while resolving connectivity, permissions,
9393 or rate limits.
94+
95+## Linked pull requests
96+
97+A thread can hold several pull requests, including reviews from another repository on the same host.
98+Use **Link pull request** in the command palette or **Linked pull requests** panel, or right-click a
99+pull request link in the conversation. Creating a pull request from Git actions links it automatically.
100+Agents can link their pull requests with the `link_pull_request` tool.
101+
102+Use **Link this PR** in a branch-detected badge's tooltip to keep it with the thread. From a review
103+on the Pull Requests page, **Link to thread** lets you search for an active thread. The review header
104+also lists the threads that link to it, including archived threads, so you can return to their context.
105+
106+Thread badges show a stack's layer count or the current review number with a count of additional
107+links. On mobile, the Git overview lists linked reviews and their stacks; tap a review to open it.
108+Linking and unlinking are available in the web and desktop clients.
109+
110+The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its
111+row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server;
112+closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh
113+when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked
114+review is terminal. An open or unsynced link keeps it active.
115+
116+Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
117+out from the matching organization and repository.
packages/client-runtime/package.json+4−0
@@ -230,6 +230,10 @@ · 229 unmodified lines
230230 "./work-log/scroll-anchor": {
231231 "types": "./src/work-log/scrollAnchor.ts",
232232 "default": "./src/work-log/scrollAnchor.ts"
233+ },
234+ "./thread-pull-request-compatibility": {
235+ "types": "./src/threadPullRequestCompatibility.ts",
236+ "default": "./src/threadPullRequestCompatibility.ts"
233237 }
234238 },
235239 "scripts": {
packages/client-runtime/src/operations/commands.ts+20−0
@@ -44,6 +44,8 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">; · 43 unmodified lines
4444 export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
4545 export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
4646 export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
47+export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;
48+export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">;
4749 export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4850 export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
4951 export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
@@ -252,6 +254,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command · 202 unmodified lines
252254 });
253255 });
254256
257+export const linkThreadPullRequest: (input: LinkThreadPullRequestInput) => CommandEffect =
258+ Effect.fn("EnvironmentCommands.linkThreadPullRequest")(function* (input) {
259+ return yield* dispatch({
260+ ...input,
261+ type: "thread.pull-request.link",
262+ commandId: yield* commandId(input),
263+ });
264+ });
265+
266+export const unlinkThreadPullRequest: (input: UnlinkThreadPullRequestInput) => CommandEffect =
267+ Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(function* (input) {
268+ return yield* dispatch({
269+ ...input,
270+ type: "thread.pull-request.unlink",
271+ commandId: yield* commandId(input),
272+ });
273+ });
274+
255275 export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(
256276 "EnvironmentCommands.setThreadRuntimeMode",
257277 )(function* (input) {
packages/client-runtime/src/state/entities.test.ts+1−0
@@ -99,6 +99,7 @@ const THREAD_SHELL = { · 98 unmodified lines
9999 archivedAt: null,
100100 settledOverride: null,
101101 settledAt: null,
102+ pullRequests: [],
102103 session: null,
103104 latestUserMessageAt: null,
104105 hasPendingApprovals: false,
packages/client-runtime/src/state/environmentHttpAuth.test.ts+1−0
@@ -84,6 +84,7 @@ const THREAD = { · 83 unmodified lines
8484 runtimeMode: "full-access",
8585 interactionMode: "default",
8686 branch: null,
87+ pullRequests: [],
8788 worktreePath: null,
8889 latestTurn: null,
8990 createdAt: "2026-09-04T00:00:00.000Z",
packages/client-runtime/src/state/pullRequests.test.ts+89−38
@@ -41,6 +41,93 @@ function session(client: WsRpcProtocolClient): RpcSession { · 40 unmodified lines
4141 };
4242 }
4343
44+const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcProtocolClient) {
45+ const connectionState: SupervisorConnectionState = {
46+ ...AVAILABLE_CONNECTION_STATE,
47+ desired: true,
48+ network: "online",
49+ phase: "connected",
50+ attempt: 1,
51+ generation: 1,
52+ };
53+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
54+ target: TARGET,
55+ state: yield* SubscriptionRef.make(connectionState),
56+ session: yield* SubscriptionRef.make(Option.some(session(client))),
57+ prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
58+ connect: Effect.void,
59+ disconnect: Effect.void,
60+ retryNow: Effect.void,
61+ } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
62+ const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
63+ run: (_environmentId, effect) =>
64+ Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
65+ runStream: (_environmentId, stream) =>
66+ Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
67+ followStream: (_environmentId, stream) =>
68+ Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
69+ } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
70+ const runtime = Atom.runtime(
71+ Layer.merge(
72+ Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
73+ Layer.succeed(
74+ PullRequestDiffLoader,
75+ PullRequestDiffLoader.of({ load: () => Effect.die("unused") }),
76+ ),
77+ ),
78+ );
79+ const atoms = createPullRequestEnvironmentAtoms(runtime);
80+ const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
81+ Effect.sync(() => registry.dispose()),
82+ );
83+ return { atoms, registry };
84+});
85+
86+it.effect("keeps concurrent diff file reads on different hosts separate", () =>
87+ Effect.scoped(
88+ Effect.gen(function* () {
89+ const release = yield* Latch.make();
90+ const started = yield* Latch.make();
91+ const calls: string[] = [];
92+ const client = {
93+ [WS_METHODS.pullRequestsDiffFileContents]: (input: { readonly host: string }) =>
94+ Effect.gen(function* () {
95+ calls.push(input.host);
96+ yield* started.open;
97+ yield* release.await;
98+ return { oldContents: "", newContents: input.host };
99+ }),
100+ } as unknown as WsRpcProtocolClient;
101+ const { atoms, registry } = yield* makeTestRuntime(client);
102+ const input = {
103+ projectId: ProjectId.make("project-1"),
104+ repository: "acme/web",
105+ number: 1,
106+ changeType: "change",
107+ oldPath: "src/app.ts",
108+ newPath: "src/app.ts",
109+ } as const;
110+ const first = atoms.diffFileContents.run(registry, {
111+ environmentId: TARGET.environmentId,
112+ input: { ...input, host: "github.com" },
113+ });
114+ yield* started.await;
115+ const second = atoms.diffFileContents.run(registry, {
116+ environmentId: TARGET.environmentId,
117+ input: { ...input, host: "github.example.com" },
118+ });
119+ yield* release.open;
120+
121+ const results = yield* Effect.promise(() => Promise.all([first, second]));
122+ expect(results).toMatchObject([
123+ { _tag: "Success", value: { newContents: "github.com" } },
124+ { _tag: "Success", value: { newContents: "github.example.com" } },
125+ ]);
126+ expect(calls).toEqual(["github.com", "github.example.com"]);
127+ }),
128+ ),
129+);
130+
44131 it.effect("refreshes pull request activity after a comment is updated", () =>
45132 Effect.scoped(
46133 Effect.gen(function* () {
@@ -76,46 +163,10 @@ it.effect("refreshes pull request activity after a comment is updated", () => · 29 unmodified lines
76163 commentBody = input.body;
77164 }),
78165 } as unknown as WsRpcProtocolClient;
79− const connectionState: SupervisorConnectionState = {
80− ...AVAILABLE_CONNECTION_STATE,
81− desired: true,
82− network: "online",
83− phase: "connected",
84− attempt: 1,
85− generation: 1,
86− };
87− const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
88− target: TARGET,
89− state: yield* SubscriptionRef.make(connectionState),
90− session: yield* SubscriptionRef.make(Option.some(session(client))),
91− prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
92− connect: Effect.void,
93− disconnect: Effect.void,
94− retryNow: Effect.void,
95− } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
96− const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
97− run: (_environmentId, effect) =>
98− Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
99− runStream: (_environmentId, stream) =>
100− Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
101− followStream: (_environmentId, stream) =>
102− Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
103− } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
104− const runtime = Atom.runtime(
105− Layer.merge(
106− Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
107− Layer.succeed(
108− PullRequestDiffLoader,
109− PullRequestDiffLoader.of({ load: () => Effect.die("unused") }),
110− ),
111− ),
112− );
113− const atoms = createPullRequestEnvironmentAtoms(runtime);
114− const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
115− Effect.sync(() => registry.dispose()),
116− );
166+ const { atoms, registry } = yield* makeTestRuntime(client);
117167 const reference = {
118168 projectId: ProjectId.make("project-1"),
169+ host: "github.example.com",
119170 repository: "acme/web",
120171 number: 1,
121172 } as const;
packages/client-runtime/src/state/pullRequests.ts+27−2
@@ -60,6 +60,18 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( · 59 unmodified lines
6060 });
6161 }
6262
63+/** The host-native stack a pull request belongs to; null where it is not stacked. */
64+export function createPullRequestStackAtomFamily<R, E>(
65+ runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
66+) {
67+ return createEnvironmentRpcQueryAtomFamily(runtime, {
68+ label: "environment-data:pull-requests:stack",
69+ tag: WS_METHODS.pullRequestsStack,
70+ staleTimeMs: 60_000,
71+ idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
72+ });
73+}
74+
6375 export function pullRequestDetailToVcsStatus(
6476 detail: PullRequestDetail | PullRequestSummary,
6577 ): NonNullable<VcsStatusResult["pr"]> {
@@ -97,6 +109,13 @@ export function createPullRequestEnvironmentAtoms<R, E>( · 31 unmodified lines
97109 });
98110 return {
99111 refreshes,
112+ linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, {
113+ label: "environment-data:pull-requests:linked-threads",
114+ tag: WS_METHODS.pullRequestsLinkedThreads,
115+ staleTimeMs: 0,
116+ refreshIntervalMs: 10_000,
117+ refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
118+ }),
100119 list: createEnvironmentRpcQueryAtomFamily(runtime, {
101120 label: "environment-data:pull-requests:list",
102121 tag: WS_METHODS.pullRequestsList,
@@ -159,6 +178,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( · 56 unmodified lines
159178 JSON.stringify([
160179 environmentId,
161180 input.projectId,
181+ input.host?.toLowerCase() ?? null,
162182 input.repository,
163183 input.number,
164184 input.commit ?? null,
@@ -191,9 +211,14 @@ export function createPullRequestEnvironmentAtoms<R, E>( · 26 unmodified lines
191211 tag: WS_METHODS.pullRequestsUpdateComment,
192212 scheduler: commandScheduler,
193213 concurrency: serialPerEnvironment,
194− onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) =>
214+ onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>
195215 Effect.sync(() =>
196− registry.refresh(activity({ environmentId, input: { projectId, repository, number } })),
216+ registry.refresh(
217+ activity({
218+ environmentId,
219+ input: { projectId, ...(host === undefined ? {} : { host }), repository, number },
220+ }),
221+ ),
197222 ),
198223 }),
199224 submitReview: createEnvironmentRpcCommand(runtime, {
packages/client-runtime/src/state/shellReducer.test.ts+1−0
@@ -38,6 +38,7 @@ const stubThread = { · 37 unmodified lines
3838 archivedAt: null,
3939 settledOverride: null,
4040 settledAt: null,
41+ pullRequests: [],
4142 latestUserMessageAt: null,
4243 hasPendingApprovals: false,
4344 hasPendingUserInput: false,
packages/client-runtime/src/state/threadCommands.ts+18−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 type CreateThreadInput,
1313 type DeleteThreadInput,
1414 type InterruptThreadTurnInput,
15+ type LinkThreadPullRequestInput,
1516 type RespondToThreadApprovalInput,
1617 type RespondToThreadUserInputInput,
1718 type DismissThreadUserInputInput,
@@ -26,6 +27,7 @@ import { · 8 unmodified lines
2627 type StartThreadTurnInput,
2728 type StopThreadSessionInput,
2829 type UnarchiveThreadInput,
30+ type UnlinkThreadPullRequestInput,
2931 type UnpinThreadInput,
3032 type UnsettleThreadInput,
3133 type UnsnoozeThreadInput,
@@ -34,6 +36,7 @@ import { · 2 unmodified lines
3436 createThread,
3537 deleteThread,
3638 interruptThreadTurn,
39+ linkThreadPullRequest,
3740 respondToThreadApproval,
3841 respondToThreadUserInput,
3942 dismissThreadUserInput,
@@ -48,6 +51,7 @@ import { · 8 unmodified lines
4851 startThreadTurn,
4952 stopThreadSession,
5053 unarchiveThread,
54+ unlinkThreadPullRequest,
5155 unpinThread,
5256 unsettleThread,
5357 unsnoozeThread,
@@ -60,6 +64,7 @@ export type { · 6 unmodified lines
6064 CreateThreadInput,
6165 DeleteThreadInput,
6266 InterruptThreadTurnInput,
67+ LinkThreadPullRequestInput,
6368 RespondToThreadApprovalInput,
6469 RespondToThreadUserInputInput,
6570 DismissThreadUserInputInput,
@@ -74,6 +79,7 @@ export type { · 8 unmodified lines
7479 StartThreadTurnInput,
7580 StopThreadSessionInput,
7681 UnarchiveThreadInput,
82+ UnlinkThreadPullRequestInput,
7783 UnpinThreadInput,
7884 UnsettleThreadInput,
7985 UnsnoozeThreadInput,
@@ -168,6 +174,18 @@ export function createThreadEnvironmentAtoms<R, E>( · 88 unmodified lines
168174 scheduler,
169175 concurrency,
170176 }),
177+ linkPullRequest: createEnvironmentCommand(runtime, {
178+ label: "environment-data:commands:thread:link-pull-request",
179+ execute: (input: LinkThreadPullRequestInput) => linkThreadPullRequest(input),
180+ scheduler,
181+ concurrency,
182+ }),
183+ unlinkPullRequest: createEnvironmentCommand(runtime, {
184+ label: "environment-data:commands:thread:unlink-pull-request",
185+ execute: (input: UnlinkThreadPullRequestInput) => unlinkThreadPullRequest(input),
186+ scheduler,
187+ concurrency,
188+ }),
171189 setRuntimeMode: createEnvironmentCommand(runtime, {
172190 label: "environment-data:commands:thread:set-runtime-mode",
173191 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),
packages/client-runtime/src/state/threadReducer.test.ts+119−0
@@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = { · 36 unmodified lines
3737 archivedAt: null,
3838 settledOverride: null,
3939 settledAt: null,
40+ pullRequests: [],
4041 deletedAt: null,
4142 messages: [],
4243 proposedPlans: [],
@@ -406,6 +407,124 @@ describe("applyThreadDetailEvent", () => { · 363 unmodified lines
406407 );
407408 });
408409
410+ describe("thread pull request links", () => {
411+ const link = {
412+ host: "github.com",
413+ repository: "pingdotgg/t3code",
414+ number: 42,
415+ url: "https://github.com/pingdotgg/t3code/pull/42",
416+ source: "manual" as const,
417+ linkedAt: "2026-04-01T05:00:00.000Z",
418+ snapshot: null,
419+ stack: null,
420+ };
421+ const key = { host: "github.com", repository: "pingdotgg/t3code", number: 42 };
422+ const linkEvent = (sequence: number) =>
423+ ({
424+ ...baseEventFields,
425+ sequence,
426+ occurredAt: "2026-04-01T05:00:00.000Z",
427+ aggregateKind: "thread",
428+ aggregateId: ThreadId.make("thread-1"),
429+ type: "thread.pull-request-linked",
430+ payload: {
431+ threadId: ThreadId.make("thread-1"),
432+ link,
433+ updatedAt: "2026-04-01T05:00:00.000Z",
434+ },
435+ }) as const;
436+
437+ it("links without inventing a legacy route and replaces by key", () => {
438+ const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
439+ expect(linked.kind).toBe("updated");
440+ if (linked.kind !== "updated") return;
441+ expect(linked.thread.pullRequests).toEqual([link]);
442+ expect(linked.thread.linkedPullRequest).toBeNull();
443+
444+ const relinked = applyThreadDetailEvent(linked.thread, {
445+ ...linkEvent(6),
446+ payload: {
447+ threadId: ThreadId.make("thread-1"),
448+ link: { ...link, host: "GitHub.com", source: "agent" },
449+ updatedAt: "2026-04-01T06:00:00.000Z",
450+ },
451+ });
452+ if (relinked.kind !== "updated") throw new Error("expected update");
453+ expect(relinked.thread.pullRequests).toHaveLength(1);
454+ expect(relinked.thread.pullRequests[0]?.source).toBe("agent");
455+ });
456+
457+ it("syncs snapshot and stack onto the matching link only", () => {
458+ const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
459+ if (linked.kind !== "updated") throw new Error("expected update");
460+ const snapshot = {
461+ state: "merged" as const,
462+ title: "Ship it",
463+ headBranch: "feature",
464+ baseBranch: "main",
465+ isDraft: false,
466+ updatedAt: "2026-04-02T00:00:00.000Z",
467+ syncedAt: "2026-04-02T00:01:00.000Z",
468+ };
469+ const synced = applyThreadDetailEvent(linked.thread, {
470+ ...baseEventFields,
471+ sequence: 6,
472+ occurredAt: "2026-04-02T00:01:00.000Z",
473+ aggregateKind: "thread",
474+ aggregateId: ThreadId.make("thread-1"),
475+ type: "thread.pull-request-synced",
476+ payload: {
477+ threadId: ThreadId.make("thread-1"),
478+ ...key,
479+ snapshot,
480+ stack: null,
481+ updatedAt: "2026-04-02T00:01:00.000Z",
482+ },
483+ });
484+ if (synced.kind !== "updated") throw new Error("expected update");
485+ expect(synced.thread.pullRequests[0]?.snapshot).toEqual(snapshot);
486+
487+ const unknown = applyThreadDetailEvent(synced.thread, {
488+ ...baseEventFields,
489+ sequence: 7,
490+ occurredAt: "2026-04-02T00:02:00.000Z",
491+ aggregateKind: "thread",
492+ aggregateId: ThreadId.make("thread-1"),
493+ type: "thread.pull-request-synced",
494+ payload: {
495+ threadId: ThreadId.make("thread-1"),
496+ ...key,
497+ number: 99,
498+ snapshot,
499+ stack: null,
500+ updatedAt: "2026-04-02T00:02:00.000Z",
501+ },
502+ });
503+ expect(unknown.kind).toBe("unchanged");
504+ });
505+
506+ it("unlinks and clears the compat field", () => {
507+ const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
508+ if (linked.kind !== "updated") throw new Error("expected update");
509+ const unlinked = applyThreadDetailEvent(linked.thread, {
510+ ...baseEventFields,
511+ sequence: 6,
512+ occurredAt: "2026-04-01T06:00:00.000Z",
513+ aggregateKind: "thread",
514+ aggregateId: ThreadId.make("thread-1"),
515+ type: "thread.pull-request-unlinked",
516+ payload: {
517+ threadId: ThreadId.make("thread-1"),
518+ ...key,
519+ updatedAt: "2026-04-01T06:00:00.000Z",
520+ },
521+ });
522+ if (unlinked.kind !== "updated") throw new Error("expected update");
523+ expect(unlinked.thread.pullRequests).toEqual([]);
524+ expect(unlinked.thread.linkedPullRequest).toBeNull();
525+ });
526+ });
527+
409528 describe("thread.message-sent", () => {
410529 it("appends a new message", () => {
411530 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+60−0
@@ -10,8 +10,10 @@ import type { · 9 unmodified lines
1010 OrchestrationSession,
1111 OrchestrationThread,
1212 OrchestrationThreadActivity,
13+ ThreadPullRequestLink,
1314 TurnId,
1415 } from "@t3tools/contracts";
16+import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests";
1517 import { isImportedAgentSessionMessageId } from "@t3tools/contracts";
1618 import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1719
@@ -20,6 +22,29 @@ export type ThreadDetailReducerResult = · 2 unmodified lines
2022 | { readonly kind: "deleted" }
2123 | { readonly kind: "unchanged" };
2224
25+/** Keep only a legacy route supplied by the server; detail events cannot resolve project hosts. */
26+function withPullRequests(
27+ thread: OrchestrationThread,
28+ pullRequests: ReadonlyArray<ThreadPullRequestLink>,
29+ updatedAt: string,
30+): ThreadDetailReducerResult {
31+ return {
32+ kind: "updated",
33+ thread: {
34+ ...thread,
35+ pullRequests,
36+ linkedPullRequest:
37+ thread.linkedPullRequest &&
38+ pullRequests.some(
39+ (link) => link.source !== "stack-dismissed" && link.url === thread.linkedPullRequest?.url,
40+ )
41+ ? thread.linkedPullRequest
42+ : null,
43+ updatedAt,
44+ },
45+ };
46+}
47+
2348 const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
2449 O.mapInput(O.String, (p) => p.createdAt),
2550 O.mapInput(O.String, (p) => p.id),
@@ -109,6 +134,7 @@ export function applyThreadDetailEvent( · 83 unmodified lines
109134 snoozedUntil: null,
110135 snoozedAt: null,
111136 deletedAt: null,
137+ pullRequests: [],
112138 messages: [],
113139 proposedPlans: [],
114140 activities: [],
@@ -253,6 +279,40 @@ export function applyThreadDetailEvent( · 138 unmodified lines
253279 },
254280 };
255281
282+ case "thread.pull-request-linked": {
283+ const link = event.payload.link;
284+ const others = thread.pullRequests.filter(
285+ (existing) => !threadPullRequestKeysEqual(existing, link),
286+ );
287+ return withPullRequests(thread, [...others, link], event.payload.updatedAt);
288+ }
289+
290+ case "thread.pull-request-unlinked":
291+ return withPullRequests(
292+ thread,
293+ thread.pullRequests.filter(
294+ (existing) => !threadPullRequestKeysEqual(existing, event.payload),
295+ ),
296+ event.payload.updatedAt,
297+ );
298+
299+ case "thread.pull-request-synced": {
300+ if (
301+ !thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, event.payload))
302+ ) {
303+ return { kind: "unchanged" };
304+ }
305+ return withPullRequests(
306+ thread,
307+ thread.pullRequests.map((existing) =>
308+ threadPullRequestKeysEqual(existing, event.payload)
309+ ? { ...existing, snapshot: event.payload.snapshot, stack: event.payload.stack }
310+ : existing,
311+ ),
312+ event.payload.updatedAt,
313+ );
314+ }
315+
256316 case "thread.runtime-mode-set":
257317 return {
258318 kind: "updated",
packages/client-runtime/src/state/threads-atoms.test.ts+1−0
@@ -65,6 +65,7 @@ const THREAD: OrchestrationThread = { · 64 unmodified lines
6565 runtimeMode: "full-access",
6666 interactionMode: "default",
6767 branch: "main",
68+ pullRequests: [],
6869 worktreePath: null,
6970 latestTurn: null,
7071 createdAt: "2026-04-01T00:00:00.000Z",
packages/client-runtime/src/state/threads-pagination.test.ts+1−0
@@ -104,6 +104,7 @@ const BASE_THREAD: OrchestrationThread = { · 103 unmodified lines
104104 archivedAt: null,
105105 settledOverride: null,
106106 settledAt: null,
107+ pullRequests: [],
107108 deletedAt: null,
108109 messages: [RECENT_MESSAGE],
109110 proposedPlans: [],
packages/client-runtime/src/state/threads-sync.test.ts+1−0
@@ -75,6 +75,7 @@ const BASE_THREAD: OrchestrationThread = { · 74 unmodified lines
7575 archivedAt: null,
7676 settledOverride: null,
7777 settledAt: null,
78+ pullRequests: [],
7879 deletedAt: null,
7980 messages: [],
8081 proposedPlans: [],
packages/client-runtime/src/state/vcsAction.test.ts+11−2
@@ -1,7 +1,9 @@
11 import {
22 EnvironmentId,
3+ ThreadId,
34 WS_METHODS,
45 type GitActionProgressEvent,
6+ type GitRunStackedActionInput,
57 type GitRunStackedActionResult,
68 } from "@t3tools/contracts";
79 import { describe, expect, it } from "@effect/vitest";
@@ -589,9 +591,10 @@ describe("vcsActionState", () => { · 581 unmodified lines
589591 successfulActionId,
590592 );
591593 const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId);
594+ const rpcInputs = new Array<GitRunStackedActionInput>();
592595 const client = {
593− [WS_METHODS.gitRunStackedAction]: (input: { readonly actionId: string }) =>
594− input.actionId === successfulTransportActionId
596+ [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) =>
597+ (rpcInputs.push(input), input.actionId === successfulTransportActionId)
595598 ? Stream.make(
596599 progress({
597600 kind: "action_finished",
@@ -652,16 +655,22 @@ describe("vcsActionState", () => { · 54 unmodified lines
652655 const state = vcsRefsCacheStateAtom({ environmentId });
653656
654657 expect(registry.get(state).revision).toBe(0);
658+ const threadId = ThreadId.make("thread-stacked-action");
655659 const successfulResult = yield* Effect.promise(() =>
656660 manager.runStackedAction(targetKey).run(registry, {
657661 actionId: successfulActionId,
658662 action,
663+ threadId,
659664 }),
660665 );
661666
662667 expect(AsyncResult.isSuccess(successfulResult)).toBe(true);
663668 expect(registry.get(state).revision).toBe(1);
664669 expect(removed).toEqual([`${environmentId}:*`]);
670+ // The server links a created pull request to this thread, so the id must ride along.
671+ expect(rpcInputs).toEqual([
672+ { actionId: successfulTransportActionId, cwd, action, threadId },
673+ ]);
665674
666675 const failedResult = yield* Effect.promise(() =>
667676 manager.runStackedAction(targetKey).run(registry, {
packages/client-runtime/src/state/vcsAction.ts+4−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 type GitRunStackedActionInput,
77 type GitRunStackedActionResult,
88 GitStackedAction,
9+ type ThreadId,
910 WS_METHODS,
1011 } from "@t3tools/contracts";
1112 import * as Cause from "effect/Cause";
@@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput { · 64 unmodified lines
7677 readonly commitMessage?: string;
7778 readonly featureBranch?: boolean;
7879 readonly filePaths?: ReadonlyArray<string>;
80+ /** The thread the action runs beside; the server links a pull request it creates to it. */
81+ readonly threadId?: ThreadId;
7982 readonly onProgress?: (event: GitActionProgressEvent) => void;
8083 }
8184
@@ -463,6 +466,7 @@ export function createVcsActionManager<R, E>( · 381 unmodified lines
463466 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
464467 ...(input.featureBranch ? { featureBranch: true } : {}),
465468 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
469+ ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
466470 };
467471 return consumeVcsActionProgress(
468472 runStreamInEnvironment(
packages/client-runtime/src/threadPullRequestCompatibility.test.ts+115−0
@@ -0,0 +1,115 @@
1+import { ProjectId, ThreadId } from "@t3tools/contracts";
2+import { describe, expect, it } from "@effect/vitest";
3+import {
4+ planThreadPullRequestMutation,
5+ threadPullRequestLinkMode,
6+} from "./threadPullRequestCompatibility.ts";
7+
8+const reference = {
9+ host: "github.example",
10+ repository: "team/repo",
11+ number: 7,
12+ url: "https://github.example/team/repo/pull/7",
13+};
14+const input = {
15+ threadId: ThreadId.make("thread"),
16+ reference,
17+ legacyProjectId: ProjectId.make("exact-checkout"),
18+ linked: true,
19+};
20+
21+describe("thread pull request capability negotiation", () => {
22+ it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(
23+ "does not dispatch when linking is unadvertised: %j",
24+ (capabilities) => {
25+ expect(threadPullRequestLinkMode(capabilities)).toBe("unsupported");
26+ expect(planThreadPullRequestMutation({ ...input, capabilities })).toBeNull();
27+ },
28+ );
29+ it("uses metadata updates for old single-link servers, including unlink", () => {
30+ const capabilities = { threadPullRequestLinking: true };
31+ expect(planThreadPullRequestMutation({ ...input, capabilities })).toEqual({
32+ type: "thread.meta.update",
33+ input: {
34+ threadId: input.threadId,
35+ linkedPullRequest: {
36+ projectId: input.legacyProjectId,
37+ repository: reference.repository,
38+ number: 7,
39+ url: reference.url,
40+ },
41+ },
42+ });
43+ expect(
44+ planThreadPullRequestMutation({
45+ ...input,
46+ capabilities,
47+ linked: false,
48+ legacyProjectId: null,
49+ }),
50+ ).toEqual({
51+ type: "thread.meta.update",
52+ input: { threadId: input.threadId, linkedPullRequest: null },
53+ });
54+ });
55+ it("uses the checkout's Azure selector only for legacy metadata commands", () => {
56+ const azure = {
57+ ...input,
58+ reference: {
59+ host: "dev.azure.com",
60+ repository: "org/project/_git/web",
61+ number: 42,
62+ url: "https://dev.azure.com/org/project/_git/web/pullrequest/42",
63+ },
64+ legacyRepository: "web",
65+ };
66+ expect(
67+ planThreadPullRequestMutation({
68+ ...azure,
69+ capabilities: { threadPullRequestLinking: true },
70+ }),
71+ ).toMatchObject({
72+ type: "thread.meta.update",
73+ input: { linkedPullRequest: { repository: "web", number: 42 } },
74+ });
75+ expect(
76+ planThreadPullRequestMutation({
77+ ...azure,
78+ capabilities: { threadPullRequests: true },
79+ }),
80+ ).toMatchObject({
81+ type: "thread.pull-request.link",
82+ input: { repository: "org/project/_git/web", number: 42 },
83+ });
84+ });
85+
86+ it("never sends a same-host route as an exact repository to an old server", () => {
87+ expect(
88+ planThreadPullRequestMutation({
89+ ...input,
90+ capabilities: { threadPullRequestLinking: true },
91+ legacyProjectId: null,
92+ }),
93+ ).toBeNull();
94+ });
95+ it.each([
96+ { threadPullRequests: true },
97+ { threadPullRequests: true, threadPullRequestLinking: true },
98+ ])("prefers multi-link commands when available: %j", (capabilities) => {
99+ expect(
100+ planThreadPullRequestMutation({ ...input, capabilities, legacyProjectId: null }),
101+ ).toEqual({
102+ type: "thread.pull-request.link",
103+ input: { threadId: input.threadId, ...reference, source: "manual" },
104+ });
105+ expect(planThreadPullRequestMutation({ ...input, capabilities, linked: false })).toEqual({
106+ type: "thread.pull-request.unlink",
107+ input: {
108+ threadId: input.threadId,
109+ host: reference.host,
110+ repository: reference.repository,
111+ number: reference.number,
112+ },
113+ });
114+ });
115+});
packages/client-runtime/src/threadPullRequestCompatibility.ts+75−0
@@ -0,0 +1,75 @@
1+import type {
2+ ExecutionEnvironmentCapabilities,
3+ ProjectId,
4+ ThreadId,
5+ ThreadPullRequestKey,
6+} from "@t3tools/contracts";
7+
8+type LinkingCapabilities = Pick<
9+ ExecutionEnvironmentCapabilities,
10+ "threadPullRequests" | "threadPullRequestLinking"
11+>;
12+
13+/** Capability negotiation keeps both single-link and multi-link environments usable. */
14+export function threadPullRequestLinkMode(capabilities: LinkingCapabilities | null | undefined) {
15+ return capabilities?.threadPullRequests === true
16+ ? "multiple"
17+ : capabilities?.threadPullRequestLinking === true
18+ ? "single"
19+ : "unsupported";
20+}
21+
22+export function planThreadPullRequestMutation({
23+ capabilities,
24+ threadId,
25+ reference,
26+ legacyProjectId,
27+ legacyRepository,
28+ linked,
29+}: {
30+ capabilities: LinkingCapabilities | null | undefined;
31+ threadId: ThreadId;
32+ reference: ThreadPullRequestKey & { readonly url: string };
33+ /** Older servers need the exact repository checkout; same-host routing is not available. */
34+ legacyProjectId: ProjectId | null;
35+ /** Provider selector for legacy APIs, which omit Azure organization and project paths. */
36+ legacyRepository?: string | undefined;
37+ linked: boolean;
38+}) {
39+ switch (threadPullRequestLinkMode(capabilities)) {
40+ case "multiple":
41+ return linked
42+ ? {
43+ type: "thread.pull-request.link" as const,
44+ input: { threadId, ...reference, source: "manual" as const },
45+ }
46+ : {
47+ type: "thread.pull-request.unlink" as const,
48+ input: {
49+ threadId,
50+ host: reference.host,
51+ repository: reference.repository,
52+ number: reference.number,
53+ },
54+ };
55+ case "single":
56+ if (linked && legacyProjectId === null) return null;
57+ return {
58+ type: "thread.meta.update" as const,
59+ input: {
60+ threadId,
61+ linkedPullRequest:
62+ linked && legacyProjectId !== null
63+ ? {
64+ projectId: legacyProjectId,
65+ repository: legacyRepository ?? reference.repository,
66+ number: reference.number,
67+ url: reference.url,
68+ }
69+ : null,
70+ },
71+ };
72+ case "unsupported":
73+ return null;
74+ }
75+}
packages/contracts/src/environment.ts+6−1
@@ -123,8 +123,13 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ · 122 unmodified lines
123123 /** Server understands regenerateTitle on thread.meta.update. Absent on
124124 older servers, so clients hide the action instead of sending it. */
125125 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
126− /** Server persists a pull request reference on thread.meta.update. */
126+ /** Server supports legacy linkedPullRequest updates through thread.meta.update.
127+ Independent of threadPullRequests; servers supporting both advertise both. */
127128 threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),
129+ /** Server understands thread.pull-request.link / .unlink, exposes `pullRequests` on
130+ threads, and routes PullRequestRef.host across projects on the same host. Same
131+ version-skew contract as threadSettlement. */
132+ threadPullRequests: Schema.optionalKey(Schema.Boolean),
128133 /** The update path clients should offer for this server. Absent on
129134 servers that must be relaunched manually (dev checkouts, Windows
130135 foreground runs, pre-update servers). */
packages/contracts/src/git.ts+2−0
@@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({ · 118 unmodified lines
119119 filePaths: Schema.optional(
120120 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
121121 ),
122+ /** The thread the action runs beside; a pull request it creates is linked to it. */
123+ threadId: Schema.optional(ThreadId),
122124 });
123125 export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
124126
packages/contracts/src/orchestration.test.ts+161−11
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 OrchestrationMessage,
2727 ThreadMessageSentPayload,
2828 ThreadMetaUpdatedPayload,
29+ ThreadLinkedPullRequest,
2930 ThreadTurnStartCommand,
3031 ThreadCreatedPayload,
3132 ThreadTurnDiff,
@@ -682,6 +683,116 @@ it.effect("defaults settled fields when decoding historical thread data", () => · 650 unmodified lines
682683 assert.strictEqual(thread.settledAt, null);
683684 assert.strictEqual(shell.settledOverride, null);
684685 assert.strictEqual(shell.settledAt, null);
686+ // Pre-link servers omit the array entirely.
687+ assert.deepStrictEqual(thread.pullRequests, []);
688+ assert.deepStrictEqual(shell.pullRequests, []);
689+
690+ const legacyLink = {
691+ projectId: ProjectId.make("project-1"),
692+ repository: "acme/web",
693+ number: 42,
694+ url: "https://github.com/acme/web/pull/42",
695+ };
696+ const oldServerShell = yield* decodeOrchestrationThreadShell({
697+ ...common,
698+ latestUserMessageAt: null,
699+ hasPendingApprovals: false,
700+ hasPendingUserInput: false,
701+ hasActionableProposedPlan: false,
702+ linkedPullRequest: legacyLink,
703+ });
704+ assert.deepStrictEqual(oldServerShell.pullRequests, []);
705+ assert.deepStrictEqual(oldServerShell.linkedPullRequest, legacyLink);
706+
707+ // A decoder from before the array must still read its single-link field
708+ // after a new server encodes the expanded snapshot.
709+ const oldLinkFields = Schema.Struct({
710+ linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
711+ });
712+ const newServerWire = yield* Schema.encodeEffect(OrchestrationThreadShell)({
713+ ...oldServerShell,
714+ pullRequests: [
715+ {
716+ host: "github.com",
717+ repository: legacyLink.repository,
718+ number: legacyLink.number,
719+ url: legacyLink.url,
720+ source: "agent",
721+ linkedAt: common.createdAt,
722+ snapshot: null,
723+ stack: null,
724+ },
725+ ],
726+ });
727+ const oldClientFields = yield* Schema.decodeUnknownEffect(oldLinkFields)(newServerWire);
728+ assert.deepStrictEqual(oldClientFields.linkedPullRequest, legacyLink);
729+ }),
730+);
731+
732+it.effect("decodes thread pull request links with snapshot and stack", () =>
733+ Effect.gen(function* () {
734+ const shell = yield* decodeOrchestrationThreadShell({
735+ id: "thread-1",
736+ projectId: "project-1",
737+ title: "Thread",
738+ modelSelection: { provider: "codex", model: "gpt-5-codex" },
739+ runtimeMode: "full-access",
740+ branch: "feature/stack-2",
741+ worktreePath: null,
742+ latestTurn: null,
743+ createdAt: "2026-01-01T00:00:00.000Z",
744+ updatedAt: "2026-01-01T00:00:00.000Z",
745+ archivedAt: null,
746+ session: null,
747+ latestUserMessageAt: null,
748+ hasPendingApprovals: false,
749+ hasPendingUserInput: false,
750+ hasActionableProposedPlan: false,
751+ pullRequests: [
752+ {
753+ host: "github.com",
754+ repository: "pingdotgg/t3code",
755+ number: 42,
756+ url: "https://github.com/pingdotgg/t3code/pull/42",
757+ source: "agent",
758+ linkedAt: "2026-01-01T00:00:00.000Z",
759+ snapshot: null,
760+ stack: null,
761+ },
762+ {
763+ host: "github.com",
764+ repository: "pingdotgg/t3code",
765+ number: 43,
766+ url: "https://github.com/pingdotgg/t3code/pull/43",
767+ source: "stack",
768+ linkedAt: "2026-01-01T00:01:00.000Z",
769+ snapshot: {
770+ state: "open",
771+ title: "Layer two",
772+ headBranch: "feature/stack-2",
773+ baseBranch: "feature/stack-1",
774+ isDraft: false,
775+ updatedAt: "2026-01-01T00:02:00.000Z",
776+ syncedAt: "2026-01-01T00:03:00.000Z",
777+ },
778+ stack: {
779+ kind: "native",
780+ id: "7",
781+ number: 3,
782+ url: "https://github.com/pingdotgg/t3code/stacks/3",
783+ base: "main",
784+ layers: [
785+ { number: 42, headBranch: "feature/stack-1", state: "open" },
786+ { number: 43, headBranch: "feature/stack-2", state: "open" },
787+ ],
788+ },
789+ },
790+ ],
791+ });
792+
793+ assert.strictEqual(shell.pullRequests.length, 2);
794+ assert.strictEqual(shell.pullRequests[1]?.stack?.layers.length, 2);
795+ assert.strictEqual(shell.pullRequests[1]?.snapshot?.state, "open");
685796 }),
686797 );
687798
@@ -966,25 +1077,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () => · 278 unmodified lines
9661077 }),
9671078 );
9681079
969−it.effect("accepts a linked pull request in thread.meta.update", () =>
1080+it.effect("accepts thread.pull-request.link and .unlink commands", () =>
9701081 Effect.gen(function* () {
971− const linkedPullRequest = {
972− projectId: "project-1",
1082+ const link = yield* decodeOrchestrationCommand({
1083+ type: "thread.pull-request.link",
1084+ commandId: "cmd-link-pull-request",
1085+ threadId: "thread-1",
1086+ host: "github.com",
9731087 repository: "pingdotgg/t3code",
9741088 number: 42,
9751089 url: "https://github.com/pingdotgg/t3code/pull/42",
976− };
977− const parsed = yield* decodeOrchestrationCommand({
978− type: "thread.meta.update",
979− commandId: "cmd-link-pull-request",
1090+ source: "manual",
1091+ });
1092+ assert.strictEqual(link.type, "thread.pull-request.link");
1093+ if (link.type === "thread.pull-request.link") {
1094+ assert.strictEqual(link.source, "manual");
1095+ assert.strictEqual(link.number, 42);
1096+ }
1097+
1098+ const unlink = yield* decodeOrchestrationCommand({
1099+ type: "thread.pull-request.unlink",
1100+ commandId: "cmd-unlink-pull-request",
9801101 threadId: "thread-1",
981− linkedPullRequest,
1102+ host: "github.com",
1103+ repository: "pingdotgg/t3code",
1104+ number: 42,
9821105 });
1106+ assert.strictEqual(unlink.type, "thread.pull-request.unlink");
1107+ }),
1108+);
9831109
984− assert.strictEqual(parsed.type, "thread.meta.update");
985− if (parsed.type === "thread.meta.update") {
986− assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest);
1110+it.effect("still decodes a persisted thread.meta-updated event carrying linkedPullRequest", () =>
1111+ Effect.gen(function* () {
1112+ const event = yield* decodeOrchestrationEvent({
1113+ sequence: 1,
1114+ eventId: "event-legacy-link",
1115+ aggregateKind: "thread",
1116+ aggregateId: "thread-1",
1117+ type: "thread.meta-updated",
1118+ occurredAt: "2026-01-01T00:00:00.000Z",
1119+ commandId: "cmd-legacy-link",
1120+ causationEventId: null,
1121+ correlationId: "cmd-legacy-link",
1122+ metadata: {},
1123+ payload: {
1124+ threadId: "thread-1",
1125+ linkedPullRequest: {
1126+ projectId: "project-1",
1127+ repository: "pingdotgg/t3code",
1128+ number: 42,
1129+ url: "https://github.com/pingdotgg/t3code/pull/42",
1130+ },
1131+ updatedAt: "2026-01-01T00:00:00.000Z",
1132+ },
1133+ });
1134+ if (event.type !== "thread.meta-updated") {
1135+ assert.fail(`Expected thread.meta-updated event, received ${event.type}.`);
9871136 }
1137+ assert.strictEqual(event.payload.linkedPullRequest?.number, 42);
9881138 }),
9891139 );
9901140
packages/contracts/src/orchestration.ts+168−0
@@ -23,6 +23,13 @@ import { · 22 unmodified lines
2323 TurnId,
2424 } from "./baseSchemas.ts";
2525 import { ProviderInstanceId } from "./providerInstance.ts";
26+import {
27+ PullRequestActor,
28+ PullRequestChecksState,
29+ PullRequestMergeability,
30+ PullRequestReviewDecision,
31+ PullRequestState,
32+} from "./pullRequest.ts";
2633
2734 export const ORCHESTRATION_WS_METHODS = {
2835 dispatchCommand: "orchestration.dispatchCommand",
@@ -599,6 +606,11 @@ export const ThreadTitleRegeneration = Schema.Struct({ · 570 unmodified lines
599606 });
600607 export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
601608
609+/**
610+ * Legacy single-PR link. Still emitted as the thread's derived current pull
611+ * request (see `@t3tools/shared/threadPullRequests`) so clients from before
612+ * `pullRequests` keep working independently of their release schedule.
613+ */
602614 export const ThreadLinkedPullRequest = Schema.Struct({
603615 projectId: ProjectId,
604616 repository: TrimmedNonEmptyString,
@@ -607,6 +619,80 @@ export const ThreadLinkedPullRequest = Schema.Struct({ · 2 unmodified lines
607619 });
608620 export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type;
609621
622+/** Who created a thread ↔ pull request link. `stack-dismissed` is a tombstone
623+ * for a native-stack member the user unlinked, so the sync reactor does not
624+ * re-add it; clients hide it. */
625+export const ThreadPullRequestLinkSource = Schema.Literals([
626+ "manual",
627+ "created",
628+ "agent",
629+ "stack",
630+ "stack-dismissed",
631+]);
632+export type ThreadPullRequestLinkSource = typeof ThreadPullRequestLinkSource.Type;
633+
634+/**
635+ * Host state persisted on a link by the sync reactor; null until first sync. The overview
636+ * fields are optional: a host whose cheap read lacks them leaves them out, and snapshots
637+ * written before they existed still decode.
638+ */
639+export const ThreadPullRequestSnapshot = Schema.Struct({
640+ state: PullRequestState,
641+ title: TrimmedNonEmptyString,
642+ headBranch: TrimmedNonEmptyString,
643+ baseBranch: TrimmedNonEmptyString,
644+ isDraft: Schema.Boolean,
645+ updatedAt: Schema.NullOr(IsoDateTime),
646+ syncedAt: IsoDateTime,
647+ closedAt: Schema.optional(Schema.NullOr(Schema.String)),
648+ mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
649+ author: Schema.optional(Schema.NullOr(PullRequestActor)),
650+ additions: Schema.optional(NonNegativeInt),
651+ deletions: Schema.optional(NonNegativeInt),
652+ changedFiles: Schema.optional(NonNegativeInt),
653+ reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
654+ checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
655+ mergeability: Schema.optional(PullRequestMergeability),
656+});
657+export type ThreadPullRequestSnapshot = typeof ThreadPullRequestSnapshot.Type;
658+
659+export const ThreadPullRequestStackLayer = Schema.Struct({
660+ number: PositiveInt,
661+ headBranch: TrimmedNonEmptyString,
662+ state: PullRequestState,
663+});
664+export type ThreadPullRequestStackLayer = typeof ThreadPullRequestStackLayer.Type;
665+
666+/** A host-native stack the pull request belongs to. Layers run bottom to top. */
667+export const ThreadPullRequestStack = Schema.Struct({
668+ kind: Schema.Literal("native"),
669+ id: TrimmedNonEmptyString,
670+ number: PositiveInt,
671+ url: TrimmedNonEmptyString,
672+ base: TrimmedNonEmptyString,
673+ layers: Schema.Array(ThreadPullRequestStackLayer),
674+});
675+export type ThreadPullRequestStack = typeof ThreadPullRequestStack.Type;
676+
677+/** Identity of a pull request as a thread link sees it: host-level, so the
678+ * same PR linked from two projects (or two environments) compares equal. */
679+export const ThreadPullRequestKey = Schema.Struct({
680+ host: TrimmedNonEmptyString,
681+ repository: TrimmedNonEmptyString,
682+ number: PositiveInt,
683+});
684+export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type;
685+
686+export const ThreadPullRequestLink = Schema.Struct({
687+ ...ThreadPullRequestKey.fields,
688+ url: TrimmedNonEmptyString,
689+ source: ThreadPullRequestLinkSource,
690+ linkedAt: IsoDateTime,
691+ snapshot: Schema.NullOr(ThreadPullRequestSnapshot),
692+ stack: Schema.NullOr(ThreadPullRequestStack),
693+});
694+export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
695+
610696 export const OrchestrationThread = Schema.Struct({
611697 id: ThreadId,
612698 projectId: ProjectId,
@@ -619,6 +705,10 @@ export const OrchestrationThread = Schema.Struct({ · 6 unmodified lines
619705 branch: Schema.NullOr(TrimmedNonEmptyString),
620706 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
621707 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
708+ // Optional so payloads from pre-link servers still decode.
709+ pullRequests: Schema.Array(ThreadPullRequestLink).pipe(
710+ Schema.withDecodingDefault(Effect.succeed([])),
711+ ),
622712 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
623713 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
624714 createdAt: IsoDateTime,
@@ -701,6 +791,9 @@ export const OrchestrationThreadShell = Schema.Struct({ · 76 unmodified lines
701791 branch: Schema.NullOr(TrimmedNonEmptyString),
702792 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
703793 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
794+ pullRequests: Schema.Array(ThreadPullRequestLink).pipe(
795+ Schema.withDecodingDefault(Effect.succeed([])),
796+ ),
704797 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
705798 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
706799 createdAt: IsoDateTime,
@@ -1050,6 +1143,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({ · 343 unmodified lines
10501143 ),
10511144 );
10521145
1146+const ThreadPullRequestLinkCommand = Schema.Struct({
1147+ type: Schema.Literal("thread.pull-request.link"),
1148+ commandId: CommandId,
1149+ threadId: ThreadId,
1150+ ...ThreadPullRequestKey.fields,
1151+ url: TrimmedNonEmptyString,
1152+ source: ThreadPullRequestLinkSource,
1153+});
1154+
1155+const ThreadPullRequestUnlinkCommand = Schema.Struct({
1156+ type: Schema.Literal("thread.pull-request.unlink"),
1157+ commandId: CommandId,
1158+ threadId: ThreadId,
1159+ ...ThreadPullRequestKey.fields,
1160+});
1161+
10531162 const ThreadRuntimeModeSetCommand = Schema.Struct({
10541163 type: Schema.Literal("thread.runtime-mode.set"),
10551164 commandId: CommandId,
@@ -1208,6 +1317,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ · 152 unmodified lines
12081317 ThreadPinReorderCommand,
12091318 ThreadActiveReorderCommand,
12101319 ThreadMetaUpdateCommand,
1320+ ThreadPullRequestLinkCommand,
1321+ ThreadPullRequestUnlinkCommand,
12111322 ThreadRuntimeModeSetCommand,
12121323 ThreadInteractionModeSetCommand,
12131324 ThreadTurnStartCommand,
@@ -1238,6 +1349,8 @@ export const ClientOrchestrationCommand = Schema.Union([ · 24 unmodified lines
12381349 ThreadPinReorderCommand,
12391350 ThreadActiveReorderCommand,
12401351 ThreadMetaUpdateCommand,
1352+ ThreadPullRequestLinkCommand,
1353+ ThreadPullRequestUnlinkCommand,
12411354 ThreadRuntimeModeSetCommand,
12421355 ThreadInteractionModeSetCommand,
12431356 ClientThreadTurnStartCommand,
@@ -1354,8 +1467,19 @@ const ThreadPullRequestSyncCommand = Schema.Struct({ · 110 unmodified lines
13541467 linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
13551468 });
13561469
1470+const ThreadPullRequestLinkSyncCommand = Schema.Struct({
1471+ type: Schema.Literal("thread.pull-request-link.sync"),
1472+ commandId: CommandId,
1473+ threadId: ThreadId,
1474+ ...ThreadPullRequestKey.fields,
1475+ snapshot: ThreadPullRequestSnapshot,
1476+ stack: Schema.NullOr(ThreadPullRequestStack),
1477+});
1478+
13571479 const InternalOrchestrationCommand = Schema.Union([
13581480 ThreadAutoSettleCommand,
1481+ ThreadPullRequestSyncCommand,
1482+ ThreadPullRequestLinkSyncCommand,
13591483 ThreadSessionSetCommand,
13601484 ThreadMessageAssistantDeltaCommand,
13611485 ThreadMessageAssistantCompleteCommand,
@@ -1366,6 +1490,7 @@ const InternalOrchestrationCommand = Schema.Union([ · 4 unmodified lines
13661490 ThreadRevertCompleteCommand,
13671491 ThreadTitleRegenerationCompleteCommand,
13681492 ThreadPullRequestSyncCommand,
1493+ ThreadPullRequestLinkSyncCommand,
13691494 ]);
13701495 export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
13711496
@@ -1391,6 +1516,9 @@ export const OrchestrationEventType = Schema.Literals([ · 19 unmodified lines
13911516 "thread.unpinned",
13921517 "thread.pin-reordered",
13931518 "thread.meta-updated",
1519+ "thread.pull-request-linked",
1520+ "thread.pull-request-unlinked",
1521+ "thread.pull-request-synced",
13941522 "thread.runtime-mode-set",
13951523 "thread.interaction-mode-set",
13961524 "thread.message-sent",
@@ -1541,11 +1669,36 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ · 144 unmodified lines
15411669 modelSelection: Schema.optional(ModelSelection),
15421670 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
15431671 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1672+ // No longer produced; kept so persisted events from before
1673+ // thread.pull-request-linked still decode and replay into the link table.
15441674 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
15451675 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
15461676 updatedAt: IsoDateTime,
15471677 });
15481678
1679+export const ThreadPullRequestLinkedPayload = Schema.Struct({
1680+ threadId: ThreadId,
1681+ link: ThreadPullRequestLink,
1682+ updatedAt: IsoDateTime,
1683+});
1684+export type ThreadPullRequestLinkedPayload = typeof ThreadPullRequestLinkedPayload.Type;
1685+
1686+export const ThreadPullRequestUnlinkedPayload = Schema.Struct({
1687+ threadId: ThreadId,
1688+ ...ThreadPullRequestKey.fields,
1689+ updatedAt: IsoDateTime,
1690+});
1691+export type ThreadPullRequestUnlinkedPayload = typeof ThreadPullRequestUnlinkedPayload.Type;
1692+
1693+export const ThreadPullRequestSyncedPayload = Schema.Struct({
1694+ threadId: ThreadId,
1695+ ...ThreadPullRequestKey.fields,
1696+ snapshot: ThreadPullRequestSnapshot,
1697+ stack: Schema.NullOr(ThreadPullRequestStack),
1698+ updatedAt: IsoDateTime,
1699+});
1700+export type ThreadPullRequestSyncedPayload = typeof ThreadPullRequestSyncedPayload.Type;
1701+
15491702 export const ThreadRuntimeModeSetPayload = Schema.Struct({
15501703 threadId: ThreadId,
15511704 runtimeMode: RuntimeMode,
@@ -1759,6 +1912,21 @@ export const OrchestrationEvent = Schema.Union([ · 207 unmodified lines
17591912 type: Schema.Literal("thread.meta-updated"),
17601913 payload: ThreadMetaUpdatedPayload,
17611914 }),
1915+ Schema.Struct({
1916+ ...EventBaseFields,
1917+ type: Schema.Literal("thread.pull-request-linked"),
1918+ payload: ThreadPullRequestLinkedPayload,
1919+ }),
1920+ Schema.Struct({
1921+ ...EventBaseFields,
1922+ type: Schema.Literal("thread.pull-request-unlinked"),
1923+ payload: ThreadPullRequestUnlinkedPayload,
1924+ }),
1925+ Schema.Struct({
1926+ ...EventBaseFields,
1927+ type: Schema.Literal("thread.pull-request-synced"),
1928+ payload: ThreadPullRequestSyncedPayload,
1929+ }),
17621930 Schema.Struct({
17631931 ...EventBaseFields,
17641932 type: Schema.Literal("thread.runtime-mode-set"),
packages/contracts/src/previewAutomation.ts+21−4
@@ -633,14 +633,31 @@ export const PreviewAutomationResponse = Schema.Struct({ · 632 unmodified lines
633633 });
634634 export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
635635
636+const McpCapabilityErrorFields = {
637+ environmentId: EnvironmentId,
638+ threadId: ThreadId,
639+ providerSessionId: TrimmedNonEmptyString,
640+ providerInstanceId: ProviderInstanceId,
641+};
642+
636643 export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(
637644 "PreviewAutomationUnavailableError",
638645 {
639646 capability: Schema.Literal("preview"),
640− environmentId: EnvironmentId,
641− threadId: ThreadId,
642− providerSessionId: TrimmedNonEmptyString,
643− providerInstanceId: ProviderInstanceId,
647+ ...McpCapabilityErrorFields,
648+ },
649+) {
650+ override get message(): string {
651+ return `MCP credential does not grant the ${this.capability} capability.`;
652+ }
653+}
654+
655+/** A `t3-code` MCP tool was called with a credential that does not carry its capability. */
656+export class McpCapabilityUnavailableError extends Schema.TaggedError<McpCapabilityUnavailableError>()(
657+ "McpCapabilityUnavailableError",
658+ {
659+ capability: TrimmedNonEmptyString,
660+ ...McpCapabilityErrorFields,
644661 },
645662 ) {
646663 override get message(): string {
packages/contracts/src/pullRequest.ts+49−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 NonNegativeInt,
88 PositiveInt,
99 ProjectId,
10+ ThreadId,
1011 TrimmedNonEmptyString,
1112 } from "./baseSchemas.ts";
1213 import { SourceControlProviderKind } from "./sourceControl.ts";
@@ -412,6 +413,11 @@ export const PullRequestCapabilities = Schema.Struct({ · 399 unmodified lines
412413 * what every server before this one was.
413414 */
414415 edit: Schema.optional(PullRequestEditCapabilities),
416+ /**
417+ * The host keeps stacks of change requests as objects of its own, so a linked thread can show
418+ * the stack the host shows. Absent means chains are only ever inferred from base branches.
419+ */
420+ stacks: Schema.optional(Schema.Boolean),
415421 /**
416422 * The repository's labels can be listed, and one put on a change request or taken off it.
417423 * Optional for the same reason `edit` is: a server that says nothing about labels has no way
@@ -615,13 +621,33 @@ export const PullRequestListResult = Schema.Struct({ · 197 unmodified lines
615621 });
616622 export type PullRequestListResult = typeof PullRequestListResult.Type;
617623
624+/**
625+ * Addresses one pull request for reads and writes. `projectId` picks the checkout the host
626+ * CLI runs in and, when its repository matches, the credentials; `host` lets the server
627+ * route a pull request from another repository through any project on the same host
628+ * (a frontend project's thread linking a backend PR). Absent `host` means "the project's
629+ * own host", which is every reference from before thread links became host-level.
630+ */
618631 export const PullRequestRef = Schema.Struct({
619632 projectId: ProjectId,
633+ host: Schema.optional(TrimmedNonEmptyString),
620634 repository: TrimmedNonEmptyString,
621635 number: PositiveInt,
622636 });
623637 export type PullRequestRef = typeof PullRequestRef.Type;
624638
639+export const PullRequestLinkedThreadsResult = Schema.Struct({
640+ threads: Schema.Array(
641+ Schema.Struct({
642+ id: ThreadId,
643+ projectId: ProjectId,
644+ title: Schema.String,
645+ archivedAt: Schema.NullOr(IsoDateTime),
646+ }),
647+ ),
648+});
649+export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;
650+
625651 /**
626652 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
627653 * status check never loads permissions, repository settings, checks, or base comparison data.
@@ -641,9 +667,32 @@ export const PullRequestSummary = Schema.Struct({ · 13 unmodified lines
641667 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
642668 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
643669 updatedAt: IsoDateTime,
670+ author: Schema.optional(Schema.NullOr(PullRequestActor)),
671+ additions: Schema.optional(NonNegativeInt),
672+ deletions: Schema.optional(NonNegativeInt),
673+ changedFiles: Schema.optional(NonNegativeInt),
674+ reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
675+ checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
676+ mergeability: Schema.optional(PullRequestMergeability),
644677 });
645678 export type PullRequestSummary = typeof PullRequestSummary.Type;
646679
680+/** The host-native stack a pull request belongs to, in the thread link's shape. */
681+export const PullRequestStack = Schema.Struct({
682+ id: TrimmedNonEmptyString,
683+ number: PositiveInt,
684+ url: TrimmedNonEmptyString,
685+ base: TrimmedNonEmptyString,
686+ layers: Schema.Array(
687+ Schema.Struct({
688+ number: PositiveInt,
689+ headBranch: TrimmedNonEmptyString,
690+ state: PullRequestState,
691+ }),
692+ ),
693+});
694+export type PullRequestStack = typeof PullRequestStack.Type;
695+
647696 /**
648697 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
649698 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve
packages/contracts/src/rpc.ts+18−0
@@ -115,6 +115,8 @@ import { · 114 unmodified lines
115115 PullRequestOperationError,
116116 PullRequestReactionInput,
117117 PullRequestRef,
118+ PullRequestStack,
119+ PullRequestLinkedThreadsResult,
118120 PullRequestSummary,
119121 PullRequestReviewerCandidateList,
120122 PullRequestReviewerRequestInput,
@@ -344,6 +346,8 @@ export const WS_METHODS = { · 223 unmodified lines
344346 pullRequestsList: "pullRequests.list",
345347 pullRequestsListStats: "pullRequests.listStats",
346348 pullRequestsSummary: "pullRequests.summary",
349+ pullRequestsStack: "pullRequests.stack",
350+ pullRequestsLinkedThreads: "pullRequests.linkedThreads",
347351 pullRequestsDetail: "pullRequests.detail",
348352 pullRequestsActivity: "pullRequests.activity",
349353 pullRequestsThreadComments: "pullRequests.threadComments",
@@ -648,6 +652,18 @@ const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { · 298 unmodified lines
648652 error: PullRequestRpcError,
649653 });
650654
655+const WsPullRequestsStackRpc = Rpc.make(WS_METHODS.pullRequestsStack, {
656+ payload: PullRequestRef,
657+ success: Schema.NullOr(PullRequestStack),
658+ error: PullRequestRpcError,
659+});
660+
661+const WsPullRequestsLinkedThreadsRpc = Rpc.make(WS_METHODS.pullRequestsLinkedThreads, {
662+ payload: PullRequestRef,
663+ success: PullRequestLinkedThreadsResult,
664+ error: PullRequestRpcError,
665+});
666+
651667 const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
652668 payload: PullRequestRef,
653669 success: PullRequestDetail,
@@ -1221,6 +1237,8 @@ export const WsRpcGroup = RpcGroup.make( · 567 unmodified lines
12211237 WsPullRequestsListRpc,
12221238 WsPullRequestsListStatsRpc,
12231239 WsPullRequestsSummaryRpc,
1240+ WsPullRequestsStackRpc,
1241+ WsPullRequestsLinkedThreadsRpc,
12241242 WsPullRequestsDetailRpc,
12251243 WsPullRequestsActivityRpc,
12261244 WsPullRequestsThreadCommentsRpc,
packages/shared/package.json+8−0
@@ -175,6 +175,14 @@ · 174 unmodified lines
175175 "types": "./src/threadReference.ts",
176176 "import": "./src/threadReference.ts"
177177 },
178+ "./threadPullRequests": {
179+ "types": "./src/threadPullRequests.ts",
180+ "import": "./src/threadPullRequests.ts"
181+ },
182+ "./changeRequestUrl": {
183+ "types": "./src/changeRequestUrl.ts",
184+ "import": "./src/changeRequestUrl.ts"
185+ },
178186 "./composerTrigger": {
179187 "types": "./src/composerTrigger.ts",
180188 "import": "./src/composerTrigger.ts"
packages/shared/src/changeRequestUrl.test.ts+142−0
@@ -0,0 +1,142 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ changeRequestUrlFor,
5+ parseChangeRequestUrl,
6+ pullRequestCandidateUrlFromReferenceAutolink,
7+ siblingPullRequestUrl,
8+} from "./changeRequestUrl.ts";
9+
10+describe("parseChangeRequestUrl", () => {
11+ it("reads a GitHub pull request, lower-casing the repository", () => {
12+ expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({
13+ host: "github.com",
14+ repository: "t3tools/t3code",
15+ number: 123,
16+ });
17+ });
18+
19+ it("reads a pull request on a GitHub Enterprise host", () => {
20+ expect(parseChangeRequestUrl("https://github.acme.test/platform/api/pull/7")).toEqual({
21+ host: "github.acme.test",
22+ repository: "platform/api",
23+ number: 7,
24+ });
25+ });
26+
27+ it("reads a supported GitHub host with a middle DNS label", () => {
28+ expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
29+ host: "code.github.example.com",
30+ repository: "acme/web",
31+ number: 42,
32+ });
33+ expect(
34+ pullRequestCandidateUrlFromReferenceAutolink(
35+ "https://code.github.example.com/acme/web/issues/42",
36+ ),
37+ ).toBe("https://code.github.example.com/acme/web/pull/42");
38+ });
39+
40+ it("reads a GitLab merge request on any host, nested groups and all", () => {
41+ expect(
42+ parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"),
43+ ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 });
44+ expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual(
45+ { host: "code.acme.test", repository: "team/project", number: 9 },
46+ );
47+ });
48+
49+ it("reads Bitbucket and both Azure DevOps URL forms", () => {
50+ expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({
51+ host: "bitbucket.org",
52+ repository: "workspace/repo",
53+ number: 5,
54+ });
55+ expect(
56+ parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"),
57+ ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 });
58+ expect(
59+ parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"),
60+ ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 });
61+ });
62+
63+ it("survives trailing segments, a trailing slash and a query string", () => {
64+ expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({
65+ host: "github.com",
66+ repository: "t3tools/t3code",
67+ number: 123,
68+ });
69+ expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({
70+ host: "github.com",
71+ repository: "t3tools/t3code",
72+ number: 123,
73+ });
74+ });
75+
76+ it("claims nothing it cannot be sure of", () => {
77+ for (const link of [
78+ "https://github.com/t3tools/t3code/issues/123",
79+ "https://github.com/t3tools/t3code/commit/0a1b2c3",
80+ "https://github.com/t3tools/t3code",
81+ "https://github.com/t3tools/t3code/pull/abc",
82+ "https://gitlab.com/t3tools/t3code/-/issues/12",
83+ "https://blog.example.test/2026/updates/pull/3",
84+ "javascript:alert(1)//github.com/t3tools/t3code/pull/1",
85+ "not a url",
86+ ]) {
87+ expect(parseChangeRequestUrl(link), link).toBeNull();
88+ }
89+ });
90+});
91+
92+describe("siblingPullRequestUrl", () => {
93+ it.each([
94+ ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
95+ [
96+ "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
97+ "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/43",
98+ ],
99+
100+ ["https://github.com/acme/web/pull/42#discussion_r123", "https://github.com/acme/web/pull/43"],
101+ ["https://github.com/acme/web/pull/42/files?w=1", "https://github.com/acme/web/pull/43"],
102+ [
103+ "https://github.acme.test:8443/acme/web/pull/42/",
104+ "https://github.acme.test:8443/acme/web/pull/43",
105+ ],
106+ [
107+ "https://git.acme.test/acme/web/-/merge_requests/42/diffs",
108+ "https://git.acme.test/acme/web/-/merge_requests/43",
109+ ],
110+ [
111+ "https://bitbucket.org/acme/web/pull-requests/42",
112+ "https://bitbucket.org/acme/web/pull-requests/43",
113+ ],
114+ [
115+ "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files",
116+ "https://dev.azure.com/acme/project/_git/web/pullrequest/43",
117+ ],
118+ ])("builds a canonical sibling of %s", (url, expected) => {
119+ expect(siblingPullRequestUrl(url, 43)).toBe(expected);
120+ });
121+ it("rejects non-PR URLs and invalid numbers", () => {
122+ expect(siblingPullRequestUrl("https://github.com/acme/web/issues/42", 43)).toBeNull();
123+ expect(siblingPullRequestUrl("https://github.com/acme/web/pull/42", 0)).toBeNull();
124+ });
125+});
126+
127+describe("changeRequestUrlFor", () => {
128+ it.each([
129+ ["ssh.dev.azure.com", "v3/org/project/web"],
130+ ["vs-ssh.visualstudio.com", "v3/org/project/web"],
131+ ["org.visualstudio.com", "defaultcollection/project/_git/web"],
132+ ["dev.azure.com", "org/project/_git/web"],
133+ ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => {
134+ const url = changeRequestUrlFor("azure-devops", host, repository, 42);
135+ expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42");
136+ expect(parseChangeRequestUrl(url!)).toEqual({
137+ host: "dev.azure.com",
138+ repository: "org/project/_git/web",
139+ number: 42,
140+ });
141+ });
142+});
packages/shared/src/changeRequestUrl.ts+210−0
@@ -0,0 +1,210 @@
1+import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts";
2+import { canonicalRepositoryKey } from "./sourceControl.ts";
3+
4+/**
5+ * A change request named the way a thread link names one: the host below which the repository
6+ * is addressed, the repository path as that host writes it, and the number.
7+ *
8+ * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce
9+ * from a git remote — lower case, no port, the full path below the host — because links are
10+ * matched against those. Anything else matches nothing.
11+ */
12+export interface ChangeRequestLink {
13+ readonly host: string;
14+ readonly repository: string;
15+ readonly number: number;
16+}
17+
18+/** The host itself, one of its subdomains, or an install named after the provider. */
19+function isHostOf(hostname: string, apex: string, label?: string): boolean {
20+ if (hostname === apex || hostname.endsWith(`.${apex}`)) return true;
21+ return label !== undefined && hostname.split(".").includes(label);
22+}
23+
24+/**
25+ * The repository and number behind a change request URL on a host this can read, or null for
26+ * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an
27+ * ordinary link. A doubtful match is worse than no match, so nothing here guesses.
28+ *
29+ * Each host is recognised by the path shape it alone uses, guarded by a hostname it could
30+ * plausibly be served from, since self-hosted installs are named whatever their admin chose:
31+ * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic
32+ * enough that it is only believed from a GitHub-ish host.
33+ *
34+ * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and
35+ * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a
36+ * registrable domain). What a claim is worth is decided where it is used.
37+ */
38+export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
39+ let url: URL;
40+ try {
41+ url = new URL(targetUrl);
42+ } catch {
43+ return null;
44+ }
45+ // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open.
46+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
47+ const host = url.hostname.toLowerCase();
48+
49+ // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
50+ if (isHostOf(host, "github.com", "github")) {
51+ const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
52+ return claim(host, match);
53+ }
54+ // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
55+ // separator is GitLab's own, so the hostname is not asked about.
56+ const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
57+ if (gitlab) return claim(host, gitlab);
58+ // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
59+ if (isHostOf(host, "bitbucket.org", "bitbucket")) {
60+ const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
61+ return claim(host, match);
62+ }
63+ // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
64+ // of the repository path there, as it is in the remote URL the identity is read from.
65+ if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) {
66+ const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
67+ return claim(host, match);
68+ }
69+ return null;
70+}
71+
72+function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
73+ const repository = match?.[1];
74+ const number = Number(match?.[2]);
75+ return repository && Number.isSafeInteger(number) && number > 0
76+ ? { host, repository: repository.toLowerCase(), number }
77+ : null;
78+}
79+
80+/** The web URL a host writes for a change request; null when the host shape is unknown. */
81+export function changeRequestUrlFor(
82+ kind: string | null | undefined,
83+ host: string,
84+ repository: string,
85+ number: number,
86+): string | null {
87+ switch (kind) {
88+ case "github":
89+ return `https://${host}/${repository}/pull/${number}`;
90+ case "gitlab":
91+ return `https://${host}/${repository}/-/merge_requests/${number}`;
92+ case "bitbucket":
93+ return `https://${host}/${repository}/pull-requests/${number}`;
94+ case "azure-devops":
95+ return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`;
96+ default:
97+ return null;
98+ }
99+}
100+
101+/** Builds a GitHub URL that remains available when the pull request API cannot be read. */
102+export function gitHubPullRequestBrowserUrl(
103+ identity: RepositoryIdentity | null | undefined,
104+ repository: string,
105+ number: number,
106+): string | null {
107+ if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;
108+ const repositoryPath = repository.split("/");
109+ if (
110+ repositoryPath.length !== 2 ||
111+ repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..")
112+ ) {
113+ return null;
114+ }
115+
116+ let origin: string | null = null;
117+ try {
118+ const remoteUrl = new URL(identity.locator.remoteUrl.trim());
119+ if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") {
120+ origin = remoteUrl.origin;
121+ }
122+ } catch {
123+ // SCP-style remotes are read from their normalized identity below.
124+ }
125+ const hostname = identity.canonicalKey.split("/")[0];
126+ if (origin === null && !hostname) return null;
127+
128+ try {
129+ const url = new URL(origin ?? `https://${hostname}`);
130+ url.pathname = `/${repositoryPath.join("/")}/pull/${number}`;
131+ return url.toString();
132+ } catch {
133+ return null;
134+ }
135+}
136+
137+/**
138+ * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
139+ * reference through `/issues/`, including pull requests, so this only builds a candidate: the
140+ * caller must successfully read it as a pull request before treating it as one.
141+ */
142+export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
143+ let url: URL;
144+ try {
145+ url = new URL(targetUrl);
146+ } catch {
147+ return null;
148+ }
149+ if (
150+ (url.protocol !== "https:" && url.protocol !== "http:") ||
151+ !(
152+ url.hostname.toLowerCase() === "github.com" ||
153+ url.hostname.toLowerCase().endsWith(".github.com") ||
154+ url.hostname.toLowerCase().split(".").includes("github")
155+ )
156+ ) {
157+ return null;
158+ }
159+ const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
160+ if (match?.[1] === undefined || match[2] === undefined) return null;
161+ url.pathname = `/${match[1]}/pull/${match[2]}`;
162+ return url.toString();
163+}
164+
165+/** Match a stored PR without requiring its project to remain available. */
166+export function matchesLinkedPullRequestUrl(
167+ linkedPullRequest: ThreadLinkedPullRequest,
168+ targetUrl: string,
169+): boolean {
170+ const linked = parseChangeRequestUrl(linkedPullRequest.url);
171+ const target = parseChangeRequestUrl(targetUrl);
172+ return (
173+ linked !== null &&
174+ target !== null &&
175+ linked.host === target.host &&
176+ linked.repository === target.repository &&
177+ linked.number === target.number
178+ );
179+}
180+
181+/** The repository root behind a recognised change-request URL, without PR-specific state. */
182+export function changeRequestRepositoryUrl(targetUrl: string): string | null {
183+ const changeRequest = parseChangeRequestUrl(targetUrl);
184+ if (changeRequest === null) return null;
185+ const url = new URL(targetUrl);
186+ const repositoryPath =
187+ /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
188+ /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
189+ url.pathname,
190+ )?.[1];
191+ if (!repositoryPath) return null;
192+ url.pathname = repositoryPath;
193+ url.search = "";
194+ url.hash = "";
195+ return url.toString();
196+}
197+
198+export function siblingPullRequestUrl(url: string, number: number): string | null {
199+ const reference = parseChangeRequestUrl(url);
200+ if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
201+ const sibling = new URL(url);
202+ const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
203+ sibling.pathname.slice(reference.repository.length + 1),
204+ )?.[1];
205+ if (route === undefined) return null;
206+ sibling.pathname = `/${reference.repository}/${route}/${number}`;
207+ sibling.search = "";
208+ sibling.hash = "";
209+ return sibling.toString();
210+}
packages/shared/src/sourceControl.test.ts+32−0
@@ -1,6 +1,7 @@
11 import { describe, expect, it } from "vite-plus/test";
22
33 import {
4+ sourceControlRepositorySelector,
45 detectSourceControlProviderFromRemoteUrl,
56 getChangeRequestTerminologyForKind,
67 isSshRemoteUrl,
@@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => { · 153 unmodified lines
160161 expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
161162 });
162163 });
164+
165+it("names an Azure DevOps repository by its own name, not its project path", () => {
166+ // `az repos pr list --repository` takes a name and detects the organisation and project from
167+ // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
168+ // reads as unavailable on the page.
169+ const selector = sourceControlRepositorySelector({
170+ provider: "azure-devops",
171+ displayName: "contoso/payments/_git/checkout",
172+ owner: "contoso",
173+ name: "checkout",
174+ });
175+ expect(selector).toBe("checkout");
176+});
177+
178+it("falls back to the path's last segment where an Azure identity has no name", () => {
179+ const selector = sourceControlRepositorySelector({
180+ provider: "azure-devops",
181+ displayName: "contoso/payments/_git/checkout",
182+ });
183+ expect(selector).toBe("checkout");
184+});
185+
186+it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
187+ const selector = sourceControlRepositorySelector({
188+ provider: "gitlab",
189+ displayName: "group/subgroup/service",
190+ owner: "group",
191+ name: "service",
192+ });
193+ expect(selector).toBe("group/subgroup/service");
194+});
packages/shared/src/sourceControl.ts+45−1
@@ -1,4 +1,8 @@
1−import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
1+import type {
2+ RepositoryIdentity,
3+ SourceControlProviderInfo,
4+ SourceControlProviderKind,
5+} from "@t3tools/contracts";
26
37 export interface ChangeRequestPresentation {
48 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
@@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl( · 229 unmodified lines
234238 baseUrl: toBaseUrl(host),
235239 };
236240 }
241+
242+/**
243+ * The provider-native repository selector. `displayName` is the full path below the host, which
244+ * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
245+ * recorded before that field existed.
246+ *
247+ * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
248+ * takes the organisation and project from the checkout it detects — so the recorded
249+ * `org/project/_git/repo` path is refused outright and the whole repository reads as
250+ * unavailable. Its name is the last segment, which is what this hands over.
251+ *
252+ * One function because everything downstream is keyed by what it answers: the rows' own
253+ * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
254+ */
255+export function sourceControlRepositorySelector(
256+ identity:
257+ | Pick<RepositoryIdentity, "provider" | "displayName" | "owner" | "name">
258+ | null
259+ | undefined,
260+): string | null {
261+ if (!identity) return null;
262+ if (identity.provider === "azure-devops") {
263+ const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
264+ return identity.name || segments.at(-1) || null;
265+ }
266+ if (identity.displayName) return identity.displayName;
267+ return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
268+}
269+
270+export function canonicalRepositoryKey(key: string): string {
271+ return key
272+ .replace(
273+ /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
274+ "dev.azure.com/$1/$2/_git/$3",
275+ )
276+ .replace(
277+ /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
278+ "dev.azure.com/$1/$2/_git/$3",
279+ );
280+}
packages/shared/src/threadPullRequests.test.ts+351−0
@@ -0,0 +1,351 @@
1+import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import {
5+ legacyLinkedPullRequestOf,
6+ legacyThreadPullRequestKey,
7+ resolveThreadCurrentPullRequest,
8+ resolveThreadPullRequestChains,
9+ resolveThreadPullRequestBadge,
10+ threadPullRequestKeysEqual,
11+} from "./threadPullRequests.ts";
12+
13+function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {
14+ return {
15+ state: "open",
16+ title: "Change",
17+ headBranch: "feature",
18+ baseBranch: "main",
19+ isDraft: false,
20+ updatedAt: "2026-01-01T00:00:00.000Z",
21+ syncedAt: "2026-01-01T00:00:00.000Z",
22+ ...input,
23+ };
24+}
25+
26+function link(
27+ number: number,
28+ input: Partial<Omit<ThreadPullRequestLink, "number">> = {},
29+): ThreadPullRequestLink {
30+ return {
31+ host: "github.com",
32+ repository: "pingdotgg/t3code",
33+ number,
34+ url: `https://github.com/pingdotgg/t3code/pull/${number}`,
35+ source: "manual",
36+ linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`,
37+ snapshot: null,
38+ stack: null,
39+ ...input,
40+ };
41+}
42+
43+describe("threadPullRequestKeysEqual", () => {
44+ it("ignores host and repository case", () => {
45+ expect(
46+ threadPullRequestKeysEqual(
47+ { host: "GitHub.com", repository: "PingDotGG/t3code", number: 1 },
48+ { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
49+ ),
50+ ).toBe(true);
51+ expect(
52+ threadPullRequestKeysEqual(
53+ { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
54+ { host: "gitlab.com", repository: "pingdotgg/t3code", number: 1 },
55+ ),
56+ ).toBe(false);
57+ });
58+});
59+
60+describe("resolveThreadCurrentPullRequest", () => {
61+ it("returns null with no visible links", () => {
62+ expect(resolveThreadCurrentPullRequest([])).toBeNull();
63+ expect(resolveThreadCurrentPullRequest([link(1, { source: "stack-dismissed" })])).toBeNull();
64+ });
65+
66+ it("treats an unsynced link as open", () => {
67+ expect(resolveThreadCurrentPullRequest([link(1)])).toMatchObject({
68+ kind: "single",
69+ link: { number: 1 },
70+ });
71+ });
72+
73+ it("prefers the single open link over terminal ones", () => {
74+ const current = resolveThreadCurrentPullRequest([
75+ link(1, { snapshot: snapshot({ state: "merged" }) }),
76+ link(2, { snapshot: snapshot({ state: "open" }) }),
77+ link(3, { snapshot: snapshot({ state: "closed" }) }),
78+ ]);
79+ expect(current).toMatchObject({ kind: "single", link: { number: 2 } });
80+ });
81+
82+ it("reports a stack when several links are open and puts the highest layer on top", () => {
83+ const stack = {
84+ kind: "native" as const,
85+ id: "s1",
86+ number: 1,
87+ url: "https://github.com/pingdotgg/t3code/stacks/1",
88+ base: "main",
89+ layers: [
90+ { number: 10, headBranch: "a", state: "open" as const },
91+ { number: 11, headBranch: "b", state: "open" as const },
92+ ],
93+ };
94+ const current = resolveThreadCurrentPullRequest([
95+ link(11, { snapshot: snapshot(), stack }),
96+ link(10, { snapshot: snapshot(), stack }),
97+ ]);
98+ expect(current).toMatchObject({ kind: "stack", top: { number: 11 } });
99+ if (current?.kind === "stack") {
100+ expect(current.open.map((entry) => entry.number)).toEqual([11, 10]);
101+ }
102+ });
103+
104+ it("orders an open set without stack data by most recent link", () => {
105+ const current = resolveThreadCurrentPullRequest([link(1), link(2)]);
106+ expect(current).toMatchObject({ kind: "stack", top: { number: 2 } });
107+ });
108+
109+ it("falls back to the most recently updated terminal link", () => {
110+ const current = resolveThreadCurrentPullRequest([
111+ link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }),
112+ link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }),
113+ ]);
114+ expect(current).toMatchObject({ kind: "single", link: { number: 1 } });
115+ });
116+});
117+
118+describe("legacyLinkedPullRequestOf", () => {
119+ const identity = {
120+ canonicalKey: "github.com/pingdotgg/t3code",
121+ provider: "github",
122+ displayName: "pingdotgg/t3code",
123+ locator: {
124+ source: "git-remote" as const,
125+ remoteName: "origin",
126+ remoteUrl: "https://github.com/pingdotgg/t3code.git",
127+ },
128+ };
129+ it("projects only links the owning project can route without a host", () => {
130+ expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, identity)).toEqual({
131+ projectId: "project-1",
132+ repository: "pingdotgg/t3code",
133+ number: 7,
134+ url: "https://github.com/pingdotgg/t3code/pull/7",
135+ });
136+ expect(legacyLinkedPullRequestOf([], "project-1" as never, identity)).toBeNull();
137+ });
138+ it.each([
139+ {
140+ host: "github.enterprise.test",
141+ url: "https://github.enterprise.test/pingdotgg/t3code/pull/7",
142+ },
143+ { repository: "acme/other", url: "https://github.com/acme/other/pull/7" },
144+ ])("omits an unsafe legacy route %j", (foreign) => {
145+ expect(
146+ legacyLinkedPullRequestOf([link(7, foreign)], "project-1" as never, identity),
147+ ).toBeNull();
148+ expect(
149+ legacyLinkedPullRequestOf([link(7, foreign), link(8)], "project-1" as never, identity)
150+ ?.number,
151+ ).toBe(8);
152+ });
153+ it.each([
154+ "dev.azure.com/org-a/project/_git/web",
155+ "ssh.dev.azure.com/v3/org-a/project/web",
156+ "org-a.visualstudio.com/DefaultCollection/project/_git/web",
157+ ])("projects Azure links through the legacy selector for %s", (canonicalKey) => {
158+ const azureIdentity = {
159+ ...identity,
160+ provider: "azure-devops",
161+ canonicalKey,
162+ displayName: canonicalKey.slice(canonicalKey.indexOf("/") + 1),
163+ name: "web",
164+ };
165+ const own = link(7, {
166+ host: "dev.azure.com",
167+ repository: "org-a/project/_git/web",
168+ url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
169+ });
170+ const foreign = link(7, {
171+ host: "dev.azure.com",
172+ repository: "web",
173+ url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7",
174+ linkedAt: "2026-01-02T00:00:00.000Z",
175+ });
176+ for (const repository of ["web", "org-a/project/_git/web"]) {
177+ expect(
178+ legacyLinkedPullRequestOf(
179+ [{ ...own, repository }, foreign],
180+ "project-1" as never,
181+ azureIdentity,
182+ ),
183+ ).toEqual({ projectId: "project-1", repository: "web", number: 7, url: own.url });
184+ }
185+ expect(legacyLinkedPullRequestOf([foreign], "project-1" as never, azureIdentity)).toBeNull();
186+ expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({
187+ host: "dev.azure.com",
188+ repository: "org-a/project/_git/web",
189+ number: 7,
190+ });
191+ expect(
192+ threadPullRequestKeysEqual(own, {
193+ host: "org-a.visualstudio.com",
194+ repository: "DefaultCollection/project/_git/web",
195+ number: 7,
196+ }),
197+ ).toBe(true);
198+ });
199+ it("does not guess when the project identity is unavailable", () => {
200+ expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull();
201+ });
202+});
203+
204+describe("resolveThreadPullRequestChains", () => {
205+ it("chains links by base → head within a repository, bottom to top", () => {
206+ const chains = resolveThreadPullRequestChains([
207+ link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }),
208+ link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }),
209+ link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
210+ link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }),
211+ ]);
212+ expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
213+ ["derived", [1, 2, 3]],
214+ ["derived", [9]],
215+ ]);
216+ });
217+
218+ it("uses the native stack order when the host provides one", () => {
219+ const stack = {
220+ kind: "native" as const,
221+ id: "s1",
222+ number: 1,
223+ url: "https://github.com/pingdotgg/t3code/stacks/1",
224+ base: "main",
225+ layers: [
226+ { number: 5, headBranch: "a", state: "merged" as const },
227+ { number: 6, headBranch: "b", state: "open" as const },
228+ ],
229+ };
230+ const chains = resolveThreadPullRequestChains([
231+ link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }),
232+ link(5, {
233+ snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }),
234+ stack,
235+ }),
236+ link(8),
237+ ]);
238+ expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
239+ ["native", [5, 6]],
240+ ["derived", [8]],
241+ ]);
242+ });
243+});
244+
245+describe("chain selection and badge state", () => {
246+ it.each(["open", "merged", "closed"] as const)(
247+ "targets the top of a derived %s chain despite a later bottom update and link",
248+ (state) => {
249+ const bottom = link(2, {
250+ snapshot: snapshot({ state, headBranch: "base", updatedAt: "2026-02-01T00:00:00.000Z" }),
251+ });
252+ const top = link(1, {
253+ snapshot: snapshot({ state, headBranch: "top", baseBranch: "base" }),
254+ });
255+ expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject(
256+ state === "open"
257+ ? { kind: "stack", top: { number: 1 }, open: [top, bottom] }
258+ : { kind: "single", link: { number: 1 } },
259+ );
260+ expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
261+ kind: "stack",
262+ layers: 2,
263+ state,
264+ });
265+ },
266+ );
267+
268+ it("keeps the top native layer after completion and treats an unsynced layer as open", () => {
269+ const stack = {
270+ kind: "native" as const,
271+ id: "native-1",
272+ number: 1,
273+ url: "https://github.com/pingdotgg/t3code/stacks/1",
274+ base: "main",
275+ layers: [
276+ { number: 2, headBranch: "base", state: "merged" as const },
277+ { number: 1, headBranch: "top", state: "merged" as const },
278+ ],
279+ };
280+ const bottom = link(2, {
281+ stack,
282+ snapshot: snapshot({ state: "merged", updatedAt: "2026-02-01T00:00:00.000Z" }),
283+ });
284+ const top = link(1, { stack, snapshot: snapshot({ state: "merged" }) });
285+ expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject({
286+ kind: "single",
287+ link: { number: 1 },
288+ });
289+ expect(resolveThreadPullRequestBadge([bottom, { ...top, snapshot: null }])).toEqual({
290+ kind: "stack",
291+ layers: 2,
292+ state: "open",
293+ });
294+ });
295+
296+ it("uses aggregate state and distinguishes unrelated work from a stack", () => {
297+ const bottom = link(1, { snapshot: snapshot({ state: "merged", headBranch: "base" }) });
298+ const top = link(2, { snapshot: snapshot({ state: "closed", baseBranch: "base" }) });
299+ expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
300+ kind: "stack",
301+ layers: 2,
302+ state: "closed",
303+ });
304+ expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({
305+ kind: "pull-request",
306+ others: 2,
307+ });
308+ expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();
309+ });
310+
311+ it("keeps branch matching case-sensitive while ignoring repository case", () => {
312+ const bottom = link(1, {
313+ repository: "PingDotGG/T3code",
314+ snapshot: snapshot({ headBranch: "Base" }),
315+ });
316+ const top = link(2, { snapshot: snapshot({ headBranch: "top", baseBranch: "base" }) });
317+ expect(resolveThreadPullRequestChains([bottom, top])).toHaveLength(2);
318+ expect(
319+ resolveThreadPullRequestChains([
320+ bottom,
321+ { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) },
322+ ])[0]?.layers,
323+ ).toEqual([bottom, { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) }]);
324+ });
325+
326+ it("preserves cyclic links without presenting a guessed stack order", () => {
327+ const links = [
328+ link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "b" }) }),
329+ link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
330+ ];
331+ expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
332+ links.map((entry) => [entry]),
333+ );
334+ expect(resolveThreadCurrentPullRequest(links)).toMatchObject({
335+ kind: "stack",
336+ top: { number: 2 },
337+ });
338+ expect(resolveThreadPullRequestBadge(links)).toEqual({ kind: "pull-request", others: 1 });
339+ });
340+
341+ it("does not guess a parent when a head branch was reused", () => {
342+ const links = [
343+ link(1, { snapshot: snapshot({ state: "merged", headBranch: "reused" }) }),
344+ link(2, { snapshot: snapshot({ headBranch: "reused" }) }),
345+ link(3, { snapshot: snapshot({ headBranch: "top", baseBranch: "reused" }) }),
346+ ];
347+ expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
348+ links.map((entry) => [entry]),
349+ );
350+ });
351+});
packages/shared/src/threadPullRequests.ts+271−0
@@ -0,0 +1,271 @@
1+import type {
2+ RepositoryIdentity,
3+ SourceControlProviderKind,
4+ ThreadLinkedPullRequest,
5+ ThreadPullRequestKey,
6+ ThreadPullRequestLink,
7+} from "@t3tools/contracts";
8+
9+import { pullRequestHostOf } from "@t3tools/contracts";
10+import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
11+import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
12+
13+/** Normalize stored link identity, including Azure's SSH and browser host aliases. */
14+export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey {
15+ const canonical = canonicalRepositoryKey(
16+ `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
17+ );
18+ const separator = canonical.indexOf("/");
19+ return {
20+ host: canonical.slice(0, separator),
21+ repository: canonical.slice(separator + 1),
22+ number: key.number,
23+ };
24+}
25+
26+/** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */
27+export function legacyThreadPullRequestKey(
28+ linked: Pick<ThreadLinkedPullRequest, "repository" | "number" | "url">,
29+ fallbackHost?: string,
30+): ThreadPullRequestKey {
31+ const parsed = parseChangeRequestUrl(linked.url);
32+ if (parsed !== null && parsed.number === linked.number) {
33+ const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
34+ if (canonical.startsWith("dev.azure.com/")) {
35+ return normalizeThreadPullRequestKey(parsed);
36+ }
37+ }
38+ let host = fallbackHost;
39+ if (host === undefined) {
40+ try {
41+ host = new URL(linked.url).hostname;
42+ } catch {
43+ host = "unknown";
44+ }
45+ }
46+ return {
47+ host: host.trim().toLowerCase() || "unknown",
48+ repository: linked.repository.trim().toLowerCase(),
49+ number: linked.number,
50+ };
51+}
52+
53+/** Identity comparison for links: host-level, case-insensitive on host and repository. */
54+export function threadPullRequestKeysEqual(
55+ left: ThreadPullRequestKey,
56+ right: ThreadPullRequestKey,
57+): boolean {
58+ return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
59+}
60+
61+export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string {
62+ const normalized = normalizeThreadPullRequestKey(key);
63+ return `${normalized.host}/${normalized.repository}#${normalized.number}`;
64+}
65+
66+/** Links a user should see. Tombstoned stack members stay in the array only so the
67+ * sync reactor does not re-add them. */
68+export function visibleThreadPullRequests(
69+ links: ReadonlyArray<ThreadPullRequestLink>,
70+): ReadonlyArray<ThreadPullRequestLink> {
71+ return links.filter((link) => link.source !== "stack-dismissed");
72+}
73+
74+function isOpen(link: ThreadPullRequestLink): boolean {
75+ // Unsynced links are treated as open: they were just linked, and hiding them
76+ // behind a terminal PR until the first sync would make the link look lost.
77+ return link.snapshot === null || link.snapshot.state === "open";
78+}
79+
80+function latestUpdatedAt(link: ThreadPullRequestLink): number {
81+ const value = link.snapshot?.updatedAt ?? link.linkedAt;
82+ const ms = Date.parse(value);
83+ return Number.isNaN(ms) ? 0 : ms;
84+}
85+
86+/** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */
87+export type ThreadCurrentPullRequest =
88+ | { readonly kind: "single"; readonly link: ThreadPullRequestLink }
89+ | {
90+ readonly kind: "stack";
91+ readonly open: ReadonlyArray<ThreadPullRequestLink>;
92+ /** Highest layer of the open set; the one "View PR" and copy-link target. */
93+ readonly top: ThreadPullRequestLink;
94+ };
95+
96+/**
97+ * Prefer open work and the highest open layer within a chain. A completed chain still
98+ * points at its top; unrelated terminal links use the most recently updated request.
99+ */
100+export function resolveThreadCurrentPullRequest(
101+ links: ReadonlyArray<ThreadPullRequestLink>,
102+): ThreadCurrentPullRequest | null {
103+ const visible = visibleThreadPullRequests(links);
104+ if (visible.length === 0) return null;
105+ const open = visible.filter(isOpen);
106+ if (open.length === 1) return { kind: "single", link: open[0]! };
107+ const chains = resolveThreadPullRequestChains(visible);
108+ if (open.length > 1) {
109+ const openChains = chains
110+ .map((chain) => chain.layers.toReversed().filter(isOpen))
111+ .filter((layers) => layers.length > 0)
112+ .sort(
113+ (left, right) =>
114+ Math.max(...right.map((link) => Date.parse(link.linkedAt))) -
115+ Math.max(...left.map((link) => Date.parse(link.linkedAt))),
116+ );
117+ const ordered = openChains.flat();
118+ return { kind: "stack", open: ordered, top: ordered[0]! };
119+ }
120+ if (chains.length === 1) {
121+ return { kind: "single", link: chains[0]!.layers.at(-1)! };
122+ }
123+ const terminal = [...visible].sort(
124+ (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left),
125+ );
126+ return { kind: "single", link: terminal[0]! };
127+}
128+
129+/** The one link a legacy `linkedPullRequest` consumer should see, or null. */
130+export function resolveThreadCurrentPullRequestLink(
131+ links: ReadonlyArray<ThreadPullRequestLink>,
132+): ThreadPullRequestLink | null {
133+ const current = resolveThreadCurrentPullRequest(links);
134+ if (current === null) return null;
135+ return current.kind === "single" ? current.link : current.top;
136+}
137+
138+/** Legacy clients can only route links belonging to the thread's own repository. */
139+export function legacyLinkedPullRequestOf(
140+ links: ReadonlyArray<ThreadPullRequestLink>,
141+ projectId: ThreadLinkedPullRequest["projectId"],
142+ identity: RepositoryIdentity | null | undefined,
143+): ThreadLinkedPullRequest | null {
144+ if (!identity) return null;
145+ const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind);
146+ const repository = sourceControlRepositorySelector(identity);
147+ if (repository === null) return null;
148+ const azureKey =
149+ identity.provider === "azure-devops"
150+ ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase())
151+ : null;
152+ const link = resolveThreadCurrentPullRequestLink(
153+ links.filter((link) => {
154+ if (azureKey !== null) {
155+ const key = legacyThreadPullRequestKey(link, link.host);
156+ return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
157+ }
158+ return (
159+ link.host.toLowerCase() === host.toLowerCase() &&
160+ link.repository.toLowerCase() === repository.toLowerCase()
161+ );
162+ }),
163+ );
164+ if (link === null) return null;
165+ return {
166+ projectId,
167+ repository: azureKey === null ? link.repository : repository,
168+ number: link.number,
169+ url: link.url,
170+ };
171+}
172+
173+export interface ThreadPullRequestChain {
174+ readonly kind: "native" | "derived";
175+ /** Bottom to top. */
176+ readonly layers: ReadonlyArray<ThreadPullRequestLink>;
177+}
178+
179+/**
180+ * Groups a thread's links into stacks. Native stacks come from the host and win; the rest
181+ * are chained by matching one link's base branch to another's head branch within the same
182+ * repository. A link that chains to nothing is a one-layer chain.
183+ */
184+export function resolveThreadPullRequestChains(
185+ links: ReadonlyArray<ThreadPullRequestLink>,
186+): ReadonlyArray<ThreadPullRequestChain> {
187+ const visible = visibleThreadPullRequests(links);
188+ const chains: Array<ThreadPullRequestChain> = [];
189+ const placed = new Set<string>();
190+
191+ const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();
192+ for (const link of visible) {
193+ if (link.stack === null) continue;
194+ const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`;
195+ const members = nativeStacks.get(stackKey) ?? [];
196+ members.push(link);
197+ nativeStacks.set(stackKey, members);
198+ }
199+ for (const members of nativeStacks.values()) {
200+ const order = new Map(members[0]!.stack!.layers.map((layer, index) => [layer.number, index]));
201+ members.sort((left, right) => (order.get(left.number) ?? 0) - (order.get(right.number) ?? 0));
202+ for (const member of members) placed.add(threadPullRequestKeyOf(member));
203+ chains.push({ kind: "native", layers: members });
204+ }
205+
206+ const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
207+ const branchKey = (link: ThreadPullRequestLink, branch: string) =>
208+ `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`;
209+ // Reused head names cannot identify a parent unambiguously.
210+ const byHead = new Map<string, ThreadPullRequestLink | null>();
211+ for (const link of remaining) {
212+ if (link.snapshot === null) continue;
213+ const key = branchKey(link, link.snapshot.headBranch);
214+ byHead.set(key, byHead.has(key) ? null : link);
215+ }
216+ const hasChild = new Set<string>();
217+ for (const link of remaining) {
218+ if (link.snapshot === null) continue;
219+ const parent = byHead.get(branchKey(link, link.snapshot.baseBranch));
220+ if (parent != null && parent !== link) hasChild.add(threadPullRequestKeyOf(parent));
221+ }
222+ // Walk from each top (a link nothing builds on) down its base chain.
223+ for (const top of remaining) {
224+ if (hasChild.has(threadPullRequestKeyOf(top))) continue;
225+ const layers: Array<ThreadPullRequestLink> = [];
226+ let cursor: ThreadPullRequestLink | undefined = top;
227+ while (cursor !== undefined && !placed.has(threadPullRequestKeyOf(cursor))) {
228+ placed.add(threadPullRequestKeyOf(cursor));
229+ layers.unshift(cursor);
230+ cursor =
231+ cursor.snapshot === null
232+ ? undefined
233+ : (byHead.get(branchKey(cursor, cursor.snapshot.baseBranch)) ?? undefined);
234+ }
235+ if (layers.length > 0) chains.push({ kind: "derived", layers });
236+ }
237+ // Cycles have no top. Keep those links visible without inventing a stack order.
238+ for (const link of remaining) {
239+ if (!placed.has(threadPullRequestKeyOf(link))) {
240+ chains.push({ kind: "derived", layers: [link] });
241+ }
242+ }
243+ return chains;
244+}
245+
246+export type ThreadPullRequestBadge =
247+ | {
248+ readonly kind: "stack";
249+ readonly layers: number;
250+ readonly state: "open" | "closed" | "merged";
251+ }
252+ | { readonly kind: "pull-request"; readonly others: number };
253+
254+/** Aggregate a single chain's state; unrelated links show a count beside the current PR. */
255+export function resolveThreadPullRequestBadge(
256+ pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
257+): ThreadPullRequestBadge | null {
258+ const visible = visibleThreadPullRequests(pullRequests ?? []);
259+ if (visible.length === 0) return null;
260+ const chains = resolveThreadPullRequestChains(visible);
261+ if (visible.length > 1 && chains.length === 1) {
262+ const states = visible.map((link) => link.snapshot?.state ?? "open");
263+ const state = states.includes("open")
264+ ? "open"
265+ : states.every((entry) => entry === "merged")
266+ ? "merged"
267+ : "closed";
268+ return { kind: "stack", layers: visible.length, state };
269+ }
270+ return { kind: "pull-request", others: visible.length - 1 };
271+}
packages/shared/src/threadReference.test.ts+66−0
@@ -1,13 +1,26 @@
1+import type { ThreadPullRequestLink } from "@t3tools/contracts";
12 import { describe, expect, it } from "vite-plus/test";
23
34 import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";
45
6+const crossRepositoryPullRequest: ThreadPullRequestLink = {
7+ host: "github.com",
8+ repository: "other/repo",
9+ number: 42,
10+ url: "https://github.com/other/repo/pull/42",
11+ source: "manual",
12+ linkedAt: "2026-01-01T00:00:00.000Z",
13+ snapshot: null,
14+ stack: null,
15+};
16+
517 describe("resolveThreadReferenceCopyTarget", () => {
618 it("does not copy another reference while the open panel URL is unavailable", () => {
719 expect(
820 resolveThreadReferenceCopyTarget({
921 threadId: "thread-1",
1022 openPanelPullRequestUrl: null,
23+ pullRequests: [crossRepositoryPullRequest],
1124 linkedPullRequestUrl: "https://github.com/t3/pr/12",
1225 }),
1326 ).toBeNull();
@@ -18,6 +31,7 @@ describe("resolveThreadReferenceCopyTarget", () => { · 4 unmodified lines
1831 resolveThreadReferenceCopyTarget({
1932 threadId: "thread-1",
2033 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
34+ pullRequests: [crossRepositoryPullRequest],
2135 linkedPullRequestUrl: "https://github.com/t3/pr/12",
2236 }),
2337 ).toMatchObject({
@@ -27,6 +41,58 @@ describe("resolveThreadReferenceCopyTarget", () => { · 3 unmodified lines
2741 });
2842 });
2943
44+ it.each([null, "https://github.com/t3/pr/12"])(
45+ "copies a native cross-repository link before the fallback URL %s",
46+ (linkedPullRequestUrl) => {
47+ expect(
48+ resolveThreadReferenceCopyTarget({
49+ threadId: "thread-1",
50+ pullRequests: [crossRepositoryPullRequest],
51+ linkedPullRequestUrl,
52+ }),
53+ ).toMatchObject({
54+ kind: "pull-request",
55+ value: crossRepositoryPullRequest.url,
56+ });
57+ },
58+ );
59+
60+ it("copies the highest open stack layer instead of the first link", () => {
61+ const stack: NonNullable<ThreadPullRequestLink["stack"]> = {
62+ kind: "native",
63+ id: "stack-1",
64+ number: 1,
65+ url: "https://github.com/other/repo/stacks/1",
66+ base: "main",
67+ layers: [
68+ { number: 42, headBranch: "first", state: "open" },
69+ { number: 43, headBranch: "second", state: "open" },
70+ ],
71+ };
72+ const top = {
73+ ...crossRepositoryPullRequest,
74+ number: 43,
75+ url: "https://github.com/other/repo/pull/43",
76+ stack,
77+ };
78+ expect(
79+ resolveThreadReferenceCopyTarget({
80+ threadId: "thread-1",
81+ pullRequests: [{ ...crossRepositoryPullRequest, stack }, top],
82+ }),
83+ ).toMatchObject({ kind: "pull-request", value: top.url });
84+ });
85+
86+ it("uses the fallback URL when native links are dismissed", () => {
87+ expect(
88+ resolveThreadReferenceCopyTarget({
89+ threadId: "thread-1",
90+ pullRequests: [{ ...crossRepositoryPullRequest, source: "stack-dismissed" }],
91+ linkedPullRequestUrl: "https://github.com/t3/pr/12",
92+ }),
93+ ).toMatchObject({ kind: "pull-request", value: "https://github.com/t3/pr/12" });
94+ });
95+
3096 it("uses the thread pull request when no panel is open", () => {
3197 expect(
3298 resolveThreadReferenceCopyTarget({
packages/shared/src/threadReference.ts+9−1
@@ -1,3 +1,7 @@
1+import type { ThreadPullRequestLink } from "@t3tools/contracts";
2+
3+import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts";
4+
15 export interface ThreadReferenceCopyTarget {
26 readonly kind: "pull-request" | "thread";
37 readonly value: string;
@@ -10,10 +14,14 @@ export function resolveThreadReferenceCopyTarget(input: { · 6 unmodified lines
1014 readonly threadId: string;
1115 /** Undefined means no PR panel; null means its URL is not available yet. */
1216 readonly openPanelPullRequestUrl?: string | null | undefined;
17+ readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
1318 readonly linkedPullRequestUrl?: string | null;
1419 }): ThreadReferenceCopyTarget | null {
1520 if (input.openPanelPullRequestUrl === null) return null;
16− const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl;
21+ const pullRequestUrl =
22+ input.openPanelPullRequestUrl ??
23+ resolveThreadCurrentPullRequestLink(input.pullRequests ?? [])?.url ??
24+ input.linkedPullRequestUrl;
1725 return pullRequestUrl
1826 ? {
1927 kind: "pull-request",