History

fix(server): link thread PRs without an open client (#10101)

Theo Browne committed 2 months agov0.0.39-nightly.20260906.1303

Commit 223ff441 parent 5fa35d250 files · +2309 −1486Browse files

Unified · Split

50 files
  1. apps/mobile/src/features/home/HomeScreen.tsx +1 −21
  2. apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +4 −33
  3. apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +0 −15
  4. apps/mobile/src/features/threads/thread-list-items.tsx +1 −2
  5. apps/mobile/src/features/threads/thread-list-v2-items.tsx +1 −2
  6. apps/mobile/src/state/use-thread-pr.ts +23 −46
  7. apps/mobile/src/state/use-thread-selection.ts +1 −0
  8. apps/server/integration/OrchestrationEngineHarness.integration.ts +7 −0
  9. apps/server/src/git/GitManager.test.ts +228 −7
  10. apps/server/src/git/GitManager.ts +46 −20
  11. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +211 −0
  12. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +17 −0
  13. apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +11 −0
  14. apps/server/src/orchestration/Layers/OrchestrationReactor.ts +3 −0
  15. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +92 −0
  16. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +4 −0
  17. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +36 −0
  18. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +11 −0
  19. apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +613 −0
  20. apps/server/src/orchestration/ThreadPullRequestReactor.ts +371 −0
  21. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +104 −22
  22. apps/server/src/orchestration/ThreadSettlementReactor.ts +52 −42
  23. apps/server/src/orchestration/decider.ts +59 −0
  24. apps/server/src/orchestration/projector.test.ts +62 −0
  25. apps/server/src/orchestration/projector.ts +4 −0
  26. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +17 −1
  27. apps/server/src/persistence/Layers/ProjectionThreads.ts +6 −0
  28. apps/server/src/persistence/Migrations.ts +2 −0
  29. apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts +16 −0
  30. apps/server/src/persistence/Services/ProjectionThreads.ts +1 −0
  31. apps/server/src/project/RepositoryIdentityResolver.test.ts +46 −19
  32. apps/server/src/project/RepositoryIdentityResolver.ts +7 −2
  33. apps/server/src/server.ts +2 −0
  34. apps/web/src/components/BranchToolbarBranchSelector.tsx +8 −11
  35. apps/web/src/components/ChatView.logic.test.ts +35 −0
  36. apps/web/src/components/ChatView.logic.ts +19 −0
  37. apps/web/src/components/ChatView.tsx +34 −194
  38. apps/web/src/components/CommandPalette.tsx +4 −39
  39. apps/web/src/components/LegacySidebar.tsx +5 −50
  40. apps/web/src/components/Sidebar.tsx +5 −58
  41. apps/web/src/components/ThreadStatusIndicators.test.ts +0 −525
  42. apps/web/src/components/ThreadStatusIndicators.tsx +8 −303
  43. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +0 −12
  44. docs/user/thread-sidebar.md +6 −1
  45. packages/client-runtime/src/state/threadReducer.test.ts +56 −41
  46. packages/client-runtime/src/state/threadReducer.ts +4 −0
  47. packages/contracts/src/orchestration.test.ts +42 −0
  48. packages/contracts/src/orchestration.ts +21 −0
  49. packages/shared/src/threadReference.test.ts +2 −17
  50. packages/shared/src/threadReference.ts +1 −3
apps/mobile/src/features/home/HomeScreen.tsx+1−21
@@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) { · 415 unmodified lines
416416 [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
417417 );
418418
419− const projectCwdByKey = useMemo(() => {
420− const map = new Map<string, string>();
421− for (const project of props.projects) {
422− map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
423− }
424− return map;
425− }, [props.projects]);
426−
427419 const projectByKey = useMemo(() => {
428420 const map = new Map<string, EnvironmentProject>();
429421 for (const project of props.projects) {
@@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) { · 420 unmodified lines
850842 onPinThread={handlePinThread}
851843 onUnpinThread={handleUnpinThread}
852844 onMovePinnedThread={handleMovePinnedThread}
853− projectCwd={
854− projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
855− }
856845 onSwipeableClose={handleSwipeableClose}
857846 onSwipeableWillOpen={handleSwipeableWillOpen}
858847 />
@@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) { · 16 unmodified lines
875864 machineByEnvironmentId,
876865 pinReorderEnvironmentIds,
877866 projectByKey,
878− projectCwdByKey,
879867 props.onArchiveThread,
880868 props.onDeletePendingTask,
881869 props.onSelectPendingTask,
@@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) { · 21 unmodified lines
903891 const v2ExtraData = useMemo(
904892 () => ({
905893 projectByKey,
906− projectCwdByKey,
907894 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
908895 serverConfigs,
909896 savedConnectionsById: props.savedConnectionsById,
@@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) { · 3 unmodified lines
913900 }),
914901 [
915902 projectByKey,
916− projectCwdByKey,
917903 props.searchQuery,
918904 props.savedConnectionsById,
919905 serverConfigs,
@@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) { · 5 unmodified lines
925911
926912 const extraData = useMemo(
927913 () => ({
928− projectCwdByKey,
929914 savedConnectionsById: props.savedConnectionsById,
930915 searchQuery: props.searchQuery,
931916 threadSearchMatchByKey,
932917 }),
933− [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
918+ [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
934919 );
935920
936921 const renderItem = useCallback(
@@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) { · 45 unmodified lines
982967 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
983968 }
984969 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
985− projectCwd={
986− projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
987− null
988− }
989970 isLast={item.isLast}
990971 searchMatch={threadSearchMatchByKey.get(
991972 threadSearchMatchKey({
@@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) { · 29 unmodified lines
10211002 handleSwipeableWillOpen,
10221003 handleRegenerateThreadTitle,
10231004 machineByEnvironmentId,
1024− projectCwdByKey,
10251005 props.onArchiveThread,
10261006 props.onDeletePendingTask,
10271007 props.onDeleteThread,
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+4−33
@@ -12,10 +12,8 @@ import { · 11 unmodified lines
1212
1313 import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1414 import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15−import { useProject, useThreadShell } from "../../state/entities";
16−import { useEnvironmentQuery } from "../../state/query";
15+import { useThreadShell } from "../../state/entities";
1716 import type { GitActionProgress } from "../../state/use-vcs-action-state";
18−import { vcsEnvironment } from "../../state/vcs";
1917 import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
2018 import {
2119 dispatchHardwareKeyboardCommand,
@@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({ · 18 unmodified lines
4038 const navigation = useNavigation();
4139 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
4240 const activeThread = useThreadShell(activeThreadRef);
43− const activeProjectRef = useMemo(
44− () =>
45− activeThread === null
46− ? null
47− : {
48− environmentId: activeThread.environmentId,
49− projectId: activeThread.projectId,
50− },
51− [activeThread],
52− );
53− const activeProject = useProject(activeProjectRef);
54− const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null;
55− const gitStatus = useEnvironmentQuery(
56− activeThread !== null &&
57− activeThread.linkedPullRequest == null &&
58− activeThread.branch !== null &&
59− activeThreadCwd !== null
60− ? vcsEnvironment.status({
61− environmentId: activeThread.environmentId,
62− input: { cwd: activeThreadCwd },
63− })
64− : null,
65− ).data;
66− const detectedPullRequestUrl =
67− activeThread?.branch != null && gitStatus?.refName === activeThread.branch
68− ? (gitStatus.pr?.url ?? null)
69− : null;
7041 const copyTarget = useMemo(
7142 () =>
7243 activeThreadRef === null
7344 ? null
7445 : resolveThreadReferenceCopyTarget({
7546 threadId: activeThread?.id ?? activeThreadRef.threadId,
76− linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null,
77− detectedPullRequestUrl,
47+ linkedPullRequestUrl:
48+ (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
7849 }),
79− [activeThread, activeThreadRef, detectedPullRequestUrl],
50+ [activeThread, activeThreadRef],
8051 );
8152 const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
8253 const copyRequestIdRef = useRef(0);
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+0−15
@@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane( · 352 unmodified lines
353353 }),
354354 [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery],
355355 );
356− const projectCwdByKey = useMemo(() => {
357− const map = new Map<string, string>();
358− for (const project of projects) {
359− map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
360− }
361− return map;
362− }, [projects]);
363356 const projectByKey = useMemo(() => {
364357 const map = new Map<string, EnvironmentProject>();
365358 for (const project of projects) {
@@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane( · 376 unmodified lines
742735 () => ({
743736 selectedThreadKey: props.selectedThreadKey ?? "",
744737 projectByKey,
745− projectCwdByKey,
746738 projectTitleByProjectKey,
747739 savedConnectionsById,
748740 serverConfigs,
@@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane( · 3 unmodified lines
752744 [
753745 props.selectedThreadKey,
754746 projectByKey,
755− projectCwdByKey,
756747 projectTitleByProjectKey,
757748 savedConnectionsById,
758749 serverConfigs,
@@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane( · 155 unmodified lines
914905 onPinThread={pinThread}
915906 onUnpinThread={unpinThread}
916907 onMovePinnedThread={movePinnedThread}
917− projectCwd={projectCwdByKey.get(scopeKey) ?? null}
918908 onSwipeableClose={handleSwipeableClose}
919909 onSwipeableWillOpen={handleSwipeableWillOpen}
920910 simultaneousSwipeGesture={sidebarScrollGesture}
@@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane( · 79 unmodified lines
1000990 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
1001991 }
1002992 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
1003− projectCwd={
1004− projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
1005− null
1006− }
1007993 isLast={item.isLast}
1008994 searchMatch={threadSearchMatchByKey.get(
1009995 threadSearchMatchKey({
@@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane( · 44 unmodified lines
10541040 pinThread,
10551041 pinningEnvironmentIds,
10561042 projectByKey,
1057− projectCwdByKey,
10581043 projectTitleByProjectKey,
10591044 regenerateThreadTitle,
10601045 props.onNewThreadInProject,
apps/mobile/src/features/threads/thread-list-items.tsx+1−2
@@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 415 unmodified lines
416416 readonly thread: EnvironmentThreadShell;
417417 readonly environmentLabel: string | null;
418418 readonly environmentMachine?: EnvironmentMachineKind;
419− readonly projectCwd: string | null;
420419 readonly searchMatch?: EnvironmentThreadSearchMatch;
421420 readonly searchQuery?: string;
422421 readonly isLast: boolean;
@@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 30 unmodified lines
453452 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
454453 props;
455454 const status = resolveThreadStatus(thread);
456− const pr = useThreadPr(thread, props.projectCwd);
455+ const pr = useThreadPr(thread);
457456 const timestamp = relativeTime(
458457 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
459458 );
apps/mobile/src/features/threads/thread-list-v2-items.tsx+1−2
@@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 376 unmodified lines
377377 readonly canMovePinnedDown?: boolean;
378378 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
379379 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
380− readonly projectCwd?: string | null;
381380 readonly searchMatch?: EnvironmentThreadSearchMatch;
382381 readonly searchQuery?: string;
383382 readonly simultaneousSwipeGesture?: ComponentProps<
@@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 19 unmodified lines
403402 const snoozedRow = props.snoozed === true;
404403 const pinnedRow = props.pinned === true;
405404
406− const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
405+ const pr = useThreadPr(thread);
407406
408407 const theme = useUniwindTheme();
409408 const screenColor = theme["--color-screen"];
apps/mobile/src/state/use-thread-pr.ts+23−46
@@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; · 11 unmodified lines
1212 import { appAtomRegistry } from "./atom-registry";
1313 import { useEnvironmentQuery } from "./query";
1414 import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15−import { vcsEnvironment } from "./vcs";
1615
17−const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
16+const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1817 const MAX_THREAD_PR_SNAPSHOTS = 500;
1918
2019 interface ThreadPrSnapshot {
@@ -23,7 +22,7 @@ interface ThreadPrSnapshot { · 2 unmodified lines
2322 }
2423
2524 // One bounded cache survives row virtualization without retaining one live
26−// atom for every thread, branch, directory, or linked pull request ever seen.
25+// atom for every thread or pull request ever seen.
2726 const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(
2827 Atom.keepAlive,
2928 Atom.withLabel("mobile:thread-pr-snapshots"),
@@ -36,20 +35,13 @@ export { · 6 unmodified lines
3635 } from "./thread-pr-presentation";
3736
3837 /**
39− * Live PR status for a thread's branch. Subscriptions are deduplicated per
40− * (environmentId, cwd) by the atom family, so many rows on the same worktree
41− * or project root share one stream — and virtualization means only visible
42− * rows subscribe at all.
38+ * Live status for a thread's server-provided PR. Visible rows share a summary
39+ * request for the same PR in the same environment.
4340 */
44−export function useThreadPr(
45− thread: EnvironmentThreadShell,
46− projectCwd: string | null,
47−): ThreadPrPresentation | null {
48− const cwd = thread.worktreePath ?? projectCwd;
41+export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
42+ const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null;
4943 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
50− const snapshotIdentity = JSON.stringify(
51− thread.linkedPullRequest ?? { branch: thread.branch, cwd },
52− );
44+ const snapshotIdentity = JSON.stringify(pullRequestRef);
5345 // Select this row's entry so writes for other rows do not re-render it.
5446 const snapshotEntry = useAtomValue(
5547 threadPrSnapshotsAtom,
@@ -59,45 +51,30 @@ export function useThreadPr( · 3 unmodified lines
5951 ),
6052 );
6153 const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null;
62− const gitStatus = useEnvironmentQuery(
63− thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
64− ? vcsEnvironment.status({
65− environmentId: thread.environmentId,
66− input: { cwd },
67− })
68− : null,
69− );
70− const linkedPullRequest = useEnvironmentQuery(
71− thread.linkedPullRequest == null
54+ const pullRequestSummary = useEnvironmentQuery(
55+ pullRequestRef === null
7256 ? null
73− : linkedPullRequestDetailAtom({
57+ : pullRequestSummaryAtom({
7458 environmentId: thread.environmentId,
7559 input: {
76− projectId: thread.linkedPullRequest.projectId,
77− repository: thread.linkedPullRequest.repository,
78− number: thread.linkedPullRequest.number,
60+ projectId: pullRequestRef.projectId,
61+ repository: pullRequestRef.repository,
62+ number: pullRequestRef.number,
7963 },
8064 }),
8165 );
8266
8367 const live = useMemo<ThreadPrPresentation | null | undefined>(() => {
84− if (thread.linkedPullRequest != null) {
85− const detail = linkedPullRequest.data;
86− return detail === null
87− ? undefined
88− : presentThreadPr(pullRequestDetailToVcsStatus(detail), {
89− kind: detail.provider,
90− name: detail.provider,
91− baseUrl: "",
92− });
93− }
94−
95− const status = gitStatus.data;
96− if (thread.branch === null) return null;
97− if (status === null) return undefined;
98− if (status.refName !== thread.branch || !status.pr) return null;
99− return presentThreadPr(status.pr, status.sourceControlProvider);
100− }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]);
68+ if (pullRequestRef === null) return null;
69+ const summary = pullRequestSummary.data;
70+ return summary === null
71+ ? undefined
72+ : presentThreadPr(pullRequestDetailToVcsStatus(summary), {
73+ kind: summary.provider,
74+ name: summary.provider,
75+ baseUrl: "",
76+ });
77+ }, [pullRequestRef, pullRequestSummary.data]);
10178
10279 useEffect(() => {
10380 if (live === undefined) return;
apps/mobile/src/state/use-thread-selection.ts+1−0
@@ -55,6 +55,7 @@ function threadDetailToShell( · 54 unmodified lines
5555 branch: thread.branch,
5656 worktreePath: thread.worktreePath,
5757 linkedPullRequest: thread.linkedPullRequest ?? null,
58+ branchPullRequest: thread.branchPullRequest ?? null,
5859 latestTurn: thread.latestTurn,
5960 createdAt: thread.createdAt,
6061 updatedAt: thread.updatedAt,
apps/server/integration/OrchestrationEngineHarness.integration.ts+7−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 ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
7071 import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
7172 import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
7273 import {
@@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = ( · 321 unmodified lines
394395 drainThrough: () => Effect.void,
395396 }),
396397 ),
398+ Layer.provideMerge(
399+ Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
400+ start: () => Effect.void,
401+ drain: Effect.void,
402+ }),
403+ ),
397404 Layer.provideMerge(
398405 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
399406 start: () => Effect.void,
apps/server/src/git/GitManager.test.ts+228−7
@@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; · 12 unmodified lines
1313 import * as Option from "effect/Option";
1414 import * as PlatformError from "effect/PlatformError";
1515 import * as References from "effect/References";
16+import * as Schema from "effect/Schema";
1617 import * as Scope from "effect/Scope";
1718 import { ChildProcessSpawner } from "effect/unstable/process";
1819 import { expect } from "vite-plus/test";
@@ -30,10 +31,13 @@ import { · 11 unmodified lines
3031 TextGenerationError,
3132 } from "@t3tools/contracts";
3233 import * as GitHubCli from "../sourceControl/GitHubCli.ts";
34+import * as GitLabCli from "../sourceControl/GitLabCli.ts";
3335 import * as TextGeneration from "../textGeneration/TextGeneration.ts";
3436 import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3537 import * as VcsProcess from "../vcs/VcsProcess.ts";
3638 import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
39+import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
40+import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
3741 import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3842 import * as ServerConfig from "../config.ts";
3943 import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
@@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; · 1 unmodified line
4145 import * as ServerSettings from "../serverSettings.ts";
4246 import * as GitManager from "./GitManager.ts";
4347
48+const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49+
4450 interface FakeGhScenario {
4551 prListSequence?: string[];
4652 prListByHeadSelector?: Record<string, string>;
@@ -620,6 +626,7 @@ function preparePullRequestThread( · 573 unmodified lines
620626
621627 function makeManager(input?: {
622628 ghScenario?: FakeGhScenario;
629+ sourceControlProvider?: SourceControlProvider["Service"];
623630 textGeneration?: Partial<FakeGitTextGeneration>;
624631 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
625632 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
@@ -659,7 +666,10 @@ function makeManager(input?: { · 33 unmodified lines
659666 );
660667 const sourceControlRegistryLayer = Layer.effect(
661668 SourceControlProviderRegistry.SourceControlProviderRegistry,
662− GitHubSourceControlProvider.make.pipe(
669+ (input?.sourceControlProvider === undefined
670+ ? GitHubSourceControlProvider.make
671+ : Effect.succeed(input.sourceControlProvider)
672+ ).pipe(
663673 Effect.map((provider) =>
664674 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
665675 get: () => Effect.succeed(provider),
@@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 480 unmodified lines
11461156 branch: "feature/saved-branch",
11471157 });
11481158
1149− expect(pullRequest).toEqual({
1159+ expect(pullRequest).toMatchObject({
1160+ number: 216,
1161+ title: "Saved branch PR",
1162+ url: "https://github.com/pingdotgg/t3code/pull/216",
1163+ baseRef: "main",
1164+ headRef: "feature/saved-branch",
11501165 state: "open",
11511166 closedAt: null,
11521167 mergedAt: null,
@@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 38 unmodified lines
11911206
11921207 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
11931208
1194− expect(pullRequest).toEqual({
1209+ expect(pullRequest).toMatchObject({
11951210 state: "merged",
11961211 closedAt: null,
11971212 mergedAt: "2026-04-07T15:00:00Z",
@@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 46 unmodified lines
12441259 branch: "feature/deleted-local-branch",
12451260 });
12461261
1247− expect(pullRequest).toEqual({
1262+ expect(pullRequest).toMatchObject({
12481263 state: "merged",
12491264 closedAt: null,
12501265 mergedAt: null,
@@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 59 unmodified lines
13101325 branch: "feature/deleted-fork-branch",
13111326 });
13121327
1313− expect(pullRequest).toEqual({
1328+ expect(pullRequest).toMatchObject({
13141329 state: "merged",
13151330 closedAt: null,
13161331 mergedAt: null,
@@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 115 unmodified lines
14321447 updatedAt: "2026-04-07T15:00:00Z",
14331448 },
14341449 ]),
1450+ encodeCliJson([
1451+ {
1452+ number: 221,
1453+ title: "New PR on the same branch",
1454+ url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1455+ baseRefName: "main",
1456+ headRefName: "feature/shared-pr-cache",
1457+ state: "OPEN",
1458+ updatedAt: "2026-04-08T15:00:00Z",
1459+ },
1460+ ]),
14351461 ],
14361462 },
14371463 });
@@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 7 unmodified lines
14451471 expect(status.pr?.state).toBe("merged");
14461472 expect(pullRequest?.state).toBe("merged");
14471473 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1474+ const refreshed = yield* manager.branchPullRequest(
1475+ { cwd: repoDir, branch: "feature/shared-pr-cache" },
1476+ { refresh: true },
1477+ );
1478+ expect(refreshed).toMatchObject({
1479+ number: 221,
1480+ state: "open",
1481+ repositoryKey: "github.com/pingdotgg/codething-mvp",
1482+ });
1483+ expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
14481484 }),
14491485 );
14501486
@@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 8 unmodified lines
14591495 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);
14601496 yield* runGit(repoDir, ["checkout", "main"]);
14611497
1462− const { manager } = yield* makeManager({
1498+ const { manager, ghCalls } = yield* makeManager({
14631499 ghScenario: {
14641500 failWith: new GitHubCli.GitHubCliUnavailableError({
14651501 command: "gh",
@@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 8 unmodified lines
14741510 .pipe(Effect.flip);
14751511
14761512 expect(error._tag).toBe("SourceControlProviderError");
1513+ const refreshError = yield* manager
1514+ .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true })
1515+ .pipe(Effect.flip);
1516+ expect(refreshError._tag).toBe("SourceControlProviderError");
1517+ expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
14771518 }),
14781519 );
14791520
@@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 114 unmodified lines
15941635 expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000);
15951636 });
15961637
1638+ it.each([
1639+ [
1640+ "https://github.example.com/team/repository/pull/42?tab=files",
1641+ "github.example.com/team/repository",
1642+ ],
1643+ [
1644+ "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42",
1645+ "gitlab.example.com/group/subgroup/repository",
1646+ ],
1647+ ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"],
1648+ [
1649+ "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
1650+ "dev.azure.com/org/project/_git/repository",
1651+ ],
1652+ [
1653+ "https://org.visualstudio.com/project/_git/repository/pullrequest/42",
1654+ "org.visualstudio.com/project/_git/repository",
1655+ ],
1656+ [
1657+ "https://gitlab.example/group/pull/123/repository/-/merge_requests/42",
1658+ "gitlab.example/group/pull/123/repository",
1659+ ],
1660+ ["https://github.example.com/team/repository/issues/42", null],
1661+ ] as const)("reads the repository from the returned PR URL %s", (url, expected) => {
1662+ expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected);
1663+ });
1664+
1665+ it.effect("distinguishes Enterprise forks with the same head branch", () =>
1666+ Effect.gen(function* () {
1667+ const repoDir = yield* makeTempDir("t3code-git-manager-");
1668+ yield* initRepo(repoDir);
1669+ const originDir = yield* createBareRemote();
1670+ const forkDir = yield* createBareRemote();
1671+ yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1672+ yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1673+ yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1674+ yield* runGit(repoDir, ["checkout", "-b", "feature"]);
1675+ yield* runGit(repoDir, ["push", "-u", "fork", "feature"]);
1676+ yield* configureVisibleRemoteUrlWithLocalRewrite(
1677+ repoDir,
1678+ "origin",
1679+ "git@github.example.com:team/repository.git",
1680+ originDir,
1681+ );
1682+ yield* configureVisibleRemoteUrlWithLocalRewrite(
1683+ repoDir,
1684+ "fork",
1685+ "git@github.example.com:alice/repository.git",
1686+ forkDir,
1687+ );
1688+ const output = encodeCliJson([
1689+ {
1690+ number: 2,
1691+ title: "Another fork",
1692+ url: "https://github.example.com/team/repository/pull/2",
1693+ baseRefName: "main",
1694+ headRefName: "feature",
1695+ state: "OPEN",
1696+ updatedAt: "2026-04-08T15:00:00Z",
1697+ isCrossRepository: true,
1698+ headRepository: { nameWithOwner: "bob/repository" },
1699+ headRepositoryOwner: { login: "bob" },
1700+ },
1701+ {
1702+ number: 1,
1703+ title: "This fork",
1704+ url: "https://github.example.com/team/repository/pull/1",
1705+ baseRefName: "main",
1706+ headRefName: "feature",
1707+ state: "OPEN",
1708+ updatedAt: "2026-04-07T15:00:00Z",
1709+ isCrossRepository: true,
1710+ headRepository: { nameWithOwner: "alice/repository" },
1711+ headRepositoryOwner: { login: "alice" },
1712+ },
1713+ ]);
1714+ const { manager } = yield* makeManager({
1715+ ghScenario: {
1716+ prListByHeadSelector: {
1717+ "alice:feature": output,
1718+ "fork:feature": output,
1719+ feature: output,
1720+ },
1721+ },
1722+ });
1723+ expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({
1724+ number: 1,
1725+ repositoryKey: "github.example.com/team/repository",
1726+ });
1727+ }),
1728+ );
1729+
1730+ it.effect.each([
1731+ "git@gitlab.com:Group/Subgroup/Fork.git",
1732+ "https://gitlab.com/Group/Subgroup/Fork.git",
1733+ ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) =>
1734+ Effect.gen(function* () {
1735+ const repoDir = yield* makeTempDir("t3code-git-manager-");
1736+ yield* initRepo(repoDir);
1737+ const originDir = yield* createBareRemote();
1738+ const forkDir = yield* createBareRemote();
1739+ const branch = "feature/NestedGroups";
1740+ yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1741+ yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1742+ yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1743+ yield* runGit(repoDir, ["checkout", "-b", branch]);
1744+ yield* runGit(repoDir, ["push", "-u", "fork", branch]);
1745+ yield* configureVisibleRemoteUrlWithLocalRewrite(
1746+ repoDir,
1747+ "origin",
1748+ "git@gitlab.com:Group/Upstream/Repository.git",
1749+ originDir,
1750+ );
1751+ yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir);
1752+ const output = encodeCliJson([
1753+ {
1754+ iid: 2,
1755+ title: "Another subgroup's fork",
1756+ web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2",
1757+ target_branch: "main",
1758+ source_branch: branch,
1759+ state: "opened",
1760+ updated_at: "2026-04-08T15:00:00Z",
1761+ source_project_id: 102,
1762+ target_project_id: 100,
1763+ source_project: { path_with_namespace: "Group/Other/Fork" },
1764+ },
1765+ {
1766+ iid: 1,
1767+ title: "This subgroup's fork",
1768+ web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1",
1769+ target_branch: "main",
1770+ source_branch: branch,
1771+ state: "opened",
1772+ updated_at: "2026-04-07T15:00:00Z",
1773+ source_project_id: 101,
1774+ target_project_id: 100,
1775+ source_project: { path_with_namespace: "Group/Subgroup/Fork" },
1776+ },
1777+ ]);
1778+ const calls: VcsProcess.VcsProcessInput[] = [];
1779+ const provider = yield* GitLabSourceControlProvider.make.pipe(
1780+ Effect.provide(
1781+ GitLabCli.layer.pipe(
1782+ Layer.provide(
1783+ Layer.mock(VcsProcess.VcsProcess)({
1784+ run: (input) =>
1785+ Effect.sync(() => {
1786+ calls.push(input);
1787+ return fakeGhOutput(output);
1788+ }),
1789+ }),
1790+ ),
1791+ ),
1792+ ),
1793+ );
1794+ const { manager } = yield* makeManager({ sourceControlProvider: provider });
1795+
1796+ expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({
1797+ number: 1,
1798+ repositoryKey: "gitlab.com/group/upstream/repository",
1799+ });
1800+ expect(calls.length).toBeGreaterThan(0);
1801+ for (const call of calls) {
1802+ expect(call.command).toBe("glab");
1803+ expect(call.args).toEqual([
1804+ "mr",
1805+ "list",
1806+ "--source-branch",
1807+ branch,
1808+ "--all",
1809+ "--per-page",
1810+ "20",
1811+ "--output",
1812+ "json",
1813+ ]);
1814+ }
1815+ }),
1816+ );
1817+
15971818 it.effect(
15981819 "status ignores unrelated fork PRs when the current branch tracks the same repository",
15991820 () =>
@@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 549 unmodified lines
21492370 branch: "feature/fork-settle",
21502371 });
21512372
2152− expect(pullRequest).toEqual({
2373+ expect(pullRequest).toMatchObject({
21532374 state: "merged",
21542375 closedAt: null,
21552376 mergedAt: null,
apps/server/src/git/GitManager.ts+46−20
@@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti · 76 unmodified lines
7777 readonly refreshMissingPullRequest?: boolean;
7878 }
7979
80+export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {
81+ readonly repositoryKey: string | null;
82+ readonly updatedAt: string | null;
83+ readonly closedAt?: string | null;
84+ readonly mergedAt?: string | null;
85+};
86+
8087 interface SourceControlTextGenerationSettings {
8188 readonly modelSelection: ModelSelection;
8289 readonly style: SourceControlWritingStyleSettings;
@@ -96,18 +103,10 @@ export class GitManager extends Context.Service< · 13 unmodified lines
96103 options?: GitRemoteStatusOptions,
97104 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
98105 /** Resolve the PR for a saved branch without changing the current checkout. */
99− readonly branchPullRequest: (input: {
100− readonly cwd: string;
101− readonly branch: string;
102− }) => Effect.Effect<
103− {
104− readonly state: "open" | "closed" | "merged";
105− readonly updatedAt: string | null;
106− readonly closedAt?: string | null;
107− readonly mergedAt?: string | null;
108− } | null,
109− GitManagerServiceError
110− >;
106+ readonly branchPullRequest: (
107+ input: { readonly cwd: string; readonly branch: string },
108+ options?: { readonly refresh?: boolean },
109+ ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
111110 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
112111 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
113112 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
@@ -214,9 +213,26 @@ interface BranchHeadContext { · 100 unmodified lines
214213 isCrossRepository: boolean;
215214 }
216215
216+export function pullRequestRepositoryKey(value: string): string | null {
217+ try {
218+ const url = new URL(value);
219+ const match =
220+ /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec(
221+ url.pathname,
222+ );
223+ if (match?.[1] === undefined) return null;
224+ url.pathname = match[1];
225+ url.search = "";
226+ url.hash = "";
227+ return normalizeGitRemoteUrl(url.toString());
228+ } catch {
229+ return null;
230+ }
231+}
232+
217233 function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
218234 const trimmed = url.trim();
219− const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
235+ const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
220236 const repositoryName = match?.[1]?.trim() ?? "";
221237 return repositoryName.length > 0 ? repositoryName : null;
222238 }
@@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName( · 31 unmodified lines
254270 return `t3code/pr-${pullRequest.number}/${suffix}`;
255271 }
256272
257−function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
273+function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
258274 const trimmed = url?.trim() ?? "";
259275 if (trimmed.length === 0) {
260276 return null;
261277 }
262278
263279 const match =
264− /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
280+ /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
265281 trimmed,
266282 );
267283 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
@@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null · 5 unmodified lines
273289 if (trimmed.length === 0) {
274290 return null;
275291 }
292+ // GitLab reports the top-level group as owner. The full path distinguishes subgroups.
276293 const [ownerLogin] = trimmed.split("/");
277294 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
278295 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
@@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () { · 982 unmodified lines
12611278 }
12621279
12631280 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1264− const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
1281+ const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
12651282 return {
12661283 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
12671284 repositoryNameWithOwner,
@@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () { · 763 unmodified lines
20312048 });
20322049 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
20332050 "branchPullRequest",
2034− )(function* ({ cwd, branch }) {
2051+ )(function* ({ cwd, branch }, options) {
20352052 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
20362053 const remotes = yield* gitCore.execute({
20372054 operation: "GitManager.branchPullRequest.remotes",
@@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () { · 73 unmodified lines
21112128 localBranchExists,
21122129 ...(localBranchExists ? {} : { remoteName }),
21132130 });
2131+ if (options?.refresh) {
2132+ // A completed turn can create a PR or reuse a merged PR's branch.
2133+ // Refresh successful answers, but keep failed lookups' retry backoff.
2134+ const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
2135+ Effect.orElseSucceed(() => Option.none()),
2136+ );
2137+ if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey);
2138+ }
21142139 let cached = yield* Cache.get(prLookupCache, cacheKey);
21152140 // The cached head context may have resolved on a different remote than
21162141 // the saved upstream: a branch tracking origin/main but pushed to a fork
@@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () { · 48 unmodified lines
21652190 ) {
21662191 return null;
21672192 }
2168− const statusPr = toStatusPr(latest);
21692193 return {
2170− state: statusPr.state,
2171− updatedAt: statusPr.updatedAt,
2194+ ...toStatusPr(latest),
21722195 closedAt: latest.closedAt ?? null,
21732196 mergedAt: latest.mergedAt ?? null,
2197+ // Hosting CLIs can select an upstream repository instead of origin.
2198+ // The returned PR URL names the repository that actually owns it.
2199+ repositoryKey: pullRequestRepositoryKey(latest.url),
21742200 };
21752201 });
21762202 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 ProjectId,
1414 ThreadId,
1515 TurnId,
16+ type OrchestrationCommand,
1617 type OrchestrationEvent,
1718 ProviderInstanceId,
1819 } from "@t3tools/contracts";
@@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => { · 974 unmodified lines
993994 await system.dispose();
994995 });
995996
997+ it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)(
998+ "rejects PR discovery completed after a newer %s command",
999+ async (change) => {
1000+ const system = await createOrchestrationSystem();
1001+ try {
1002+ const projectId = ProjectId.make("pr-race-project");
1003+ const threadId = ThreadId.make("pr-race-thread");
1004+ const previous = {
1005+ projectId,
1006+ repository: "owner/repository",
1007+ number: 1,
1008+ url: "https://example.test/owner/repository/pull/1",
1009+ };
1010+ const replacement = {
1011+ ...previous,
1012+ number: 2,
1013+ url: "https://example.test/owner/repository/pull/2",
1014+ };
1015+ await system.run(
1016+ system.engine.dispatch({
1017+ type: "project.create",
1018+ commandId: CommandId.make("pr-race-project-create"),
1019+ projectId,
1020+ title: "PR race project",
1021+ workspaceRoot: "/tmp/pr-race-project",
1022+ defaultModelSelection: null,
1023+ createdAt: now(),
1024+ }),
1025+ );
1026+ await system.run(
1027+ system.engine.dispatch({
1028+ type: "thread.create",
1029+ commandId: CommandId.make("pr-race-thread-create"),
1030+ threadId,
1031+ projectId,
1032+ title: "PR race thread",
1033+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1034+ runtimeMode: "full-access",
1035+ interactionMode: "default",
1036+ branch: "feature",
1037+ worktreePath: null,
1038+ createdAt: now(),
1039+ }),
1040+ );
1041+ const observed = await system.run(
1042+ system.engine.dispatch({
1043+ type: "thread.meta.update",
1044+ commandId: CommandId.make("pr-race-link"),
1045+ threadId,
1046+ linkedPullRequest: previous,
1047+ }),
1048+ );
1049+ const metadataChanges = {
1050+ unlink: { linkedPullRequest: null },
1051+ relink: {
1052+ linkedPullRequest: {
1053+ ...previous,
1054+ number: 3,
1055+ url: "https://example.test/owner/repository/pull/3",
1056+ },
1057+ },
1058+ branch: { branch: "another-feature" },
1059+ worktree: { worktreePath: "/tmp/another-worktree" },
1060+ project: {},
1061+ };
1062+ await system.run(
1063+ system.engine.dispatch(
1064+ change === "project"
1065+ ? {
1066+ type: "project.meta.update",
1067+ commandId: CommandId.make("pr-race-project-move"),
1068+ projectId,
1069+ workspaceRoot: "/tmp/another-project-root",
1070+ }
1071+ : change === "delete"
1072+ ? { type: "thread.delete", commandId: CommandId.make("pr-race-delete"), threadId }
1073+ : {
1074+ type: "thread.meta.update",
1075+ commandId: CommandId.make(`pr-race-${change}`),
1076+ threadId,
1077+ ...metadataChanges[change],
1078+ },
1079+ ),
1080+ );
1081+ const command = {
1082+ type: "thread.pull-request.sync",
1083+ commandId: CommandId.make("pr-race-stale-sync"),
1084+ threadId,
1085+ projectId,
1086+ snapshotSequence: observed.sequence,
1087+ expected: {
1088+ workspaceRoot: "/tmp/pr-race-project",
1089+ branch: "feature",
1090+ worktreePath: null,
1091+ linkedPullRequest: previous,
1092+ branchPullRequest: null,
1093+ },
1094+ branchPullRequest: replacement,
1095+ linkedPullRequest: replacement,
1096+ } satisfies OrchestrationCommand;
1097+ const error = await system.run(system.engine.dispatch(command).pipe(Effect.flip));
1098+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
1099+ if (change === "delete") return;
1100+ const current = (await system.readModel()).threads[0];
1101+ expect(current?.branchPullRequest ?? null).toBeNull();
1102+ expect(current?.linkedPullRequest ?? null).toEqual(
1103+ change === "unlink"
1104+ ? null
1105+ : change === "relink"
1106+ ? metadataChanges.relink.linkedPullRequest
1107+ : previous,
1108+ );
1109+ } finally {
1110+ await system.dispose();
1111+ }
1112+ },
1113+ );
1114+
1115+ it("saves PR associations through streaming and unrelated metadata edits", async () => {
1116+ const system = await createOrchestrationSystem();
1117+ try {
1118+ const projectId = ProjectId.make("pr-sync-project");
1119+ const threadId = ThreadId.make("pr-sync-thread");
1120+ await system.run(
1121+ system.engine.dispatch({
1122+ type: "project.create",
1123+ commandId: CommandId.make("pr-sync-project-create"),
1124+ projectId,
1125+ title: "PR sync project",
1126+ workspaceRoot: "/tmp/pr-sync-project",
1127+ defaultModelSelection: null,
1128+ createdAt: now(),
1129+ }),
1130+ );
1131+ const created = await system.run(
1132+ system.engine.dispatch({
1133+ type: "thread.create",
1134+ commandId: CommandId.make("pr-sync-thread-create"),
1135+ threadId,
1136+ projectId,
1137+ title: "PR sync thread",
1138+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1139+ runtimeMode: "full-access",
1140+ interactionMode: "default",
1141+ branch: "feature",
1142+ worktreePath: null,
1143+ createdAt: now(),
1144+ }),
1145+ );
1146+ const reference = {
1147+ projectId,
1148+ repository: "owner/repository",
1149+ number: 42,
1150+ url: "https://example.test/owner/repository/pull/42",
1151+ };
1152+ const activityAt = "2026-01-01T01:00:00.000Z";
1153+ await system.run(
1154+ system.engine.dispatch({
1155+ type: "thread.message.assistant.delta",
1156+ commandId: CommandId.make("pr-sync-streaming-message"),
1157+ threadId,
1158+ messageId: MessageId.make("pr-sync-message"),
1159+ delta: "The PR is ready.",
1160+ createdAt: activityAt,
1161+ }),
1162+ );
1163+ await system.run(
1164+ system.engine.dispatch({
1165+ type: "thread.meta.update",
1166+ commandId: CommandId.make("pr-sync-title-and-model"),
1167+ threadId,
1168+ title: "Renamed thread",
1169+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
1170+ }),
1171+ );
1172+ await system.run(
1173+ system.engine.dispatch({
1174+ type: "project.meta.update",
1175+ commandId: CommandId.make("pr-sync-project-title"),
1176+ projectId,
1177+ title: "Renamed project",
1178+ }),
1179+ );
1180+ const beforeSync = (await system.readModel()).threads[0];
1181+ await system.run(
1182+ system.engine.dispatch({
1183+ type: "thread.pull-request.sync",
1184+ commandId: CommandId.make("pr-sync-discovery"),
1185+ projectId,
1186+ threadId,
1187+ snapshotSequence: created.sequence,
1188+ expected: {
1189+ workspaceRoot: "/tmp/pr-sync-project",
1190+ branch: "feature",
1191+ worktreePath: null,
1192+ linkedPullRequest: null,
1193+ branchPullRequest: null,
1194+ },
1195+ branchPullRequest: reference,
1196+ }),
1197+ );
1198+ const current = (await system.readModel()).threads[0];
1199+ expect(current?.branchPullRequest).toEqual(reference);
1200+ expect(current?.linkedPullRequest ?? null).toBeNull();
1201+ expect(current?.updatedAt).toBe(beforeSync?.updatedAt);
1202+ } finally {
1203+ await system.dispose();
1204+ }
1205+ });
1206+
9961207 it("allows authoritative worktree bootstrap to assign a temporary branch", async () => {
9971208 const system = await createOrchestrationSystem();
9981209 const { engine } = system;
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+17−0
@@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 184 unmodified lines
185185 });
186186 }
187187
188+ // The decider compares the lookup inputs. Only recreation needs an
189+ // event check, since it can reset a thread to the same field values.
190+ if (
191+ envelope.command.type === "thread.pull-request.sync" &&
192+ (yield* eventStore.hasEventAfter({
193+ aggregateKind: "thread",
194+ aggregateId: envelope.command.threadId,
195+ sequenceExclusive: envelope.command.snapshotSequence,
196+ type: "thread.created",
197+ }))
198+ ) {
199+ return yield* new OrchestrationCommandInvariantError({
200+ commandType: envelope.command.type,
201+ detail: `thread ${envelope.command.threadId} was recreated before pull request discovery`,
202+ });
203+ }
204+
188205 if (
189206 envelope.command.type === "thread.auto-settle" &&
190207 threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11−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 ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1314 import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1415 import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
1516 import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
@@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { · 49 unmodified lines
6566 drainThrough: () => Effect.void,
6667 }),
6768 ),
69+ Layer.provideMerge(
70+ Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
71+ start: () => {
72+ started.push("thread-pull-request-reactor");
73+ return Effect.void;
74+ },
75+ drain: Effect.void,
76+ }),
77+ ),
6878 Layer.provideMerge(
6979 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
7080 start: () => {
@@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { · 24 unmodified lines
95105 "provider-command-reactor",
96106 "checkpoint-reactor",
97107 "thread-deletion-reactor",
108+ "thread-pull-request-reactor",
98109 "thread-settlement-reactor",
99110 "agent-awareness-relay",
100111 ]);
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 ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1314 import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
1516 export const makeOrchestrationReactor = Effect.gen(function* () {
@@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 2 unmodified lines
1819 const checkpointReactor = yield* CheckpointReactor;
1920 const threadDeletionReactor = yield* ThreadDeletionReactor;
2021 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
22+ const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
2123 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2224
2325 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
@@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 1 unmodified line
2527 yield* providerCommandReactor.start();
2628 yield* checkpointReactor.start();
2729 yield* threadDeletionReactor.start();
30+ yield* threadPullRequestReactor.start();
2831 yield* threadSettlementReactor.start();
2932 yield* agentAwarenessRelay.start();
3033 });
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+92−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 MessageId,
99 ProjectId,
1010 ThreadId,
11+ ThreadLinkedPullRequest,
1112 TurnId,
1213 ProviderInstanceId,
1314 } from "@t3tools/contracts";
@@ -18,6 +19,7 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines
1819 import * as FileSystem from "effect/FileSystem";
1920 import * as Layer from "effect/Layer";
2021 import * as Path from "effect/Path";
22+import * as Schema from "effect/Schema";
2123 import * as SqlClient from "effect/unstable/sql/SqlClient";
2224
2325 import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
@@ -59,6 +61,9 @@ const exists = (filePath: string) => · 35 unmodified lines
5961 });
6062
6163 const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
64+const encodeThreadLinkedPullRequest = Schema.encodeSync(
65+ Schema.fromJsonString(ThreadLinkedPullRequest),
66+);
6267
6368 it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))(
6469 "OrchestrationProjectionPipeline cursor batches",
@@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-") · 139 unmodified lines
204209 },
205210 );
206211
212+it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-projection-")))(
213+ "branch pull request projection",
214+ (it) => {
215+ it.effect("persists branch pull request updates without changing manual links", () =>
216+ Effect.gen(function* () {
217+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
218+ const eventStore = yield* OrchestrationEventStore;
219+ const sql = yield* SqlClient.SqlClient;
220+ const now = "2026-01-01T00:00:00.000Z";
221+ const threadId = ThreadId.make("thread-pull-request");
222+ const projectId = ProjectId.make("project-pull-request");
223+ const eventFields = {
224+ aggregateKind: "thread" as const,
225+ aggregateId: threadId,
226+ occurredAt: now,
227+ commandId: null,
228+ causationEventId: null,
229+ correlationId: null,
230+ metadata: {},
231+ };
232+ const created = yield* eventStore.append({
233+ ...eventFields,
234+ type: "thread.created",
235+ eventId: EventId.make("evt-pull-request-created"),
236+ payload: {
237+ threadId,
238+ projectId,
239+ title: "Pull request thread",
240+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
241+ runtimeMode: "full-access",
242+ interactionMode: "default",
243+ branch: "feature",
244+ worktreePath: null,
245+ createdAt: now,
246+ updatedAt: now,
247+ },
248+ });
249+ yield* projectionPipeline.projectEvent(created);
250+ const linkedPullRequest = {
251+ projectId,
252+ repository: "pingdotgg/t3code",
253+ number: 42,
254+ url: "https://github.com/pingdotgg/t3code/pull/42",
255+ };
256+ const branchPullRequest = {
257+ ...linkedPullRequest,
258+ number: 43,
259+ url: "https://github.com/pingdotgg/t3code/pull/43",
260+ };
261+ const updates = [
262+ { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest },
263+ { payload: { title: "Renamed thread" }, expected: branchPullRequest },
264+ { payload: { branchPullRequest: null }, expected: null },
265+ ];
266+
267+ for (const [index, update] of updates.entries()) {
268+ const event = yield* eventStore.append({
269+ ...eventFields,
270+ type: "thread.meta-updated",
271+ eventId: EventId.make(`evt-pull-request-update-${index}`),
272+ payload: { threadId, updatedAt: now, ...update.payload },
273+ });
274+ yield* projectionPipeline.projectEvent(event);
275+
276+ const rows = yield* sql<{
277+ readonly linkedPullRequest: string | null;
278+ readonly branchPullRequest: string | null;
279+ }>`
280+ SELECT
281+ linked_pull_request_json AS "linkedPullRequest",
282+ branch_pull_request_json AS "branchPullRequest"
283+ FROM projection_threads
284+ WHERE thread_id = ${threadId}
285+ `;
286+ assert.deepEqual(rows, [
287+ {
288+ linkedPullRequest: encodeThreadLinkedPullRequest(linkedPullRequest),
289+ branchPullRequest:
290+ update.expected === null ? null : encodeThreadLinkedPullRequest(update.expected),
291+ },
292+ ]);
293+ }
294+ }),
295+ );
296+ },
297+);
298+
207299 it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
208300 it.effect("bootstraps all projection states and writes projection rows", () =>
209301 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4−0
@@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 604 unmodified lines
605605 branch: event.payload.branch,
606606 worktreePath: event.payload.worktreePath,
607607 linkedPullRequest: null,
608+ branchPullRequest: null,
608609 latestTurnId: null,
609610 createdAt: event.payload.createdAt,
610611 updatedAt: event.payload.updatedAt,
@@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 194 unmodified lines
805806 ...(event.payload.linkedPullRequest !== undefined
806807 ? { linkedPullRequest: event.payload.linkedPullRequest }
807808 : {}),
809+ ...(event.payload.branchPullRequest !== undefined
810+ ? { branchPullRequest: event.payload.branchPullRequest }
811+ : {}),
808812 updatedAt: event.payload.updatedAt,
809813 });
810814 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+36−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 MessageId,
77 ProjectId,
88 ThreadId,
9+ ThreadLinkedPullRequest,
910 TurnId,
1011 ProviderInstanceId,
1112 } from "@t3tools/contracts";
@@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val · 24 unmodified lines
3637 const encodeChatAttachments = Schema.encodeEffect(
3738 Schema.fromJsonString(Schema.Array(ChatAttachment)),
3839 );
40+const encodeThreadLinkedPullRequest = Schema.encodeSync(
41+ Schema.fromJsonString(ThreadLinkedPullRequest),
42+);
3943
4044 const projectionSnapshotLayer = it.layer(
4145 OrchestrationProjectionSnapshotQueryLive.pipe(
@@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 10 unmodified lines
5256 Effect.gen(function* () {
5357 const snapshotQuery = yield* ProjectionSnapshotQuery;
5458 const sql = yield* SqlClient.SqlClient;
59+ const branchPullRequest = {
60+ projectId: asProjectId("project-1"),
61+ repository: "pingdotgg/t3code",
62+ number: 43,
63+ url: "https://github.com/pingdotgg/t3code/pull/43",
64+ };
5565
5666 yield* sql`DELETE FROM projection_projects`;
5767 yield* sql`DELETE FROM projection_state`;
@@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 34 unmodified lines
92102 branch,
93103 worktree_path,
94104 linked_pull_request_json,
105+ branch_pull_request_json,
95106 latest_turn_id,
96107 latest_user_message_at,
97108 pending_approval_count,
@@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 15 unmodified lines
113124 NULL,
114125 NULL,
115126 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
127+ ${encodeThreadLinkedPullRequest(branchPullRequest)},
116128 'turn-1',
117129 '2026-02-24T00:00:04.000Z',
118130 1,
@@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 204 unmodified lines
323335 number: 42,
324336 url: "https://github.com/pingdotgg/t3code/pull/42",
325337 },
338+ branchPullRequest,
326339 latestTurn: {
327340 turnId: asTurnId("turn-1"),
328341 state: "completed",
@@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 122 unmodified lines
451464 number: 42,
452465 url: "https://github.com/pingdotgg/t3code/pull/42",
453466 },
467+ branchPullRequest,
454468 latestTurn: {
455469 turnId: asTurnId("turn-1"),
456470 state: "completed",
@@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 41 unmodified lines
498512 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
499513 }
500514
515+ const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
516+ assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
517+ const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
518+ assert.equal(threadShell._tag, "Some");
519+ if (threadShell._tag === "Some") {
520+ assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest);
521+ }
522+
501523 yield* sql`
502524 INSERT INTO projection_thread_activities (
503525 activity_id,
@@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 239 unmodified lines
743765 Effect.gen(function* () {
744766 const snapshotQuery = yield* ProjectionSnapshotQuery;
745767 const sql = yield* SqlClient.SqlClient;
768+ const branchPullRequest = {
769+ projectId: asProjectId("project-archive-test"),
770+ repository: "pingdotgg/t3code",
771+ number: 43,
772+ url: "https://github.com/pingdotgg/t3code/pull/43",
773+ };
746774
747775 yield* sql`DELETE FROM projection_projects`;
748776 yield* sql`DELETE FROM projection_threads`;
@@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 100 unmodified lines
849877 shellSnapshot.threads.map((thread) => thread.id),
850878 [ThreadId.make("thread-active")],
851879 );
880+ assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null);
881+
882+ yield* sql`
883+ UPDATE projection_threads
884+ SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)}
885+ WHERE thread_id = 'thread-archived'
886+ `;
852887
853888 const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();
854889 assert.deepEqual(
@@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 1 unmodified line
856891 [ThreadId.make("thread-archived")],
857892 );
858893 assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");
894+ assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
859895 const activeContext = yield* snapshotQuery.getThreadRuntimeContext(
860896 ThreadId.make("thread-active"),
861897 );
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11−0
@@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( · 115 unmodified lines
116116 Struct.assign({
117117 modelSelection: Schema.fromJsonString(ModelSelection),
118118 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119+ branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119120 }),
120121 );
121122 const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
@@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 378 unmodified lines
500501 branch,
501502 worktree_path AS "worktreePath",
502503 linked_pull_request_json AS "linkedPullRequest",
504+ branch_pull_request_json AS "branchPullRequest",
503505 latest_turn_id AS "latestTurnId",
504506 created_at AS "createdAt",
505507 updated_at AS "updatedAt",
@@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 32 unmodified lines
538540 branch,
539541 worktree_path AS "worktreePath",
540542 linked_pull_request_json AS "linkedPullRequest",
543+ branch_pull_request_json AS "branchPullRequest",
541544 latest_turn_id AS "latestTurnId",
542545 created_at AS "createdAt",
543546 updated_at AS "updatedAt",
@@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 34 unmodified lines
578581 branch,
579582 worktree_path AS "worktreePath",
580583 linked_pull_request_json AS "linkedPullRequest",
584+ branch_pull_request_json AS "branchPullRequest",
581585 latest_turn_id AS "latestTurnId",
582586 created_at AS "createdAt",
583587 updated_at AS "updatedAt",
@@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 483 unmodified lines
10671071 branch,
10681072 worktree_path AS "worktreePath",
10691073 linked_pull_request_json AS "linkedPullRequest",
1074+ branch_pull_request_json AS "branchPullRequest",
10701075 latest_turn_id AS "latestTurnId",
10711076 created_at AS "createdAt",
10721077 updated_at AS "updatedAt",
@@ -2062,6 +2067,7 @@ pending_approval_requests AS ( · 989 unmodified lines
20622067 interactionMode: row.interactionMode,
20632068 branch: row.branch,
20642069 worktreePath: row.worktreePath,
2070+ branchPullRequest: row.branchPullRequest,
20652071 ...(row.linkedPullRequest === null
20662072 ? {}
20672073 : { linkedPullRequest: row.linkedPullRequest }),
@@ -2275,6 +2281,7 @@ pending_approval_requests AS ( · 207 unmodified lines
22752281 interactionMode: row.interactionMode,
22762282 branch: row.branch,
22772283 worktreePath: row.worktreePath,
2284+ branchPullRequest: row.branchPullRequest,
22782285 ...(row.linkedPullRequest === null
22792286 ? {}
22802287 : { linkedPullRequest: row.linkedPullRequest }),
@@ -2415,6 +2422,7 @@ pending_approval_requests AS ( · 134 unmodified lines
24152422 interactionMode: row.interactionMode,
24162423 branch: row.branch,
24172424 worktreePath: row.worktreePath,
2425+ branchPullRequest: row.branchPullRequest,
24182426 ...(row.linkedPullRequest === null
24192427 ? {}
24202428 : { linkedPullRequest: row.linkedPullRequest }),
@@ -2563,6 +2571,7 @@ pending_approval_requests AS ( · 142 unmodified lines
25632571 interactionMode: row.interactionMode,
25642572 branch: row.branch,
25652573 worktreePath: row.worktreePath,
2574+ branchPullRequest: row.branchPullRequest,
25662575 ...(row.linkedPullRequest === null
25672576 ? {}
25682577 : { linkedPullRequest: row.linkedPullRequest }),
@@ -2884,6 +2893,7 @@ pending_approval_requests AS ( · 315 unmodified lines
28842893 interactionMode: threadRow.value.interactionMode,
28852894 branch: threadRow.value.branch,
28862895 worktreePath: threadRow.value.worktreePath,
2896+ branchPullRequest: threadRow.value.branchPullRequest,
28872897 ...(threadRow.value.linkedPullRequest === null
28882898 ? {}
28892899 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
@@ -3165,6 +3175,7 @@ pending_approval_requests AS ( · 275 unmodified lines
31653175 interactionMode: threadRow.value.interactionMode,
31663176 branch: threadRow.value.branch,
31673177 worktreePath: threadRow.value.worktreePath,
3178+ branchPullRequest: threadRow.value.branchPullRequest,
31683179 ...(threadRow.value.linkedPullRequest === null
31693180 ? {}
31703181 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+613−0
@@ -0,0 +1,613 @@
1+import {
2+ EventId,
3+ GitManagerError,
4+ ProjectId,
5+ ProviderInstanceId,
6+ ThreadId,
7+ type OrchestrationCommand,
8+ type OrchestrationEvent,
9+ type OrchestrationProjectShell,
10+ type OrchestrationShellSnapshot,
11+ type OrchestrationThreadShell,
12+ type PullRequestRef,
13+ type PullRequestSummary,
14+ type ThreadLinkedPullRequest,
15+} from "@t3tools/contracts";
16+import { describe, expect, it } from "@effect/vitest";
17+import * as Crypto from "effect/Crypto";
18+import * as Deferred from "effect/Deferred";
19+import * as Effect from "effect/Effect";
20+import * as FileSystem from "effect/FileSystem";
21+import * as Layer from "effect/Layer";
22+import * as PubSub from "effect/PubSub";
23+import * as Queue from "effect/Queue";
24+import * as Ref from "effect/Ref";
25+import * as Stream from "effect/Stream";
26+import { TestClock } from "effect/testing";
27+
28+import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
29+import { PullRequestService } from "../pullRequest/PullRequestService.ts";
30+import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts";
31+import { ServerActivation } from "../serverActivation.ts";
32+import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
33+import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
34+import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts";
35+
36+const NOW = "2026-09-01T12:00:00.000Z";
37+const PROJECT_ID = ProjectId.make("project");
38+const REPOSITORY = "owner/repository";
39+const REPOSITORY_KEY = `github.com/${REPOSITORY}`;
40+type SyncCommand = Extract<OrchestrationCommand, { type: "thread.pull-request.sync" }>;
41+
42+function reference(number: number): ThreadLinkedPullRequest {
43+ return {
44+ projectId: PROJECT_ID,
45+ repository: REPOSITORY,
46+ number,
47+ url: `https://github.com/${REPOSITORY}/pull/${number}`,
48+ };
49+}
50+
51+function branchPullRequest(
52+ number = 42,
53+ state: GitBranchPullRequest["state"] = "open",
54+): GitBranchPullRequest {
55+ return {
56+ ...reference(number),
57+ title: "Branch pull request",
58+ baseRef: "main",
59+ headRef: "feature",
60+ state,
61+ updatedAt: NOW,
62+ repositoryKey: REPOSITORY_KEY,
63+ };
64+}
65+
66+function summary(input: PullRequestRef, state: PullRequestSummary["state"]): PullRequestSummary {
67+ return {
68+ ...input,
69+ provider: "github",
70+ title: "Pull request",
71+ url: reference(input.number).url,
72+ state,
73+ headBranch: "feature",
74+ baseBranch: "main",
75+ updatedAt: NOW,
76+ };
77+}
78+
79+function thread(
80+ id: string,
81+ overrides: Partial<OrchestrationThreadShell> = {},
82+): OrchestrationThreadShell {
83+ return {
84+ id: ThreadId.make(id),
85+ projectId: PROJECT_ID,
86+ title: id,
87+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
88+ runtimeMode: "full-access",
89+ interactionMode: "default",
90+ branch: "feature",
91+ worktreePath: null,
92+ latestTurn: null,
93+ createdAt: NOW,
94+ updatedAt: NOW,
95+ archivedAt: null,
96+ settledOverride: null,
97+ settledAt: null,
98+ session: null,
99+ latestUserMessageAt: NOW,
100+ hasPendingApprovals: false,
101+ hasPendingUserInput: false,
102+ hasActionableProposedPlan: false,
103+ ...overrides,
104+ };
105+}
106+
107+const project = {
108+ id: PROJECT_ID,
109+ title: "Project",
110+ workspaceRoot: "/workspace/project",
111+ repositoryIdentity: {
112+ canonicalKey: REPOSITORY_KEY,
113+ displayName: REPOSITORY,
114+ rootPath: "/workspace/project",
115+ locator: {
116+ source: "git-remote",
117+ remoteName: "origin",
118+ remoteUrl: `git@github.com:${REPOSITORY}.git`,
119+ },
120+ },
121+ defaultModelSelection: null,
122+ scripts: [],
123+ createdAt: NOW,
124+ updatedAt: NOW,
125+} satisfies OrchestrationProjectShell;
126+
127+const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options: {
128+ readonly threads: ReadonlyArray<OrchestrationThreadShell>;
129+ readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
130+ readonly summary?: PullRequestService["Service"]["summary"];
131+ readonly existingWorktrees?: ReadonlyArray<string>;
132+ readonly project?: OrchestrationProjectShell;
133+ readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"];
134+}) {
135+ const activation = yield* Deferred.make<void>();
136+ const snapshots = yield* Ref.make<OrchestrationShellSnapshot>({
137+ snapshotSequence: 1,
138+ projects: [options.project ?? project],
139+ threads: options.threads,
140+ updatedAt: NOW,
141+ });
142+ const reads = yield* Queue.unbounded<void>();
143+ const events = yield* PubSub.unbounded<OrchestrationEvent>();
144+ const commands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
145+ const branchCalls = yield* Ref.make<
146+ ReadonlyArray<{ readonly cwd: string; readonly branch: string; readonly refresh: boolean }>
147+ >([]);
148+ const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
149+ let uuid = 0;
150+ const dependencies = Layer.mergeAll(
151+ Layer.mock(ProjectionSnapshotQuery)({
152+ getShellSnapshot: () =>
153+ Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))),
154+ }),
155+ Layer.mock(GitManager)({
156+ branchPullRequest: (input, readOptions) =>
157+ Ref.update(branchCalls, (calls) => [
158+ ...calls,
159+ { ...input, refresh: readOptions?.refresh === true },
160+ ]).pipe(
161+ Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)),
162+ ),
163+ }),
164+ Layer.mock(PullRequestService)({
165+ summary: (input, readOptions) =>
166+ Ref.update(summaryCalls, (calls) => [...calls, input]).pipe(
167+ Effect.andThen(
168+ options.summary?.(input, readOptions) ?? Effect.succeed(summary(input, "open")),
169+ ),
170+ ),
171+ }),
172+ Layer.mock(RepositoryIdentityResolver)({
173+ resolve:
174+ options.resolveRepositoryIdentity ??
175+ (() => Effect.succeed(options.project?.repositoryIdentity ?? project.repositoryIdentity)),
176+ }),
177+ Layer.mock(OrchestrationEngineService)({
178+ subscribeDomainEvents: PubSub.subscribe(events).pipe(
179+ Effect.map((subscription) => Stream.fromSubscription(subscription)),
180+ ),
181+ dispatch: (command) => {
182+ if (command.type !== "thread.pull-request.sync") {
183+ return Effect.die(`Unexpected command: ${command.type}`);
184+ }
185+ return Ref.update(commands, (current) => [...current, command]).pipe(
186+ Effect.andThen(
187+ Ref.updateAndGet(snapshots, (snapshot) => ({
188+ ...snapshot,
189+ snapshotSequence: snapshot.snapshotSequence + 1,
190+ threads: snapshot.threads.map((current) =>
191+ current.id === command.threadId
192+ ? {
193+ ...current,
194+ branchPullRequest: command.branchPullRequest,
195+ ...(command.linkedPullRequest !== undefined
196+ ? { linkedPullRequest: command.linkedPullRequest }
197+ : {}),
198+ }
199+ : current,
200+ ),
201+ })),
202+ ),
203+ Effect.map((snapshot) => ({ sequence: snapshot.snapshotSequence })),
204+ );
205+ },
206+ }),
207+ Layer.succeed(ServerActivation, Deferred.await(activation)),
208+ Layer.succeed(
209+ Crypto.Crypto,
210+ Crypto.make({
211+ randomBytes: (size) => new Uint8Array(size).fill(++uuid),
212+ digest: (_algorithm, data) => Effect.succeed(data),
213+ }),
214+ ),
215+ FileSystem.layerNoop({
216+ exists: (path) => Effect.succeed(options.existingWorktrees?.includes(path) ?? false),
217+ }),
218+ );
219+
220+ const start = Effect.fn("startThreadPullRequestHarness")(function* () {
221+ const reactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
222+ yield* reactor.start();
223+ yield* Deferred.succeed(activation, undefined);
224+ yield* Queue.take(reads);
225+ yield* reactor.drain;
226+ return reactor;
227+ });
228+
229+ return {
230+ start,
231+ reads,
232+ snapshots,
233+ commands,
234+ branchCalls,
235+ summaryCalls,
236+ publish: (event: OrchestrationEvent) => PubSub.publish(events, event),
237+ layer: ThreadPullRequestReactor.layer.pipe(Layer.provide(dependencies)),
238+ };
239+});
240+
241+describe("ThreadPullRequestReactor", () => {
242+ it.effect("discovers saved branch PRs without a client and shares branch lookups", () =>
243+ Effect.scoped(
244+ Effect.gen(function* () {
245+ const fixture = yield* makeHarness({
246+ threads: [
247+ thread("first"),
248+ thread("second"),
249+ thread("archived", { archivedAt: NOW }),
250+ thread("no-branch", { branch: null }),
251+ ],
252+ branchPullRequest: () => Effect.succeed(branchPullRequest()),
253+ });
254+ yield* Effect.gen(function* () {
255+ const reactor = yield* fixture.start();
256+ expect(yield* Ref.get(fixture.branchCalls)).toEqual([
257+ { cwd: project.workspaceRoot, branch: "feature", refresh: false },
258+ { cwd: project.workspaceRoot, branch: "feature", refresh: false },
259+ ]);
260+ expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
261+ "first",
262+ "second",
263+ ]);
264+ expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual(
265+ reference(42),
266+ );
267+
268+ yield* TestClock.adjust("1 minute");
269+ yield* Queue.take(fixture.reads);
270+ yield* reactor.drain;
271+ expect(yield* Ref.get(fixture.commands)).toHaveLength(2);
272+ }).pipe(Effect.provide(fixture.layer));
273+ }),
274+ ),
275+ );
276+
277+ it.effect("replaces terminal manual links but preserves open links and explicit unlink", () =>
278+ Effect.scoped(
279+ Effect.gen(function* () {
280+ const fixture = yield* makeHarness({
281+ threads: [
282+ thread("merged", { linkedPullRequest: reference(1) }),
283+ thread("closed", { linkedPullRequest: reference(2) }),
284+ thread("open", { linkedPullRequest: reference(3) }),
285+ thread("unlinked", { linkedPullRequest: null }),
286+ ],
287+ branchPullRequest: () => Effect.succeed(branchPullRequest()),
288+ summary: (input) =>
289+ Effect.succeed(
290+ summary(
291+ input,
292+ input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open",
293+ ),
294+ ),
295+ });
296+ yield* Effect.gen(function* () {
297+ yield* fixture.start();
298+ const snapshot = yield* Ref.get(fixture.snapshots);
299+ expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([
300+ reference(42),
301+ reference(42),
302+ reference(3),
303+ null,
304+ ]);
305+ expect(
306+ snapshot.threads.every((current) => current.branchPullRequest?.number === 42),
307+ ).toBe(true);
308+ }).pipe(Effect.provide(fixture.layer));
309+ }),
310+ ),
311+ );
312+
313+ it.effect("refreshes discovery after a turn ends without client demand", () =>
314+ Effect.scoped(
315+ Effect.gen(function* () {
316+ const current = thread("turn-thread");
317+ const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
318+ const fixture = yield* makeHarness({
319+ threads: [current],
320+ branchPullRequest: (_input, options) =>
321+ options?.refresh
322+ ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
323+ : Ref.get(detected),
324+ });
325+ yield* Effect.gen(function* () {
326+ const reactor = yield* fixture.start();
327+ expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
328+ yield* fixture.publish({
329+ type: "thread.session-set",
330+ sequence: 2,
331+ eventId: EventId.make("turn-finished"),
332+ aggregateKind: "thread",
333+ aggregateId: current.id,
334+ occurredAt: NOW,
335+ commandId: null,
336+ causationEventId: null,
337+ correlationId: null,
338+ metadata: {},
339+ payload: {
340+ threadId: current.id,
341+ session: {
342+ threadId: current.id,
343+ status: "ready",
344+ providerName: "Codex",
345+ runtimeMode: "full-access",
346+ activeTurnId: null,
347+ lastError: null,
348+ updatedAt: NOW,
349+ },
350+ },
351+ });
352+ yield* Queue.take(fixture.reads);
353+ yield* reactor.drain;
354+ expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
355+ expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength(
356+ 1,
357+ );
358+ }).pipe(Effect.provide(fixture.layer));
359+ }),
360+ ),
361+ );
362+
363+ it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
364+ Effect.scoped(
365+ Effect.gen(function* () {
366+ const fixture = yield* makeHarness({
367+ threads: [
368+ thread("live", { worktreePath: "/workspace/worktree" }),
369+ thread("removed", { worktreePath: "/workspace/removed" }),
370+ ],
371+ existingWorktrees: ["/workspace/worktree"],
372+ branchPullRequest: () => Effect.succeed(branchPullRequest()),
373+ });
374+ yield* Effect.gen(function* () {
375+ yield* fixture.start();
376+ expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual(
377+ new Set(["/workspace/project", "/workspace/worktree"]),
378+ );
379+ }).pipe(Effect.provide(fixture.layer));
380+ }),
381+ ),
382+ );
383+
384+ it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () =>
385+ Effect.scoped(
386+ Effect.gen(function* () {
387+ const fixture = yield* makeHarness({
388+ threads: [
389+ thread("terminal", { branch: "main", branchPullRequest: reference(1) }),
390+ thread("open", { branchPullRequest: reference(2) }),
391+ thread("worktree", {
392+ worktreePath: "/workspace/worktree",
393+ branchPullRequest: reference(1),
394+ }),
395+ thread("cleared", {
396+ branch: null,
397+ branchPullRequest: reference(1),
398+ linkedPullRequest: reference(3),
399+ }),
400+ ],
401+ summary: (input) =>
402+ Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")),
403+ });
404+ yield* Effect.gen(function* () {
405+ yield* fixture.start();
406+ const snapshot = yield* Ref.get(fixture.snapshots);
407+ expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([
408+ reference(1),
409+ null,
410+ null,
411+ null,
412+ ]);
413+ expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3));
414+ }).pipe(Effect.provide(fixture.layer));
415+ }),
416+ ),
417+ );
418+
419+ it.effect("keeps saved links on lookup failures and rejects a different repository", () =>
420+ Effect.scoped(
421+ Effect.gen(function* () {
422+ const fixture = yield* makeHarness({
423+ threads: [
424+ thread("failed", { branch: "failed", branchPullRequest: reference(1) }),
425+ thread("wrong-repository", {
426+ branch: "wrong-repository",
427+ branchPullRequest: reference(2),
428+ }),
429+ thread("healthy"),
430+ ],
431+ branchPullRequest: ({ cwd, branch }) =>
432+ branch === "failed"
433+ ? Effect.fail(
434+ new GitManagerError({
435+ operation: "branchPullRequest",
436+ cwd,
437+ detail: "Lookup failed",
438+ }),
439+ )
440+ : Effect.succeed({
441+ ...branchPullRequest(),
442+ repositoryKey:
443+ branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY,
444+ }),
445+ });
446+ yield* Effect.gen(function* () {
447+ yield* fixture.start();
448+ expect(
449+ (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest),
450+ ).toEqual([reference(1), reference(2), reference(42)]);
451+ expect(yield* Ref.get(fixture.commands)).toHaveLength(1);
452+ }).pipe(Effect.provide(fixture.layer));
453+ }),
454+ ),
455+ );
456+
457+ it.effect("retries failed settled backfills and stops querying them after success", () =>
458+ Effect.scoped(
459+ Effect.gen(function* () {
460+ const online = yield* Ref.make(false);
461+ const fixture = yield* makeHarness({
462+ threads: [
463+ thread("backfill", { settledOverride: "settled", settledAt: NOW }),
464+ thread("known", {
465+ branch: "known",
466+ settledOverride: "settled",
467+ settledAt: NOW,
468+ branchPullRequest: reference(1),
469+ }),
470+ ],
471+ branchPullRequest: ({ cwd }) =>
472+ Ref.get(online).pipe(
473+ Effect.flatMap((connected) =>
474+ connected
475+ ? Effect.succeed(branchPullRequest(42, "merged"))
476+ : Effect.fail(
477+ new GitManagerError({
478+ operation: "branchPullRequest",
479+ cwd,
480+ detail: "Offline",
481+ }),
482+ ),
483+ ),
484+ ),
485+ });
486+ yield* Effect.gen(function* () {
487+ const reactor = yield* fixture.start();
488+ expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
489+ yield* Ref.set(online, true);
490+ yield* TestClock.adjust("1 minute");
491+ yield* Queue.take(fixture.reads);
492+ yield* reactor.drain;
493+ expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
494+ yield* TestClock.adjust("1 minute");
495+ yield* Queue.take(fixture.reads);
496+ yield* reactor.drain;
497+ expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
498+ "feature",
499+ "feature",
500+ "feature",
501+ ]);
502+ }).pipe(Effect.provide(fixture.layer));
503+ }),
504+ ),
505+ );
506+
507+ it.effect("stops retrying a settled backfill after repeated lookup failures", () =>
508+ Effect.scoped(
509+ Effect.gen(function* () {
510+ const fixture = yield* makeHarness({
511+ threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })],
512+ branchPullRequest: ({ cwd }) =>
513+ Effect.fail(
514+ new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }),
515+ ),
516+ });
517+ yield* Effect.gen(function* () {
518+ const reactor = yield* fixture.start();
519+ for (
520+ let attempt = 1;
521+ attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2;
522+ attempt++
523+ ) {
524+ yield* TestClock.adjust("1 minute");
525+ yield* Queue.take(fixture.reads);
526+ yield* reactor.drain;
527+ }
528+ expect(yield* Ref.get(fixture.branchCalls)).toHaveLength(
529+ ThreadPullRequestReactor.BACKFILL_ATTEMPTS,
530+ );
531+ expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
532+ }).pipe(Effect.provide(fixture.layer));
533+ }),
534+ ),
535+ );
536+
537+ it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () =>
538+ Effect.scoped(
539+ Effect.gen(function* () {
540+ const fixture = yield* makeHarness({
541+ threads: [thread("azure")],
542+ project: {
543+ ...project,
544+ repositoryIdentity: {
545+ canonicalKey: "ssh.dev.azure.com/v3/org/project/repository",
546+ displayName: "v3/org/project/repository",
547+ name: "repository",
548+ provider: "azure-devops",
549+ rootPath: project.workspaceRoot,
550+ locator: {
551+ source: "git-remote",
552+ remoteName: "origin",
553+ remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository",
554+ },
555+ },
556+ },
557+ branchPullRequest: () =>
558+ Effect.succeed({
559+ ...branchPullRequest(),
560+ repositoryKey: "dev.azure.com/org/project/_git/repository",
561+ url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
562+ }),
563+ });
564+ yield* Effect.gen(function* () {
565+ yield* fixture.start();
566+ expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({
567+ projectId: PROJECT_ID,
568+ repository: "repository",
569+ number: 42,
570+ url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
571+ });
572+ }).pipe(Effect.provide(fixture.layer));
573+ }),
574+ ),
575+ );
576+
577+ it.effect.each(["primary", "branch"] as const)(
578+ "rejects the group's links if the %s remote changes during a summary read",
579+ (changedRemote) =>
580+ Effect.scoped(
581+ Effect.gen(function* () {
582+ const identity = yield* Ref.make(project.repositoryIdentity);
583+ const detected = yield* Ref.make(branchPullRequest());
584+ const fixture = yield* makeHarness({
585+ threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")],
586+ branchPullRequest: () => Ref.get(detected),
587+ resolveRepositoryIdentity: (_cwd, options) =>
588+ options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity),
589+ summary: (input) =>
590+ (changedRemote === "primary"
591+ ? Ref.set(identity, {
592+ ...project.repositoryIdentity,
593+ canonicalKey: "github.com/other/repository",
594+ displayName: "other/repository",
595+ })
596+ : Ref.set(detected, {
597+ ...branchPullRequest(99),
598+ repositoryKey: "github.com/other/repository",
599+ url: "https://github.com/other/repository/pull/99",
600+ })
601+ ).pipe(Effect.as(summary(input, "merged"))),
602+ });
603+ yield* Effect.gen(function* () {
604+ yield* fixture.start();
605+ expect(yield* Ref.get(fixture.commands)).toEqual([]);
606+ expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual(
607+ reference(1),
608+ );
609+ }).pipe(Effect.provide(fixture.layer));
610+ }),
611+ ),
612+ );
613+});
apps/server/src/orchestration/ThreadPullRequestReactor.ts+371−0
@@ -0,0 +1,371 @@
1+import {
2+ CommandId,
3+ type OrchestrationEvent,
4+ type OrchestrationProjectShell,
5+ type ThreadId,
6+ type ThreadLinkedPullRequest,
7+} from "@t3tools/contracts";
8+import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
9+import * as Cause from "effect/Cause";
10+import * as Context from "effect/Context";
11+import * as Crypto from "effect/Crypto";
12+import * as Effect from "effect/Effect";
13+import * as FileSystem from "effect/FileSystem";
14+import * as Layer from "effect/Layer";
15+import * as Schedule from "effect/Schedule";
16+import type * as Scope from "effect/Scope";
17+import * as Stream from "effect/Stream";
18+
19+import * as GitManager from "../git/GitManager.ts";
20+import * as PullRequestService from "../pullRequest/PullRequestService.ts";
21+import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
22+import { forkParked } from "../serverActivation.ts";
23+import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
24+import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
25+
26+export class ThreadPullRequestReactor extends Context.Service<
27+ ThreadPullRequestReactor,
28+ {
29+ readonly start: () => Effect.Effect<void, never, Scope.Scope>;
30+ readonly drain: Effect.Effect<void>;
31+ }
32+>()("t3/orchestration/ThreadPullRequestReactor") {}
33+
34+function samePullRequest(
35+ left: ThreadLinkedPullRequest | null | undefined,
36+ right: ThreadLinkedPullRequest | null,
37+): boolean {
38+ if (left == null || right === null) return left == null && right === null;
39+ return (
40+ left.projectId === right.projectId &&
41+ left.repository.toLowerCase() === right.repository.toLowerCase() &&
42+ left.number === right.number &&
43+ left.url === right.url
44+ );
45+}
46+
47+/** Startup lookups per settled thread before discovery gives up on it. */
48+export const BACKFILL_ATTEMPTS = 5;
49+
50+interface RefreshRequest {
51+ readonly threadId: ThreadId | null;
52+ readonly refresh: boolean;
53+ readonly backfill?: boolean;
54+}
55+
56+function canonicalRepositoryKey(key: string): string {
57+ return key
58+ .replace(
59+ /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
60+ "dev.azure.com/$1/$2/_git/$3",
61+ )
62+ .replace(
63+ /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
64+ "dev.azure.com/$1/$2/_git/$3",
65+ );
66+}
67+
68+export function pullRequestMatchesProject(
69+ pullRequest: GitManager.GitBranchPullRequest,
70+ project: OrchestrationProjectShell,
71+): boolean {
72+ return (
73+ pullRequest.repositoryKey !== null &&
74+ project.repositoryIdentity != null &&
75+ canonicalRepositoryKey(pullRequest.repositoryKey) ===
76+ canonicalRepositoryKey(project.repositoryIdentity.canonicalKey)
77+ );
78+}
79+
80+export const make = Effect.gen(function* () {
81+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
82+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
83+ const git = yield* GitManager.GitManager;
84+ const pullRequests = yield* PullRequestService.PullRequestService;
85+ const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
86+ const crypto = yield* Crypto.Crypto;
87+ const fileSystem = yield* FileSystem.FileSystem;
88+ // Settled threads get one link discovery at startup. Failed lookups retry on
89+ // the periodic pass a few times, then stop until the thread changes or the
90+ // server restarts, so a missing or logged-out CLI cannot loop forever.
91+ const pendingBackfill = new Map<ThreadId, number>();
92+ const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => {
93+ for (const thread of threads) pendingBackfill.delete(thread.id);
94+ };
95+ const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => {
96+ for (const thread of threads) {
97+ const remaining = pendingBackfill.get(thread.id);
98+ if (remaining === undefined) continue;
99+ if (remaining <= 1) pendingBackfill.delete(thread.id);
100+ else pendingBackfill.set(thread.id, remaining - 1);
101+ }
102+ };
103+
104+ const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* (
105+ request: RefreshRequest,
106+ ) {
107+ const snapshot = yield* snapshots.getShellSnapshot();
108+ const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
109+ if (request.backfill) {
110+ for (const thread of snapshot.threads) {
111+ if (thread.settledOverride === "settled" && thread.branchPullRequest == null) {
112+ pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS);
113+ }
114+ }
115+ }
116+ const threadIds = new Set(snapshot.threads.map((thread) => thread.id));
117+ for (const threadId of pendingBackfill.keys()) {
118+ if (!threadIds.has(threadId)) pendingBackfill.delete(threadId);
119+ }
120+ const threads = snapshot.threads.filter(
121+ (thread) =>
122+ thread.archivedAt === null &&
123+ (request.threadId === null || thread.id === request.threadId) &&
124+ (thread.settledOverride !== "settled" ||
125+ request.threadId !== null ||
126+ pendingBackfill.has(thread.id)) &&
127+ (thread.branch !== null || thread.branchPullRequest != null),
128+ );
129+ const groups = Map.groupBy(threads, (thread) =>
130+ JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]),
131+ );
132+
133+ yield* Effect.forEach(
134+ groups.values(),
135+ (group) =>
136+ Effect.gen(function* () {
137+ const first = group[0]!;
138+ const project = projects.get(first.projectId);
139+ if (project === undefined) return finishBackfill(group);
140+ const repository = PullRequestService.repositoryIdentityOf(project);
141+ if (first.branch !== null && repository === null) return finishBackfill(group);
142+ const worktreeExists =
143+ first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));
144+ const cwd =
145+ worktreeExists && first.worktreePath !== null
146+ ? first.worktreePath
147+ : project.workspaceRoot;
148+ const detected =
149+ first.branch === null
150+ ? null
151+ : yield* git.branchPullRequest(
152+ { cwd, branch: first.branch },
153+ { refresh: request.refresh },
154+ );
155+ // A worktree can have different remotes, and the project identity
156+ // can lag a remote edit. Do not attach its PR to the wrong repository.
157+ if (detected !== null && !pullRequestMatchesProject(detected, project)) {
158+ return finishBackfill(group);
159+ }
160+ const detectedReference =
161+ detected !== null && repository !== null
162+ ? {
163+ projectId: project.id,
164+ repository,
165+ number: detected.number,
166+ url: detected.url,
167+ }
168+ : null;
169+
170+ const plans = yield* Effect.forEach(group, (thread) =>
171+ Effect.gen(function* () {
172+ let branchPullRequest = detectedReference;
173+ // Shared checkouts often return to the default branch after
174+ // a merge. Keep that thread's terminal PR across the change.
175+ if (
176+ branchPullRequest === null &&
177+ thread.branch !== null &&
178+ thread.worktreePath === null &&
179+ thread.branchPullRequest != null
180+ ) {
181+ const previous = yield* pullRequests.summary(thread.branchPullRequest, {
182+ recoverTransientFailure: false,
183+ });
184+ if (previous.state === "merged" || previous.state === "closed") {
185+ branchPullRequest = thread.branchPullRequest;
186+ }
187+ }
188+
189+ let replacement: ThreadLinkedPullRequest | undefined;
190+ if (
191+ thread.linkedPullRequest != null &&
192+ detected?.state === "open" &&
193+ detectedReference !== null &&
194+ !samePullRequest(thread.linkedPullRequest, detectedReference)
195+ ) {
196+ const linked = yield* pullRequests.summary(thread.linkedPullRequest, {
197+ recoverTransientFailure: false,
198+ });
199+ if (linked.state === "merged" || linked.state === "closed") {
200+ replacement = detectedReference;
201+ }
202+ }
203+
204+ if (
205+ samePullRequest(thread.branchPullRequest, branchPullRequest) &&
206+ replacement === undefined
207+ ) {
208+ pendingBackfill.delete(thread.id);
209+ return null;
210+ }
211+ return { thread, branchPullRequest, replacement };
212+ }).pipe(
213+ Effect.catchCause((cause) =>
214+ Cause.hasInterruptsOnly(cause)
215+ ? Effect.failCause(cause)
216+ : Effect.logWarning("thread pull request discovery failed", {
217+ threadId: thread.id,
218+ cause: Cause.pretty(cause),
219+ }).pipe(
220+ Effect.tap(() => Effect.sync(() => failBackfill([thread]))),
221+ Effect.as(null),
222+ ),
223+ ),
224+ ),
225+ );
226+ const updates = plans.filter((plan) => plan !== null);
227+ if (updates.length === 0) return;
228+
229+ if (detected !== null && first.branch !== null) {
230+ // Summary reads can outlast a remote edit. Recheck the branch and
231+ // the project's primary remote before saving the group's links.
232+ const current = yield* git.branchPullRequest({ cwd, branch: first.branch });
233+ const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, {
234+ refresh: true,
235+ });
236+ if (
237+ current === null ||
238+ current.number !== detected.number ||
239+ current.url !== detected.url ||
240+ current.state !== detected.state ||
241+ current.repositoryKey !== detected.repositoryKey ||
242+ !pullRequestMatchesProject(current, {
243+ ...project,
244+ repositoryIdentity: currentIdentity,
245+ })
246+ ) {
247+ return failBackfill(updates.map((update) => update.thread));
248+ }
249+ }
250+
251+ yield* Effect.forEach(
252+ updates,
253+ ({ thread, branchPullRequest, replacement }) =>
254+ Effect.gen(function* () {
255+ const uuid = yield* crypto.randomUUIDv4;
256+ yield* engine.dispatch({
257+ type: "thread.pull-request.sync",
258+ commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`),
259+ threadId: thread.id,
260+ projectId: project.id,
261+ snapshotSequence: snapshot.snapshotSequence,
262+ expected: {
263+ workspaceRoot: project.workspaceRoot,
264+ branch: thread.branch,
265+ worktreePath: thread.worktreePath,
266+ linkedPullRequest: thread.linkedPullRequest ?? null,
267+ branchPullRequest: thread.branchPullRequest ?? null,
268+ },
269+ branchPullRequest,
270+ ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}),
271+ });
272+ pendingBackfill.delete(thread.id);
273+ }).pipe(
274+ // The thread changed since the lookup. Its own events requeue it.
275+ Effect.catchTags({
276+ OrchestrationCommandInvariantError: () =>
277+ Effect.sync(() => finishBackfill([thread])),
278+ }),
279+ Effect.catchCause((cause) =>
280+ Cause.hasInterruptsOnly(cause)
281+ ? Effect.failCause(cause)
282+ : Effect.logWarning("thread pull request update failed", {
283+ threadId: thread.id,
284+ cause: Cause.pretty(cause),
285+ }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))),
286+ ),
287+ ),
288+ { discard: true },
289+ );
290+ }).pipe(
291+ Effect.catchCause((cause) =>
292+ Cause.hasInterruptsOnly(cause)
293+ ? Effect.failCause(cause)
294+ : Effect.logWarning("thread branch pull request lookup failed", {
295+ threadIds: group.map((thread) => thread.id),
296+ cause: Cause.pretty(cause),
297+ }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),
298+ ),
299+ ),
300+ { concurrency: 8, discard: true },
301+ );
302+ });
303+
304+ const worker = yield* makeDrainableWorker((request: RefreshRequest) =>
305+ synchronize(request).pipe(
306+ Effect.catchCause((cause) =>
307+ Cause.hasInterruptsOnly(cause)
308+ ? Effect.failCause(cause)
309+ : Effect.logWarning("thread pull request refresh failed", {
310+ cause: Cause.pretty(cause),
311+ }),
312+ ),
313+ ),
314+ );
315+
316+ const processEvent = (event: OrchestrationEvent) => {
317+ switch (event.type) {
318+ case "thread.created":
319+ case "thread.unarchived":
320+ return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
321+ case "thread.meta-updated":
322+ if (
323+ event.payload.branchPullRequest === undefined &&
324+ (event.payload.branch !== undefined ||
325+ event.payload.worktreePath !== undefined ||
326+ event.payload.linkedPullRequest !== undefined)
327+ ) {
328+ return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
329+ }
330+ break;
331+ case "thread.session-set":
332+ if (
333+ event.payload.session.status !== "running" &&
334+ event.payload.session.status !== "starting"
335+ ) {
336+ return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
337+ }
338+ break;
339+ case "thread.turn-diff-completed":
340+ case "thread.unsettled":
341+ return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
342+ case "project.meta-updated":
343+ if (event.payload.workspaceRoot !== undefined) {
344+ return worker.enqueue({ threadId: null, refresh: false });
345+ }
346+ break;
347+ }
348+ return Effect.void;
349+ };
350+
351+ const start = Effect.fn("ThreadPullRequestReactor.start")(function* () {
352+ const events = yield* engine.subscribeDomainEvents;
353+ yield* forkParked(Stream.runForEach(events, processEvent));
354+ // Run without client demand. Saved branch lookups share GitManager's
355+ // provider cache and retry backoff with status and automatic settlement.
356+ yield* forkParked(
357+ Effect.gen(function* () {
358+ yield* worker.enqueue({ threadId: null, refresh: false, backfill: true });
359+ yield* worker.drain;
360+ yield* Effect.gen(function* () {
361+ yield* worker.enqueue({ threadId: null, refresh: false });
362+ yield* worker.drain;
363+ }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute"));
364+ }).pipe(Effect.asVoid),
365+ );
366+ });
367+
368+ return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"];
369+});
370+
371+export const layer = Layer.effect(ThreadPullRequestReactor, make);
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+104−22
@@ -25,7 +25,7 @@ import * as Ref from "effect/Ref"; · 24 unmodified lines
2525 import * as Stream from "effect/Stream";
2626 import { TestClock } from "effect/testing";
2727
28−import { GitManager } from "../git/GitManager.ts";
28+import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
2929 import {
3030 PullRequestService,
3131 type PullRequestMergeEvent,
@@ -132,6 +132,24 @@ function makePullRequestSummary(input: { · 100 unmodified lines
132132 };
133133 }
134134
135+function makeBranchPullRequest(
136+ state: GitBranchPullRequest["state"],
137+ updatedAt: string | null = NOW,
138+): GitBranchPullRequest {
139+ return {
140+ number: 42,
141+ title: "Branch pull request",
142+ url: "https://example.test/owner/repository/pull/42",
143+ baseRef: "main",
144+ headRef: "saved-feature",
145+ repositoryKey: "example.test/owner/repository",
146+ state,
147+ updatedAt,
148+ closedAt: state === "closed" ? updatedAt : null,
149+ mergedAt: state === "merged" ? updatedAt : null,
150+ };
151+}
152+
135153 interface HarnessOptions {
136154 readonly snapshot: OrchestrationShellSnapshot;
137155 readonly settings?: ServerSettings;
@@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 35 unmodified lines
173191 return next;
174192 });
175193
176− const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
194+ const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) =>
177195 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
178− Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
196+ Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)),
179197 );
180198 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
181199 Effect.gen(function* () {
@@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( · 99 unmodified lines
281299 });
282300
283301 describe("ThreadSettlementReactor", () => {
302+ it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
303+ Effect.scoped(
304+ Effect.gen(function* () {
305+ yield* TestClock.setTime(Date.parse(NOW));
306+ const previous = {
307+ projectId: PROJECT_ID,
308+ repository: "owner/repository",
309+ number: 1,
310+ url: "https://example.test/owner/repository/pull/1",
311+ };
312+ const project = {
313+ ...makeProject(),
314+ repositoryIdentity: {
315+ canonicalKey: "example.test/owner/repository",
316+ rootPath: "/workspace/project",
317+ displayName: "owner/repository",
318+ locator: {
319+ source: "git-remote" as const,
320+ remoteName: "origin",
321+ remoteUrl: "https://example.test/owner/repository.git",
322+ },
323+ },
324+ };
325+ const fixture = yield* makeHarness({
326+ snapshot: makeSnapshot(
327+ [
328+ makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }),
329+ makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }),
330+ makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }),
331+ makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }),
332+ makeThread("resumed-manual", {
333+ branch: "main",
334+ linkedPullRequest: previous,
335+ latestUserMessageAt: "2026-08-28T00:00:00.000Z",
336+ }),
337+ makeThread("resumed-detected", {
338+ branch: "main",
339+ branchPullRequest: previous,
340+ latestUserMessageAt: "2026-08-28T00:00:00.000Z",
341+ }),
342+ ],
343+ [project],
344+ ),
345+ settings: {
346+ ...DEFAULT_SERVER_SETTINGS,
347+ sidebarAutoSettleAfterDays: null,
348+ sidebarAutoSettleOnMerge: true,
349+ },
350+ branchPullRequest: ({ branch }, options) =>
351+ Effect.succeed(
352+ branch === "reused"
353+ ? makeBranchPullRequest(options?.refresh ? "open" : "merged")
354+ : branch === "foreign"
355+ ? {
356+ ...makeBranchPullRequest("open"),
357+ repositoryKey: "example.test/another/repository",
358+ }
359+ : null,
360+ ),
361+ pullRequestSummary: (input) =>
362+ Effect.succeed({
363+ ...makePullRequestSummary({ ...input, state: "merged" }),
364+ mergedAt: "2026-08-27T00:00:00.000Z",
365+ }),
366+ });
367+ yield* Effect.gen(function* () {
368+ const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
369+ yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
370+ assert.deepStrictEqual(
371+ new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
372+ new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]),
373+ );
374+ }).pipe(Effect.provide(fixture.layer));
375+ }),
376+ ),
377+ );
378+
284379 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
285380 Effect.scoped(
286381 Effect.gen(function* () {
@@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => { · 88 unmodified lines
375470 }),
376471 ]),
377472 branchPullRequest: () =>
378− Ref.get(pullRequest).pipe(
379− Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })),
380− ),
473+ Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))),
381474 });
382475
383476 yield* Effect.gen(function* () {
@@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => { · 46 unmodified lines
430523 Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
431524 Effect.flatMap((count) =>
432525 count === 1
433− ? Effect.succeed({ state: "open" as const, updatedAt: NOW })
526+ ? Effect.succeed(makeBranchPullRequest("open"))
434527 : Deferred.succeed(periodicLookupStarted, undefined).pipe(
435528 Effect.andThen(Deferred.await(releasePeriodicLookup)),
436− Effect.as({ state: "open" as const, updatedAt: NOW }),
529+ Effect.as(makeBranchPullRequest("open")),
437530 ),
438531 ),
439532 ),
@@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => { · 37 unmodified lines
477570 ]),
478571 branchPullRequest: () =>
479572 Ref.get(state).pipe(
480− Effect.map((pullRequestState) => ({
481− state: pullRequestState,
482− updatedAt: NOW,
483− closedAt: NOW,
484− mergedAt: NOW,
485− })),
573+ Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)),
486574 ),
487575 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
488576 });
@@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => { · 129 unmodified lines
618706 : Effect.void,
619707 ),
620708 Effect.andThen(Ref.get(state)),
621− Effect.map((pullRequestState) => ({
622− state: pullRequestState,
623− updatedAt: NOW,
624− closedAt: NOW,
625− mergedAt: NOW,
626− })),
709+ Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)),
627710 ),
628711 });
629712
@@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => { · 266 unmodified lines
896979 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
897980 ],
898981 ),
899− branchPullRequest: () =>
900− Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }),
982+ branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")),
901983 pullRequestSummary: (input) =>
902984 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
903985 });
apps/server/src/orchestration/ThreadSettlementReactor.ts+52−42
@@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; · 16 unmodified lines
1717 import { forkParked } from "../serverActivation.ts";
1818 import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
1919 import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20+import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts";
2021 import {
2122 isAutoSettlementCandidate,
2223 resolveAutoSettlementAt,
@@ -46,10 +47,8 @@ export const make = Effect.gen(function* () { · 23 unmodified lines
4647 const snapshot = yield* snapshots.getShellSnapshot();
4748 const now = DateTime.formatIso(yield* DateTime.now);
4849 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
49− // A merge event re-sweeps every candidate, not just the threads linked to
50− // the merged pull request: most threads carry no link and settle from
51− // their branch lookup, which would otherwise wait for the next minute's
52− // sweep on a possibly stale cached answer.
50+ // A merge rechecks all candidates, including branches that discovery has
51+ // not linked yet. Those lookups can still have cached the PR as open.
5352 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
5453
5554 // Return the thread when it still needs a pull request decision. A rejected
@@ -101,14 +100,14 @@ export const make = Effect.gen(function* () { · 45 unmodified lines
101100 },
102101 )).filter((thread) => thread !== null);
103102
104− // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
103+ // Use the same cwd as PR discovery so both paths share GitManager's cache.
105104 const lookupCwdByThreadId = new Map<string, string>();
106105 yield* Effect.forEach(
107106 lookupCandidates,
108107 (thread) =>
109108 Effect.gen(function* () {
110109 const project = projects.get(thread.projectId);
111− if (project === undefined || thread.linkedPullRequest != null) return;
110+ if (project === undefined || thread.branch === null) return;
112111 const worktreeExists =
113112 thread.worktreePath !== null &&
114113 (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
@@ -122,12 +121,8 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
122121 { concurrency: 8, discard: true },
123122 );
124123 if (mergedPullRequest !== null) {
125− // The merge just confirmed a terminal state the lookup caches can still
126− // call open (branch answers live two minutes, the sweep runs every
127− // minute). Drop the swept checkouts' cached answers so the merge settles
128− // its branch threads now instead of on a later sweep. Threads linked to
129− // the merged pull request settle from the event itself below and need no
130− // lookup, so they are absent from this map by construction.
124+ // The merge confirmed a state the branch cache can still call open.
125+ // Recheck those branches now instead of waiting for cache expiry.
131126 const cwds = [...new Set(lookupCwdByThreadId.values())];
132127 yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
133128 concurrency: 8,
@@ -135,12 +130,15 @@ export const make = Effect.gen(function* () { · 1 unmodified line
135130 });
136131 }
137132 const lookupKey = (thread: (typeof candidates)[number]) => {
138− if (thread.linkedPullRequest != null) {
133+ const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
134+ if (reference != null) {
139135 return JSON.stringify([
140136 "linked",
141− thread.linkedPullRequest.projectId,
142− thread.linkedPullRequest.repository,
143− thread.linkedPullRequest.number,
137+ reference.projectId,
138+ reference.repository,
139+ reference.number,
140+ lookupCwdByThreadId.get(thread.id),
141+ thread.branch,
144142 ]);
145143 }
146144 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
@@ -154,35 +152,47 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
154152 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
155153 thread: (typeof candidates)[number],
156154 ) {
157− if (thread.linkedPullRequest != null) {
158− // The event carries the merged state, so only the threads linked to
159− // that exact pull request settle from it. Every other linked thread
160− // falls through to a fresh summary lookup below: the merge sweep
161− // covers all candidates, and an unrelated merge must never settle
162− // them.
163− if (
155+ const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
156+ if (reference != null) {
157+ const matchesMerge =
164158 mergedPullRequest !== null &&
165− thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
166− thread.linkedPullRequest.repository.toLowerCase() ===
167− mergedPullRequest.repository.toLowerCase() &&
168− thread.linkedPullRequest.number === mergedPullRequest.number
169− ) {
170− return {
171− state: "merged",
172− mergedAt: mergedPullRequest.mergedAt,
173− } satisfies SettlementPullRequest;
174− }
175− if (!projects.has(thread.linkedPullRequest.projectId)) {
159+ reference.projectId === mergedPullRequest.projectId &&
160+ reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() &&
161+ reference.number === mergedPullRequest.number;
162+ if (!matchesMerge && !projects.has(reference.projectId)) {
176163 return yield* Effect.die(new Error("linked pull request project not found"));
177164 }
178− const summary = yield* pullRequests.summary(
179− {
180− projectId: thread.linkedPullRequest.projectId,
181− repository: thread.linkedPullRequest.repository,
182− number: thread.linkedPullRequest.number,
183− },
184− { recoverTransientFailure: false },
185− );
165+ const summary = matchesMerge
166+ ? ({
167+ state: "merged",
168+ closedAt: null,
169+ mergedAt: mergedPullRequest.mergedAt,
170+ } satisfies SettlementPullRequest)
171+ : yield* pullRequests.summary(
172+ {
173+ projectId: reference.projectId,
174+ repository: reference.repository,
175+ number: reference.number,
176+ },
177+ { recoverTransientFailure: false },
178+ );
179+ const cwd = lookupCwdByThreadId.get(thread.id);
180+ if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) {
181+ // A reused branch can already have a new open PR while discovery
182+ // is replacing its old link. Do not let settlement win that race.
183+ const current = yield* git.branchPullRequest(
184+ { cwd, branch: thread.branch },
185+ { refresh: true },
186+ );
187+ const project = projects.get(thread.projectId);
188+ if (
189+ current?.state === "open" &&
190+ project !== undefined &&
191+ pullRequestMatchesProject(current, project)
192+ ) {
193+ return current;
194+ }
195+ }
186196 return {
187197 state: summary.state,
188198 closedAt: summary.closedAt ?? null,
apps/server/src/orchestration/decider.ts+59−0
@@ -1,6 +1,7 @@
11 import {
22 EventId,
33 MessageId,
4+ ThreadLinkedPullRequest,
45 UserInputRequestedPayload,
56 isImportedAgentSessionMessageId,
67 type OrchestrationCommand,
@@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; · 31 unmodified lines
3839
3940 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
4041 const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);
42+const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));
4143
4244 /**
4345 * Blocked-on-you work derived from the thread's retained activities: an
@@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 792 unmodified lines
836838 };
837839 }
838840
841+ case "thread.pull-request.sync": {
842+ const thread = yield* requireThreadNotArchived({
843+ readModel,
844+ command,
845+ threadId: command.threadId,
846+ });
847+ if (thread.deletedAt !== null) {
848+ return yield* new OrchestrationCommandInvariantError({
849+ commandType: command.type,
850+ detail: `thread ${command.threadId} was deleted before pull request discovery`,
851+ });
852+ }
853+ if (
854+ thread.projectId !== command.projectId ||
855+ thread.branch !== command.expected.branch ||
856+ thread.worktreePath !== command.expected.worktreePath ||
857+ !threadPullRequestLinksEqual(
858+ thread.linkedPullRequest ?? null,
859+ command.expected.linkedPullRequest,
860+ ) ||
861+ !threadPullRequestLinksEqual(
862+ thread.branchPullRequest ?? null,
863+ command.expected.branchPullRequest,
864+ )
865+ ) {
866+ return yield* new OrchestrationCommandInvariantError({
867+ commandType: command.type,
868+ detail: `thread ${command.threadId} changed before pull request discovery`,
869+ });
870+ }
871+ const project = yield* requireProject({ readModel, command, projectId: command.projectId });
872+ if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) {
873+ return yield* new OrchestrationCommandInvariantError({
874+ commandType: command.type,
875+ detail: `project ${command.projectId} changed before pull request discovery`,
876+ });
877+ }
878+ const occurredAt = yield* nowIso;
879+ return {
880+ ...(yield* withEventBase({
881+ aggregateKind: "thread",
882+ aggregateId: command.threadId,
883+ occurredAt,
884+ commandId: command.commandId,
885+ })),
886+ type: "thread.meta-updated",
887+ payload: {
888+ threadId: command.threadId,
889+ branchPullRequest: command.branchPullRequest,
890+ ...(command.linkedPullRequest !== undefined
891+ ? { linkedPullRequest: command.linkedPullRequest }
892+ : {}),
893+ updatedAt: thread.updatedAt,
894+ },
895+ };
896+ }
897+
839898 case "thread.title.regeneration.complete": {
840899 const thread = yield* requireThread({
841900 readModel,
apps/server/src/orchestration/projector.test.ts+62−0
@@ -86,6 +86,7 @@ describe("orchestration projector", () => { · 85 unmodified lines
8686 interactionMode: "default",
8787 branch: null,
8888 worktreePath: null,
89+ branchPullRequest: null,
8990 latestTurn: null,
9091 createdAt: now,
9192 updatedAt: now,
@@ -105,6 +106,67 @@ describe("orchestration projector", () => { · 13 unmodified lines
105106 ]);
106107 });
107108
109+ effectIt.effect("sets and clears branch pull requests without changing manual links", () =>
110+ Effect.gen(function* () {
111+ const now = "2026-01-01T00:00:00.000Z";
112+ const eventFields = {
113+ aggregateKind: "thread" as const,
114+ aggregateId: "thread-1",
115+ occurredAt: now,
116+ commandId: null,
117+ };
118+ let model = yield* projectEvent(
119+ createEmptyReadModel(now),
120+ makeEvent({
121+ ...eventFields,
122+ sequence: 1,
123+ type: "thread.created",
124+ payload: {
125+ threadId: "thread-1",
126+ projectId: "project-1",
127+ title: "Pull request thread",
128+ modelSelection: { provider: "codex", model: "gpt-5-codex" },
129+ runtimeMode: "full-access",
130+ branch: "feature",
131+ worktreePath: null,
132+ createdAt: now,
133+ updatedAt: now,
134+ },
135+ }),
136+ );
137+ const linkedPullRequest = {
138+ projectId: "project-1",
139+ repository: "pingdotgg/t3code",
140+ number: 42,
141+ url: "https://github.com/pingdotgg/t3code/pull/42",
142+ };
143+ const branchPullRequest = {
144+ ...linkedPullRequest,
145+ number: 43,
146+ url: "https://github.com/pingdotgg/t3code/pull/43",
147+ };
148+ const updates = [
149+ { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest },
150+ { payload: { title: "Renamed thread" }, expected: branchPullRequest },
151+ { payload: { branchPullRequest: null }, expected: null },
152+ ];
153+
154+ for (const [index, update] of updates.entries()) {
155+ model = yield* projectEvent(
156+ model,
157+ makeEvent({
158+ ...eventFields,
159+ sequence: index + 2,
160+ type: "thread.meta-updated",
161+ payload: { threadId: "thread-1", updatedAt: now, ...update.payload },
162+ }),
163+ );
164+ expect(model.threads[0]?.branchPullRequest).toEqual(update.expected);
165+ expect(model.threads[0]?.linkedPullRequest).toEqual(linkedPullRequest);
166+ }
167+ }),
168+ );
169+
108170 it("fails when event payload cannot be decoded by runtime schema", async () => {
109171 const now = "2026-01-01T00:00:00.000Z";
110172 const model = createEmptyReadModel(now);
apps/server/src/orchestration/projector.ts+4−0
@@ -336,6 +336,7 @@ export function projectEvent( · 335 unmodified lines
336336 interactionMode: payload.interactionMode,
337337 branch: payload.branch,
338338 worktreePath: payload.worktreePath,
339+ branchPullRequest: null,
339340 latestTurn: null,
340341 createdAt: payload.createdAt,
341342 updatedAt: payload.updatedAt,
@@ -510,6 +511,9 @@ export function projectEvent( · 168 unmodified lines
510511 ...(payload.linkedPullRequest !== undefined
511512 ? { linkedPullRequest: payload.linkedPullRequest }
512513 : {}),
514+ ...(payload.branchPullRequest !== undefined
515+ ? { branchPullRequest: payload.branchPullRequest }
516+ : {}),
513517 updatedAt: payload.updatedAt,
514518 }),
515519 })),
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+17−1
@@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 461 unmodified lines
462462 }),
463463 );
464464
465− it.effect("round-trips a linked pull request through the thread row", () =>
465+ it.effect("round-trips manual and branch pull requests through the thread row", () =>
466466 Effect.gen(function* () {
467467 const threads = yield* ProjectionThreadRepository;
468468 const linkedPullRequest = {
@@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 2 unmodified lines
471471 number: 42,
472472 url: "https://github.com/pingdotgg/t3code/pull/42",
473473 };
474+ const branchPullRequest = {
475+ ...linkedPullRequest,
476+ number: 43,
477+ url: "https://github.com/pingdotgg/t3code/pull/43",
478+ };
474479
475480 yield* threads.upsert({
476481 threadId: ThreadId.make("thread-linked-pr"),
@@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 8 unmodified lines
485490 branch: null,
486491 worktreePath: null,
487492 linkedPullRequest,
493+ branchPullRequest,
488494 latestTurnId: null,
489495 createdAt: "2026-03-24T00:00:00.000Z",
490496 updatedAt: "2026-03-24T00:00:00.000Z",
@@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 13 unmodified lines
504510
505511 const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
506512 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
513+ assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest);
514+
515+ const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId });
516+ assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest);
507517
508518 const row = Option.getOrNull(persisted);
509519 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
@@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 1 unmodified line
511521
512522 const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
513523 assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null);
524+ assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest);
525+
526+ yield* threads.upsert({ ...row, branchPullRequest: null });
527+ const branchCleared = yield* threads.getById({ threadId: row.threadId });
528+ assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null);
529+ assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);
514530 }),
515531 );
516532 });
apps/server/src/persistence/Layers/ProjectionThreads.ts+6−0
@@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( · 19 unmodified lines
2020 Struct.assign({
2121 modelSelection: Schema.fromJsonString(ModelSelection),
2222 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
23+ branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
2324 }),
2425 );
2526 type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
@@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 15 unmodified lines
4142 branch,
4243 worktree_path,
4344 linked_pull_request_json,
45+ branch_pull_request_json,
4446 latest_turn_id,
4547 created_at,
4648 updated_at,
@@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 23 unmodified lines
7072 ${row.branch},
7173 ${row.worktreePath},
7274 ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)},
75+ ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)},
7376 ${row.latestTurnId},
7477 ${row.createdAt},
7578 ${row.updatedAt},
@@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 23 unmodified lines
99102 branch = excluded.branch,
100103 worktree_path = excluded.worktree_path,
101104 linked_pull_request_json = excluded.linked_pull_request_json,
105+ branch_pull_request_json = excluded.branch_pull_request_json,
102106 latest_turn_id = excluded.latest_turn_id,
103107 created_at = excluded.created_at,
104108 updated_at = excluded.updated_at,
@@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 30 unmodified lines
135139 branch,
136140 worktree_path AS "worktreePath",
137141 linked_pull_request_json AS "linkedPullRequest",
142+ branch_pull_request_json AS "branchPullRequest",
138143 latest_turn_id AS "latestTurnId",
139144 created_at AS "createdAt",
140145 updated_at AS "updatedAt",
@@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 32 unmodified lines
173178 branch,
174179 worktree_path AS "worktreePath",
175180 linked_pull_request_json AS "linkedPullRequest",
181+ branch_pull_request_json AS "branchPullRequest",
176182 latest_turn_id AS "latestTurnId",
177183 created_at AS "createdAt",
178184 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2−0
@@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t · 58 unmodified lines
5959 import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
6060 import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
6161 import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
62+import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
6263
6364 /**
6465 * Migration loader with all migrations defined inline.
@@ -118,6 +119,7 @@ export const migrationEntries = [ · 53 unmodified lines
118119 [45, "ProjectionProjectsAutoPull", Migration0045],
119120 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
120121 [47, "ProjectionProjectIcon", Migration0047],
122+ [48, "ProjectionThreadBranchPullRequest", Migration0048],
121123 ] as const;
122124
123125 export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts+16−0
@@ -0,0 +1,16 @@
1+import * as Effect from "effect/Effect";
2+import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+ const columns = yield* sql<{ readonly name: string }>`
7+ PRAGMA table_info(projection_threads)
8+ `;
9+
10+ if (!columns.some((column) => column.name === "branch_pull_request_json")) {
11+ yield* sql`
12+ ALTER TABLE projection_threads
13+ ADD COLUMN branch_pull_request_json TEXT
14+ `;
15+ }
16+});
apps/server/src/persistence/Services/ProjectionThreads.ts+1−0
@@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ · 34 unmodified lines
3535 branch: Schema.NullOr(Schema.String),
3636 worktreePath: Schema.NullOr(Schema.String),
3737 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
38+ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
3839 latestTurnId: Schema.NullOr(TurnId),
3940 createdAt: IsoDateTime,
4041 updatedAt: IsoDateTime,
apps/server/src/project/RepositoryIdentityResolver.test.ts+46−19
@@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: { · 37 unmodified lines
3838 ).pipe(Layer.provide(ProcessRunner.layer));
3939
4040 it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
41− it.effect("reuses the cached Git root for repeated workspace lookups", () => {
41+ it.effect("refreshes the Git root only when requested", () => {
4242 const calls: Array<ReadonlyArray<string>> = [];
43+ let rootPath = "/repo";
4344 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
4445 run: (input) =>
4546 Effect.sync(() => {
4647 calls.push(input.args);
4748 return {
4849 stdout: input.args.includes("rev-parse")
49− ? "/repo\n"
50+ ? `${rootPath}\n`
5051 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
5152 stderr: "",
5253 code: ChildProcessSpawner.ExitCode(0),
@@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 13 unmodified lines
6667 return Effect.gen(function* () {
6768 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
6869 const first = yield* resolver.resolve("/repo/packages/web");
70+ rootPath = "/repo/packages/web";
6971 const second = yield* resolver.resolve("/repo/packages/web");
7072
7173 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
@@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 2 unmodified lines
7476 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
7577 ["-C", "/repo", "remote", "-v"],
7678 ]);
79+
80+ const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true });
81+ expect(refreshed?.rootPath).toBe("/repo/packages/web");
82+ expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed);
83+ expect(calls.slice(2)).toEqual([
84+ ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
85+ ["-C", "/repo/packages/web", "remote", "-v"],
86+ ]);
7787 }).pipe(Effect.provide(resolverLayer));
7888 });
7989
@@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 117 unmodified lines
197207 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
198208 );
199209
200− it.effect("prefers upstream over origin when both remotes are configured", () =>
201− Effect.gen(function* () {
202− const fileSystem = yield* FileSystem.FileSystem;
203− const cwd = yield* fileSystem.makeTempDirectoryScoped({
204− prefix: "t3-repository-identity-upstream-test-",
205− });
206−
207− yield* git(cwd, ["init"]);
208− yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
209− yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
210+ it.effect.each(["add", "replace"] as const)(
211+ "refreshes the primary upstream after %s before cache expiry",
212+ (change) =>
213+ Effect.gen(function* () {
214+ const fileSystem = yield* FileSystem.FileSystem;
215+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
216+ prefix: "t3-repository-identity-upstream-test-",
217+ });
210218
211− const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
212− const identity = yield* resolver.resolve(cwd);
219+ yield* git(cwd, ["init"]);
220+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
221+ if (change === "replace") {
222+ yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]);
223+ }
213224
214− expect(identity).not.toBeNull();
215− expect(identity?.locator.remoteName).toBe("upstream");
216− expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
217− expect(identity?.displayName).toBe("t3tools/t3code");
218− }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
225+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
226+ const initialIdentity = yield* resolver.resolve(cwd);
227+ expect(initialIdentity?.canonicalKey).toBe(
228+ change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous",
229+ );
230+
231+ yield* git(cwd, [
232+ "remote",
233+ change === "add" ? "add" : "set-url",
234+ "upstream",
235+ "git@github.com:T3Tools/t3code.git",
236+ ]);
237+ expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity);
238+ const identity = yield* resolver.resolve(cwd, { refresh: true });
239+
240+ expect(identity).not.toBeNull();
241+ expect(identity?.locator.remoteName).toBe("upstream");
242+ expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
243+ expect(identity?.displayName).toBe("t3tools/t3code");
244+ expect(yield* resolver.resolve(cwd)).toEqual(identity);
245+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
219246 );
220247
221248 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
apps/server/src/project/RepositoryIdentityResolver.ts+7−2
@@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { · 24 unmodified lines
2525 export class RepositoryIdentityResolver extends Context.Service<
2626 RepositoryIdentityResolver,
2727 {
28− readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
28+ readonly resolve: (
29+ cwd: string,
30+ options?: { readonly refresh?: boolean },
31+ ) => Effect.Effect<RepositoryIdentity | null>;
2932 }
3033 >()("t3/project/RepositoryIdentityResolver") {}
3134
@@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( · 138 unmodified lines
170173
171174 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(
172175 "RepositoryIdentityResolver.resolve",
173− )(function* (cwd) {
176+ )(function* (cwd, options) {
177+ if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd);
174178 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);
175179 if (cacheKey === null) return null;
180+ if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey);
176181 return yield* Cache.get(repositoryIdentityCache, cacheKey);
177182 });
178183
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 ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
7475 import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
7576 import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
7677 import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
@@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 202 unmodified lines
279280 Layer.provideMerge(CheckpointReactorLive),
280281 Layer.provideMerge(ThreadDeletionReactorLive),
281282 Layer.provideMerge(ThreadSettlementReactor.layer),
283+ Layer.provideMerge(ThreadPullRequestReactor.layer),
282284 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
283285 Layer.provideMerge(RuntimeReceiptBusLive),
284286 );
apps/web/src/components/BranchToolbarBranchSelector.tsx+8−11
@@ -47,11 +47,7 @@ import { · 46 unmodified lines
4747 sanitizeNewRefName,
4848 shouldIncludeBranchPickerItem,
4949 } from "./BranchToolbar.logic";
50−import {
51− ChangeRequestStatusIcon,
52− prStatusIndicator,
53− resolveThreadPr,
54−} from "./ThreadStatusIndicators";
50+import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators";
5551 import { Button } from "./ui/button";
5652 import { Switch } from "./ui/switch";
5753 import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
@@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({ · 559 unmodified lines
617613 });
618614
619615 // PR pill shown next to the branch selector when the active branch has one.
620− const branchPr = resolveThreadPr({
621− threadBranch: resolveBranchToolbarPrBranch({
622− activeThreadBranch,
623− resolvedActiveBranch,
624− }),
625− gitStatus: branchStatusQuery.data ?? null,
616+ const branchPrBranch = resolveBranchToolbarPrBranch({
617+ activeThreadBranch,
618+ resolvedActiveBranch,
626619 });
620+ const branchPr =
621+ branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch
622+ ? (branchStatusQuery.data.pr ?? null)
623+ : null;
627624 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
628625 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
629626 // state-description tooltip.
apps/web/src/components/ChatView.logic.test.ts+35−0
@@ -60,6 +60,7 @@ import { · 59 unmodified lines
6060 shouldDockDraftHeroForSubmission,
6161 shouldReleaseTimelineAnchorForToolActivity,
6262 shouldOpenProactivePullRequest,
63+ shouldRetargetThreadPullRequestPanel,
6364 shouldOpenProactiveTurnDiff,
6465 shouldRenderPreviewMiniPlayer,
6566 shouldShowBranchMismatchBanner,
@@ -249,6 +250,40 @@ describe("proactive panels", () => { · 183 unmodified lines
249250 expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);
250251 });
251252
253+ it("follows a changed server PR link without replacing an unrelated open panel", () => {
254+ const previous = {
255+ projectId: ProjectId.make("project-1"),
256+ repository: "pingdotgg/t3code",
257+ number: 42,
258+ url: "https://github.com/pingdotgg/t3code/pull/42",
259+ };
260+ const current = {
261+ ...previous,
262+ number: 43,
263+ url: "https://github.com/pingdotgg/t3code/pull/43",
264+ };
265+ const surface = {
266+ id: "pull-request:previous",
267+ kind: "pull-request",
268+ projectId: previous.projectId,
269+ repository: "PingDotGG/T3Code",
270+ number: previous.number,
271+ } satisfies RightPanelSurface;
272+
273+ expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true);
274+ expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false);
275+ expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false);
276+ expect(
277+ shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 }),
278+ ).toBe(false);
279+ expect(
280+ shouldRetargetThreadPullRequestPanel(previous, current, {
281+ ...surface,
282+ projectId: "another-project",
283+ }),
284+ ).toBe(false);
285+ });
286+
252287 it("opens the diff only when the observed running turn settles", () => {
253288 const turnId = TurnId.make("turn-1");
254289 expect(
apps/web/src/components/ChatView.logic.ts+19−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 type ScopedProjectRef,
1616 type ScopedThreadRef,
1717 type ThreadId,
18+ type ThreadLinkedPullRequest,
1819 type TurnId,
1920 } from "@t3tools/contracts";
2021 import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
@@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice( · 111 unmodified lines
132133 };
133134 }
134135
136+/** Follow a changed server link only when the panel still shows the previous linked PR. */
137+export function shouldRetargetThreadPullRequestPanel(
138+ previous: ThreadLinkedPullRequest | null,
139+ current: ThreadLinkedPullRequest | null,
140+ surface: RightPanelSurface | null,
141+): boolean {
142+ if (previous === null || current === null || surface?.kind !== "pull-request") return false;
143+ const previousRepository = previous.repository.toLowerCase();
144+ return (
145+ (previous.projectId !== current.projectId ||
146+ previousRepository !== current.repository.toLowerCase() ||
147+ previous.number !== current.number) &&
148+ surface.projectId === previous.projectId &&
149+ surface.repository.toLowerCase() === previousRepository &&
150+ surface.number === previous.number
151+ );
152+}
153+
135154 export function shouldOpenProactiveTurnDiff(input: {
136155 previousRunningTurnId: TurnId | null | undefined;
137156 runningTurnId: TurnId | null;
apps/web/src/components/ChatView.tsx+34−194
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
2424 type ResolvedKeybindingsConfig,
2525 type ScopedThreadRef,
2626 type ThreadId,
27+ type ThreadLinkedPullRequest,
2728 type TurnId,
2829 type KeybindingCommand,
2930 OrchestrationThreadActivity,
@@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; · 158 unmodified lines
188189 import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
189190 import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
190191 import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
191−import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";
192+import { RightPanelTabs } from "./RightPanelTabs";
192193 import { AgentsPanel } from "./AgentsPanel";
193194 import {
194195 deriveAgentPanelModel,
@@ -337,11 +338,6 @@ import { · 142 unmodified lines
337338 shouldShowThreadErrorBanner,
338339 ThreadErrorBanner,
339340 } from "./chat/ThreadErrorBanner";
340−import {
341− resolveDisplayedThreadPr,
342− threadChangeRequestSnapshotsAtom,
343− useLinkedThreadPullRequest,
344−} from "./ThreadStatusIndicators";
345341 import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
346342 import { ComposerSurface } from "./chat/ComposerSurface";
347343 import {
@@ -380,6 +376,7 @@ import { · 32 unmodified lines
380376 shouldShowBranchMismatchBanner,
381377 shouldShowPlanFollowUpPrompt,
382378 shouldOpenProactivePullRequest,
379+ shouldRetargetThreadPullRequestPanel,
383380 shouldOpenProactiveTurnDiff,
384381 shouldRenderPreviewMiniPlayer,
385382 getStartedThreadModelChangeBlockReason,
@@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) { · 1459 unmodified lines
18451842 );
18461843 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
18471844 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
1848− const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
18491845 const [timelineAnchor, setTimelineAnchor] = useState<{
18501846 readonly threadKey: string | null;
18511847 readonly messageId: MessageId | null;
@@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines
18641860 const activeRightPanelSurface = useRightPanelStore((state) =>
18651861 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
18661862 );
1867− const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1868− const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1869− const threadPrRelinkKeysRef = useRef(new Map<string, string>());
1870− const threadPrRelinkWriteRef = useRef(Promise.resolve());
18711863 const activePreviewState = useThreadPreviewState(activeThreadRef);
18721864 const activePreviewServerEpoch = activePreviewState.serverEpoch;
18731865 const resolvePreviewRuntimeTabId = useMemo(
@@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) { · 2096 unmodified lines
39703962 },
39713963 [activeProject, activeThreadRef],
39723964 );
3973− // The thread's own change request, placed against the project it belongs to. Without a
3974− // project there is nothing to resolve it against, so the caller falls back to the browser.
3975− const persistedLinkedThreadPullRequest = isServerThread
3976− ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
3977− : (activeThread?.linkedPullRequest ?? null);
3978− const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3979− const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest(
3980− activeThreadRef?.environmentId ?? null,
3981− persistedLinkedThreadPullRequest,
3982− );
3983− const replacementLinkedThreadPullRequest = useMemo(() => {
3984− const detected = gitStatusQuery.data?.pr;
3985− const threadBranch = activeThread?.branch;
3986− const projectId = activeProject?.id;
3987− if (
3988− persistedLinkedThreadPullRequest === null ||
3989− (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" &&
3990− persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") ||
3991− gitStatusQuery.data?.refName !== threadBranch ||
3992− detected?.state !== "open" ||
3993− detected.headRef !== threadBranch ||
3994− projectId === undefined ||
3995− activeProjectRepository === null ||
3996− (persistedLinkedThreadPullRequest.projectId === projectId &&
3997− persistedLinkedThreadPullRequest.repository.toLowerCase() ===
3998− activeProjectRepository.toLowerCase() &&
3999− persistedLinkedThreadPullRequest.number === detected.number)
4000− ) {
4001− return null;
4002− }
4003− return {
4004− projectId,
4005− repository: activeProjectRepository,
4006− number: detected.number,
4007− url: detected.url,
4008− };
4009− }, [
4010− activeProject?.id,
4011− activeProjectRepository,
4012− activeThread?.branch,
4013− gitStatusQuery.data,
4014− persistedLinkedThreadPullRequest,
4015− persistedLinkedThreadPullRequestStatus?.pr.state,
4016− ]);
3965+ // The shell carries server PR updates even while thread detail is still loading.
3966+ const activeThreadMetadata = activeThreadShell ?? activeThread;
40173967 const linkedThreadPullRequest =
4018− replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest;
3968+ activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null;
3969+ const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
40193970 const linkedThreadPullRequestKey = linkedThreadPullRequest
40203971 ? JSON.stringify([
40213972 linkedThreadPullRequest.projectId,
@@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) { · 1 unmodified line
40233974 linkedThreadPullRequest.number,
40243975 ])
40253976 : null;
4026− const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
4027− const openThreadPullRequest = useCallback(
4028− (number: number) => {
4029− if (!supportsPullRequests || !activeThreadRef) {
4030− return;
4031− }
4032− const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id;
4033− const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
4034− if (projectId === undefined || repository === null) return;
4035− useRightPanelStore.getState().openPullRequest(activeThreadRef, {
4036− projectId,
4037− repository,
4038− number,
4039− });
4040− },
4041− [
4042− activeProject,
4043− activeProjectRepository,
4044− activeThreadRef,
4045− linkedThreadPullRequest,
4046− supportsPullRequests,
4047− ],
4048− );
4049− useEffect(() => {
4050− if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
4051− return;
4052− }
4053− if (replacementLinkedThreadPullRequest === null) {
4054− threadPrRelinkKeysRef.current.delete(activeThreadKey);
4055− return;
4056− }
4057− const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
4058− if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
4059− threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);
4060− threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
4061− if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
4062− const result = await updateThreadMetadata({
4063− environmentId: activeThreadRef.environmentId,
4064− input: {
4065− threadId: activeThreadRef.threadId,
4066− linkedPullRequest: replacementLinkedThreadPullRequest,
4067− },
4068− });
4069− if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
4070− if (result._tag !== "Failure") return;
4071− threadPrRelinkKeysRef.current.delete(activeThreadKey);
4072− if (isAtomCommandInterrupted(result)) return;
4073− toastManager.add(
4074− stackedThreadToast({
4075− type: "error",
4076− title: "Unable to update the thread pull request",
4077− description: chatActionErrorMessage(squashAtomCommandFailure(result)),
4078− }),
4079− );
4080− });
4081− }, [
4082− activeThreadKey,
4083− activeThreadRef,
4084− isServerThread,
4085− replacementLinkedThreadPullRequest,
4086− updateThreadMetadata,
4087− ]);
3977+ const observedThreadPullRequestRef = useRef<{
3978+ readonly threadKey: string;
3979+ readonly reference: ThreadLinkedPullRequest | null;
3980+ } | null>(null);
40883981 const openProjectPullRequest = useCallback(
40893982 (number: number) => {
40903983 if (
@@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) { · 19 unmodified lines
41104003 useEffect(() => {
41114004 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
41124005 proactivePanelObservationRef.current = null;
4006+ observedThreadPullRequestRef.current = null;
41134007 return;
41144008 }
41154009 const panels = useRightPanelStore.getState();
@@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) { · 9 unmodified lines
41254019 userActionRevision,
41264020 } = observation;
41274021 const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef);
4022+ const previousPullRequest = observedThreadPullRequestRef.current;
4023+ observedThreadPullRequestRef.current = {
4024+ threadKey: activeThreadKey,
4025+ reference: linkedThreadPullRequest,
4026+ };
41284027 const followSelectedPullRequest =
4129− replacementLinkedThreadPullRequest !== null &&
4130− openSurface?.kind === "pull-request" &&
4131− persistedLinkedThreadPullRequest !== null &&
4132− openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
4133− openSurface.repository.toLowerCase() ===
4134− persistedLinkedThreadPullRequest.repository.toLowerCase() &&
4135− openSurface.number === persistedLinkedThreadPullRequest.number;
4028+ previousPullRequest?.threadKey === activeThreadKey &&
4029+ shouldRetargetThreadPullRequestPanel(
4030+ previousPullRequest.reference,
4031+ linkedThreadPullRequest,
4032+ openSurface,
4033+ );
41364034 // Following the selected linked PR does not open an unrelated panel, so it
41374035 // remains available with proactive panels off. It still respects a later choice.
4138− if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) {
4036+ if (followSelectedPullRequest && linkedThreadPullRequest !== null) {
41394037 panels.openProactive(
41404038 activeThreadRef,
4141− pullRequestSurface(replacementLinkedThreadPullRequest),
4039+ pullRequestSurface(linkedThreadPullRequest),
41424040 userActionRevision,
41434041 );
41444042 }
@@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) { · 62 unmodified lines
42074105 linkedThreadPullRequest,
42084106 linkedThreadPullRequestKey,
42094107 onDiffPanelOpen,
4210− persistedLinkedThreadPullRequest,
42114108 pullRequestsCapabilityKnown,
4212− replacementLinkedThreadPullRequest,
42134109 settings.proactivePanelsEnabled,
42144110 shouldUseRightPanelSheet,
42154111 supportsPullRequests,
@@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) { · 1007 unmodified lines
52235119 resizeObserver.disconnect();
52245120 };
52255121 }, [composerOverlayElement, publishComposerOverlayHeight]);
5226− const activeThreadPr =
5227− replacementLinkedThreadPullRequest !== null
5228− ? (gitStatusQuery.data?.pr ?? null)
5229− : resolveDisplayedThreadPr({
5230− threadBranch: activeThread?.branch ?? null,
5231− gitStatus: gitStatusQuery.data ?? null,
5232− snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
5233− retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
5234− linkedPullRequest: linkedThreadPullRequest,
5235− linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus,
5236− });
5237− const handlePullRequestTabStatusChange = useCallback(
5238− (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => {
5239− if (
5240− threadRepository?.toLowerCase() !== status.repository.toLowerCase() ||
5241− activeThreadPr?.number !== status.number ||
5242− activeThreadPr.state === status.state
5243− ) {
5244− sidebarPrRefreshKeyRef.current = null;
5245− return;
5246− }
5247− const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`;
5248− if (sidebarPrRefreshKeyRef.current === refreshKey) return;
5249− sidebarPrRefreshKeyRef.current = refreshKey;
5250− if (activeThreadRef === null || gitCwd === null) return;
5251− void refreshVcsStatus({
5252− environmentId: activeThreadRef.environmentId,
5253− input: { cwd: gitCwd },
5254− }).then(() => {
5255− if (sidebarPrRefreshKeyRef.current === refreshKey) {
5256− sidebarPrRefreshKeyRef.current = null;
5257− }
5258− });
5259− },
5260− [
5261− activeThreadKey,
5262− activeThreadPr?.number,
5263− activeThreadPr?.state,
5264− activeThreadRef,
5265− gitCwd,
5266− refreshVcsStatus,
5267− threadRepository,
5268− ],
5269− );
52705122 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
52715123 const activeThreadReferenceCopyTarget = useMemo(
52725124 () =>
@@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
52765128 threadId: activeThreadId,
52775129 openPanelPullRequestUrl,
52785130 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
5279− detectedPullRequestUrl: activeThreadPr?.url ?? null,
52805131 }),
5281− [
5282− activeThreadId,
5283− activeThreadPr?.url,
5284− isServerThread,
5285− linkedThreadPullRequest?.url,
5286− openPanelPullRequestUrl,
5287− ],
5132+ [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl],
52885133 );
52895134 const copyActiveThreadReference = useCallback(() => {
52905135 const target = activeThreadReferenceCopyTarget;
@@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) { · 19 unmodified lines
53105155 },
53115156 );
53125157 }, [activeThreadReferenceCopyTarget]);
5313− // The right panel offers the thread's own change request, so it can only offer it once the
5314− // branch has one; until then the picker says so rather than opening an empty panel.
53155158 const addPullRequestSurface = useCallback(() => {
5316− if (activeThreadPr === null) return;
5317− openThreadPullRequest(activeThreadPr.number);
5318− }, [activeThreadPr, openThreadPullRequest]);
5319− const pullRequestSurfaceAvailable =
5320− supportsPullRequests && activeThreadPr !== null && threadRepository !== null;
5159+ if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null)
5160+ return;
5161+ useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest);
5162+ }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]);
5163+ const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null;
53215164 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
53225165 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
53235166 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
@@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) { · 2525 unmodified lines
78497692 context={
78507693 isThreadOwnPullRequest(
78517694 {
7852− projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null,
7853− repository: threadRepository,
7854− number: activeThreadPr?.number ?? null,
7695+ projectId: linkedThreadPullRequest?.projectId ?? null,
7696+ repository: linkedThreadPullRequest?.repository ?? null,
7697+ number: linkedThreadPullRequest?.number ?? null,
78557698 },
78567699 {
78577700 projectId: renderedRightPanelSurface.projectId,
@@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines
78637706 : "page"
78647707 }
78657708 composerDraftTarget={composerDraftTarget}
7866− {...(linkedThreadPullRequest === null
7867− ? { onStateChange: handlePullRequestTabStatusChange }
7868− : {})}
78697709 />
78707710 ) : renderedRightPanelSurface?.kind === "agents" ? (
78717711 <AgentsPanel
apps/web/src/components/CommandPalette.tsx+4−39
@@ -1,10 +1,6 @@
11 "use client";
22
3−import {
4− scopedThreadKey,
5− scopeProjectRef,
6− scopeThreadRef,
7−} from "@t3tools/client-runtime/environment";
3+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
84 import {
95 canCreateProjectInEnvironment,
106 getCloneDestinationBrowsePath,
@@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem"; · 70 unmodified lines
8177 import { projectEnvironment } from "../state/projects";
8278 import { useEnvironmentQuery } from "../state/query";
8379 import { sourceControlEnvironment } from "../state/sourceControl";
84−import { vcsEnvironment } from "../state/vcs";
8580 import { useAtomCommand } from "../state/use-atom-command";
8681 import { useAtomQueryRunner } from "../state/use-atom-query-runner";
8782 import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
88−import { useProject, useProjects, useThreadShells } from "../state/entities";
83+import { useProjects, useThreadShells } from "../state/entities";
8984 import { useThreadSearch } from "../state/queries";
90−import * as ThreadPr from "./ThreadStatusIndicators";
9185 import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
9286 import {
9387 appendBrowsePathSegment,
@@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: { · 511 unmodified lines
605599 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
606600 useHandleNewThread();
607601 const projects = useProjects();
608− const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom);
609− const activeThreadProject = useProject(
610− activeThread === null
611− ? null
612− : scopeProjectRef(activeThread.environmentId, activeThread.projectId),
613− );
614− const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null;
615− const activeThreadGitStatus = useEnvironmentQuery(
616− activeThread != null &&
617− activeThread.linkedPullRequest == null &&
618− activeThread.branch !== null &&
619− activeThreadCwd !== null
620− ? vcsEnvironment.status({
621− environmentId: activeThread.environmentId,
622− input: { cwd: activeThreadCwd },
623− })
624− : null,
625− ).data;
626− const detectedPullRequestUrl =
627− activeThread == null || activeThread.linkedPullRequest != null
628− ? null
629− : (ThreadPr.resolveDisplayedThreadPr({
630− threadBranch: activeThread.branch,
631− gitStatus: activeThreadGitStatus ?? null,
632− snapshot: changeRequestSnapshotByKey.get(
633− scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)),
634− ),
635− retainTerminalOnBranchMismatch: activeThread.worktreePath === null,
636− })?.url ?? null);
637602 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
638603 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
639604 );
@@ -643,8 +608,8 @@ function OpenCommandPaletteDialog(props: { · 3 unmodified lines
643608 : resolveThreadReferenceCopyTarget({
644609 threadId: activeThread.id,
645610 openPanelPullRequestUrl,
646− linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,
647− detectedPullRequestUrl,
611+ linkedPullRequestUrl:
612+ activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null,
648613 });
649614 const copyActiveThreadReference = useCallback(async () => {
650615 const target = activeThreadReferenceCopyTarget;
apps/web/src/components/LegacySidebar.tsx+5−50
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
1616 ChangeRequestStatusIcon,
1717 prStatusIndicator,
1818 PrStatusTooltipContent,
19− resolveThreadPr,
2019 terminalStatusFromRunningIds,
2120 ThreadStatusLabel,
2221 ThreadWorktreeIndicator,
@@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus"; · 60 unmodified lines
8382 import { isMacPlatform } from "../lib/utils";
8483 import {
8584 readThreadShell,
86− useProject,
8785 useProjects,
8886 useThreadShells,
8987 useThreadShellsForProjectRefs,
@@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; · 26 unmodified lines
116114
117115 import { useThreadActions } from "../hooks/useThreadActions";
118116 import { projectEnvironment } from "../state/projects";
119−import { useEnvironmentQuery } from "../state/query";
120117 import { threadEnvironment, useEnvironmentThread } from "../state/threads";
121−import { vcsEnvironment } from "../state/vcs";
122118 import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
123119 import {
124120 buildThreadRouteParams,
@@ -191,7 +187,6 @@ import { · 66 unmodified lines
191187 orderItemsByPreferredIds,
192188 shouldClearThreadSelectionOnMouseDown,
193189 sortProjectsForSidebar,
194− useRetainedValue,
195190 useSidebarRowSubscriptionLease,
196191 useThreadJumpHintVisibility,
197192 ThreadStatusPill,
@@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: { · 116 unmodified lines
314309
315310 interface SidebarThreadRowProps {
316311 thread: SidebarThreadSummary;
317− projectCwd: string | null;
318312 orderedProjectThreadKeys: readonly string[];
319313 isActive: boolean;
320314 openPullRequestsInRightPanel: boolean;
@@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 95 unmodified lines
416410 const threadEnvironmentLabel = isRemoteThread
417411 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
418412 : null;
419− // For grouped projects, the thread may belong to a different environment
420− // than the representative project. Look up the thread's own project cwd
421− // so git status (and thus PR detection) queries the correct path.
422− const threadProject = useProject(
423− useMemo(
424− () => scopeProjectRef(thread.environmentId, thread.projectId),
425− [thread.environmentId, thread.projectId],
426− ),
427− );
428− const threadProjectCwd = threadProject?.workspaceRoot ?? null;
429− const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
430− const gitStatus = useEnvironmentQuery(
431− leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
432− ? vcsEnvironment.status({
433− environmentId: thread.environmentId,
434− input: { cwd: gitCwd },
435− })
436− : null,
437− );
438413 const isHighlighted = isActive || isSelected;
439414 const handleOpenDiscoveredPort = useCallback(
440415 (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 29 unmodified lines
470445 },
471446 });
472447 const linkedPullRequestStatus = useLinkedThreadPullRequest(
473− leaseLiveStatus ? thread.environmentId : null,
474− leaseLiveStatus ? thread.linkedPullRequest : null,
475− );
476− const visibleGitStatus = useRetainedValue(
477− JSON.stringify([thread.environmentId, gitCwd]),
478− gitStatus.data,
479− );
480− const visibleLinkedPullRequestStatus = useRetainedValue(
481− thread.linkedPullRequest === null
482− ? null
483− : JSON.stringify([thread.environmentId, thread.linkedPullRequest]),
484− linkedPullRequestStatus,
485− );
486− const pr =
487− thread.linkedPullRequest == null
488− ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus })
489− : (visibleLinkedPullRequestStatus?.pr ?? null);
490− const prStatus = prStatusIndicator(
491− pr,
492− visibleLinkedPullRequestStatus?.sourceControlProvider ??
493− visibleGitStatus?.sourceControlProvider,
448+ thread.environmentId,
449+ thread.linkedPullRequest ?? thread.branchPullRequest,
450+ leaseLiveStatus,
494451 );
452+ const pr = linkedPullRequestStatus?.pr ?? null;
453+ const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
495454 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
496455 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
497456 const threadMetaClassName = isConfirmingArchive
@@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps { · 442 unmodified lines
940899 showEmptyThreadState: boolean;
941900 shouldShowThreadPanel: boolean;
942901 isThreadListExpanded: boolean;
943− projectCwd: string;
944902 activeRouteThreadKey: string | null;
945903 openPullRequestsInRightPanel: boolean;
946904 threadJumpLabelByKey: ReadonlyMap<string, string>;
@@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 49 unmodified lines
996954 showEmptyThreadState,
997955 shouldShowThreadPanel,
998956 isThreadListExpanded,
999− projectCwd,
1000957 activeRouteThreadKey,
1001958 openPullRequestsInRightPanel,
1002959 threadJumpLabelByKey,
@@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 45 unmodified lines
10481005 <SidebarThreadRow
10491006 key={threadKey}
10501007 thread={thread}
1051− projectCwd={projectCwd}
10521008 orderedProjectThreadKeys={orderedProjectThreadKeys}
10531009 isActive={activeRouteThreadKey === threadKey}
10541010 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
@@ -2444,7 +2400,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1389 unmodified lines
24442400 showEmptyThreadState={showEmptyThreadState}
24452401 shouldShowThreadPanel={shouldShowThreadPanel}
24462402 isThreadListExpanded={isThreadListExpanded}
2447− projectCwd={project.workspaceRoot}
24482403 activeRouteThreadKey={activeRouteThreadKey}
24492404 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
24502405 threadJumpLabelByKey={threadJumpLabelByKey}
apps/web/src/components/Sidebar.tsx+5−58
@@ -167,15 +167,9 @@ import { · 166 unmodified lines
167167 import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
168168 import {
169169 ThreadWorktreeIndicator,
170− nextThreadChangeRequestSnapshot,
171170 prStatusIndicator,
172− resolveDisplayedThreadPr,
173− resolveDisplayedThreadPrProvider,
174− setThreadChangeRequestSnapshot,
175171 settledPrHoverColorClass,
176172 terminalStatusFromRunningIds,
177− threadChangeRequestSnapshotsAtom,
178− type ThreadChangeRequestSnapshot,
179173 type TerminalStatusIndicator,
180174 useLinkedThreadPullRequest,
181175 } from "./ThreadStatusIndicators";
@@ -795,16 +789,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 613 unmodified lines
795789 onUnsnooze: (threadRef: ScopedThreadRef) => void;
796790 onUnpin: (threadRef: ScopedThreadRef) => void;
797791 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
798− changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
799− onChangeRequestSnapshot: (
800− threadKey: string,
801− snapshot: ThreadChangeRequestSnapshot | null,
802− ) => void;
803792 }) {
804793 const {
805794 isRenaming,
806− changeRequestSnapshot,
807− onChangeRequestSnapshot,
808795 onCancelRename,
809796 onCommitRename,
810797 onContextMenu,
@@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 45 unmodified lines
856843
857844 const gitCwd = thread.worktreePath ?? props.projectCwd;
858845 const linkedPullRequestStatus = useLinkedThreadPullRequest(
859− leaseLiveStatus ? thread.environmentId : null,
860− leaseLiveStatus ? thread.linkedPullRequest : null,
846+ thread.environmentId,
847+ thread.linkedPullRequest ?? thread.branchPullRequest,
848+ leaseLiveStatus,
861849 );
862850 const gitStatus = useEnvironmentQuery(
863851 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
@@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 7 unmodified lines
871859 JSON.stringify([thread.environmentId, gitCwd]),
872860 gitStatus.data,
873861 );
874− const retainTerminalOnBranchMismatch = thread.worktreePath === null;
875− const pr = resolveDisplayedThreadPr({
876− threadBranch: thread.branch,
877− gitStatus: visibleGitStatus,
878− snapshot: changeRequestSnapshot,
879− retainTerminalOnBranchMismatch,
880− linkedPullRequest: thread.linkedPullRequest,
881− linkedPullRequestStatus,
882− });
862+ const pr = linkedPullRequestStatus?.pr ?? null;
883863
884864 // Same semantics as the legacy sidebar (never-visited counts as read):
885865 // switching sidebars must not light up every historical thread as unread.
@@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 86 unmodified lines
972952 activeThreadBranch: thread.branch,
973953 currentGitBranch: visibleGitStatus?.refName ?? null,
974954 });
975− const prProvider = resolveDisplayedThreadPrProvider({
976− threadBranch: thread.branch,
977− gitStatus: visibleGitStatus,
978− snapshot: changeRequestSnapshot,
979− retainTerminalOnBranchMismatch,
980− linkedPullRequest: thread.linkedPullRequest,
981− linkedPullRequestStatus,
982− });
983− const prStatus = prStatusIndicator(pr, prProvider);
955+ const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
984956 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
985− useEffect(() => {
986− const nextSnapshot = nextThreadChangeRequestSnapshot({
987− threadBranch: thread.branch,
988− gitStatus: visibleGitStatus,
989− snapshot: changeRequestSnapshot,
990− retainTerminalOnBranchMismatch,
991− linkedPullRequest: thread.linkedPullRequest,
992− linkedPullRequestStatus,
993− });
994− if (nextSnapshot === undefined) return;
995− onChangeRequestSnapshot(threadKey, nextSnapshot);
996− }, [
997− changeRequestSnapshot,
998− visibleGitStatus,
999− linkedPullRequestStatus,
1000− onChangeRequestSnapshot,
1001− retainTerminalOnBranchMismatch,
1002− thread.branch,
1003− thread.linkedPullRequest,
1004− threadKey,
1005− ]);
1006957
1007958 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
1008959 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
@@ -2104,8 +2055,6 @@ export default function Sidebar() { · 1095 unmodified lines
21042055 // fresh clock whenever it recomputes.
21052056 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
21062057
2107− const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
2108−
21092058 // Project scope: one menu above the list. Scoping filters the list without
21102059 // making the header width depend on the number or length of project names.
21112060 // The selection lives in the persisted UI store next to the other sidebar
@@ -4092,8 +4041,6 @@ export default function Sidebar() { · 1980 unmodified lines
40924041 onUnsnooze={attemptUnsnooze}
40934042 onUnpin={attemptUnpin}
40944043 onAcknowledgeWoke={acknowledgeWoke}
4095− changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
4096− onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
40974044 />
40984045 );
40994046 };
apps/web/src/components/ThreadStatusIndicators.test.ts+0−525
@@ -1,7 +1,5 @@
11 import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22 import { describe, expect, it } from "@effect/vitest";
3−import * as Effect from "effect/Effect";
4−import { AtomRegistry } from "effect/unstable/reactivity";
53 import {
64 GitMergeIcon,
75 GitPullRequestClosedIcon,
@@ -11,15 +9,8 @@ import { · 3 unmodified lines
119
1210 import {
1311 ChangeRequestStatusIcon,
14− nextThreadChangeRequestSnapshot,
1512 prStatusIndicator,
16− resolveDisplayedThreadPr,
17− resolveDisplayedThreadPrProvider,
18− resolveThreadPr,
1913 settledPrHoverColorClass,
20− threadChangeRequestSnapshotsEqual,
21− threadChangeRequestSnapshotsAtom,
22− type ThreadChangeRequestSnapshot,
2314 } from "./ThreadStatusIndicators";
2415 import { newestPullRequestSummary } from "../state/pullRequests";
2516
@@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { · 31 unmodified lines
5748 };
5849 }
5950
60−function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
61− return {
62− number: 42,
63− title: "Feature PR",
64− url: "https://github.com/pingdotgg/t3code/pull/42",
65− baseRef: "main",
66− headRef: "feature/current",
67− state: "merged",
68− };
69−}
70−
71−function snapshotFor(
72− branch: string,
73− pr: NonNullable<VcsStatusResult["pr"]>,
74− sourceControlProvider?: VcsStatusResult["sourceControlProvider"],
75−): ThreadChangeRequestSnapshot {
76− return { branch, pr, sourceControlProvider };
77−}
78−
7951 function pullRequestSummary(
8052 state: PullRequestSummary["state"],
8153 updatedAt: string,
@@ -117,503 +89,6 @@ describe("shared pull request state", () => { · 35 unmodified lines
11789 });
11890 });
11991
120−describe("resolveThreadPr", () => {
121− it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
122− expect(
123− resolveThreadPr({
124− threadBranch: "feature/other",
125− gitStatus: status(),
126− }),
127− ).toBeNull();
128− });
129−
130− it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => {
131− expect(
132− resolveThreadPr({
133− threadBranch: "stack/base",
134− gitStatus: status(),
135− }),
136− ).toBeNull();
137− });
138−
139− it("hides PR indicators when thread branch metadata is missing", () => {
140− expect(
141− resolveThreadPr({
142− threadBranch: null,
143− gitStatus: status(),
144− }),
145− ).toBeNull();
146− });
147−
148− it("shows the PR when the live checkout matches the stored thread branch", () => {
149− const gitStatus = status();
150−
151− expect(
152− resolveThreadPr({
153− threadBranch: "feature/current",
154− gitStatus,
155− }),
156− ).toBe(gitStatus.pr);
157− });
158−});
159−
160−describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
161− const featureBranch = "feature/current";
162− const mergedPr = mergedFeaturePr();
163− const linkedPullRequest = {
164− projectId: ProjectId.make("project-1"),
165− repository: "pingdotgg/t3code",
166− number: 42,
167− url: "https://github.com/pingdotgg/t3code/pull/42",
168− };
169− const provider = {
170− kind: "github" as const,
171− name: "GitHub",
172− baseUrl: "https://github.com",
173− };
174−
175− it("returns the live merged PR when the checkout matches the feature branch", () => {
176− const gitStatus = status({
177− refName: featureBranch,
178− pr: mergedPr,
179− sourceControlProvider: provider,
180− });
181−
182− expect(
183− resolveDisplayedThreadPr({
184− threadBranch: featureBranch,
185− gitStatus,
186− snapshot: undefined,
187− retainTerminalOnBranchMismatch: true,
188− }),
189− ).toBe(mergedPr);
190− expect(
191− resolveDisplayedThreadPrProvider({
192− threadBranch: featureBranch,
193− gitStatus,
194− snapshot: undefined,
195− retainTerminalOnBranchMismatch: true,
196− }),
197− ).toEqual(provider);
198− });
199−
200− it("shows a linked pull request when the checkout has a different branch", () => {
201− const linkedPullRequestStatus = {
202− pr: mergedPr,
203− sourceControlProvider: provider,
204− };
205−
206− expect(
207− resolveDisplayedThreadPr({
208− threadBranch: "feature/other",
209− gitStatus: status({ refName: "feature/other", pr: null }),
210− snapshot: undefined,
211− retainTerminalOnBranchMismatch: false,
212− linkedPullRequest,
213− linkedPullRequestStatus,
214− }),
215− ).toEqual(mergedPr);
216− expect(
217− resolveDisplayedThreadPrProvider({
218− threadBranch: "feature/other",
219− gitStatus: status({ refName: "feature/other", pr: null }),
220− snapshot: undefined,
221− retainTerminalOnBranchMismatch: false,
222− linkedPullRequest,
223− linkedPullRequestStatus,
224− }),
225− ).toEqual(provider);
226− expect(
227− nextThreadChangeRequestSnapshot({
228− threadBranch: "feature/other",
229− gitStatus: status({ refName: "feature/other", pr: null }),
230− snapshot: undefined,
231− retainTerminalOnBranchMismatch: false,
232− linkedPullRequest,
233− linkedPullRequestStatus,
234− }),
235− ).toEqual({
236− branch: "feature/other",
237− pr: mergedPr,
238− sourceControlProvider: provider,
239− linkedPullRequest,
240− });
241− });
242−
243− it("keeps a matching linked pull request snapshot while its status reloads", () => {
244− const snapshot = {
245− ...snapshotFor(featureBranch, mergedPr, provider),
246− linkedPullRequest,
247− };
248−
249− expect(
250− resolveDisplayedThreadPr({
251− threadBranch: null,
252− gitStatus: null,
253− snapshot,
254− retainTerminalOnBranchMismatch: false,
255− linkedPullRequest,
256− linkedPullRequestStatus: null,
257− }),
258− ).toEqual(mergedPr);
259− expect(
260− nextThreadChangeRequestSnapshot({
261− threadBranch: null,
262− gitStatus: null,
263− snapshot,
264− retainTerminalOnBranchMismatch: false,
265− linkedPullRequest,
266− linkedPullRequestStatus: null,
267− }),
268− ).toBeUndefined();
269− });
270−
271− it("clears an old snapshot when a different pull request is linked", () => {
272− const snapshot = {
273− ...snapshotFor(featureBranch, mergedPr, provider),
274− linkedPullRequest: { ...linkedPullRequest, number: 41 },
275− };
276−
277− expect(
278− nextThreadChangeRequestSnapshot({
279− threadBranch: featureBranch,
280− gitStatus: null,
281− snapshot,
282− retainTerminalOnBranchMismatch: true,
283− linkedPullRequest,
284− linkedPullRequestStatus: null,
285− }),
286− ).toBeNull();
287− });
288−
289− it("removes a linked pull request snapshot after the link is cleared", () => {
290− const snapshot = {
291− ...snapshotFor(featureBranch, mergedPr, provider),
292− linkedPullRequest,
293− };
294−
295− expect(
296− resolveDisplayedThreadPr({
297− threadBranch: featureBranch,
298− gitStatus: status({ refName: "main", pr: null }),
299− snapshot,
300− retainTerminalOnBranchMismatch: true,
301− }),
302− ).toBeNull();
303− expect(
304− nextThreadChangeRequestSnapshot({
305− threadBranch: featureBranch,
306− gitStatus: null,
307− snapshot,
308− retainTerminalOnBranchMismatch: true,
309− }),
310− ).toBeNull();
311− });
312−
313− it("after caching a merged PR, resolves main status back to the cached feature PR", () => {
314− const matchingStatus = status({
315− refName: featureBranch,
316− pr: mergedPr,
317− sourceControlProvider: provider,
318− });
319− const cached = nextThreadChangeRequestSnapshot({
320− threadBranch: featureBranch,
321− gitStatus: matchingStatus,
322− snapshot: undefined,
323− retainTerminalOnBranchMismatch: true,
324− });
325− expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider));
326−
327− const mainStatus = status({
328− refName: "main",
329− isDefaultRef: true,
330− pr: {
331− number: 99,
332− title: "Unrelated main PR",
333− url: "https://github.com/pingdotgg/t3code/pull/99",
334− baseRef: "main",
335− headRef: "main",
336− state: "open",
337− },
338− sourceControlProvider: provider,
339− });
340−
341− expect(
342− resolveDisplayedThreadPr({
343− threadBranch: featureBranch,
344− gitStatus: mainStatus,
345− snapshot: cached as ThreadChangeRequestSnapshot,
346− retainTerminalOnBranchMismatch: true,
347− }),
348− ).toEqual(mergedPr);
349− expect(
350− resolveDisplayedThreadPrProvider({
351− threadBranch: featureBranch,
352− gitStatus: mainStatus,
353− snapshot: cached as ThreadChangeRequestSnapshot,
354− retainTerminalOnBranchMismatch: true,
355− }),
356− ).toEqual(provider);
357− });
358−
359− it("never attaches a PR reported by main to the feature thread", () => {
360− const mainPr = {
361− number: 99,
362− title: "Unrelated main PR",
363− url: "https://github.com/pingdotgg/t3code/pull/99",
364− baseRef: "develop",
365− headRef: "main",
366− state: "merged" as const,
367− };
368− expect(
369− resolveDisplayedThreadPr({
370− threadBranch: featureBranch,
371− gitStatus: status({ refName: "main", pr: mainPr }),
372− snapshot: undefined,
373− retainTerminalOnBranchMismatch: true,
374− }),
375− ).toBeNull();
376− expect(
377− nextThreadChangeRequestSnapshot({
378− threadBranch: featureBranch,
379− gitStatus: status({ refName: "main", pr: mainPr }),
380− snapshot: undefined,
381− retainTerminalOnBranchMismatch: true,
382− }),
383− ).toBeNull();
384− });
385−
386− it("does not show a cached open PR across a branch mismatch", () => {
387− const openSnapshot = snapshotFor(featureBranch, {
388− ...mergedPr,
389− state: "open",
390− title: "Still open",
391− });
392−
393− expect(
394− resolveDisplayedThreadPr({
395− threadBranch: featureBranch,
396− gitStatus: status({ refName: "main", pr: null }),
397− snapshot: openSnapshot,
398− retainTerminalOnBranchMismatch: true,
399− }),
400− ).toBeNull();
401− });
402−
403− it("retains a cached closed PR across a branch mismatch", () => {
404− const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" };
405− const closedSnapshot = snapshotFor(featureBranch, closedPr, provider);
406−
407− expect(
408− resolveDisplayedThreadPr({
409− threadBranch: featureBranch,
410− gitStatus: status({ refName: "main", pr: null }),
411− snapshot: closedSnapshot,
412− retainTerminalOnBranchMismatch: true,
413− }),
414− ).toEqual(closedPr);
415− });
416−
417− it("does not retain or display a terminal PR when a worktree switches branches", () => {
418− const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
419− const mismatchedStatus = status({ refName: "feature/other", pr: null });
420−
421− expect(
422− resolveDisplayedThreadPr({
423− threadBranch: featureBranch,
424− gitStatus: mismatchedStatus,
425− snapshot: terminalSnapshot,
426− retainTerminalOnBranchMismatch: false,
427− }),
428− ).toBeNull();
429− expect(
430− resolveDisplayedThreadPrProvider({
431− threadBranch: featureBranch,
432− gitStatus: mismatchedStatus,
433− snapshot: terminalSnapshot,
434− retainTerminalOnBranchMismatch: false,
435− }),
436− ).toBeUndefined();
437− expect(
438− nextThreadChangeRequestSnapshot({
439− threadBranch: featureBranch,
440− gitStatus: mismatchedStatus,
441− snapshot: terminalSnapshot,
442− retainTerminalOnBranchMismatch: false,
443− }),
444− ).toBeNull();
445− });
446−
447− it("retains a local terminal snapshot when thread metadata follows the new branch", () => {
448− const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider);
449−
450− expect(
451− resolveDisplayedThreadPr({
452− threadBranch: featureBranch,
453− gitStatus: status({ refName: "main", pr: null }),
454− snapshot: otherBranchSnapshot,
455− retainTerminalOnBranchMismatch: true,
456− }),
457− ).toEqual(mergedPr);
458− });
459−
460− it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => {
461− const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
462−
463− expect(
464− nextThreadChangeRequestSnapshot({
465− threadBranch: "main",
466− gitStatus: status({ refName: "main", pr: null }),
467− snapshot: terminalSnapshot,
468− retainTerminalOnBranchMismatch: true,
469− }),
470− ).toBeUndefined();
471− expect(
472− resolveDisplayedThreadPr({
473− threadBranch: "main",
474− gitStatus: status({ refName: "main", pr: null }),
475− snapshot: terminalSnapshot,
476− retainTerminalOnBranchMismatch: true,
477− }),
478− ).toEqual(mergedPr);
479− });
480−
481− it("clears an open snapshot when a local thread moves to a branch with no PR", () => {
482− const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
483−
484− expect(
485− nextThreadChangeRequestSnapshot({
486− threadBranch: "main",
487− gitStatus: status({ refName: "main", pr: null }),
488− snapshot: openSnapshot,
489− retainTerminalOnBranchMismatch: true,
490− }),
491− ).toBeNull();
492− });
493−
494− it("clears an open snapshot when a local checkout moves to a different branch", () => {
495− const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
496−
497− expect(
498− nextThreadChangeRequestSnapshot({
499− threadBranch: featureBranch,
500− gitStatus: status({ refName: "main", pr: null }),
501− snapshot: openSnapshot,
502− retainTerminalOnBranchMismatch: true,
503− }),
504− ).toBeNull();
505− });
506−
507− it("clears a retained snapshot when the thread branch is cleared", () => {
508− const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
509−
510− expect(
511− nextThreadChangeRequestSnapshot({
512− threadBranch: null,
513− gitStatus: status({ refName: "main", pr: null }),
514− snapshot: terminalSnapshot,
515− retainTerminalOnBranchMismatch: true,
516− }),
517− ).toBeNull();
518− expect(
519− resolveDisplayedThreadPr({
520− threadBranch: null,
521− gitStatus: status({ refName: "main", pr: null }),
522− snapshot: terminalSnapshot,
523− retainTerminalOnBranchMismatch: true,
524− }),
525− ).toBeNull();
526− expect(
527− resolveDisplayedThreadPrProvider({
528− threadBranch: null,
529− gitStatus: status({ refName: "main", pr: null }),
530− snapshot: terminalSnapshot,
531− retainTerminalOnBranchMismatch: true,
532− }),
533− ).toBeUndefined();
534− });
535−
536− it("does not erase a terminal snapshot when VCS data is missing", () => {
537− const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
538−
539− expect(
540− nextThreadChangeRequestSnapshot({
541− threadBranch: featureBranch,
542− gitStatus: null,
543− snapshot: terminalSnapshot,
544− retainTerminalOnBranchMismatch: true,
545− }),
546− ).toBeUndefined();
547− expect(
548− resolveDisplayedThreadPr({
549− threadBranch: featureBranch,
550− gitStatus: null,
551− snapshot: terminalSnapshot,
552− retainTerminalOnBranchMismatch: true,
553− }),
554− ).toEqual(mergedPr);
555− });
556−
557− it("retains a merged PR after a main checkout", () => {
558− const matchingStatus = status({
559− refName: featureBranch,
560− pr: mergedPr,
561− sourceControlProvider: provider,
562− });
563− const cached = nextThreadChangeRequestSnapshot({
564− threadBranch: featureBranch,
565− gitStatus: matchingStatus,
566− snapshot: undefined,
567− retainTerminalOnBranchMismatch: true,
568− });
569− expect(cached).not.toBeNull();
570− expect(cached).not.toBeUndefined();
571−
572− const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true });
573− const displayed = resolveDisplayedThreadPr({
574− threadBranch: "main",
575− gitStatus: mainStatus,
576− snapshot: cached as ThreadChangeRequestSnapshot,
577− retainTerminalOnBranchMismatch: true,
578− });
579− expect(displayed?.state).toBe("merged");
580− });
581−
582− it("refreshes a cached snapshot when a pull request becomes ready", () => {
583− const readyPr = { ...mergedPr, state: "open" as const };
584− const draftPr = { ...readyPr, isDraft: true };
585−
586− expect(
587− threadChangeRequestSnapshotsEqual(
588− snapshotFor(featureBranch, draftPr),
589− snapshotFor(featureBranch, readyPr),
590− ),
591− ).toBe(false);
592− });
593−});
594−
595−describe("threadChangeRequestSnapshotsAtom", () => {
596− it.effect("retains snapshots while sidebar and chat consumers are unmounted", () =>
597− Effect.gen(function* () {
598− const registry = AtomRegistry.make();
599− const threadKey = "environment-1:thread-1";
600− const snapshot = snapshotFor("feature/current", mergedFeaturePr());
601−
602− const unmount = registry.mount(threadChangeRequestSnapshotsAtom);
603− registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]]));
604− unmount();
605−
606− yield* Effect.yieldNow;
607−
608− const remount = registry.mount(threadChangeRequestSnapshotsAtom);
609− expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot);
610−
611− remount();
612− registry.dispose();
613− }),
614− );
615−});
616−
61792 describe("prStatusIndicator", () => {
61893 it("formats PR tooltips with number, uppercase status, and title", () => {
61994 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
apps/web/src/components/ThreadStatusIndicators.tsx+8−303
@@ -1,8 +1,4 @@
1−import {
2− scopeProjectRef,
3− scopedThreadKey,
4− scopeThreadRef,
5−} from "@t3tools/client-runtime/environment";
1+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
62 import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
73 import {
84 type EnvironmentId,
@@ -10,17 +6,13 @@ import { · 1 unmodified line
106 type ThreadLinkedPullRequest,
117 type VcsStatusResult,
128 } from "@t3tools/contracts";
13−import { Atom } from "effect/unstable/reactivity";
149 import { FolderGit2Icon, TerminalIcon } from "lucide-react";
1510 import { useMemo } from "react";
16−import { appAtomRegistry } from "../rpc/atomRegistry";
1711 import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1812 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
19−import { useProject } from "../state/entities";
2013 import { useEnvironmentQuery } from "../state/query";
2114 import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
2215 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
23−import { vcsEnvironment } from "../state/vcs";
2416 import { useUiStateStore } from "../uiStateStore";
2517 import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
2618 import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
@@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus { · 24 unmodified lines
5143 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
5244 }
5345
46+/** Keep cached summaries visible when an offscreen row stops live queries. */
5447 export function useLinkedThreadPullRequest(
5548 environmentId: EnvironmentId | null,
5649 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
50+ enabled = true,
5751 ): LinkedThreadPullRequestStatus | null {
5852 const queried = useEnvironmentQuery(
59− environmentId === null || linkedPullRequest == null
53+ !enabled || environmentId === null || linkedPullRequest == null
6054 ? null
6155 : linkedPullRequestDetailAtom({
6256 environmentId,
@@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } · 115 unmodified lines
178172 );
179173 }
180174
181−export function resolveThreadPr(input: {
182− threadBranch: string | null;
183− gitStatus: VcsStatusResult | null;
184−}): ThreadPr | null {
185− const { threadBranch, gitStatus } = input;
186− if (gitStatus === null) {
187− return null;
188− }
189−
190− if (threadBranch === null || gitStatus.refName !== threadBranch) {
191− return null;
192− }
193−
194− return gitStatus.pr ?? null;
195−}
196−
197−/**
198− * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement
199− * partitions move them, so terminal PR metadata must live above the row.
200− */
201−export interface ThreadChangeRequestSnapshot {
202− readonly branch: string;
203− readonly pr: NonNullable<ThreadPr>;
204− readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
205− readonly linkedPullRequest?: ThreadLinkedPullRequest;
206−}
207−
208−export const threadChangeRequestSnapshotsAtom = Atom.make<
209− ReadonlyMap<string, ThreadChangeRequestSnapshot>
210−>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots"));
211−
212−function isTerminalChangeRequestState(
213− state: NonNullable<ThreadPr>["state"],
214−): state is "merged" | "closed" {
215− return state === "merged" || state === "closed";
216−}
217−
218−function sourceControlProvidersEqual(
219− left: VcsStatusResult["sourceControlProvider"] | undefined,
220− right: VcsStatusResult["sourceControlProvider"] | undefined,
221−): boolean {
222− if (left === right) return true;
223− if (left == null || right == null) return left == null && right == null;
224− return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;
225−}
226−
227−function linkedPullRequestsEqual(
228− left: ThreadLinkedPullRequest | null | undefined,
229− right: ThreadLinkedPullRequest | null | undefined,
230−): boolean {
231− if (left == null || right == null) return left == null && right == null;
232− return (
233− left.projectId === right.projectId &&
234− left.repository === right.repository &&
235− left.number === right.number &&
236− left.url === right.url
237− );
238−}
239−
240−export function threadChangeRequestSnapshotsEqual(
241− left: ThreadChangeRequestSnapshot,
242− right: ThreadChangeRequestSnapshot,
243−): boolean {
244− return (
245− left.branch === right.branch &&
246− left.pr.number === right.pr.number &&
247− left.pr.title === right.pr.title &&
248− left.pr.url === right.pr.url &&
249− left.pr.baseRef === right.pr.baseRef &&
250− left.pr.headRef === right.pr.headRef &&
251− left.pr.state === right.pr.state &&
252− left.pr.isDraft === right.pr.isDraft &&
253− (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
254− sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&
255− linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)
256− );
257−}
258−
259−export function setThreadChangeRequestSnapshot(
260− threadKey: string,
261− snapshot: ThreadChangeRequestSnapshot | null,
262−): void {
263− appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => {
264− const existing = current.get(threadKey);
265− if (snapshot === null) {
266− if (existing === undefined) return [false, current];
267− const next = new Map(current);
268− next.delete(threadKey);
269− return [true, next];
270− }
271− if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) {
272− return [false, current];
273− }
274− const next = new Map(current);
275− next.set(threadKey, snapshot);
276− return [true, next];
277− });
278−}
279−
280−/**
281− * Authoritative snapshot update from live VCS status.
282− * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone
283− * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear
284− * - snapshot: matching branch reports a PR — store/replace
285− */
286−export function nextThreadChangeRequestSnapshot(input: {
287− threadBranch: string | null;
288− gitStatus: VcsStatusResult | null;
289− snapshot: ThreadChangeRequestSnapshot | null | undefined;
290− retainTerminalOnBranchMismatch: boolean;
291− linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
292− linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
293−}): ThreadChangeRequestSnapshot | null | undefined {
294− const {
295− threadBranch,
296− gitStatus,
297− snapshot,
298− retainTerminalOnBranchMismatch,
299− linkedPullRequest,
300− linkedPullRequestStatus,
301− } = input;
302− if (linkedPullRequest != null) {
303− if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) {
304− return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
305− ? undefined
306− : null;
307− }
308− return {
309− branch: threadBranch ?? linkedPullRequestStatus.pr.headRef,
310− pr: linkedPullRequestStatus.pr,
311− sourceControlProvider: linkedPullRequestStatus.sourceControlProvider,
312− linkedPullRequest,
313− };
314− }
315− if (gitStatus === null) {
316− return snapshot?.linkedPullRequest === undefined ? undefined : null;
317− }
318− if (threadBranch === null) {
319− return null;
320− }
321− if (gitStatus.refName !== threadBranch) {
322− return retainTerminalOnBranchMismatch &&
323− snapshot != null &&
324− snapshot.linkedPullRequest === undefined &&
325− isTerminalChangeRequestState(snapshot.pr.state)
326− ? undefined
327− : null;
328− }
329− if (gitStatus.pr == null) {
330− if (
331− retainTerminalOnBranchMismatch &&
332− snapshot != null &&
333− snapshot.linkedPullRequest === undefined &&
334− isTerminalChangeRequestState(snapshot.pr.state)
335− ) {
336− return undefined;
337− }
338− return null;
339− }
340− return {
341− branch: threadBranch,
342− pr: gitStatus.pr,
343− sourceControlProvider: gitStatus.sourceControlProvider,
344− };
345−}
346−
347−/**
348− * Live PR when the checkout matches the thread branch; otherwise, for local
349− * checkouts only, a cached merged/closed PR for the thread. Local thread
350− * metadata follows the shared checkout, so the cached branch intentionally
351− * survives that metadata changing to the newly checked-out branch. Open PRs
352− * are never retained — their state can still change.
353− */
354−export function resolveDisplayedThreadPr(input: {
355− threadBranch: string | null;
356− gitStatus: VcsStatusResult | null;
357− snapshot: ThreadChangeRequestSnapshot | null | undefined;
358− retainTerminalOnBranchMismatch: boolean;
359− linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
360− linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
361−}): ThreadPr | null {
362− const {
363− threadBranch,
364− gitStatus,
365− snapshot,
366− retainTerminalOnBranchMismatch,
367− linkedPullRequest,
368− linkedPullRequestStatus,
369− } = input;
370− if (linkedPullRequest != null) {
371− return (
372− linkedPullRequestStatus?.pr ??
373− (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
374− ? (snapshot?.pr ?? null)
375− : null)
376− );
377− }
378− if (
379− threadBranch !== null &&
380− gitStatus !== null &&
381− gitStatus.refName === threadBranch &&
382− gitStatus.pr != null
383− ) {
384− return gitStatus.pr;
385− }
386−
387− if (
388− threadBranch !== null &&
389− retainTerminalOnBranchMismatch &&
390− snapshot != null &&
391− snapshot.linkedPullRequest === undefined &&
392− isTerminalChangeRequestState(snapshot.pr.state)
393− ) {
394− return snapshot.pr;
395− }
396−
397− return null;
398−}
399−
400−export function resolveDisplayedThreadPrProvider(input: {
401− threadBranch: string | null;
402− gitStatus: VcsStatusResult | null;
403− snapshot: ThreadChangeRequestSnapshot | null | undefined;
404− retainTerminalOnBranchMismatch: boolean;
405− linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
406− linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
407−}): VcsStatusResult["sourceControlProvider"] | undefined {
408− const {
409− threadBranch,
410− gitStatus,
411− snapshot,
412− retainTerminalOnBranchMismatch,
413− linkedPullRequest,
414− linkedPullRequestStatus,
415− } = input;
416− if (linkedPullRequest != null) {
417− return (
418− linkedPullRequestStatus?.sourceControlProvider ??
419− (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
420− ? snapshot?.sourceControlProvider
421− : undefined)
422− );
423− }
424− if (
425− threadBranch !== null &&
426− gitStatus !== null &&
427− gitStatus.refName === threadBranch &&
428− gitStatus.pr != null
429− ) {
430− return gitStatus.sourceControlProvider;
431− }
432−
433− if (
434− threadBranch !== null &&
435− retainTerminalOnBranchMismatch &&
436− snapshot != null &&
437− snapshot.linkedPullRequest === undefined &&
438− isTerminalChangeRequestState(snapshot.pr.state)
439− ) {
440− return snapshot.sourceControlProvider;
441− }
442−
443− return undefined;
444−}
445−
446175 export function terminalStatusFromRunningIds(
447176 runningTerminalIds: ReadonlyArray<string>,
448177 ): TerminalStatusIndicator | null {
@@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 102 unmodified lines
551280 const lastVisitedAt = useUiStateStore(
552281 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
553282 );
554− const threadProject = useProject(
555− useMemo(
556− () => scopeProjectRef(thread.environmentId, thread.projectId),
557− [thread.environmentId, thread.projectId],
558− ),
559− );
560− const threadProjectCwd = threadProject?.workspaceRoot ?? null;
561− const gitCwd = thread.worktreePath ?? threadProjectCwd;
562− const linkedPullRequest = useLinkedThreadPullRequest(
283+ const pullRequest = useLinkedThreadPullRequest(
563284 thread.environmentId,
564− thread.linkedPullRequest,
565− );
566− const gitStatus = useEnvironmentQuery(
567− thread.linkedPullRequest == null &&
568− (thread.branch != null || thread.worktreePath !== null) &&
569− gitCwd !== null
570− ? vcsEnvironment.status({
571− environmentId: thread.environmentId,
572− input: { cwd: gitCwd },
573− })
574− : null,
575− );
576− const pr =
577− thread.linkedPullRequest == null
578− ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })
579− : (linkedPullRequest?.pr ?? null);
580− const prStatus = prStatusIndicator(
581− pr,
582− linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
285+ thread.linkedPullRequest ?? thread.branchPullRequest,
583286 );
287+ const pr = pullRequest?.pr ?? null;
288+ const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);
584289 const threadStatus = resolveThreadStatusPill({
585290 thread: {
586291 ...thread,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+0−12
@@ -8,7 +8,6 @@ import { · 7 unmodified lines
88 type PullRequestListEntry,
99 type PullRequestUpdateMethod,
1010 type PullRequestRef,
11− type PullRequestState,
1211 resolveEnvironmentMachineKind,
1312 type ScopedThreadRef,
1413 } from "@t3tools/contracts";
@@ -454,7 +453,6 @@ export function PullRequestDetailPanel({ · 439 unmodified lines
454453 refreshToken: forcedRefreshToken = 0,
455454 onActed,
456455 onClose,
457− onStateChange,
458456 context = "page",
459457 composerDraftTarget,
460458 }: {
@@ -482,8 +480,6 @@ export function PullRequestDetailPanel({ · 21 unmodified lines
482480 onActed?: () => void;
483481 /** Page-owned detail columns use this to clear the selected pull request. */
484482 onClose?: () => void;
485− /** Keeps surrounding inferred thread state in step with refreshed host state. */
486− onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void;
487483 /**
488484 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's
489485 * own pull request, so the branch is already under the reader's feet — and checking it out
@@ -714,14 +710,6 @@ export function PullRequestDetailPanel({ · 224 unmodified lines
714710 }
715711 activityRevision.current = next;
716712 }, [activityQuery.refresh, coreDetail, tabScopeKey]);
717− useLayoutEffect(() => {
718− if (!resolvedCoreDetail) return;
719− onStateChange?.({
720− repository: resolvedCoreDetail.repository,
721− number: resolvedCoreDetail.number,
722− state: resolvedCoreDetail.state,
723− });
724− }, [onStateChange, resolvedCoreDetail]);
725713 // Reuse activity and diff until core detail reports a changed revision. Keyed by
726714 // the pull request rather than by the panel, because this one panel shows a different pull
727715 // request every time it is opened.
docs/user/thread-sidebar.md+6−1
@@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads. · 48 unmodified lines
4949
5050 ## Link a pull request
5151
52+The server finds the PR for each unsettled thread's saved branch, even when your
53+apps are closed. Settled threads keep their saved links. Update the server if
54+automatic branch links do not appear.
55+
5256 On web and desktop, right-click a pull request link in a thread and choose
53−**Link to thread**. Use **Unlink from thread** on the same link to remove it.
57+**Link to thread** to select a different PR. Use **Unlink from thread** on the
58+same link to return to the branch PR, if one exists.
5459 The linked pull request participates in automatic settlement.
5560
5661 ## Find and reference work
packages/client-runtime/src/state/threadReducer.test.ts+56−41
@@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => { · 305 unmodified lines
306306 }
307307 });
308308
309− it("sets and clears a linked pull request", () => {
310− const linkedPullRequest = {
311− projectId: ProjectId.make("project-1"),
312− repository: "pingdotgg/t3code",
313− number: 42,
314− url: "https://github.com/pingdotgg/t3code/pull/42",
315− };
316− const linked = applyThreadDetailEvent(baseThread, {
317− ...baseEventFields,
318− sequence: 5,
319− occurredAt: "2026-04-01T05:00:00.000Z",
320− aggregateKind: "thread",
321− aggregateId: ThreadId.make("thread-1"),
322− type: "thread.meta-updated",
323− payload: {
324− threadId: ThreadId.make("thread-1"),
325− linkedPullRequest,
326− updatedAt: "2026-04-01T05:00:00.000Z",
327− },
328− });
309+ it.each(["linkedPullRequest", "branchPullRequest"] as const)(
310+ "sets and clears %s without changing the other link",
311+ (field) => {
312+ const linkedPullRequest = {
313+ projectId: ProjectId.make("project-1"),
314+ repository: "pingdotgg/t3code",
315+ number: 42,
316+ url: "https://github.com/pingdotgg/t3code/pull/42",
317+ };
318+ const otherField =
319+ field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest";
320+ const otherPullRequest = {
321+ ...linkedPullRequest,
322+ number: 43,
323+ url: "https://github.com/pingdotgg/t3code/pull/43",
324+ };
325+ const linked = applyThreadDetailEvent(
326+ { ...baseThread, [otherField]: otherPullRequest },
327+ {
328+ ...baseEventFields,
329+ sequence: 5,
330+ occurredAt: "2026-04-01T05:00:00.000Z",
331+ aggregateKind: "thread",
332+ aggregateId: ThreadId.make("thread-1"),
333+ type: "thread.meta-updated",
334+ payload: {
335+ threadId: ThreadId.make("thread-1"),
336+ [field]: linkedPullRequest,
337+ updatedAt: "2026-04-01T05:00:00.000Z",
338+ },
339+ },
340+ );
329341
330− expect(linked.kind).toBe("updated");
331− if (linked.kind !== "updated") return;
332− expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest);
342+ expect(linked.kind).toBe("updated");
343+ if (linked.kind !== "updated") return;
344+ expect(linked.thread[field]).toEqual(linkedPullRequest);
345+ expect(linked.thread[otherField]).toEqual(otherPullRequest);
333346
334− const cleared = applyThreadDetailEvent(linked.thread, {
335− ...baseEventFields,
336− sequence: 6,
337− occurredAt: "2026-04-01T06:00:00.000Z",
338− aggregateKind: "thread",
339− aggregateId: ThreadId.make("thread-1"),
340− type: "thread.meta-updated",
341− payload: {
342− threadId: ThreadId.make("thread-1"),
343− linkedPullRequest: null,
344− updatedAt: "2026-04-01T06:00:00.000Z",
345− },
346− });
347+ const cleared = applyThreadDetailEvent(linked.thread, {
348+ ...baseEventFields,
349+ sequence: 6,
350+ occurredAt: "2026-04-01T06:00:00.000Z",
351+ aggregateKind: "thread",
352+ aggregateId: ThreadId.make("thread-1"),
353+ type: "thread.meta-updated",
354+ payload: {
355+ threadId: ThreadId.make("thread-1"),
356+ [field]: null,
357+ updatedAt: "2026-04-01T06:00:00.000Z",
358+ },
359+ });
347360
348− expect(cleared.kind).toBe("updated");
349− if (cleared.kind === "updated") {
350− expect(cleared.thread.linkedPullRequest).toBeNull();
351− }
352− });
361+ expect(cleared.kind).toBe("updated");
362+ if (cleared.kind === "updated") {
363+ expect(cleared.thread[field]).toBeNull();
364+ expect(cleared.thread[otherField]).toEqual(otherPullRequest);
365+ }
366+ },
367+ );
353368 });
354369
355370 describe("thread.message-sent", () => {
packages/client-runtime/src/state/threadReducer.ts+4−0
@@ -97,6 +97,7 @@ export function applyThreadDetailEvent( · 96 unmodified lines
9797 interactionMode: event.payload.interactionMode,
9898 branch: event.payload.branch,
9999 worktreePath: event.payload.worktreePath,
100+ branchPullRequest: null,
100101 latestTurn: null,
101102 createdAt: event.payload.createdAt,
102103 updatedAt: event.payload.updatedAt,
@@ -240,6 +241,9 @@ export function applyThreadDetailEvent( · 137 unmodified lines
240241 ...(event.payload.linkedPullRequest !== undefined
241242 ? { linkedPullRequest: event.payload.linkedPullRequest }
242243 : {}),
244+ ...(event.payload.branchPullRequest !== undefined
245+ ? { branchPullRequest: event.payload.branchPullRequest }
246+ : {}),
243247 updatedAt: event.payload.updatedAt,
244248 },
245249 };
packages/contracts/src/orchestration.test.ts+42−0
@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line
22 import * as Effect from "effect/Effect";
33 import * as Exit from "effect/Exit";
44 import * as Schema from "effect/Schema";
5+import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";
56
67 import {
78 DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () => · 829 unmodified lines
837838 }),
838839 );
839840
841+it.effect("accepts pull request synchronization only as an internal command", () =>
842+ Effect.gen(function* () {
843+ const pullRequest = {
844+ projectId: ProjectId.make("project-1"),
845+ repository: "pingdotgg/t3code",
846+ number: 42,
847+ url: "https://github.com/pingdotgg/t3code/pull/42",
848+ };
849+ const command = {
850+ type: "thread.pull-request.sync" as const,
851+ commandId: CommandId.make("cmd-pull-request-sync"),
852+ threadId: ThreadId.make("thread-1"),
853+ projectId: pullRequest.projectId,
854+ snapshotSequence: 12,
855+ expected: {
856+ workspaceRoot: "/workspace/project",
857+ branch: "feature",
858+ worktreePath: null,
859+ linkedPullRequest: null,
860+ branchPullRequest: null,
861+ },
862+ branchPullRequest: pullRequest,
863+ linkedPullRequest: pullRequest,
864+ };
865+
866+ assert.deepStrictEqual(yield* decodeOrchestrationCommand(command), command);
867+ assert.ok(yield* decodeClientOrchestrationCommand(command).pipe(Effect.flip));
868+
869+ const cleared = { ...command, branchPullRequest: null };
870+ assert.deepStrictEqual(yield* decodeOrchestrationCommand(cleared), cleared);
871+
872+ const metadata = yield* decodeClientOrchestrationCommand({
873+ type: "thread.meta.update",
874+ commandId: "cmd-forged-branch-pull-request",
875+ threadId: "thread-1",
876+ branchPullRequest: pullRequest,
877+ });
878+ assert.isFalse("branchPullRequest" in metadata);
879+ }),
880+);
881+
840882 it.effect("rejects an explicit title combined with title regeneration", () =>
841883 Effect.gen(function* () {
842884 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+21−0
@@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ · 493 unmodified lines
494494 branch: Schema.NullOr(TrimmedNonEmptyString),
495495 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
496496 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
497+ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
497498 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
498499 createdAt: IsoDateTime,
499500 updatedAt: IsoDateTime,
@@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ · 72 unmodified lines
572573 branch: Schema.NullOr(TrimmedNonEmptyString),
573574 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
574575 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
576+ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
575577 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
576578 createdAt: IsoDateTime,
577579 updatedAt: IsoDateTime,
@@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ · 605 unmodified lines
11831185 title: Schema.optional(TrimmedNonEmptyString),
11841186 });
11851187
1188+const ThreadPullRequestSyncCommand = Schema.Struct({
1189+ type: Schema.Literal("thread.pull-request.sync"),
1190+ commandId: CommandId,
1191+ threadId: ThreadId,
1192+ projectId: ProjectId,
1193+ snapshotSequence: NonNegativeInt,
1194+ expected: Schema.Struct({
1195+ workspaceRoot: TrimmedNonEmptyString,
1196+ branch: Schema.NullOr(TrimmedNonEmptyString),
1197+ worktreePath: Schema.NullOr(TrimmedNonEmptyString),
1198+ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
1199+ branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
1200+ }),
1201+ branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
1202+ linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
1203+});
1204+
11861205 const InternalOrchestrationCommand = Schema.Union([
11871206 ThreadAutoSettleCommand,
11881207 ThreadSessionSetCommand,
@@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([ · 5 unmodified lines
11941213 ThreadActivityAppendCommand,
11951214 ThreadRevertCompleteCommand,
11961215 ThreadTitleRegenerationCompleteCommand,
1216+ ThreadPullRequestSyncCommand,
11971217 ]);
11981218 export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
11991219
@@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ · 167 unmodified lines
13671387 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
13681388 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
13691389 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1390+ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
13701391 updatedAt: IsoDateTime,
13711392 });
13721393
packages/shared/src/threadReference.test.ts+2−17
@@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => { · 8 unmodified lines
99 threadId: "thread-1",
1010 openPanelPullRequestUrl: null,
1111 linkedPullRequestUrl: "https://github.com/t3/pr/12",
12− detectedPullRequestUrl: "https://github.com/t3/pr/13",
1312 }),
1413 ).toBeNull();
1514 });
1615
17− it("prefers the open panel pull request over linked and detected pull requests", () => {
16+ it("prefers the open panel pull request over the thread pull request", () => {
1817 expect(
1918 resolveThreadReferenceCopyTarget({
2019 threadId: "thread-1",
2120 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
2221 linkedPullRequestUrl: "https://github.com/t3/pr/12",
23− detectedPullRequestUrl: "https://github.com/t3/pr/13",
2422 }),
2523 ).toMatchObject({
2624 kind: "pull-request",
@@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => { · 2 unmodified lines
2927 });
3028 });
3129
32− it("prefers a durable linked pull request", () => {
30+ it("uses the thread pull request when no panel is open", () => {
3331 expect(
3432 resolveThreadReferenceCopyTarget({
3533 threadId: "thread-1",
3634 linkedPullRequestUrl: "https://github.com/t3/pr/12",
37− detectedPullRequestUrl: "https://github.com/t3/pr/13",
3835 }),
3936 ).toMatchObject({
4037 kind: "pull-request",
@@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => { · 2 unmodified lines
4340 });
4441 });
4542
46− it("uses a pull request detected from the active branch", () => {
47− expect(
48− resolveThreadReferenceCopyTarget({
49− threadId: "thread-1",
50− detectedPullRequestUrl: "https://github.com/t3/pr/13",
51− }),
52− ).toMatchObject({
53− kind: "pull-request",
54− value: "https://github.com/t3/pr/13",
55− });
56− });
57−
5843 it("falls back to the thread ID", () => {
5944 expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({
6045 kind: "thread",
packages/shared/src/threadReference.ts+1−3
@@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: { · 10 unmodified lines
1111 /** Undefined means no PR panel; null means its URL is not available yet. */
1212 readonly openPanelPullRequestUrl?: string | null | undefined;
1313 readonly linkedPullRequestUrl?: string | null;
14− readonly detectedPullRequestUrl?: string | null;
1514 }): ThreadReferenceCopyTarget | null {
1615 if (input.openPanelPullRequestUrl === null) return null;
17− const pullRequestUrl =
18− input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
16+ const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl;
1917 return pullRequestUrl
2018 ? {
2119 kind: "pull-request",