authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 04:06:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 04:06:15-07:00
log223ff4490f764a74ff911589e97b9bbcd595fee8
tree7c6ca413a547093cb6b00100119000bd95cae49b
parent5fa35d211682ee02e34fba0711838ca431ed003b
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

50 files changed, 2309 insertions(+), 1486 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+1-21
......@@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) {
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) {
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) {
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) {
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) {
913900 }),
914901 [
915902 projectByKey,
916 projectCwdByKey,
917903 props.searchQuery,
918904 props.savedConnectionsById,
919905 serverConfigs,
......@@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) {
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) {
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) {
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 {
1212
1313import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1414import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15import { useProject, useThreadShell } from "../../state/entities";
16import { useEnvironmentQuery } from "../../state/query";
15import { useThreadShell } from "../../state/entities";
1716import type { GitActionProgress } from "../../state/use-vcs-action-state";
18import { vcsEnvironment } from "../../state/vcs";
1917import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
2018import {
2119 dispatchHardwareKeyboardCommand,
......@@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({
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-15
......@@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane(
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(
742735 () => ({
743736 selectedThreadKey: props.selectedThreadKey ?? "",
744737 projectByKey,
745 projectCwdByKey,
746738 projectTitleByProjectKey,
747739 savedConnectionsById,
748740 serverConfigs,
......@@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane(
752744 [
753745 props.selectedThreadKey,
754746 projectByKey,
755 projectCwdByKey,
756747 projectTitleByProjectKey,
757748 savedConnectionsById,
758749 serverConfigs,
......@@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane(
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(
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(
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: {
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: {
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: {
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: {
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";
1212import { appAtomRegistry } from "./atom-registry";
1313import { useEnvironmentQuery } from "./query";
1414import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15import { vcsEnvironment } from "./vcs";
1615
17const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
16const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1817const MAX_THREAD_PR_SNAPSHOTS = 500;
1918
2019interface ThreadPrSnapshot {
......@@ -23,7 +22,7 @@ interface ThreadPrSnapshot {
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.
2726const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(
2827 Atom.keepAlive,
2928 Atom.withLabel("mobile:thread-pr-snapshots"),
......@@ -36,20 +35,13 @@ export {
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 */
44export function useThreadPr(
45 thread: EnvironmentThreadShell,
46 projectCwd: string | null,
47): ThreadPrPresentation | null {
48 const cwd = thread.worktreePath ?? projectCwd;
41export 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(
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
......@@ -55,6 +55,7 @@ function threadDetailToShell(
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
......@@ -67,6 +67,7 @@ import {
6767} from "../src/orchestration/Services/OrchestrationEngine.ts";
6868import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
6969import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
7071import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
7172import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
7273import {
......@@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = (
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";
1313import * as Option from "effect/Option";
1414import * as PlatformError from "effect/PlatformError";
1515import * as References from "effect/References";
16import * as Schema from "effect/Schema";
1617import * as Scope from "effect/Scope";
1718import { ChildProcessSpawner } from "effect/unstable/process";
1819import { expect } from "vite-plus/test";
......@@ -30,10 +31,13 @@ import {
3031 TextGenerationError,
3132} from "@t3tools/contracts";
3233import * as GitHubCli from "../sourceControl/GitHubCli.ts";
34import * as GitLabCli from "../sourceControl/GitLabCli.ts";
3335import * as TextGeneration from "../textGeneration/TextGeneration.ts";
3436import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3537import * as VcsProcess from "../vcs/VcsProcess.ts";
3638import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
39import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
40import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
3741import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3842import * as ServerConfig from "../config.ts";
3943import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
......@@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
4145import * as ServerSettings from "../serverSettings.ts";
4246import * as GitManager from "./GitManager.ts";
4347
48const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49
4450interface FakeGhScenario {
4551 prListSequence?: string[];
4652 prListByHeadSelector?: Record<string, string>;
......@@ -620,6 +626,7 @@ function preparePullRequestThread(
620626
621627function 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?: {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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
7777 readonly refreshMissingPullRequest?: boolean;
7878}
7979
80export 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
8087interface SourceControlTextGenerationSettings {
8188 readonly modelSelection: ModelSelection;
8289 readonly style: SourceControlWritingStyleSettings;
......@@ -96,18 +103,10 @@ export class GitManager extends Context.Service<
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 {
214213 isCrossRepository: boolean;
215214}
216215
216export 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
217233function 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(
254270 return `t3code/pr-${pullRequest.number}/${suffix}`;
255271}
256272
257function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
273function 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
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* () {
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* () {
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* () {
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* () {
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
......@@ -13,6 +13,7 @@ import {
1313 ProjectId,
1414 ThreadId,
1515 TurnId,
16 type OrchestrationCommand,
1617 type OrchestrationEvent,
1718 ProviderInstanceId,
1819} from "@t3tools/contracts";
......@@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => {
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
......@@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () {
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
......@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1314import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1415import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
1516import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
......@@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => {
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", () => {
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
......@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1314import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
1516export const makeOrchestrationReactor = Effect.gen(function* () {
......@@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
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* () {
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
......@@ -8,6 +8,7 @@ import {
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";
1819import * as FileSystem from "effect/FileSystem";
1920import * as Layer from "effect/Layer";
2021import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
2123import * as SqlClient from "effect/unstable/sql/SqlClient";
2224
2325import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
......@@ -59,6 +61,9 @@ const exists = (filePath: string) =>
5961 });
6062
6163const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
64const encodeThreadLinkedPullRequest = Schema.encodeSync(
65 Schema.fromJsonString(ThreadLinkedPullRequest),
66);
6267
6368it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))(
6469 "OrchestrationProjectionPipeline cursor batches",
......@@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")
204209 },
205210);
206211
212it.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
207299it.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
......@@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
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
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
......@@ -6,6 +6,7 @@ import {
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
3637const encodeChatAttachments = Schema.encodeEffect(
3738 Schema.fromJsonString(Schema.Array(ChatAttachment)),
3839);
40const encodeThreadLinkedPullRequest = Schema.encodeSync(
41 Schema.fromJsonString(ThreadLinkedPullRequest),
42);
3943
4044const projectionSnapshotLayer = it.layer(
4145 OrchestrationProjectionSnapshotQueryLive.pipe(
......@@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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
......@@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
116116 Struct.assign({
117117 modelSelection: Schema.fromJsonString(ModelSelection),
118118 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119120 }),
120121);
121122const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
......@@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
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* () {
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* () {
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* () {
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 (
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 (
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 (
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 (
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 (
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 (
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 created+613
......@@ -0,0 +1,613 @@
1import {
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";
16import { describe, expect, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as PubSub from "effect/PubSub";
23import * as Queue from "effect/Queue";
24import * as Ref from "effect/Ref";
25import * as Stream from "effect/Stream";
26import { TestClock } from "effect/testing";
27
28import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
29import { PullRequestService } from "../pullRequest/PullRequestService.ts";
30import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts";
31import { ServerActivation } from "../serverActivation.ts";
32import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
33import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
34import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts";
35
36const NOW = "2026-09-01T12:00:00.000Z";
37const PROJECT_ID = ProjectId.make("project");
38const REPOSITORY = "owner/repository";
39const REPOSITORY_KEY = `github.com/${REPOSITORY}`;
40type SyncCommand = Extract<OrchestrationCommand, { type: "thread.pull-request.sync" }>;
41
42function 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
51function 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
66function 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
79function 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
107const 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
127const 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
241describe("ThreadPullRequestReactor", () => {
242 it.effect("discovers saved branch PRs without a client and shares branch lookups", () =>
243 Effect.scoped(
244 Effect.gen(function* () {
245 const fixture = yield* makeHarness({
246 threads: [
247 thread("first"),
248 thread("second"),
249 thread("archived", { archivedAt: NOW }),
250 thread("no-branch", { branch: null }),
251 ],
252 branchPullRequest: () => Effect.succeed(branchPullRequest()),
253 });
254 yield* Effect.gen(function* () {
255 const reactor = yield* fixture.start();
256 expect(yield* Ref.get(fixture.branchCalls)).toEqual([
257 { cwd: project.workspaceRoot, branch: "feature", refresh: false },
258 { cwd: project.workspaceRoot, branch: "feature", refresh: false },
259 ]);
260 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
261 "first",
262 "second",
263 ]);
264 expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual(
265 reference(42),
266 );
267
268 yield* TestClock.adjust("1 minute");
269 yield* Queue.take(fixture.reads);
270 yield* reactor.drain;
271 expect(yield* Ref.get(fixture.commands)).toHaveLength(2);
272 }).pipe(Effect.provide(fixture.layer));
273 }),
274 ),
275 );
276
277 it.effect("replaces terminal manual links but preserves open links and explicit unlink", () =>
278 Effect.scoped(
279 Effect.gen(function* () {
280 const fixture = yield* makeHarness({
281 threads: [
282 thread("merged", { linkedPullRequest: reference(1) }),
283 thread("closed", { linkedPullRequest: reference(2) }),
284 thread("open", { linkedPullRequest: reference(3) }),
285 thread("unlinked", { linkedPullRequest: null }),
286 ],
287 branchPullRequest: () => Effect.succeed(branchPullRequest()),
288 summary: (input) =>
289 Effect.succeed(
290 summary(
291 input,
292 input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open",
293 ),
294 ),
295 });
296 yield* Effect.gen(function* () {
297 yield* fixture.start();
298 const snapshot = yield* Ref.get(fixture.snapshots);
299 expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([
300 reference(42),
301 reference(42),
302 reference(3),
303 null,
304 ]);
305 expect(
306 snapshot.threads.every((current) => current.branchPullRequest?.number === 42),
307 ).toBe(true);
308 }).pipe(Effect.provide(fixture.layer));
309 }),
310 ),
311 );
312
313 it.effect("refreshes discovery after a turn ends without client demand", () =>
314 Effect.scoped(
315 Effect.gen(function* () {
316 const current = thread("turn-thread");
317 const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
318 const fixture = yield* makeHarness({
319 threads: [current],
320 branchPullRequest: (_input, options) =>
321 options?.refresh
322 ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
323 : Ref.get(detected),
324 });
325 yield* Effect.gen(function* () {
326 const reactor = yield* fixture.start();
327 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
328 yield* fixture.publish({
329 type: "thread.session-set",
330 sequence: 2,
331 eventId: EventId.make("turn-finished"),
332 aggregateKind: "thread",
333 aggregateId: current.id,
334 occurredAt: NOW,
335 commandId: null,
336 causationEventId: null,
337 correlationId: null,
338 metadata: {},
339 payload: {
340 threadId: current.id,
341 session: {
342 threadId: current.id,
343 status: "ready",
344 providerName: "Codex",
345 runtimeMode: "full-access",
346 activeTurnId: null,
347 lastError: null,
348 updatedAt: NOW,
349 },
350 },
351 });
352 yield* Queue.take(fixture.reads);
353 yield* reactor.drain;
354 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
355 expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength(
356 1,
357 );
358 }).pipe(Effect.provide(fixture.layer));
359 }),
360 ),
361 );
362
363 it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
364 Effect.scoped(
365 Effect.gen(function* () {
366 const fixture = yield* makeHarness({
367 threads: [
368 thread("live", { worktreePath: "/workspace/worktree" }),
369 thread("removed", { worktreePath: "/workspace/removed" }),
370 ],
371 existingWorktrees: ["/workspace/worktree"],
372 branchPullRequest: () => Effect.succeed(branchPullRequest()),
373 });
374 yield* Effect.gen(function* () {
375 yield* fixture.start();
376 expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual(
377 new Set(["/workspace/project", "/workspace/worktree"]),
378 );
379 }).pipe(Effect.provide(fixture.layer));
380 }),
381 ),
382 );
383
384 it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () =>
385 Effect.scoped(
386 Effect.gen(function* () {
387 const fixture = yield* makeHarness({
388 threads: [
389 thread("terminal", { branch: "main", branchPullRequest: reference(1) }),
390 thread("open", { branchPullRequest: reference(2) }),
391 thread("worktree", {
392 worktreePath: "/workspace/worktree",
393 branchPullRequest: reference(1),
394 }),
395 thread("cleared", {
396 branch: null,
397 branchPullRequest: reference(1),
398 linkedPullRequest: reference(3),
399 }),
400 ],
401 summary: (input) =>
402 Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")),
403 });
404 yield* Effect.gen(function* () {
405 yield* fixture.start();
406 const snapshot = yield* Ref.get(fixture.snapshots);
407 expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([
408 reference(1),
409 null,
410 null,
411 null,
412 ]);
413 expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3));
414 }).pipe(Effect.provide(fixture.layer));
415 }),
416 ),
417 );
418
419 it.effect("keeps saved links on lookup failures and rejects a different repository", () =>
420 Effect.scoped(
421 Effect.gen(function* () {
422 const fixture = yield* makeHarness({
423 threads: [
424 thread("failed", { branch: "failed", branchPullRequest: reference(1) }),
425 thread("wrong-repository", {
426 branch: "wrong-repository",
427 branchPullRequest: reference(2),
428 }),
429 thread("healthy"),
430 ],
431 branchPullRequest: ({ cwd, branch }) =>
432 branch === "failed"
433 ? Effect.fail(
434 new GitManagerError({
435 operation: "branchPullRequest",
436 cwd,
437 detail: "Lookup failed",
438 }),
439 )
440 : Effect.succeed({
441 ...branchPullRequest(),
442 repositoryKey:
443 branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY,
444 }),
445 });
446 yield* Effect.gen(function* () {
447 yield* fixture.start();
448 expect(
449 (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest),
450 ).toEqual([reference(1), reference(2), reference(42)]);
451 expect(yield* Ref.get(fixture.commands)).toHaveLength(1);
452 }).pipe(Effect.provide(fixture.layer));
453 }),
454 ),
455 );
456
457 it.effect("retries failed settled backfills and stops querying them after success", () =>
458 Effect.scoped(
459 Effect.gen(function* () {
460 const online = yield* Ref.make(false);
461 const fixture = yield* makeHarness({
462 threads: [
463 thread("backfill", { settledOverride: "settled", settledAt: NOW }),
464 thread("known", {
465 branch: "known",
466 settledOverride: "settled",
467 settledAt: NOW,
468 branchPullRequest: reference(1),
469 }),
470 ],
471 branchPullRequest: ({ cwd }) =>
472 Ref.get(online).pipe(
473 Effect.flatMap((connected) =>
474 connected
475 ? Effect.succeed(branchPullRequest(42, "merged"))
476 : Effect.fail(
477 new GitManagerError({
478 operation: "branchPullRequest",
479 cwd,
480 detail: "Offline",
481 }),
482 ),
483 ),
484 ),
485 });
486 yield* Effect.gen(function* () {
487 const reactor = yield* fixture.start();
488 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
489 yield* Ref.set(online, true);
490 yield* TestClock.adjust("1 minute");
491 yield* Queue.take(fixture.reads);
492 yield* reactor.drain;
493 expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
494 yield* TestClock.adjust("1 minute");
495 yield* Queue.take(fixture.reads);
496 yield* reactor.drain;
497 expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
498 "feature",
499 "feature",
500 "feature",
501 ]);
502 }).pipe(Effect.provide(fixture.layer));
503 }),
504 ),
505 );
506
507 it.effect("stops retrying a settled backfill after repeated lookup failures", () =>
508 Effect.scoped(
509 Effect.gen(function* () {
510 const fixture = yield* makeHarness({
511 threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })],
512 branchPullRequest: ({ cwd }) =>
513 Effect.fail(
514 new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }),
515 ),
516 });
517 yield* Effect.gen(function* () {
518 const reactor = yield* fixture.start();
519 for (
520 let attempt = 1;
521 attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2;
522 attempt++
523 ) {
524 yield* TestClock.adjust("1 minute");
525 yield* Queue.take(fixture.reads);
526 yield* reactor.drain;
527 }
528 expect(yield* Ref.get(fixture.branchCalls)).toHaveLength(
529 ThreadPullRequestReactor.BACKFILL_ATTEMPTS,
530 );
531 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
532 }).pipe(Effect.provide(fixture.layer));
533 }),
534 ),
535 );
536
537 it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () =>
538 Effect.scoped(
539 Effect.gen(function* () {
540 const fixture = yield* makeHarness({
541 threads: [thread("azure")],
542 project: {
543 ...project,
544 repositoryIdentity: {
545 canonicalKey: "ssh.dev.azure.com/v3/org/project/repository",
546 displayName: "v3/org/project/repository",
547 name: "repository",
548 provider: "azure-devops",
549 rootPath: project.workspaceRoot,
550 locator: {
551 source: "git-remote",
552 remoteName: "origin",
553 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository",
554 },
555 },
556 },
557 branchPullRequest: () =>
558 Effect.succeed({
559 ...branchPullRequest(),
560 repositoryKey: "dev.azure.com/org/project/_git/repository",
561 url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
562 }),
563 });
564 yield* Effect.gen(function* () {
565 yield* fixture.start();
566 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({
567 projectId: PROJECT_ID,
568 repository: "repository",
569 number: 42,
570 url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42",
571 });
572 }).pipe(Effect.provide(fixture.layer));
573 }),
574 ),
575 );
576
577 it.effect.each(["primary", "branch"] as const)(
578 "rejects the group's links if the %s remote changes during a summary read",
579 (changedRemote) =>
580 Effect.scoped(
581 Effect.gen(function* () {
582 const identity = yield* Ref.make(project.repositoryIdentity);
583 const detected = yield* Ref.make(branchPullRequest());
584 const fixture = yield* makeHarness({
585 threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")],
586 branchPullRequest: () => Ref.get(detected),
587 resolveRepositoryIdentity: (_cwd, options) =>
588 options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity),
589 summary: (input) =>
590 (changedRemote === "primary"
591 ? Ref.set(identity, {
592 ...project.repositoryIdentity,
593 canonicalKey: "github.com/other/repository",
594 displayName: "other/repository",
595 })
596 : Ref.set(detected, {
597 ...branchPullRequest(99),
598 repositoryKey: "github.com/other/repository",
599 url: "https://github.com/other/repository/pull/99",
600 })
601 ).pipe(Effect.as(summary(input, "merged"))),
602 });
603 yield* Effect.gen(function* () {
604 yield* fixture.start();
605 expect(yield* Ref.get(fixture.commands)).toEqual([]);
606 expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual(
607 reference(1),
608 );
609 }).pipe(Effect.provide(fixture.layer));
610 }),
611 ),
612 );
613});
apps/server/src/orchestration/ThreadPullRequestReactor.ts created+371
......@@ -0,0 +1,371 @@
1import {
2 CommandId,
3 type OrchestrationEvent,
4 type OrchestrationProjectShell,
5 type ThreadId,
6 type ThreadLinkedPullRequest,
7} from "@t3tools/contracts";
8import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
9import * as Cause from "effect/Cause";
10import * as Context from "effect/Context";
11import * as Crypto from "effect/Crypto";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Schedule from "effect/Schedule";
16import type * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18
19import * as GitManager from "../git/GitManager.ts";
20import * as PullRequestService from "../pullRequest/PullRequestService.ts";
21import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
22import { forkParked } from "../serverActivation.ts";
23import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
24import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
25
26export 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
34function 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. */
48export const BACKFILL_ATTEMPTS = 5;
49
50interface RefreshRequest {
51 readonly threadId: ThreadId | null;
52 readonly refresh: boolean;
53 readonly backfill?: boolean;
54}
55
56function 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
68export 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
80export 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
371export 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";
2525import * as Stream from "effect/Stream";
2626import { TestClock } from "effect/testing";
2727
28import { GitManager } from "../git/GitManager.ts";
28import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
2929import {
3030 PullRequestService,
3131 type PullRequestMergeEvent,
......@@ -132,6 +132,24 @@ function makePullRequestSummary(input: {
132132 };
133133}
134134
135function 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
135153interface HarnessOptions {
136154 readonly snapshot: OrchestrationShellSnapshot;
137155 readonly settings?: ServerSettings;
......@@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
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* (
281299});
282300
283301describe("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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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";
1717import { forkParked } from "../serverActivation.ts";
1818import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
1919import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts";
2021import {
2122 isAutoSettlementCandidate,
2223 resolveAutoSettlementAt,
......@@ -46,10 +47,8 @@ export const make = Effect.gen(function* () {
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* () {
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* () {
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* () {
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* () {
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
......@@ -1,6 +1,7 @@
11import {
22 EventId,
33 MessageId,
4 ThreadLinkedPullRequest,
45 UserInputRequestedPayload,
56 isImportedAgentSessionMessageId,
67 type OrchestrationCommand,
......@@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
3839
3940const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
4041const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);
42const 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"
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
......@@ -86,6 +86,7 @@ describe("orchestration projector", () => {
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", () => {
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
......@@ -336,6 +336,7 @@ export function projectEvent(
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(
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) => {
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) => {
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) => {
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) => {
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) => {
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
......@@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(
2020 Struct.assign({
2121 modelSelection: Schema.fromJsonString(ModelSelection),
2222 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
23 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
2324 }),
2425);
2526type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
......@@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
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* () {
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* () {
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* () {
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* () {
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
......@@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t
5959import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
6060import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
6161import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
6263
6364/**
6465 * Migration loader with all migrations defined inline.
......@@ -118,6 +119,7 @@ export const migrationEntries = [
118119 [45, "ProjectionProjectsAutoPull", Migration0045],
119120 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
120121 [47, "ProjectionProjectIcon", Migration0047],
122 [48, "ProjectionThreadBranchPullRequest", Migration0048],
121123] as const;
122124
123125export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "branch_pull_request_json")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN branch_pull_request_json TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
......@@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({
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: {
3838 ).pipe(Layer.provide(ProcessRunner.layer));
3939
4040it.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) => {
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) => {
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) => {
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 {
2525export 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* (
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
......@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
7171import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
7272import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
7373import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
74import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
7475import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
7576import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
7677import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
......@@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe(
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 {
4747 sanitizeNewRefName,
4848 shouldIncludeBranchPickerItem,
4949} from "./BranchToolbar.logic";
50import {
51 ChangeRequestStatusIcon,
52 prStatusIndicator,
53 resolveThreadPr,
54} from "./ThreadStatusIndicators";
50import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators";
5551import { Button } from "./ui/button";
5652import { Switch } from "./ui/switch";
5753import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
......@@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({
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
......@@ -60,6 +60,7 @@ import {
6060 shouldDockDraftHeroForSubmission,
6161 shouldReleaseTimelineAnchorForToolActivity,
6262 shouldOpenProactivePullRequest,
63 shouldRetargetThreadPullRequestPanel,
6364 shouldOpenProactiveTurnDiff,
6465 shouldRenderPreviewMiniPlayer,
6566 shouldShowBranchMismatchBanner,
......@@ -249,6 +250,40 @@ describe("proactive panels", () => {
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
......@@ -15,6 +15,7 @@ import {
1515 type ScopedProjectRef,
1616 type ScopedThreadRef,
1717 type ThreadId,
18 type ThreadLinkedPullRequest,
1819 type TurnId,
1920} from "@t3tools/contracts";
2021import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
......@@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice(
132133 };
133134}
134135
136/** Follow a changed server link only when the panel still shows the previous linked PR. */
137export 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
135154export 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 {
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";
188189import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
189190import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
190191import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
191import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";
192import { RightPanelTabs } from "./RightPanelTabs";
192193import { AgentsPanel } from "./AgentsPanel";
193194import {
194195 deriveAgentPanelModel,
......@@ -337,11 +338,6 @@ import {
337338 shouldShowThreadErrorBanner,
338339 ThreadErrorBanner,
339340} from "./chat/ThreadErrorBanner";
340import {
341 resolveDisplayedThreadPr,
342 threadChangeRequestSnapshotsAtom,
343 useLinkedThreadPullRequest,
344} from "./ThreadStatusIndicators";
345341import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
346342import { ComposerSurface } from "./chat/ComposerSurface";
347343import {
......@@ -380,6 +376,7 @@ import {
380376 shouldShowBranchMismatchBanner,
381377 shouldShowPlanFollowUpPrompt,
382378 shouldOpenProactivePullRequest,
379 shouldRetargetThreadPullRequestPanel,
383380 shouldOpenProactiveTurnDiff,
384381 shouldRenderPreviewMiniPlayer,
385382 getStartedThreadModelChangeBlockReason,
......@@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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
3import {
4 scopedThreadKey,
5 scopeProjectRef,
6 scopeThreadRef,
7} from "@t3tools/client-runtime/environment";
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
84import {
95 canCreateProjectInEnvironment,
106 getCloneDestinationBrowsePath,
......@@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem";
8177import { projectEnvironment } from "../state/projects";
8278import { useEnvironmentQuery } from "../state/query";
8379import { sourceControlEnvironment } from "../state/sourceControl";
84import { vcsEnvironment } from "../state/vcs";
8580import { useAtomCommand } from "../state/use-atom-command";
8681import { useAtomQueryRunner } from "../state/use-atom-query-runner";
8782import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
88import { useProject, useProjects, useThreadShells } from "../state/entities";
83import { useProjects, useThreadShells } from "../state/entities";
8984import { useThreadSearch } from "../state/queries";
90import * as ThreadPr from "./ThreadStatusIndicators";
9185import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
9286import {
9387 appendBrowsePathSegment,
......@@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: {
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: {
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 {
1616 ChangeRequestStatusIcon,
1717 prStatusIndicator,
1818 PrStatusTooltipContent,
19 resolveThreadPr,
2019 terminalStatusFromRunningIds,
2120 ThreadStatusLabel,
2221 ThreadWorktreeIndicator,
......@@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus";
8382import { isMacPlatform } from "../lib/utils";
8483import {
8584 readThreadShell,
86 useProject,
8785 useProjects,
8886 useThreadShells,
8987 useThreadShellsForProjectRefs,
......@@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate";
116114
117115import { useThreadActions } from "../hooks/useThreadActions";
118116import { projectEnvironment } from "../state/projects";
119import { useEnvironmentQuery } from "../state/query";
120117import { threadEnvironment, useEnvironmentThread } from "../state/threads";
121import { vcsEnvironment } from "../state/vcs";
122118import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
123119import {
124120 buildThreadRouteParams,
......@@ -191,7 +187,6 @@ import {
191187 orderItemsByPreferredIds,
192188 shouldClearThreadSelectionOnMouseDown,
193189 sortProjectsForSidebar,
194 useRetainedValue,
195190 useSidebarRowSubscriptionLease,
196191 useThreadJumpHintVisibility,
197192 ThreadStatusPill,
......@@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: {
314309
315310interface 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
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
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 {
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(
996954 showEmptyThreadState,
997955 shouldShowThreadPanel,
998956 isThreadListExpanded,
999 projectCwd,
1000957 activeRouteThreadKey,
1001958 openPullRequestsInRightPanel,
1002959 threadJumpLabelByKey,
......@@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
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
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 {
167167import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
168168import {
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: {
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: {
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: {
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: {
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() {
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() {
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-525
......@@ -1,7 +1,5 @@
11import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import { AtomRegistry } from "effect/unstable/reactivity";
53import {
64 GitMergeIcon,
75 GitPullRequestClosedIcon,
......@@ -11,15 +9,8 @@ import {
119
1210import {
1311 ChangeRequestStatusIcon,
14 nextThreadChangeRequestSnapshot,
1512 prStatusIndicator,
16 resolveDisplayedThreadPr,
17 resolveDisplayedThreadPrProvider,
18 resolveThreadPr,
1913 settledPrHoverColorClass,
20 threadChangeRequestSnapshotsEqual,
21 threadChangeRequestSnapshotsAtom,
22 type ThreadChangeRequestSnapshot,
2314} from "./ThreadStatusIndicators";
2415import { newestPullRequestSummary } from "../state/pullRequests";
2516
......@@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
5748 };
5849}
5950
60function 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
71function snapshotFor(
72 branch: string,
73 pr: NonNullable<VcsStatusResult["pr"]>,
74 sourceControlProvider?: VcsStatusResult["sourceControlProvider"],
75): ThreadChangeRequestSnapshot {
76 return { branch, pr, sourceControlProvider };
77}
78
7951function pullRequestSummary(
8052 state: PullRequestSummary["state"],
8153 updatedAt: string,
......@@ -117,503 +89,6 @@ describe("shared pull request state", () => {
11789 });
11890});
11991
120describe("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
160describe("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
595describe("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
61792describe("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 @@
1import {
2 scopeProjectRef,
3 scopedThreadKey,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
62import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
73import {
84 type EnvironmentId,
......@@ -10,17 +6,13 @@ import {
106 type ThreadLinkedPullRequest,
117 type VcsStatusResult,
128} from "@t3tools/contracts";
13import { Atom } from "effect/unstable/reactivity";
149import { FolderGit2Icon, TerminalIcon } from "lucide-react";
1510import { useMemo } from "react";
16import { appAtomRegistry } from "../rpc/atomRegistry";
1711import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1812import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
19import { useProject } from "../state/entities";
2013import { useEnvironmentQuery } from "../state/query";
2114import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
2215import { useThreadRunningTerminalIds } from "../state/terminalSessions";
23import { vcsEnvironment } from "../state/vcs";
2416import { useUiStateStore } from "../uiStateStore";
2517import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
2618import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
......@@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus {
5143 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
5244}
5345
46/** Keep cached summaries visible when an offscreen row stops live queries. */
5447export 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 }
178172 );
179173}
180174
181export 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 */
201export interface ThreadChangeRequestSnapshot {
202 readonly branch: string;
203 readonly pr: NonNullable<ThreadPr>;
204 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
205 readonly linkedPullRequest?: ThreadLinkedPullRequest;
206}
207
208export const threadChangeRequestSnapshotsAtom = Atom.make<
209 ReadonlyMap<string, ThreadChangeRequestSnapshot>
210>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots"));
211
212function isTerminalChangeRequestState(
213 state: NonNullable<ThreadPr>["state"],
214): state is "merged" | "closed" {
215 return state === "merged" || state === "closed";
216}
217
218function 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
227function 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
240export 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
259export 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 */
286export 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 */
354export 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
400export 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
446175export function terminalStatusFromRunningIds(
447176 runningTerminalIds: ReadonlyArray<string>,
448177): TerminalStatusIndicator | null {
......@@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
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-12
......@@ -8,7 +8,6 @@ import {
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({
454453 refreshToken: forcedRefreshToken = 0,
455454 onActed,
456455 onClose,
457 onStateChange,
458456 context = "page",
459457 composerDraftTarget,
460458}: {
......@@ -482,8 +480,6 @@ export function PullRequestDetailPanel({
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({
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.
4949
5050## Link a pull request
5151
52The server finds the PR for each unsettled thread's saved branch, even when your
53apps are closed. Settled threads keep their saved links. Update the server if
54automatic branch links do not appear.
55
5256On 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
58same link to return to the branch PR, if one exists.
5459The 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", () => {
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
......@@ -97,6 +97,7 @@ export function applyThreadDetailEvent(
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(
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
......@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Exit from "effect/Exit";
44import * as Schema from "effect/Schema";
5import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";
56
67import {
78 DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () =>
837838 }),
838839);
839840
841it.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
840882it.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
......@@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({
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({
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({
11831185 title: Schema.optional(TrimmedNonEmptyString),
11841186});
11851187
1188const 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
11861205const InternalOrchestrationCommand = Schema.Union([
11871206 ThreadAutoSettleCommand,
11881207 ThreadSessionSetCommand,
......@@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([
11941213 ThreadActivityAppendCommand,
11951214 ThreadRevertCompleteCommand,
11961215 ThreadTitleRegenerationCompleteCommand,
1216 ThreadPullRequestSyncCommand,
11971217]);
11981218export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
11991219
......@@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
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", () => {
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", () => {
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", () => {
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: {
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",