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) {...@@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) {
416 [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery],416 [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
417 );417 );
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
427 const projectByKey = useMemo(() => {419 const projectByKey = useMemo(() => {
428 const map = new Map<string, EnvironmentProject>();420 const map = new Map<string, EnvironmentProject>();
429 for (const project of props.projects) {421 for (const project of props.projects) {
...@@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) {
850 onPinThread={handlePinThread}842 onPinThread={handlePinThread}
851 onUnpinThread={handleUnpinThread}843 onUnpinThread={handleUnpinThread}
852 onMovePinnedThread={handleMovePinnedThread}844 onMovePinnedThread={handleMovePinnedThread}
853 projectCwd={
854 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
855 }
856 onSwipeableClose={handleSwipeableClose}845 onSwipeableClose={handleSwipeableClose}
857 onSwipeableWillOpen={handleSwipeableWillOpen}846 onSwipeableWillOpen={handleSwipeableWillOpen}
858 />847 />
...@@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) {
875 machineByEnvironmentId,864 machineByEnvironmentId,
876 pinReorderEnvironmentIds,865 pinReorderEnvironmentIds,
877 projectByKey,866 projectByKey,
878 projectCwdByKey,
879 props.onArchiveThread,867 props.onArchiveThread,
880 props.onDeletePendingTask,868 props.onDeletePendingTask,
881 props.onSelectPendingTask,869 props.onSelectPendingTask,
...@@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) {
903 const v2ExtraData = useMemo(891 const v2ExtraData = useMemo(
904 () => ({892 () => ({
905 projectByKey,893 projectByKey,
906 projectCwdByKey,
907 projectTitleByProjectKey: v2ProjectTitleByProjectKey,894 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
908 serverConfigs,895 serverConfigs,
909 savedConnectionsById: props.savedConnectionsById,896 savedConnectionsById: props.savedConnectionsById,
...@@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) {
913 }),900 }),
914 [901 [
915 projectByKey,902 projectByKey,
916 projectCwdByKey,
917 props.searchQuery,903 props.searchQuery,
918 props.savedConnectionsById,904 props.savedConnectionsById,
919 serverConfigs,905 serverConfigs,
...@@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) {
925911
926 const extraData = useMemo(912 const extraData = useMemo(
927 () => ({913 () => ({
928 projectCwdByKey,
929 savedConnectionsById: props.savedConnectionsById,914 savedConnectionsById: props.savedConnectionsById,
930 searchQuery: props.searchQuery,915 searchQuery: props.searchQuery,
931 threadSearchMatchByKey,916 threadSearchMatchByKey,
932 }),917 }),
933 [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],918 [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
934 );919 );
935920
936 const renderItem = useCallback(921 const renderItem = useCallback(
...@@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) {
982 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null967 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
983 }968 }
984 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}969 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
985 projectCwd={
986 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
987 null
988 }
989 isLast={item.isLast}970 isLast={item.isLast}
990 searchMatch={threadSearchMatchByKey.get(971 searchMatch={threadSearchMatchByKey.get(
991 threadSearchMatchKey({972 threadSearchMatchKey({
...@@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) {
1021 handleSwipeableWillOpen,1002 handleSwipeableWillOpen,
1022 handleRegenerateThreadTitle,1003 handleRegenerateThreadTitle,
1023 machineByEnvironmentId,1004 machineByEnvironmentId,
1024 projectCwdByKey,
1025 props.onArchiveThread,1005 props.onArchiveThread,
1026 props.onDeletePendingTask,1006 props.onDeletePendingTask,
1027 props.onDeleteThread,1007 props.onDeleteThread,
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+4-33
...@@ -12,10 +12,8 @@ import {...@@ -12,10 +12,8 @@ import {
1212
13import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";13import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
14import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";14import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15import { useProject, useThreadShell } from "../../state/entities";15import { useThreadShell } from "../../state/entities";
16import { useEnvironmentQuery } from "../../state/query";
17import type { GitActionProgress } from "../../state/use-vcs-action-state";16import type { GitActionProgress } from "../../state/use-vcs-action-state";
18import { vcsEnvironment } from "../../state/vcs";
19import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";17import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
20import {18import {
21 dispatchHardwareKeyboardCommand,19 dispatchHardwareKeyboardCommand,
...@@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({...@@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({
40 const navigation = useNavigation();38 const navigation = useNavigation();
41 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);39 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
42 const activeThread = useThreadShell(activeThreadRef);40 const activeThread = useThreadShell(activeThreadRef);
43 const activeProjectRef = useMemo(
44 () =>
45 activeThread === null
46 ? null
47 : {
48 environmentId: activeThread.environmentId,
49 projectId: activeThread.projectId,
50 },
51 [activeThread],
52 );
53 const activeProject = useProject(activeProjectRef);
54 const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null;
55 const gitStatus = useEnvironmentQuery(
56 activeThread !== null &&
57 activeThread.linkedPullRequest == null &&
58 activeThread.branch !== null &&
59 activeThreadCwd !== null
60 ? vcsEnvironment.status({
61 environmentId: activeThread.environmentId,
62 input: { cwd: activeThreadCwd },
63 })
64 : null,
65 ).data;
66 const detectedPullRequestUrl =
67 activeThread?.branch != null && gitStatus?.refName === activeThread.branch
68 ? (gitStatus.pr?.url ?? null)
69 : null;
70 const copyTarget = useMemo(41 const copyTarget = useMemo(
71 () =>42 () =>
72 activeThreadRef === null43 activeThreadRef === null
73 ? null44 ? null
74 : resolveThreadReferenceCopyTarget({45 : resolveThreadReferenceCopyTarget({
75 threadId: activeThread?.id ?? activeThreadRef.threadId,46 threadId: activeThread?.id ?? activeThreadRef.threadId,
76 linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null,47 linkedPullRequestUrl:
77 detectedPullRequestUrl,48 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
78 }),49 }),
79 [activeThread, activeThreadRef, detectedPullRequestUrl],50 [activeThread, activeThreadRef],
80 );51 );
81 const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);52 const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
82 const copyRequestIdRef = useRef(0);53 const copyRequestIdRef = useRef(0);
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx-15
...@@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane(...@@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane(
353 }),353 }),
354 [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery],354 [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery],
355 );355 );
356 const projectCwdByKey = useMemo(() => {
357 const map = new Map<string, string>();
358 for (const project of projects) {
359 map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
360 }
361 return map;
362 }, [projects]);
363 const projectByKey = useMemo(() => {356 const projectByKey = useMemo(() => {
364 const map = new Map<string, EnvironmentProject>();357 const map = new Map<string, EnvironmentProject>();
365 for (const project of projects) {358 for (const project of projects) {
...@@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane(...@@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane(
742 () => ({735 () => ({
743 selectedThreadKey: props.selectedThreadKey ?? "",736 selectedThreadKey: props.selectedThreadKey ?? "",
744 projectByKey,737 projectByKey,
745 projectCwdByKey,
746 projectTitleByProjectKey,738 projectTitleByProjectKey,
747 savedConnectionsById,739 savedConnectionsById,
748 serverConfigs,740 serverConfigs,
...@@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane(...@@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane(
752 [744 [
753 props.selectedThreadKey,745 props.selectedThreadKey,
754 projectByKey,746 projectByKey,
755 projectCwdByKey,
756 projectTitleByProjectKey,747 projectTitleByProjectKey,
757 savedConnectionsById,748 savedConnectionsById,
758 serverConfigs,749 serverConfigs,
...@@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane(...@@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane(
914 onPinThread={pinThread}905 onPinThread={pinThread}
915 onUnpinThread={unpinThread}906 onUnpinThread={unpinThread}
916 onMovePinnedThread={movePinnedThread}907 onMovePinnedThread={movePinnedThread}
917 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
918 onSwipeableClose={handleSwipeableClose}908 onSwipeableClose={handleSwipeableClose}
919 onSwipeableWillOpen={handleSwipeableWillOpen}909 onSwipeableWillOpen={handleSwipeableWillOpen}
920 simultaneousSwipeGesture={sidebarScrollGesture}910 simultaneousSwipeGesture={sidebarScrollGesture}
...@@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane(...@@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane(
1000 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null990 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
1001 }991 }
1002 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}992 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
1003 projectCwd={
1004 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
1005 null
1006 }
1007 isLast={item.isLast}993 isLast={item.isLast}
1008 searchMatch={threadSearchMatchByKey.get(994 searchMatch={threadSearchMatchByKey.get(
1009 threadSearchMatchKey({995 threadSearchMatchKey({
...@@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane(...@@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane(
1054 pinThread,1040 pinThread,
1055 pinningEnvironmentIds,1041 pinningEnvironmentIds,
1056 projectByKey,1042 projectByKey,
1057 projectCwdByKey,
1058 projectTitleByProjectKey,1043 projectTitleByProjectKey,
1059 regenerateThreadTitle,1044 regenerateThreadTitle,
1060 props.onNewThreadInProject,1045 props.onNewThreadInProject,
apps/mobile/src/features/threads/thread-list-items.tsx+1-2
...@@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
416 readonly thread: EnvironmentThreadShell;416 readonly thread: EnvironmentThreadShell;
417 readonly environmentLabel: string | null;417 readonly environmentLabel: string | null;
418 readonly environmentMachine?: EnvironmentMachineKind;418 readonly environmentMachine?: EnvironmentMachineKind;
419 readonly projectCwd: string | null;
420 readonly searchMatch?: EnvironmentThreadSearchMatch;419 readonly searchMatch?: EnvironmentThreadSearchMatch;
421 readonly searchQuery?: string;420 readonly searchQuery?: string;
422 readonly isLast: boolean;421 readonly isLast: boolean;
...@@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =452 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
454 props;453 props;
455 const status = resolveThreadStatus(thread);454 const status = resolveThreadStatus(thread);
456 const pr = useThreadPr(thread, props.projectCwd);455 const pr = useThreadPr(thread);
457 const timestamp = relativeTime(456 const timestamp = relativeTime(
458 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,457 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
459 );458 );
apps/mobile/src/features/threads/thread-list-v2-items.tsx+1-2
...@@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
377 readonly canMovePinnedDown?: boolean;377 readonly canMovePinnedDown?: boolean;
378 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;378 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
379 readonly onSwipeableClose: (methods: SwipeableMethods) => void;379 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
380 readonly projectCwd?: string | null;
381 readonly searchMatch?: EnvironmentThreadSearchMatch;380 readonly searchMatch?: EnvironmentThreadSearchMatch;
382 readonly searchQuery?: string;381 readonly searchQuery?: string;
383 readonly simultaneousSwipeGesture?: ComponentProps<382 readonly simultaneousSwipeGesture?: ComponentProps<
...@@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
403 const snoozedRow = props.snoozed === true;402 const snoozedRow = props.snoozed === true;
404 const pinnedRow = props.pinned === true;403 const pinnedRow = props.pinned === true;
405404
406 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);405 const pr = useThreadPr(thread);
407406
408 const theme = useUniwindTheme();407 const theme = useUniwindTheme();
409 const screenColor = theme["--color-screen"];408 const screenColor = theme["--color-screen"];
apps/mobile/src/state/use-thread-pr.ts+23-46
...@@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime";...@@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime";
12import { appAtomRegistry } from "./atom-registry";12import { appAtomRegistry } from "./atom-registry";
13import { useEnvironmentQuery } from "./query";13import { useEnvironmentQuery } from "./query";
14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15import { vcsEnvironment } from "./vcs";
1615
17const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);16const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
18const MAX_THREAD_PR_SNAPSHOTS = 500;17const MAX_THREAD_PR_SNAPSHOTS = 500;
1918
20interface ThreadPrSnapshot {19interface ThreadPrSnapshot {
...@@ -23,7 +22,7 @@ interface ThreadPrSnapshot {...@@ -23,7 +22,7 @@ interface ThreadPrSnapshot {
23}22}
2423
25// One bounded cache survives row virtualization without retaining one live24// 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.
27const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(26const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(
28 Atom.keepAlive,27 Atom.keepAlive,
29 Atom.withLabel("mobile:thread-pr-snapshots"),28 Atom.withLabel("mobile:thread-pr-snapshots"),
...@@ -36,20 +35,13 @@ export {...@@ -36,20 +35,13 @@ export {
36} from "./thread-pr-presentation";35} from "./thread-pr-presentation";
3736
38/**37/**
39 * Live PR status for a thread's branch. Subscriptions are deduplicated per38 * Live status for a thread's server-provided PR. Visible rows share a summary
40 * (environmentId, cwd) by the atom family, so many rows on the same worktree39 * request for the same PR in the same environment.
41 * or project root share one stream — and virtualization means only visible
42 * rows subscribe at all.
43 */40 */
44export function useThreadPr(41export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
45 thread: EnvironmentThreadShell,42 const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null;
46 projectCwd: string | null,
47): ThreadPrPresentation | null {
48 const cwd = thread.worktreePath ?? projectCwd;
49 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));43 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
50 const snapshotIdentity = JSON.stringify(44 const snapshotIdentity = JSON.stringify(pullRequestRef);
51 thread.linkedPullRequest ?? { branch: thread.branch, cwd },
52 );
53 // Select this row's entry so writes for other rows do not re-render it.45 // Select this row's entry so writes for other rows do not re-render it.
54 const snapshotEntry = useAtomValue(46 const snapshotEntry = useAtomValue(
55 threadPrSnapshotsAtom,47 threadPrSnapshotsAtom,
...@@ -59,45 +51,30 @@ export function useThreadPr(...@@ -59,45 +51,30 @@ export function useThreadPr(
59 ),51 ),
60 );52 );
61 const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null;53 const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null;
62 const gitStatus = useEnvironmentQuery(54 const pullRequestSummary = useEnvironmentQuery(
63 thread.linkedPullRequest == null && thread.branch !== null && cwd !== null55 pullRequestRef === null
64 ? vcsEnvironment.status({
65 environmentId: thread.environmentId,
66 input: { cwd },
67 })
68 : null,
69 );
70 const linkedPullRequest = useEnvironmentQuery(
71 thread.linkedPullRequest == null
72 ? null56 ? null
73 : linkedPullRequestDetailAtom({57 : pullRequestSummaryAtom({
74 environmentId: thread.environmentId,58 environmentId: thread.environmentId,
75 input: {59 input: {
76 projectId: thread.linkedPullRequest.projectId,60 projectId: pullRequestRef.projectId,
77 repository: thread.linkedPullRequest.repository,61 repository: pullRequestRef.repository,
78 number: thread.linkedPullRequest.number,62 number: pullRequestRef.number,
79 },63 },
80 }),64 }),
81 );65 );
8266
83 const live = useMemo<ThreadPrPresentation | null | undefined>(() => {67 const live = useMemo<ThreadPrPresentation | null | undefined>(() => {
84 if (thread.linkedPullRequest != null) {68 if (pullRequestRef === null) return null;
85 const detail = linkedPullRequest.data;69 const summary = pullRequestSummary.data;
86 return detail === null70 return summary === null
87 ? undefined71 ? undefined
88 : presentThreadPr(pullRequestDetailToVcsStatus(detail), {72 : presentThreadPr(pullRequestDetailToVcsStatus(summary), {
89 kind: detail.provider,73 kind: summary.provider,
90 name: detail.provider,74 name: summary.provider,
91 baseUrl: "",75 baseUrl: "",
92 });76 });
93 }77 }, [pullRequestRef, pullRequestSummary.data]);
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]);
10178
102 useEffect(() => {79 useEffect(() => {
103 if (live === undefined) return;80 if (live === undefined) return;
apps/mobile/src/state/use-thread-selection.ts+1
...@@ -55,6 +55,7 @@ function threadDetailToShell(...@@ -55,6 +55,7 @@ function threadDetailToShell(
55 branch: thread.branch,55 branch: thread.branch,
56 worktreePath: thread.worktreePath,56 worktreePath: thread.worktreePath,
57 linkedPullRequest: thread.linkedPullRequest ?? null,57 linkedPullRequest: thread.linkedPullRequest ?? null,
58 branchPullRequest: thread.branchPullRequest ?? null,
58 latestTurn: thread.latestTurn,59 latestTurn: thread.latestTurn,
59 createdAt: thread.createdAt,60 createdAt: thread.createdAt,
60 updatedAt: thread.updatedAt,61 updatedAt: thread.updatedAt,
apps/server/integration/OrchestrationEngineHarness.integration.ts+7
...@@ -67,6 +67,7 @@ import {...@@ -67,6 +67,7 @@ import {
67} from "../src/orchestration/Services/OrchestrationEngine.ts";67} from "../src/orchestration/Services/OrchestrationEngine.ts";
68import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";68import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
69import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";69import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
70import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";71import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
71import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";72import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
72import {73import {
...@@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = (
394 drainThrough: () => Effect.void,395 drainThrough: () => Effect.void,
395 }),396 }),
396 ),397 ),
398 Layer.provideMerge(
399 Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
400 start: () => Effect.void,
401 drain: Effect.void,
402 }),
403 ),
397 Layer.provideMerge(404 Layer.provideMerge(
398 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {405 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
399 start: () => Effect.void,406 start: () => Effect.void,
apps/server/src/git/GitManager.test.ts+228-7
...@@ -13,6 +13,7 @@ import * as Logger from "effect/Logger";...@@ -13,6 +13,7 @@ import * as Logger from "effect/Logger";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";14import * as PlatformError from "effect/PlatformError";
15import * as References from "effect/References";15import * as References from "effect/References";
16import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";17import * as Scope from "effect/Scope";
17import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
18import { expect } from "vite-plus/test";19import { expect } from "vite-plus/test";
...@@ -30,10 +31,13 @@ import {...@@ -30,10 +31,13 @@ import {
30 TextGenerationError,31 TextGenerationError,
31} from "@t3tools/contracts";32} from "@t3tools/contracts";
32import * as GitHubCli from "../sourceControl/GitHubCli.ts";33import * as GitHubCli from "../sourceControl/GitHubCli.ts";
34import * as GitLabCli from "../sourceControl/GitLabCli.ts";
33import * as TextGeneration from "../textGeneration/TextGeneration.ts";35import * as TextGeneration from "../textGeneration/TextGeneration.ts";
34import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";36import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
35import * as VcsProcess from "../vcs/VcsProcess.ts";37import * as VcsProcess from "../vcs/VcsProcess.ts";
36import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";38import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
39import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
40import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
37import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";41import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
38import * as ServerConfig from "../config.ts";42import * as ServerConfig from "../config.ts";
39import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";43import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
...@@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";...@@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
41import * as ServerSettings from "../serverSettings.ts";45import * as ServerSettings from "../serverSettings.ts";
42import * as GitManager from "./GitManager.ts";46import * as GitManager from "./GitManager.ts";
4347
48const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49
44interface FakeGhScenario {50interface FakeGhScenario {
45 prListSequence?: string[];51 prListSequence?: string[];
46 prListByHeadSelector?: Record<string, string>;52 prListByHeadSelector?: Record<string, string>;
...@@ -620,6 +626,7 @@ function preparePullRequestThread(...@@ -620,6 +626,7 @@ function preparePullRequestThread(
620626
621function makeManager(input?: {627function makeManager(input?: {
622 ghScenario?: FakeGhScenario;628 ghScenario?: FakeGhScenario;
629 sourceControlProvider?: SourceControlProvider["Service"];
623 textGeneration?: Partial<FakeGitTextGeneration>;630 textGeneration?: Partial<FakeGitTextGeneration>;
624 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];631 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
625 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];632 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
...@@ -659,7 +666,10 @@ function makeManager(input?: {...@@ -659,7 +666,10 @@ function makeManager(input?: {
659 );666 );
660 const sourceControlRegistryLayer = Layer.effect(667 const sourceControlRegistryLayer = Layer.effect(
661 SourceControlProviderRegistry.SourceControlProviderRegistry,668 SourceControlProviderRegistry.SourceControlProviderRegistry,
662 GitHubSourceControlProvider.make.pipe(669 (input?.sourceControlProvider === undefined
670 ? GitHubSourceControlProvider.make
671 : Effect.succeed(input.sourceControlProvider)
672 ).pipe(
663 Effect.map((provider) =>673 Effect.map((provider) =>
664 SourceControlProviderRegistry.SourceControlProviderRegistry.of({674 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
665 get: () => Effect.succeed(provider),675 get: () => Effect.succeed(provider),
...@@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1146 branch: "feature/saved-branch",1156 branch: "feature/saved-branch",
1147 });1157 });
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",
1150 state: "open",1165 state: "open",
1151 closedAt: null,1166 closedAt: null,
1152 mergedAt: null,1167 mergedAt: null,
...@@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11911206
1192 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });1207 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
11931208
1194 expect(pullRequest).toEqual({1209 expect(pullRequest).toMatchObject({
1195 state: "merged",1210 state: "merged",
1196 closedAt: null,1211 closedAt: null,
1197 mergedAt: "2026-04-07T15:00:00Z",1212 mergedAt: "2026-04-07T15:00:00Z",
...@@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1244 branch: "feature/deleted-local-branch",1259 branch: "feature/deleted-local-branch",
1245 });1260 });
12461261
1247 expect(pullRequest).toEqual({1262 expect(pullRequest).toMatchObject({
1248 state: "merged",1263 state: "merged",
1249 closedAt: null,1264 closedAt: null,
1250 mergedAt: null,1265 mergedAt: null,
...@@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1310 branch: "feature/deleted-fork-branch",1325 branch: "feature/deleted-fork-branch",
1311 });1326 });
13121327
1313 expect(pullRequest).toEqual({1328 expect(pullRequest).toMatchObject({
1314 state: "merged",1329 state: "merged",
1315 closedAt: null,1330 closedAt: null,
1316 mergedAt: null,1331 mergedAt: null,
...@@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1432 updatedAt: "2026-04-07T15:00:00Z",1447 updatedAt: "2026-04-07T15:00:00Z",
1433 },1448 },
1434 ]),1449 ]),
1450 encodeCliJson([
1451 {
1452 number: 221,
1453 title: "New PR on the same branch",
1454 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1455 baseRefName: "main",
1456 headRefName: "feature/shared-pr-cache",
1457 state: "OPEN",
1458 updatedAt: "2026-04-08T15:00:00Z",
1459 },
1460 ]),
1435 ],1461 ],
1436 },1462 },
1437 });1463 });
...@@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1445 expect(status.pr?.state).toBe("merged");1471 expect(status.pr?.state).toBe("merged");
1446 expect(pullRequest?.state).toBe("merged");1472 expect(pullRequest?.state).toBe("merged");
1447 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);1473 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1474 const refreshed = yield* manager.branchPullRequest(
1475 { cwd: repoDir, branch: "feature/shared-pr-cache" },
1476 { refresh: true },
1477 );
1478 expect(refreshed).toMatchObject({
1479 number: 221,
1480 state: "open",
1481 repositoryKey: "github.com/pingdotgg/codething-mvp",
1482 });
1483 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1448 }),1484 }),
1449 );1485 );
14501486
...@@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1459 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);1495 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);
1460 yield* runGit(repoDir, ["checkout", "main"]);1496 yield* runGit(repoDir, ["checkout", "main"]);
14611497
1462 const { manager } = yield* makeManager({1498 const { manager, ghCalls } = yield* makeManager({
1463 ghScenario: {1499 ghScenario: {
1464 failWith: new GitHubCli.GitHubCliUnavailableError({1500 failWith: new GitHubCli.GitHubCliUnavailableError({
1465 command: "gh",1501 command: "gh",
...@@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1474 .pipe(Effect.flip);1510 .pipe(Effect.flip);
14751511
1476 expect(error._tag).toBe("SourceControlProviderError");1512 expect(error._tag).toBe("SourceControlProviderError");
1513 const refreshError = yield* manager
1514 .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true })
1515 .pipe(Effect.flip);
1516 expect(refreshError._tag).toBe("SourceControlProviderError");
1517 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1477 }),1518 }),
1478 );1519 );
14791520
...@@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1594 expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000);1635 expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000);
1595 });1636 });
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
1597 it.effect(1818 it.effect(
1598 "status ignores unrelated fork PRs when the current branch tracks the same repository",1819 "status ignores unrelated fork PRs when the current branch tracks the same repository",
1599 () =>1820 () =>
...@@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2149 branch: "feature/fork-settle",2370 branch: "feature/fork-settle",
2150 });2371 });
21512372
2152 expect(pullRequest).toEqual({2373 expect(pullRequest).toMatchObject({
2153 state: "merged",2374 state: "merged",
2154 closedAt: null,2375 closedAt: null,
2155 mergedAt: null,2376 mergedAt: null,
apps/server/src/git/GitManager.ts+46-20
...@@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti...@@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti
77 readonly refreshMissingPullRequest?: boolean;77 readonly refreshMissingPullRequest?: boolean;
78}78}
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
80interface SourceControlTextGenerationSettings {87interface SourceControlTextGenerationSettings {
81 readonly modelSelection: ModelSelection;88 readonly modelSelection: ModelSelection;
82 readonly style: SourceControlWritingStyleSettings;89 readonly style: SourceControlWritingStyleSettings;
...@@ -96,18 +103,10 @@ export class GitManager extends Context.Service<...@@ -96,18 +103,10 @@ export class GitManager extends Context.Service<
96 options?: GitRemoteStatusOptions,103 options?: GitRemoteStatusOptions,
97 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;104 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
98 /** Resolve the PR for a saved branch without changing the current checkout. */105 /** Resolve the PR for a saved branch without changing the current checkout. */
99 readonly branchPullRequest: (input: {106 readonly branchPullRequest: (
100 readonly cwd: string;107 input: { readonly cwd: string; readonly branch: string },
101 readonly branch: string;108 options?: { readonly refresh?: boolean },
102 }) => Effect.Effect<109 ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
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 >;
111 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;110 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
112 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;111 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
113 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;112 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
...@@ -214,9 +213,26 @@ interface BranchHeadContext {...@@ -214,9 +213,26 @@ interface BranchHeadContext {
214 isCrossRepository: boolean;213 isCrossRepository: boolean;
215}214}
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
217function parseRepositoryNameFromPullRequestUrl(url: string): string | null {233function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
218 const trimmed = url.trim();234 const trimmed = url.trim();
219 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);235 const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
220 const repositoryName = match?.[1]?.trim() ?? "";236 const repositoryName = match?.[1]?.trim() ?? "";
221 return repositoryName.length > 0 ? repositoryName : null;237 return repositoryName.length > 0 ? repositoryName : null;
222}238}
...@@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName(...@@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName(
254 return `t3code/pr-${pullRequest.number}/${suffix}`;270 return `t3code/pr-${pullRequest.number}/${suffix}`;
255}271}
256272
257function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {273function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
258 const trimmed = url?.trim() ?? "";274 const trimmed = url?.trim() ?? "";
259 if (trimmed.length === 0) {275 if (trimmed.length === 0) {
260 return null;276 return null;
261 }277 }
262278
263 const match =279 const match =
264 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(280 /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
265 trimmed,281 trimmed,
266 );282 );
267 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";283 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
...@@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null...@@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null
273 if (trimmed.length === 0) {289 if (trimmed.length === 0) {
274 return null;290 return null;
275 }291 }
292 // GitLab reports the top-level group as owner. The full path distinguishes subgroups.
276 const [ownerLogin] = trimmed.split("/");293 const [ownerLogin] = trimmed.split("/");
277 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";294 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
278 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;295 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
...@@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () {...@@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () {
1261 }1278 }
12621279
1263 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);1280 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1264 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);1281 const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
1265 return {1282 return {
1266 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,1283 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
1267 repositoryNameWithOwner,1284 repositoryNameWithOwner,
...@@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () {...@@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () {
2031 });2048 });
2032 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(2049 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
2033 "branchPullRequest",2050 "branchPullRequest",
2034 )(function* ({ cwd, branch }) {2051 )(function* ({ cwd, branch }, options) {
2035 const cacheCwd = yield* normalizeStatusCacheKey(cwd);2052 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
2036 const remotes = yield* gitCore.execute({2053 const remotes = yield* gitCore.execute({
2037 operation: "GitManager.branchPullRequest.remotes",2054 operation: "GitManager.branchPullRequest.remotes",
...@@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () {...@@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () {
2111 localBranchExists,2128 localBranchExists,
2112 ...(localBranchExists ? {} : { remoteName }),2129 ...(localBranchExists ? {} : { remoteName }),
2113 });2130 });
2131 if (options?.refresh) {
2132 // A completed turn can create a PR or reuse a merged PR's branch.
2133 // Refresh successful answers, but keep failed lookups' retry backoff.
2134 const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
2135 Effect.orElseSucceed(() => Option.none()),
2136 );
2137 if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey);
2138 }
2114 let cached = yield* Cache.get(prLookupCache, cacheKey);2139 let cached = yield* Cache.get(prLookupCache, cacheKey);
2115 // The cached head context may have resolved on a different remote than2140 // The cached head context may have resolved on a different remote than
2116 // the saved upstream: a branch tracking origin/main but pushed to a fork2141 // the saved upstream: a branch tracking origin/main but pushed to a fork
...@@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () {...@@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () {
2165 ) {2190 ) {
2166 return null;2191 return null;
2167 }2192 }
2168 const statusPr = toStatusPr(latest);
2169 return {2193 return {
2170 state: statusPr.state,2194 ...toStatusPr(latest),
2171 updatedAt: statusPr.updatedAt,
2172 closedAt: latest.closedAt ?? null,2195 closedAt: latest.closedAt ?? null,
2173 mergedAt: latest.mergedAt ?? null,2196 mergedAt: latest.mergedAt ?? null,
2197 // Hosting CLIs can select an upstream repository instead of origin.
2198 // The returned PR URL names the repository that actually owns it.
2199 repositoryKey: pullRequestRepositoryKey(latest.url),
2174 };2200 };
2175 });2201 });
2176 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(2202 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 ProjectId,13 ProjectId,
14 ThreadId,14 ThreadId,
15 TurnId,15 TurnId,
16 type OrchestrationCommand,
16 type OrchestrationEvent,17 type OrchestrationEvent,
17 ProviderInstanceId,18 ProviderInstanceId,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
...@@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => {...@@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => {
993 await system.dispose();994 await system.dispose();
994 });995 });
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
996 it("allows authoritative worktree bootstrap to assign a temporary branch", async () => {1207 it("allows authoritative worktree bootstrap to assign a temporary branch", async () => {
997 const system = await createOrchestrationSystem();1208 const system = await createOrchestrationSystem();
998 const { engine } = system;1209 const { engine } = system;
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+17
...@@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () {
185 });185 });
186 }186 }
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
188 if (205 if (
189 envelope.command.type === "thread.auto-settle" &&206 envelope.command.type === "thread.auto-settle" &&
190 threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null207 threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11
...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
13import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";14import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
14import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";15import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
15import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";16import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
...@@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => {...@@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => {
65 drainThrough: () => Effect.void,66 drainThrough: () => Effect.void,
66 }),67 }),
67 ),68 ),
69 Layer.provideMerge(
70 Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
71 start: () => {
72 started.push("thread-pull-request-reactor");
73 return Effect.void;
74 },
75 drain: Effect.void,
76 }),
77 ),
68 Layer.provideMerge(78 Layer.provideMerge(
69 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {79 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
70 start: () => {80 start: () => {
...@@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => {...@@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => {
95 "provider-command-reactor",105 "provider-command-reactor",
96 "checkpoint-reactor",106 "checkpoint-reactor",
97 "thread-deletion-reactor",107 "thread-deletion-reactor",
108 "thread-pull-request-reactor",
98 "thread-settlement-reactor",109 "thread-settlement-reactor",
99 "agent-awareness-relay",110 "agent-awareness-relay",
100 ]);111 ]);
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
13import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
15export const makeOrchestrationReactor = Effect.gen(function* () {16export const makeOrchestrationReactor = Effect.gen(function* () {
...@@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
18 const checkpointReactor = yield* CheckpointReactor;19 const checkpointReactor = yield* CheckpointReactor;
19 const threadDeletionReactor = yield* ThreadDeletionReactor;20 const threadDeletionReactor = yield* ThreadDeletionReactor;
20 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;21 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
22 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
21 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;23 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2224
23 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {25 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
...@@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
25 yield* providerCommandReactor.start();27 yield* providerCommandReactor.start();
26 yield* checkpointReactor.start();28 yield* checkpointReactor.start();
27 yield* threadDeletionReactor.start();29 yield* threadDeletionReactor.start();
30 yield* threadPullRequestReactor.start();
28 yield* threadSettlementReactor.start();31 yield* threadSettlementReactor.start();
29 yield* agentAwarenessRelay.start();32 yield* agentAwarenessRelay.start();
30 });33 });
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+92
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 MessageId,8 MessageId,
9 ProjectId,9 ProjectId,
10 ThreadId,10 ThreadId,
11 ThreadLinkedPullRequest,
11 TurnId,12 TurnId,
12 ProviderInstanceId,13 ProviderInstanceId,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -18,6 +19,7 @@ import * as Effect from "effect/Effect";...@@ -18,6 +19,7 @@ import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";19import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
21import * as SqlClient from "effect/unstable/sql/SqlClient";23import * as SqlClient from "effect/unstable/sql/SqlClient";
2224
23import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";25import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
...@@ -59,6 +61,9 @@ const exists = (filePath: string) =>...@@ -59,6 +61,9 @@ const exists = (filePath: string) =>
59 });61 });
6062
61const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");63const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
64const encodeThreadLinkedPullRequest = Schema.encodeSync(
65 Schema.fromJsonString(ThreadLinkedPullRequest),
66);
6267
63it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))(68it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))(
64 "OrchestrationProjectionPipeline cursor batches",69 "OrchestrationProjectionPipeline cursor batches",
...@@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")...@@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")
204 },209 },
205);210);
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
207it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {299it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
208 it.effect("bootstraps all projection states and writes projection rows", () =>300 it.effect("bootstraps all projection states and writes projection rows", () =>
209 Effect.gen(function* () {301 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
605 branch: event.payload.branch,605 branch: event.payload.branch,
606 worktreePath: event.payload.worktreePath,606 worktreePath: event.payload.worktreePath,
607 linkedPullRequest: null,607 linkedPullRequest: null,
608 branchPullRequest: null,
608 latestTurnId: null,609 latestTurnId: null,
609 createdAt: event.payload.createdAt,610 createdAt: event.payload.createdAt,
610 updatedAt: event.payload.updatedAt,611 updatedAt: event.payload.updatedAt,
...@@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
805 ...(event.payload.linkedPullRequest !== undefined806 ...(event.payload.linkedPullRequest !== undefined
806 ? { linkedPullRequest: event.payload.linkedPullRequest }807 ? { linkedPullRequest: event.payload.linkedPullRequest }
807 : {}),808 : {}),
809 ...(event.payload.branchPullRequest !== undefined
810 ? { branchPullRequest: event.payload.branchPullRequest }
811 : {}),
808 updatedAt: event.payload.updatedAt,812 updatedAt: event.payload.updatedAt,
809 });813 });
810 return;814 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+36
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 MessageId,6 MessageId,
7 ProjectId,7 ProjectId,
8 ThreadId,8 ThreadId,
9 ThreadLinkedPullRequest,
9 TurnId,10 TurnId,
10 ProviderInstanceId,11 ProviderInstanceId,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
...@@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val...@@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
36const encodeChatAttachments = Schema.encodeEffect(37const encodeChatAttachments = Schema.encodeEffect(
37 Schema.fromJsonString(Schema.Array(ChatAttachment)),38 Schema.fromJsonString(Schema.Array(ChatAttachment)),
38);39);
40const encodeThreadLinkedPullRequest = Schema.encodeSync(
41 Schema.fromJsonString(ThreadLinkedPullRequest),
42);
3943
40const projectionSnapshotLayer = it.layer(44const projectionSnapshotLayer = it.layer(
41 OrchestrationProjectionSnapshotQueryLive.pipe(45 OrchestrationProjectionSnapshotQueryLive.pipe(
...@@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
52 Effect.gen(function* () {56 Effect.gen(function* () {
53 const snapshotQuery = yield* ProjectionSnapshotQuery;57 const snapshotQuery = yield* ProjectionSnapshotQuery;
54 const sql = yield* SqlClient.SqlClient;58 const sql = yield* SqlClient.SqlClient;
59 const branchPullRequest = {
60 projectId: asProjectId("project-1"),
61 repository: "pingdotgg/t3code",
62 number: 43,
63 url: "https://github.com/pingdotgg/t3code/pull/43",
64 };
5565
56 yield* sql`DELETE FROM projection_projects`;66 yield* sql`DELETE FROM projection_projects`;
57 yield* sql`DELETE FROM projection_state`;67 yield* sql`DELETE FROM projection_state`;
...@@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
92 branch,102 branch,
93 worktree_path,103 worktree_path,
94 linked_pull_request_json,104 linked_pull_request_json,
105 branch_pull_request_json,
95 latest_turn_id,106 latest_turn_id,
96 latest_user_message_at,107 latest_user_message_at,
97 pending_approval_count,108 pending_approval_count,
...@@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
113 NULL,124 NULL,
114 NULL,125 NULL,
115 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',126 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
127 ${encodeThreadLinkedPullRequest(branchPullRequest)},
116 'turn-1',128 'turn-1',
117 '2026-02-24T00:00:04.000Z',129 '2026-02-24T00:00:04.000Z',
118 1,130 1,
...@@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
323 number: 42,335 number: 42,
324 url: "https://github.com/pingdotgg/t3code/pull/42",336 url: "https://github.com/pingdotgg/t3code/pull/42",
325 },337 },
338 branchPullRequest,
326 latestTurn: {339 latestTurn: {
327 turnId: asTurnId("turn-1"),340 turnId: asTurnId("turn-1"),
328 state: "completed",341 state: "completed",
...@@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
451 number: 42,464 number: 42,
452 url: "https://github.com/pingdotgg/t3code/pull/42",465 url: "https://github.com/pingdotgg/t3code/pull/42",
453 },466 },
467 branchPullRequest,
454 latestTurn: {468 latestTurn: {
455 turnId: asTurnId("turn-1"),469 turnId: asTurnId("turn-1"),
456 state: "completed",470 state: "completed",
...@@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
498 assert.deepEqual(threadDetail.value, snapshot.threads[0]);512 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
499 }513 }
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
501 yield* sql`523 yield* sql`
502 INSERT INTO projection_thread_activities (524 INSERT INTO projection_thread_activities (
503 activity_id,525 activity_id,
...@@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
743 Effect.gen(function* () {765 Effect.gen(function* () {
744 const snapshotQuery = yield* ProjectionSnapshotQuery;766 const snapshotQuery = yield* ProjectionSnapshotQuery;
745 const sql = yield* SqlClient.SqlClient;767 const sql = yield* SqlClient.SqlClient;
768 const branchPullRequest = {
769 projectId: asProjectId("project-archive-test"),
770 repository: "pingdotgg/t3code",
771 number: 43,
772 url: "https://github.com/pingdotgg/t3code/pull/43",
773 };
746774
747 yield* sql`DELETE FROM projection_projects`;775 yield* sql`DELETE FROM projection_projects`;
748 yield* sql`DELETE FROM projection_threads`;776 yield* sql`DELETE FROM projection_threads`;
...@@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
849 shellSnapshot.threads.map((thread) => thread.id),877 shellSnapshot.threads.map((thread) => thread.id),
850 [ThreadId.make("thread-active")],878 [ThreadId.make("thread-active")],
851 );879 );
880 assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null);
881
882 yield* sql`
883 UPDATE projection_threads
884 SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)}
885 WHERE thread_id = 'thread-archived'
886 `;
852887
853 const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();888 const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();
854 assert.deepEqual(889 assert.deepEqual(
...@@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
856 [ThreadId.make("thread-archived")],891 [ThreadId.make("thread-archived")],
857 );892 );
858 assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");893 assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");
894 assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
859 const activeContext = yield* snapshotQuery.getThreadRuntimeContext(895 const activeContext = yield* snapshotQuery.getThreadRuntimeContext(
860 ThreadId.make("thread-active"),896 ThreadId.make("thread-active"),
861 );897 );
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11
...@@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(...@@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
116 Struct.assign({116 Struct.assign({
117 modelSelection: Schema.fromJsonString(ModelSelection),117 modelSelection: Schema.fromJsonString(ModelSelection),
118 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),118 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
119 }),120 }),
120);121);
121const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(122const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
...@@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
500 branch,501 branch,
501 worktree_path AS "worktreePath",502 worktree_path AS "worktreePath",
502 linked_pull_request_json AS "linkedPullRequest",503 linked_pull_request_json AS "linkedPullRequest",
504 branch_pull_request_json AS "branchPullRequest",
503 latest_turn_id AS "latestTurnId",505 latest_turn_id AS "latestTurnId",
504 created_at AS "createdAt",506 created_at AS "createdAt",
505 updated_at AS "updatedAt",507 updated_at AS "updatedAt",
...@@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
538 branch,540 branch,
539 worktree_path AS "worktreePath",541 worktree_path AS "worktreePath",
540 linked_pull_request_json AS "linkedPullRequest",542 linked_pull_request_json AS "linkedPullRequest",
543 branch_pull_request_json AS "branchPullRequest",
541 latest_turn_id AS "latestTurnId",544 latest_turn_id AS "latestTurnId",
542 created_at AS "createdAt",545 created_at AS "createdAt",
543 updated_at AS "updatedAt",546 updated_at AS "updatedAt",
...@@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
578 branch,581 branch,
579 worktree_path AS "worktreePath",582 worktree_path AS "worktreePath",
580 linked_pull_request_json AS "linkedPullRequest",583 linked_pull_request_json AS "linkedPullRequest",
584 branch_pull_request_json AS "branchPullRequest",
581 latest_turn_id AS "latestTurnId",585 latest_turn_id AS "latestTurnId",
582 created_at AS "createdAt",586 created_at AS "createdAt",
583 updated_at AS "updatedAt",587 updated_at AS "updatedAt",
...@@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1067 branch,1071 branch,
1068 worktree_path AS "worktreePath",1072 worktree_path AS "worktreePath",
1069 linked_pull_request_json AS "linkedPullRequest",1073 linked_pull_request_json AS "linkedPullRequest",
1074 branch_pull_request_json AS "branchPullRequest",
1070 latest_turn_id AS "latestTurnId",1075 latest_turn_id AS "latestTurnId",
1071 created_at AS "createdAt",1076 created_at AS "createdAt",
1072 updated_at AS "updatedAt",1077 updated_at AS "updatedAt",
...@@ -2062,6 +2067,7 @@ pending_approval_requests AS (...@@ -2062,6 +2067,7 @@ pending_approval_requests AS (
2062 interactionMode: row.interactionMode,2067 interactionMode: row.interactionMode,
2063 branch: row.branch,2068 branch: row.branch,
2064 worktreePath: row.worktreePath,2069 worktreePath: row.worktreePath,
2070 branchPullRequest: row.branchPullRequest,
2065 ...(row.linkedPullRequest === null2071 ...(row.linkedPullRequest === null
2066 ? {}2072 ? {}
2067 : { linkedPullRequest: row.linkedPullRequest }),2073 : { linkedPullRequest: row.linkedPullRequest }),
...@@ -2275,6 +2281,7 @@ pending_approval_requests AS (...@@ -2275,6 +2281,7 @@ pending_approval_requests AS (
2275 interactionMode: row.interactionMode,2281 interactionMode: row.interactionMode,
2276 branch: row.branch,2282 branch: row.branch,
2277 worktreePath: row.worktreePath,2283 worktreePath: row.worktreePath,
2284 branchPullRequest: row.branchPullRequest,
2278 ...(row.linkedPullRequest === null2285 ...(row.linkedPullRequest === null
2279 ? {}2286 ? {}
2280 : { linkedPullRequest: row.linkedPullRequest }),2287 : { linkedPullRequest: row.linkedPullRequest }),
...@@ -2415,6 +2422,7 @@ pending_approval_requests AS (...@@ -2415,6 +2422,7 @@ pending_approval_requests AS (
2415 interactionMode: row.interactionMode,2422 interactionMode: row.interactionMode,
2416 branch: row.branch,2423 branch: row.branch,
2417 worktreePath: row.worktreePath,2424 worktreePath: row.worktreePath,
2425 branchPullRequest: row.branchPullRequest,
2418 ...(row.linkedPullRequest === null2426 ...(row.linkedPullRequest === null
2419 ? {}2427 ? {}
2420 : { linkedPullRequest: row.linkedPullRequest }),2428 : { linkedPullRequest: row.linkedPullRequest }),
...@@ -2563,6 +2571,7 @@ pending_approval_requests AS (...@@ -2563,6 +2571,7 @@ pending_approval_requests AS (
2563 interactionMode: row.interactionMode,2571 interactionMode: row.interactionMode,
2564 branch: row.branch,2572 branch: row.branch,
2565 worktreePath: row.worktreePath,2573 worktreePath: row.worktreePath,
2574 branchPullRequest: row.branchPullRequest,
2566 ...(row.linkedPullRequest === null2575 ...(row.linkedPullRequest === null
2567 ? {}2576 ? {}
2568 : { linkedPullRequest: row.linkedPullRequest }),2577 : { linkedPullRequest: row.linkedPullRequest }),
...@@ -2884,6 +2893,7 @@ pending_approval_requests AS (...@@ -2884,6 +2893,7 @@ pending_approval_requests AS (
2884 interactionMode: threadRow.value.interactionMode,2893 interactionMode: threadRow.value.interactionMode,
2885 branch: threadRow.value.branch,2894 branch: threadRow.value.branch,
2886 worktreePath: threadRow.value.worktreePath,2895 worktreePath: threadRow.value.worktreePath,
2896 branchPullRequest: threadRow.value.branchPullRequest,
2887 ...(threadRow.value.linkedPullRequest === null2897 ...(threadRow.value.linkedPullRequest === null
2888 ? {}2898 ? {}
2889 : { linkedPullRequest: threadRow.value.linkedPullRequest }),2899 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
...@@ -3165,6 +3175,7 @@ pending_approval_requests AS (...@@ -3165,6 +3175,7 @@ pending_approval_requests AS (
3165 interactionMode: threadRow.value.interactionMode,3175 interactionMode: threadRow.value.interactionMode,
3166 branch: threadRow.value.branch,3176 branch: threadRow.value.branch,
3167 worktreePath: threadRow.value.worktreePath,3177 worktreePath: threadRow.value.worktreePath,
3178 branchPullRequest: threadRow.value.branchPullRequest,
3168 ...(threadRow.value.linkedPullRequest === null3179 ...(threadRow.value.linkedPullRequest === null
3169 ? {}3180 ? {}
3170 : { linkedPullRequest: threadRow.value.linkedPullRequest }),3181 : { 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";...@@ -25,7 +25,7 @@ import * as Ref from "effect/Ref";
25import * as Stream from "effect/Stream";25import * as Stream from "effect/Stream";
26import { TestClock } from "effect/testing";26import { TestClock } from "effect/testing";
2727
28import { GitManager } from "../git/GitManager.ts";28import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
29import {29import {
30 PullRequestService,30 PullRequestService,
31 type PullRequestMergeEvent,31 type PullRequestMergeEvent,
...@@ -132,6 +132,24 @@ function makePullRequestSummary(input: {...@@ -132,6 +132,24 @@ function makePullRequestSummary(input: {
132 };132 };
133}133}
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
135interface HarnessOptions {153interface HarnessOptions {
136 readonly snapshot: OrchestrationShellSnapshot;154 readonly snapshot: OrchestrationShellSnapshot;
137 readonly settings?: ServerSettings;155 readonly settings?: ServerSettings;
...@@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
173 return next;191 return next;
174 });192 });
175193
176 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>194 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) =>
177 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(195 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
178 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),196 Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)),
179 );197 );
180 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>198 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
181 Effect.gen(function* () {199 Effect.gen(function* () {
...@@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (...@@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
281});299});
282300
283describe("ThreadSettlementReactor", () => {301describe("ThreadSettlementReactor", () => {
302 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
303 Effect.scoped(
304 Effect.gen(function* () {
305 yield* TestClock.setTime(Date.parse(NOW));
306 const previous = {
307 projectId: PROJECT_ID,
308 repository: "owner/repository",
309 number: 1,
310 url: "https://example.test/owner/repository/pull/1",
311 };
312 const project = {
313 ...makeProject(),
314 repositoryIdentity: {
315 canonicalKey: "example.test/owner/repository",
316 rootPath: "/workspace/project",
317 displayName: "owner/repository",
318 locator: {
319 source: "git-remote" as const,
320 remoteName: "origin",
321 remoteUrl: "https://example.test/owner/repository.git",
322 },
323 },
324 };
325 const fixture = yield* makeHarness({
326 snapshot: makeSnapshot(
327 [
328 makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }),
329 makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }),
330 makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }),
331 makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }),
332 makeThread("resumed-manual", {
333 branch: "main",
334 linkedPullRequest: previous,
335 latestUserMessageAt: "2026-08-28T00:00:00.000Z",
336 }),
337 makeThread("resumed-detected", {
338 branch: "main",
339 branchPullRequest: previous,
340 latestUserMessageAt: "2026-08-28T00:00:00.000Z",
341 }),
342 ],
343 [project],
344 ),
345 settings: {
346 ...DEFAULT_SERVER_SETTINGS,
347 sidebarAutoSettleAfterDays: null,
348 sidebarAutoSettleOnMerge: true,
349 },
350 branchPullRequest: ({ branch }, options) =>
351 Effect.succeed(
352 branch === "reused"
353 ? makeBranchPullRequest(options?.refresh ? "open" : "merged")
354 : branch === "foreign"
355 ? {
356 ...makeBranchPullRequest("open"),
357 repositoryKey: "example.test/another/repository",
358 }
359 : null,
360 ),
361 pullRequestSummary: (input) =>
362 Effect.succeed({
363 ...makePullRequestSummary({ ...input, state: "merged" }),
364 mergedAt: "2026-08-27T00:00:00.000Z",
365 }),
366 });
367 yield* Effect.gen(function* () {
368 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
369 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
370 assert.deepStrictEqual(
371 new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
372 new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]),
373 );
374 }).pipe(Effect.provide(fixture.layer));
375 }),
376 ),
377 );
378
284 it.effect("starts without clients and skips protected threads before pull request lookup", () =>379 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
285 Effect.scoped(380 Effect.scoped(
286 Effect.gen(function* () {381 Effect.gen(function* () {
...@@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => {...@@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => {
375 }),470 }),
376 ]),471 ]),
377 branchPullRequest: () =>472 branchPullRequest: () =>
378 Ref.get(pullRequest).pipe(473 Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))),
379 Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })),
380 ),
381 });474 });
382475
383 yield* Effect.gen(function* () {476 yield* Effect.gen(function* () {
...@@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => {...@@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => {
430 Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(523 Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
431 Effect.flatMap((count) =>524 Effect.flatMap((count) =>
432 count === 1525 count === 1
433 ? Effect.succeed({ state: "open" as const, updatedAt: NOW })526 ? Effect.succeed(makeBranchPullRequest("open"))
434 : Deferred.succeed(periodicLookupStarted, undefined).pipe(527 : Deferred.succeed(periodicLookupStarted, undefined).pipe(
435 Effect.andThen(Deferred.await(releasePeriodicLookup)),528 Effect.andThen(Deferred.await(releasePeriodicLookup)),
436 Effect.as({ state: "open" as const, updatedAt: NOW }),529 Effect.as(makeBranchPullRequest("open")),
437 ),530 ),
438 ),531 ),
439 ),532 ),
...@@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => {...@@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => {
477 ]),570 ]),
478 branchPullRequest: () =>571 branchPullRequest: () =>
479 Ref.get(state).pipe(572 Ref.get(state).pipe(
480 Effect.map((pullRequestState) => ({573 Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)),
481 state: pullRequestState,
482 updatedAt: NOW,
483 closedAt: NOW,
484 mergedAt: NOW,
485 })),
486 ),574 ),
487 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),575 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
488 });576 });
...@@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => {...@@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => {
618 : Effect.void,706 : Effect.void,
619 ),707 ),
620 Effect.andThen(Ref.get(state)),708 Effect.andThen(Ref.get(state)),
621 Effect.map((pullRequestState) => ({709 Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)),
622 state: pullRequestState,
623 updatedAt: NOW,
624 closedAt: NOW,
625 mergedAt: NOW,
626 })),
627 ),710 ),
628 });711 });
629712
...@@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => {...@@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => {
896 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),979 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
897 ],980 ],
898 ),981 ),
899 branchPullRequest: () =>982 branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")),
900 Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }),
901 pullRequestSummary: (input) =>983 pullRequestSummary: (input) =>
902 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),984 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
903 });985 });
apps/server/src/orchestration/ThreadSettlementReactor.ts+52-42
...@@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts";
17import { forkParked } from "../serverActivation.ts";17import { forkParked } from "../serverActivation.ts";
18import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";18import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
19import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";19import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts";
20import {21import {
21 isAutoSettlementCandidate,22 isAutoSettlementCandidate,
22 resolveAutoSettlementAt,23 resolveAutoSettlementAt,
...@@ -46,10 +47,8 @@ export const make = Effect.gen(function* () {...@@ -46,10 +47,8 @@ export const make = Effect.gen(function* () {
46 const snapshot = yield* snapshots.getShellSnapshot();47 const snapshot = yield* snapshots.getShellSnapshot();
47 const now = DateTime.formatIso(yield* DateTime.now);48 const now = DateTime.formatIso(yield* DateTime.now);
48 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));49 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
49 // A merge event re-sweeps every candidate, not just the threads linked to50 // A merge rechecks all candidates, including branches that discovery has
50 // the merged pull request: most threads carry no link and settle from51 // not linked yet. Those lookups can still have cached the PR as open.
51 // their branch lookup, which would otherwise wait for the next minute's
52 // sweep on a possibly stale cached answer.
53 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));52 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
5453
55 // Return the thread when it still needs a pull request decision. A rejected54 // Return the thread when it still needs a pull request decision. A rejected
...@@ -101,14 +100,14 @@ export const make = Effect.gen(function* () {...@@ -101,14 +100,14 @@ export const make = Effect.gen(function* () {
101 },100 },
102 )).filter((thread) => thread !== null);101 )).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.
105 const lookupCwdByThreadId = new Map<string, string>();104 const lookupCwdByThreadId = new Map<string, string>();
106 yield* Effect.forEach(105 yield* Effect.forEach(
107 lookupCandidates,106 lookupCandidates,
108 (thread) =>107 (thread) =>
109 Effect.gen(function* () {108 Effect.gen(function* () {
110 const project = projects.get(thread.projectId);109 const project = projects.get(thread.projectId);
111 if (project === undefined || thread.linkedPullRequest != null) return;110 if (project === undefined || thread.branch === null) return;
112 const worktreeExists =111 const worktreeExists =
113 thread.worktreePath !== null &&112 thread.worktreePath !== null &&
114 (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));113 (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
...@@ -122,12 +121,8 @@ export const make = Effect.gen(function* () {...@@ -122,12 +121,8 @@ export const make = Effect.gen(function* () {
122 { concurrency: 8, discard: true },121 { concurrency: 8, discard: true },
123 );122 );
124 if (mergedPullRequest !== null) {123 if (mergedPullRequest !== null) {
125 // The merge just confirmed a terminal state the lookup caches can still124 // The merge confirmed a state the branch cache can still call open.
126 // call open (branch answers live two minutes, the sweep runs every125 // Recheck those branches now instead of waiting for cache expiry.
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.
131 const cwds = [...new Set(lookupCwdByThreadId.values())];126 const cwds = [...new Set(lookupCwdByThreadId.values())];
132 yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {127 yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
133 concurrency: 8,128 concurrency: 8,
...@@ -135,12 +130,15 @@ export const make = Effect.gen(function* () {...@@ -135,12 +130,15 @@ export const make = Effect.gen(function* () {
135 });130 });
136 }131 }
137 const lookupKey = (thread: (typeof candidates)[number]) => {132 const lookupKey = (thread: (typeof candidates)[number]) => {
138 if (thread.linkedPullRequest != null) {133 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
134 if (reference != null) {
139 return JSON.stringify([135 return JSON.stringify([
140 "linked",136 "linked",
141 thread.linkedPullRequest.projectId,137 reference.projectId,
142 thread.linkedPullRequest.repository,138 reference.repository,
143 thread.linkedPullRequest.number,139 reference.number,
140 lookupCwdByThreadId.get(thread.id),
141 thread.branch,
144 ]);142 ]);
145 }143 }
146 if (thread.branch === null) return JSON.stringify(["none", thread.id]);144 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
...@@ -154,35 +152,47 @@ export const make = Effect.gen(function* () {...@@ -154,35 +152,47 @@ export const make = Effect.gen(function* () {
154 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (152 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
155 thread: (typeof candidates)[number],153 thread: (typeof candidates)[number],
156 ) {154 ) {
157 if (thread.linkedPullRequest != null) {155 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
158 // The event carries the merged state, so only the threads linked to156 if (reference != null) {
159 // that exact pull request settle from it. Every other linked thread157 const matchesMerge =
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 (
164 mergedPullRequest !== null &&158 mergedPullRequest !== null &&
165 thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&159 reference.projectId === mergedPullRequest.projectId &&
166 thread.linkedPullRequest.repository.toLowerCase() ===160 reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() &&
167 mergedPullRequest.repository.toLowerCase() &&161 reference.number === mergedPullRequest.number;
168 thread.linkedPullRequest.number === mergedPullRequest.number162 if (!matchesMerge && !projects.has(reference.projectId)) {
169 ) {
170 return {
171 state: "merged",
172 mergedAt: mergedPullRequest.mergedAt,
173 } satisfies SettlementPullRequest;
174 }
175 if (!projects.has(thread.linkedPullRequest.projectId)) {
176 return yield* Effect.die(new Error("linked pull request project not found"));163 return yield* Effect.die(new Error("linked pull request project not found"));
177 }164 }
178 const summary = yield* pullRequests.summary(165 const summary = matchesMerge
179 {166 ? ({
180 projectId: thread.linkedPullRequest.projectId,167 state: "merged",
181 repository: thread.linkedPullRequest.repository,168 closedAt: null,
182 number: thread.linkedPullRequest.number,169 mergedAt: mergedPullRequest.mergedAt,
183 },170 } satisfies SettlementPullRequest)
184 { recoverTransientFailure: false },171 : yield* pullRequests.summary(
185 );172 {
173 projectId: reference.projectId,
174 repository: reference.repository,
175 number: reference.number,
176 },
177 { recoverTransientFailure: false },
178 );
179 const cwd = lookupCwdByThreadId.get(thread.id);
180 if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) {
181 // A reused branch can already have a new open PR while discovery
182 // is replacing its old link. Do not let settlement win that race.
183 const current = yield* git.branchPullRequest(
184 { cwd, branch: thread.branch },
185 { refresh: true },
186 );
187 const project = projects.get(thread.projectId);
188 if (
189 current?.state === "open" &&
190 project !== undefined &&
191 pullRequestMatchesProject(current, project)
192 ) {
193 return current;
194 }
195 }
186 return {196 return {
187 state: summary.state,197 state: summary.state,
188 closedAt: summary.closedAt ?? null,198 closedAt: summary.closedAt ?? null,
apps/server/src/orchestration/decider.ts+59
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 EventId,2 EventId,
3 MessageId,3 MessageId,
4 ThreadLinkedPullRequest,
4 UserInputRequestedPayload,5 UserInputRequestedPayload,
5 isImportedAgentSessionMessageId,6 isImportedAgentSessionMessageId,
6 type OrchestrationCommand,7 type OrchestrationCommand,
...@@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";...@@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
3839
39const nowIso = Effect.map(DateTime.now, DateTime.formatIso);40const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
40const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);41const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);
42const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));
4143
42/**44/**
43 * Blocked-on-you work derived from the thread's retained activities: an45 * Blocked-on-you work derived from the thread's retained activities: an
...@@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
836 };838 };
837 }839 }
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
839 case "thread.title.regeneration.complete": {898 case "thread.title.regeneration.complete": {
840 const thread = yield* requireThread({899 const thread = yield* requireThread({
841 readModel,900 readModel,
apps/server/src/orchestration/projector.test.ts+62
...@@ -86,6 +86,7 @@ describe("orchestration projector", () => {...@@ -86,6 +86,7 @@ describe("orchestration projector", () => {
86 interactionMode: "default",86 interactionMode: "default",
87 branch: null,87 branch: null,
88 worktreePath: null,88 worktreePath: null,
89 branchPullRequest: null,
89 latestTurn: null,90 latestTurn: null,
90 createdAt: now,91 createdAt: now,
91 updatedAt: now,92 updatedAt: now,
...@@ -105,6 +106,67 @@ describe("orchestration projector", () => {...@@ -105,6 +106,67 @@ describe("orchestration projector", () => {
105 ]);106 ]);
106 });107 });
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
108 it("fails when event payload cannot be decoded by runtime schema", async () => {170 it("fails when event payload cannot be decoded by runtime schema", async () => {
109 const now = "2026-01-01T00:00:00.000Z";171 const now = "2026-01-01T00:00:00.000Z";
110 const model = createEmptyReadModel(now);172 const model = createEmptyReadModel(now);
apps/server/src/orchestration/projector.ts+4
...@@ -336,6 +336,7 @@ export function projectEvent(...@@ -336,6 +336,7 @@ export function projectEvent(
336 interactionMode: payload.interactionMode,336 interactionMode: payload.interactionMode,
337 branch: payload.branch,337 branch: payload.branch,
338 worktreePath: payload.worktreePath,338 worktreePath: payload.worktreePath,
339 branchPullRequest: null,
339 latestTurn: null,340 latestTurn: null,
340 createdAt: payload.createdAt,341 createdAt: payload.createdAt,
341 updatedAt: payload.updatedAt,342 updatedAt: payload.updatedAt,
...@@ -510,6 +511,9 @@ export function projectEvent(...@@ -510,6 +511,9 @@ export function projectEvent(
510 ...(payload.linkedPullRequest !== undefined511 ...(payload.linkedPullRequest !== undefined
511 ? { linkedPullRequest: payload.linkedPullRequest }512 ? { linkedPullRequest: payload.linkedPullRequest }
512 : {}),513 : {}),
514 ...(payload.branchPullRequest !== undefined
515 ? { branchPullRequest: payload.branchPullRequest }
516 : {}),
513 updatedAt: payload.updatedAt,517 updatedAt: payload.updatedAt,
514 }),518 }),
515 })),519 })),
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+17-1
...@@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
462 }),462 }),
463 );463 );
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", () =>
466 Effect.gen(function* () {466 Effect.gen(function* () {
467 const threads = yield* ProjectionThreadRepository;467 const threads = yield* ProjectionThreadRepository;
468 const linkedPullRequest = {468 const linkedPullRequest = {
...@@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
471 number: 42,471 number: 42,
472 url: "https://github.com/pingdotgg/t3code/pull/42",472 url: "https://github.com/pingdotgg/t3code/pull/42",
473 };473 };
474 const branchPullRequest = {
475 ...linkedPullRequest,
476 number: 43,
477 url: "https://github.com/pingdotgg/t3code/pull/43",
478 };
474479
475 yield* threads.upsert({480 yield* threads.upsert({
476 threadId: ThreadId.make("thread-linked-pr"),481 threadId: ThreadId.make("thread-linked-pr"),
...@@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
485 branch: null,490 branch: null,
486 worktreePath: null,491 worktreePath: null,
487 linkedPullRequest,492 linkedPullRequest,
493 branchPullRequest,
488 latestTurnId: null,494 latestTurnId: null,
489 createdAt: "2026-03-24T00:00:00.000Z",495 createdAt: "2026-03-24T00:00:00.000Z",
490 updatedAt: "2026-03-24T00:00:00.000Z",496 updatedAt: "2026-03-24T00:00:00.000Z",
...@@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
504510
505 const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });511 const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
506 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);512 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
513 assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest);
514
515 const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId });
516 assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest);
507517
508 const row = Option.getOrNull(persisted);518 const row = Option.getOrNull(persisted);
509 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");519 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
...@@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
511521
512 const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });522 const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") });
513 assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null);523 assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null);
524 assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest);
525
526 yield* threads.upsert({ ...row, branchPullRequest: null });
527 const branchCleared = yield* threads.getById({ threadId: row.threadId });
528 assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null);
529 assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);
514 }),530 }),
515 );531 );
516});532});
apps/server/src/persistence/Layers/ProjectionThreads.ts+6
...@@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(...@@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(
20 Struct.assign({20 Struct.assign({
21 modelSelection: Schema.fromJsonString(ModelSelection),21 modelSelection: Schema.fromJsonString(ModelSelection),
22 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),22 linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
23 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
23 }),24 }),
24);25);
25type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;26type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
...@@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
41 branch,42 branch,
42 worktree_path,43 worktree_path,
43 linked_pull_request_json,44 linked_pull_request_json,
45 branch_pull_request_json,
44 latest_turn_id,46 latest_turn_id,
45 created_at,47 created_at,
46 updated_at,48 updated_at,
...@@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
70 ${row.branch},72 ${row.branch},
71 ${row.worktreePath},73 ${row.worktreePath},
72 ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)},74 ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)},
75 ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)},
73 ${row.latestTurnId},76 ${row.latestTurnId},
74 ${row.createdAt},77 ${row.createdAt},
75 ${row.updatedAt},78 ${row.updatedAt},
...@@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
99 branch = excluded.branch,102 branch = excluded.branch,
100 worktree_path = excluded.worktree_path,103 worktree_path = excluded.worktree_path,
101 linked_pull_request_json = excluded.linked_pull_request_json,104 linked_pull_request_json = excluded.linked_pull_request_json,
105 branch_pull_request_json = excluded.branch_pull_request_json,
102 latest_turn_id = excluded.latest_turn_id,106 latest_turn_id = excluded.latest_turn_id,
103 created_at = excluded.created_at,107 created_at = excluded.created_at,
104 updated_at = excluded.updated_at,108 updated_at = excluded.updated_at,
...@@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
135 branch,139 branch,
136 worktree_path AS "worktreePath",140 worktree_path AS "worktreePath",
137 linked_pull_request_json AS "linkedPullRequest",141 linked_pull_request_json AS "linkedPullRequest",
142 branch_pull_request_json AS "branchPullRequest",
138 latest_turn_id AS "latestTurnId",143 latest_turn_id AS "latestTurnId",
139 created_at AS "createdAt",144 created_at AS "createdAt",
140 updated_at AS "updatedAt",145 updated_at AS "updatedAt",
...@@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
173 branch,178 branch,
174 worktree_path AS "worktreePath",179 worktree_path AS "worktreePath",
175 linked_pull_request_json AS "linkedPullRequest",180 linked_pull_request_json AS "linkedPullRequest",
181 branch_pull_request_json AS "branchPullRequest",
176 latest_turn_id AS "latestTurnId",182 latest_turn_id AS "latestTurnId",
177 created_at AS "createdAt",183 created_at AS "createdAt",
178 updated_at AS "updatedAt",184 updated_at AS "updatedAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t...@@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t
59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
6263
63/**64/**
64 * Migration loader with all migrations defined inline.65 * Migration loader with all migrations defined inline.
...@@ -118,6 +119,7 @@ export const migrationEntries = [...@@ -118,6 +119,7 @@ export const migrationEntries = [
118 [45, "ProjectionProjectsAutoPull", Migration0045],119 [45, "ProjectionProjectsAutoPull", Migration0045],
119 [46, "RepairAutomaticSettlementTimestamps", Migration0046],120 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
120 [47, "ProjectionProjectIcon", Migration0047],121 [47, "ProjectionProjectIcon", Migration0047],
122 [48, "ProjectionThreadBranchPullRequest", Migration0048],
121] as const;123] as const;
122124
123export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);125export 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({...@@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({
35 branch: Schema.NullOr(Schema.String),35 branch: Schema.NullOr(Schema.String),
36 worktreePath: Schema.NullOr(Schema.String),36 worktreePath: Schema.NullOr(Schema.String),
37 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),37 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
38 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
38 latestTurnId: Schema.NullOr(TurnId),39 latestTurnId: Schema.NullOr(TurnId),
39 createdAt: IsoDateTime,40 createdAt: IsoDateTime,
40 updatedAt: IsoDateTime,41 updatedAt: IsoDateTime,
apps/server/src/project/RepositoryIdentityResolver.test.ts+46-19
...@@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: {...@@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: {
38 ).pipe(Layer.provide(ProcessRunner.layer));38 ).pipe(Layer.provide(ProcessRunner.layer));
3939
40it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {40it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
41 it.effect("reuses the cached Git root for repeated workspace lookups", () => {41 it.effect("refreshes the Git root only when requested", () => {
42 const calls: Array<ReadonlyArray<string>> = [];42 const calls: Array<ReadonlyArray<string>> = [];
43 let rootPath = "/repo";
43 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {44 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
44 run: (input) =>45 run: (input) =>
45 Effect.sync(() => {46 Effect.sync(() => {
46 calls.push(input.args);47 calls.push(input.args);
47 return {48 return {
48 stdout: input.args.includes("rev-parse")49 stdout: input.args.includes("rev-parse")
49 ? "/repo\n"50 ? `${rootPath}\n`
50 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",51 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
51 stderr: "",52 stderr: "",
52 code: ChildProcessSpawner.ExitCode(0),53 code: ChildProcessSpawner.ExitCode(0),
...@@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
66 return Effect.gen(function* () {67 return Effect.gen(function* () {
67 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;68 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
68 const first = yield* resolver.resolve("/repo/packages/web");69 const first = yield* resolver.resolve("/repo/packages/web");
70 rootPath = "/repo/packages/web";
69 const second = yield* resolver.resolve("/repo/packages/web");71 const second = yield* resolver.resolve("/repo/packages/web");
7072
71 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");73 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
...@@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
74 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],76 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
75 ["-C", "/repo", "remote", "-v"],77 ["-C", "/repo", "remote", "-v"],
76 ]);78 ]);
79
80 const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true });
81 expect(refreshed?.rootPath).toBe("/repo/packages/web");
82 expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed);
83 expect(calls.slice(2)).toEqual([
84 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
85 ["-C", "/repo/packages/web", "remote", "-v"],
86 ]);
77 }).pipe(Effect.provide(resolverLayer));87 }).pipe(Effect.provide(resolverLayer));
78 });88 });
7989
...@@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
197 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),207 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
198 );208 );
199209
200 it.effect("prefers upstream over origin when both remotes are configured", () =>210 it.effect.each(["add", "replace"] as const)(
201 Effect.gen(function* () {211 "refreshes the primary upstream after %s before cache expiry",
202 const fileSystem = yield* FileSystem.FileSystem;212 (change) =>
203 const cwd = yield* fileSystem.makeTempDirectoryScoped({213 Effect.gen(function* () {
204 prefix: "t3-repository-identity-upstream-test-",214 const fileSystem = yield* FileSystem.FileSystem;
205 });215 const cwd = yield* fileSystem.makeTempDirectoryScoped({
206216 prefix: "t3-repository-identity-upstream-test-",
207 yield* git(cwd, ["init"]);217 });
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"]);
210218
211 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;219 yield* git(cwd, ["init"]);
212 const identity = yield* resolver.resolve(cwd);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();225 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
215 expect(identity?.locator.remoteName).toBe("upstream");226 const initialIdentity = yield* resolver.resolve(cwd);
216 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");227 expect(initialIdentity?.canonicalKey).toBe(
217 expect(identity?.displayName).toBe("t3tools/t3code");228 change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous",
218 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),229 );
230
231 yield* git(cwd, [
232 "remote",
233 change === "add" ? "add" : "set-url",
234 "upstream",
235 "git@github.com:T3Tools/t3code.git",
236 ]);
237 expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity);
238 const identity = yield* resolver.resolve(cwd, { refresh: true });
239
240 expect(identity).not.toBeNull();
241 expect(identity?.locator.remoteName).toBe("upstream");
242 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
243 expect(identity?.displayName).toBe("t3tools/t3code");
244 expect(yield* resolver.resolve(cwd)).toEqual(identity);
245 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
219 );246 );
220247
221 it.effect("uses the last remote path segment as the repository name for nested groups", () =>248 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
apps/server/src/project/RepositoryIdentityResolver.ts+7-2
...@@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions {...@@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions {
25export class RepositoryIdentityResolver extends Context.Service<25export class RepositoryIdentityResolver extends Context.Service<
26 RepositoryIdentityResolver,26 RepositoryIdentityResolver,
27 {27 {
28 readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;28 readonly resolve: (
29 cwd: string,
30 options?: { readonly refresh?: boolean },
31 ) => Effect.Effect<RepositoryIdentity | null>;
29 }32 }
30>()("t3/project/RepositoryIdentityResolver") {}33>()("t3/project/RepositoryIdentityResolver") {}
3134
...@@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (...@@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
170173
171 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(174 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(
172 "RepositoryIdentityResolver.resolve",175 "RepositoryIdentityResolver.resolve",
173 )(function* (cwd) {176 )(function* (cwd, options) {
177 if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd);
174 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);178 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);
175 if (cacheKey === null) return null;179 if (cacheKey === null) return null;
180 if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey);
176 return yield* Cache.get(repositoryIdentityCache, cacheKey);181 return yield* Cache.get(repositoryIdentityCache, cacheKey);
177 });182 });
178183
apps/server/src/server.ts+2
...@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma...@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
71import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";71import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
72import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";72import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
73import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";73import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
74import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
74import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";75import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
75import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";76import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
76import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";77import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
...@@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe(
279 Layer.provideMerge(CheckpointReactorLive),280 Layer.provideMerge(CheckpointReactorLive),
280 Layer.provideMerge(ThreadDeletionReactorLive),281 Layer.provideMerge(ThreadDeletionReactorLive),
281 Layer.provideMerge(ThreadSettlementReactor.layer),282 Layer.provideMerge(ThreadSettlementReactor.layer),
283 Layer.provideMerge(ThreadPullRequestReactor.layer),
282 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),284 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
283 Layer.provideMerge(RuntimeReceiptBusLive),285 Layer.provideMerge(RuntimeReceiptBusLive),
284);286);
apps/web/src/components/BranchToolbarBranchSelector.tsx+8-11
...@@ -47,11 +47,7 @@ import {...@@ -47,11 +47,7 @@ import {
47 sanitizeNewRefName,47 sanitizeNewRefName,
48 shouldIncludeBranchPickerItem,48 shouldIncludeBranchPickerItem,
49} from "./BranchToolbar.logic";49} from "./BranchToolbar.logic";
50import {50import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators";
51 ChangeRequestStatusIcon,
52 prStatusIndicator,
53 resolveThreadPr,
54} from "./ThreadStatusIndicators";
55import { Button } from "./ui/button";51import { Button } from "./ui/button";
56import { Switch } from "./ui/switch";52import { Switch } from "./ui/switch";
57import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";53import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
...@@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({...@@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({
617 });613 });
618614
619 // PR pill shown next to the branch selector when the active branch has one.615 // PR pill shown next to the branch selector when the active branch has one.
620 const branchPr = resolveThreadPr({616 const branchPrBranch = resolveBranchToolbarPrBranch({
621 threadBranch: resolveBranchToolbarPrBranch({617 activeThreadBranch,
622 activeThreadBranch,618 resolvedActiveBranch,
623 resolvedActiveBranch,
624 }),
625 gitStatus: branchStatusQuery.data ?? null,
626 });619 });
620 const branchPr =
621 branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch
622 ? (branchStatusQuery.data.pr ?? null)
623 : null;
627 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);624 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
628 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's625 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
629 // state-description tooltip.626 // state-description tooltip.
apps/web/src/components/ChatView.logic.test.ts+35
...@@ -60,6 +60,7 @@ import {...@@ -60,6 +60,7 @@ import {
60 shouldDockDraftHeroForSubmission,60 shouldDockDraftHeroForSubmission,
61 shouldReleaseTimelineAnchorForToolActivity,61 shouldReleaseTimelineAnchorForToolActivity,
62 shouldOpenProactivePullRequest,62 shouldOpenProactivePullRequest,
63 shouldRetargetThreadPullRequestPanel,
63 shouldOpenProactiveTurnDiff,64 shouldOpenProactiveTurnDiff,
64 shouldRenderPreviewMiniPlayer,65 shouldRenderPreviewMiniPlayer,
65 shouldShowBranchMismatchBanner,66 shouldShowBranchMismatchBanner,
...@@ -249,6 +250,40 @@ describe("proactive panels", () => {...@@ -249,6 +250,40 @@ describe("proactive panels", () => {
249 expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);250 expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);
250 });251 });
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
252 it("opens the diff only when the observed running turn settles", () => {287 it("opens the diff only when the observed running turn settles", () => {
253 const turnId = TurnId.make("turn-1");288 const turnId = TurnId.make("turn-1");
254 expect(289 expect(
apps/web/src/components/ChatView.logic.ts+19
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type ScopedProjectRef,15 type ScopedProjectRef,
16 type ScopedThreadRef,16 type ScopedThreadRef,
17 type ThreadId,17 type ThreadId,
18 type ThreadLinkedPullRequest,
18 type TurnId,19 type TurnId,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";21import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
...@@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice(...@@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice(
132 };133 };
133}134}
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
135export function shouldOpenProactiveTurnDiff(input: {154export function shouldOpenProactiveTurnDiff(input: {
136 previousRunningTurnId: TurnId | null | undefined;155 previousRunningTurnId: TurnId | null | undefined;
137 runningTurnId: TurnId | null;156 runningTurnId: TurnId | null;
apps/web/src/components/ChatView.tsx+34-194
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 type ResolvedKeybindingsConfig,24 type ResolvedKeybindingsConfig,
25 type ScopedThreadRef,25 type ScopedThreadRef,
26 type ThreadId,26 type ThreadId,
27 type ThreadLinkedPullRequest,
27 type TurnId,28 type TurnId,
28 type KeybindingCommand,29 type KeybindingCommand,
29 OrchestrationThreadActivity,30 OrchestrationThreadActivity,
...@@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";...@@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";
188import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";189import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
189import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";190import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
190import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";191import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
191import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";192import { RightPanelTabs } from "./RightPanelTabs";
192import { AgentsPanel } from "./AgentsPanel";193import { AgentsPanel } from "./AgentsPanel";
193import {194import {
194 deriveAgentPanelModel,195 deriveAgentPanelModel,
...@@ -337,11 +338,6 @@ import {...@@ -337,11 +338,6 @@ import {
337 shouldShowThreadErrorBanner,338 shouldShowThreadErrorBanner,
338 ThreadErrorBanner,339 ThreadErrorBanner,
339} from "./chat/ThreadErrorBanner";340} from "./chat/ThreadErrorBanner";
340import {
341 resolveDisplayedThreadPr,
342 threadChangeRequestSnapshotsAtom,
343 useLinkedThreadPullRequest,
344} from "./ThreadStatusIndicators";
345import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";341import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
346import { ComposerSurface } from "./chat/ComposerSurface";342import { ComposerSurface } from "./chat/ComposerSurface";
347import {343import {
...@@ -380,6 +376,7 @@ import {...@@ -380,6 +376,7 @@ import {
380 shouldShowBranchMismatchBanner,376 shouldShowBranchMismatchBanner,
381 shouldShowPlanFollowUpPrompt,377 shouldShowPlanFollowUpPrompt,
382 shouldOpenProactivePullRequest,378 shouldOpenProactivePullRequest,
379 shouldRetargetThreadPullRequestPanel,
383 shouldOpenProactiveTurnDiff,380 shouldOpenProactiveTurnDiff,
384 shouldRenderPreviewMiniPlayer,381 shouldRenderPreviewMiniPlayer,
385 getStartedThreadModelChangeBlockReason,382 getStartedThreadModelChangeBlockReason,
...@@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) {
1845 );1842 );
1846 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1843 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1847 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);1844 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
1848 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
1849 const [timelineAnchor, setTimelineAnchor] = useState<{1845 const [timelineAnchor, setTimelineAnchor] = useState<{
1850 readonly threadKey: string | null;1846 readonly threadKey: string | null;
1851 readonly messageId: MessageId | null;1847 readonly messageId: MessageId | null;
...@@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) {
1864 const activeRightPanelSurface = useRightPanelStore((state) =>1860 const activeRightPanelSurface = useRightPanelStore((state) =>
1865 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),1861 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
1866 );1862 );
1867 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1868 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1869 const threadPrRelinkKeysRef = useRef(new Map<string, string>());
1870 const threadPrRelinkWriteRef = useRef(Promise.resolve());
1871 const activePreviewState = useThreadPreviewState(activeThreadRef);1863 const activePreviewState = useThreadPreviewState(activeThreadRef);
1872 const activePreviewServerEpoch = activePreviewState.serverEpoch;1864 const activePreviewServerEpoch = activePreviewState.serverEpoch;
1873 const resolvePreviewRuntimeTabId = useMemo(1865 const resolvePreviewRuntimeTabId = useMemo(
...@@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) {
3970 },3962 },
3971 [activeProject, activeThreadRef],3963 [activeProject, activeThreadRef],
3972 );3964 );
3973 // The thread's own change request, placed against the project it belongs to. Without a3965 // The shell carries server PR updates even while thread detail is still loading.
3974 // project there is nothing to resolve it against, so the caller falls back to the browser.3966 const activeThreadMetadata = activeThreadShell ?? activeThread;
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 ]);
4017 const linkedThreadPullRequest =3967 const linkedThreadPullRequest =
4018 replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest;3968 activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null;
3969 const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
4019 const linkedThreadPullRequestKey = linkedThreadPullRequest3970 const linkedThreadPullRequestKey = linkedThreadPullRequest
4020 ? JSON.stringify([3971 ? JSON.stringify([
4021 linkedThreadPullRequest.projectId,3972 linkedThreadPullRequest.projectId,
...@@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) {
4023 linkedThreadPullRequest.number,3974 linkedThreadPullRequest.number,
4024 ])3975 ])
4025 : null;3976 : null;
4026 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;3977 const observedThreadPullRequestRef = useRef<{
4027 const openThreadPullRequest = useCallback(3978 readonly threadKey: string;
4028 (number: number) => {3979 readonly reference: ThreadLinkedPullRequest | null;
4029 if (!supportsPullRequests || !activeThreadRef) {3980 } | null>(null);
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 ]);
4088 const openProjectPullRequest = useCallback(3981 const openProjectPullRequest = useCallback(
4089 (number: number) => {3982 (number: number) => {
4090 if (3983 if (
...@@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) {
4110 useEffect(() => {4003 useEffect(() => {
4111 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {4004 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
4112 proactivePanelObservationRef.current = null;4005 proactivePanelObservationRef.current = null;
4006 observedThreadPullRequestRef.current = null;
4113 return;4007 return;
4114 }4008 }
4115 const panels = useRightPanelStore.getState();4009 const panels = useRightPanelStore.getState();
...@@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) {...@@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) {
4125 userActionRevision,4019 userActionRevision,
4126 } = observation;4020 } = observation;
4127 const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef);4021 const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef);
4022 const previousPullRequest = observedThreadPullRequestRef.current;
4023 observedThreadPullRequestRef.current = {
4024 threadKey: activeThreadKey,
4025 reference: linkedThreadPullRequest,
4026 };
4128 const followSelectedPullRequest =4027 const followSelectedPullRequest =
4129 replacementLinkedThreadPullRequest !== null &&4028 previousPullRequest?.threadKey === activeThreadKey &&
4130 openSurface?.kind === "pull-request" &&4029 shouldRetargetThreadPullRequestPanel(
4131 persistedLinkedThreadPullRequest !== null &&4030 previousPullRequest.reference,
4132 openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&4031 linkedThreadPullRequest,
4133 openSurface.repository.toLowerCase() ===4032 openSurface,
4134 persistedLinkedThreadPullRequest.repository.toLowerCase() &&4033 );
4135 openSurface.number === persistedLinkedThreadPullRequest.number;
4136 // Following the selected linked PR does not open an unrelated panel, so it4034 // Following the selected linked PR does not open an unrelated panel, so it
4137 // remains available with proactive panels off. It still respects a later choice.4035 // remains available with proactive panels off. It still respects a later choice.
4138 if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) {4036 if (followSelectedPullRequest && linkedThreadPullRequest !== null) {
4139 panels.openProactive(4037 panels.openProactive(
4140 activeThreadRef,4038 activeThreadRef,
4141 pullRequestSurface(replacementLinkedThreadPullRequest),4039 pullRequestSurface(linkedThreadPullRequest),
4142 userActionRevision,4040 userActionRevision,
4143 );4041 );
4144 }4042 }
...@@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) {
4207 linkedThreadPullRequest,4105 linkedThreadPullRequest,
4208 linkedThreadPullRequestKey,4106 linkedThreadPullRequestKey,
4209 onDiffPanelOpen,4107 onDiffPanelOpen,
4210 persistedLinkedThreadPullRequest,
4211 pullRequestsCapabilityKnown,4108 pullRequestsCapabilityKnown,
4212 replacementLinkedThreadPullRequest,
4213 settings.proactivePanelsEnabled,4109 settings.proactivePanelsEnabled,
4214 shouldUseRightPanelSheet,4110 shouldUseRightPanelSheet,
4215 supportsPullRequests,4111 supportsPullRequests,
...@@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) {
5223 resizeObserver.disconnect();5119 resizeObserver.disconnect();
5224 };5120 };
5225 }, [composerOverlayElement, publishComposerOverlayHeight]);5121 }, [composerOverlayElement, publishComposerOverlayHeight]);
5226 const activeThreadPr =
5227 replacementLinkedThreadPullRequest !== null
5228 ? (gitStatusQuery.data?.pr ?? null)
5229 : resolveDisplayedThreadPr({
5230 threadBranch: activeThread?.branch ?? null,
5231 gitStatus: gitStatusQuery.data ?? null,
5232 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
5233 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
5234 linkedPullRequest: linkedThreadPullRequest,
5235 linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus,
5236 });
5237 const handlePullRequestTabStatusChange = useCallback(
5238 (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => {
5239 if (
5240 threadRepository?.toLowerCase() !== status.repository.toLowerCase() ||
5241 activeThreadPr?.number !== status.number ||
5242 activeThreadPr.state === status.state
5243 ) {
5244 sidebarPrRefreshKeyRef.current = null;
5245 return;
5246 }
5247 const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`;
5248 if (sidebarPrRefreshKeyRef.current === refreshKey) return;
5249 sidebarPrRefreshKeyRef.current = refreshKey;
5250 if (activeThreadRef === null || gitCwd === null) return;
5251 void refreshVcsStatus({
5252 environmentId: activeThreadRef.environmentId,
5253 input: { cwd: gitCwd },
5254 }).then(() => {
5255 if (sidebarPrRefreshKeyRef.current === refreshKey) {
5256 sidebarPrRefreshKeyRef.current = null;
5257 }
5258 });
5259 },
5260 [
5261 activeThreadKey,
5262 activeThreadPr?.number,
5263 activeThreadPr?.state,
5264 activeThreadRef,
5265 gitCwd,
5266 refreshVcsStatus,
5267 threadRepository,
5268 ],
5269 );
5270 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);5122 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
5271 const activeThreadReferenceCopyTarget = useMemo(5123 const activeThreadReferenceCopyTarget = useMemo(
5272 () =>5124 () =>
...@@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) {
5276 threadId: activeThreadId,5128 threadId: activeThreadId,
5277 openPanelPullRequestUrl,5129 openPanelPullRequestUrl,
5278 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,5130 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
5279 detectedPullRequestUrl: activeThreadPr?.url ?? null,
5280 }),5131 }),
5281 [5132 [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl],
5282 activeThreadId,
5283 activeThreadPr?.url,
5284 isServerThread,
5285 linkedThreadPullRequest?.url,
5286 openPanelPullRequestUrl,
5287 ],
5288 );5133 );
5289 const copyActiveThreadReference = useCallback(() => {5134 const copyActiveThreadReference = useCallback(() => {
5290 const target = activeThreadReferenceCopyTarget;5135 const target = activeThreadReferenceCopyTarget;
...@@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) {
5310 },5155 },
5311 );5156 );
5312 }, [activeThreadReferenceCopyTarget]);5157 }, [activeThreadReferenceCopyTarget]);
5313 // The right panel offers the thread's own change request, so it can only offer it once the
5314 // branch has one; until then the picker says so rather than opening an empty panel.
5315 const addPullRequestSurface = useCallback(() => {5158 const addPullRequestSurface = useCallback(() => {
5316 if (activeThreadPr === null) return;5159 if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null)
5317 openThreadPullRequest(activeThreadPr.number);5160 return;
5318 }, [activeThreadPr, openThreadPullRequest]);5161 useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest);
5319 const pullRequestSurfaceAvailable =5162 }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]);
5320 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;5163 const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null;
5321 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;5164 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
5322 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;5165 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
5323 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;5166 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
...@@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) {
7849 context={7692 context={
7850 isThreadOwnPullRequest(7693 isThreadOwnPullRequest(
7851 {7694 {
7852 projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null,7695 projectId: linkedThreadPullRequest?.projectId ?? null,
7853 repository: threadRepository,7696 repository: linkedThreadPullRequest?.repository ?? null,
7854 number: activeThreadPr?.number ?? null,7697 number: linkedThreadPullRequest?.number ?? null,
7855 },7698 },
7856 {7699 {
7857 projectId: renderedRightPanelSurface.projectId,7700 projectId: renderedRightPanelSurface.projectId,
...@@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) {
7863 : "page"7706 : "page"
7864 }7707 }
7865 composerDraftTarget={composerDraftTarget}7708 composerDraftTarget={composerDraftTarget}
7866 {...(linkedThreadPullRequest === null
7867 ? { onStateChange: handlePullRequestTabStatusChange }
7868 : {})}
7869 />7709 />
7870 ) : renderedRightPanelSurface?.kind === "agents" ? (7710 ) : renderedRightPanelSurface?.kind === "agents" ? (
7871 <AgentsPanel7711 <AgentsPanel
apps/web/src/components/CommandPalette.tsx+4-39
...@@ -1,10 +1,6 @@...@@ -1,10 +1,6 @@
1"use client";1"use client";
22
3import {3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4 scopedThreadKey,
5 scopeProjectRef,
6 scopeThreadRef,
7} from "@t3tools/client-runtime/environment";
8import {4import {
9 canCreateProjectInEnvironment,5 canCreateProjectInEnvironment,
10 getCloneDestinationBrowsePath,6 getCloneDestinationBrowsePath,
...@@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem";...@@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem";
81import { projectEnvironment } from "../state/projects";77import { projectEnvironment } from "../state/projects";
82import { useEnvironmentQuery } from "../state/query";78import { useEnvironmentQuery } from "../state/query";
83import { sourceControlEnvironment } from "../state/sourceControl";79import { sourceControlEnvironment } from "../state/sourceControl";
84import { vcsEnvironment } from "../state/vcs";
85import { useAtomCommand } from "../state/use-atom-command";80import { useAtomCommand } from "../state/use-atom-command";
86import { useAtomQueryRunner } from "../state/use-atom-query-runner";81import { useAtomQueryRunner } from "../state/use-atom-query-runner";
87import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";82import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
88import { useProject, useProjects, useThreadShells } from "../state/entities";83import { useProjects, useThreadShells } from "../state/entities";
89import { useThreadSearch } from "../state/queries";84import { useThreadSearch } from "../state/queries";
90import * as ThreadPr from "./ThreadStatusIndicators";
91import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";85import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
92import {86import {
93 appendBrowsePathSegment,87 appendBrowsePathSegment,
...@@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: {...@@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: {
605 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =599 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
606 useHandleNewThread();600 useHandleNewThread();
607 const projects = useProjects();601 const projects = useProjects();
608 const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom);
609 const activeThreadProject = useProject(
610 activeThread === null
611 ? null
612 : scopeProjectRef(activeThread.environmentId, activeThread.projectId),
613 );
614 const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null;
615 const activeThreadGitStatus = useEnvironmentQuery(
616 activeThread != null &&
617 activeThread.linkedPullRequest == null &&
618 activeThread.branch !== null &&
619 activeThreadCwd !== null
620 ? vcsEnvironment.status({
621 environmentId: activeThread.environmentId,
622 input: { cwd: activeThreadCwd },
623 })
624 : null,
625 ).data;
626 const detectedPullRequestUrl =
627 activeThread == null || activeThread.linkedPullRequest != null
628 ? null
629 : (ThreadPr.resolveDisplayedThreadPr({
630 threadBranch: activeThread.branch,
631 gitStatus: activeThreadGitStatus ?? null,
632 snapshot: changeRequestSnapshotByKey.get(
633 scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)),
634 ),
635 retainTerminalOnBranchMismatch: activeThread.worktreePath === null,
636 })?.url ?? null);
637 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(602 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
638 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,603 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
639 );604 );
...@@ -643,8 +608,8 @@ function OpenCommandPaletteDialog(props: {...@@ -643,8 +608,8 @@ function OpenCommandPaletteDialog(props: {
643 : resolveThreadReferenceCopyTarget({608 : resolveThreadReferenceCopyTarget({
644 threadId: activeThread.id,609 threadId: activeThread.id,
645 openPanelPullRequestUrl,610 openPanelPullRequestUrl,
646 linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,611 linkedPullRequestUrl:
647 detectedPullRequestUrl,612 activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null,
648 });613 });
649 const copyActiveThreadReference = useCallback(async () => {614 const copyActiveThreadReference = useCallback(async () => {
650 const target = activeThreadReferenceCopyTarget;615 const target = activeThreadReferenceCopyTarget;
apps/web/src/components/LegacySidebar.tsx+5-50
...@@ -16,7 +16,6 @@ import {...@@ -16,7 +16,6 @@ import {
16 ChangeRequestStatusIcon,16 ChangeRequestStatusIcon,
17 prStatusIndicator,17 prStatusIndicator,
18 PrStatusTooltipContent,18 PrStatusTooltipContent,
19 resolveThreadPr,
20 terminalStatusFromRunningIds,19 terminalStatusFromRunningIds,
21 ThreadStatusLabel,20 ThreadStatusLabel,
22 ThreadWorktreeIndicator,21 ThreadWorktreeIndicator,
...@@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus";...@@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus";
83import { isMacPlatform } from "../lib/utils";82import { isMacPlatform } from "../lib/utils";
84import {83import {
85 readThreadShell,84 readThreadShell,
86 useProject,
87 useProjects,85 useProjects,
88 useThreadShells,86 useThreadShells,
89 useThreadShellsForProjectRefs,87 useThreadShellsForProjectRefs,
...@@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate";...@@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate";
116114
117import { useThreadActions } from "../hooks/useThreadActions";115import { useThreadActions } from "../hooks/useThreadActions";
118import { projectEnvironment } from "../state/projects";116import { projectEnvironment } from "../state/projects";
119import { useEnvironmentQuery } from "../state/query";
120import { threadEnvironment, useEnvironmentThread } from "../state/threads";117import { threadEnvironment, useEnvironmentThread } from "../state/threads";
121import { vcsEnvironment } from "../state/vcs";
122import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";118import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
123import {119import {
124 buildThreadRouteParams,120 buildThreadRouteParams,
...@@ -191,7 +187,6 @@ import {...@@ -191,7 +187,6 @@ import {
191 orderItemsByPreferredIds,187 orderItemsByPreferredIds,
192 shouldClearThreadSelectionOnMouseDown,188 shouldClearThreadSelectionOnMouseDown,
193 sortProjectsForSidebar,189 sortProjectsForSidebar,
194 useRetainedValue,
195 useSidebarRowSubscriptionLease,190 useSidebarRowSubscriptionLease,
196 useThreadJumpHintVisibility,191 useThreadJumpHintVisibility,
197 ThreadStatusPill,192 ThreadStatusPill,
...@@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: {...@@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: {
314309
315interface SidebarThreadRowProps {310interface SidebarThreadRowProps {
316 thread: SidebarThreadSummary;311 thread: SidebarThreadSummary;
317 projectCwd: string | null;
318 orderedProjectThreadKeys: readonly string[];312 orderedProjectThreadKeys: readonly string[];
319 isActive: boolean;313 isActive: boolean;
320 openPullRequestsInRightPanel: boolean;314 openPullRequestsInRightPanel: boolean;
...@@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
416 const threadEnvironmentLabel = isRemoteThread410 const threadEnvironmentLabel = isRemoteThread
417 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))411 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
418 : null;412 : null;
419 // For grouped projects, the thread may belong to a different environment
420 // than the representative project. Look up the thread's own project cwd
421 // so git status (and thus PR detection) queries the correct path.
422 const threadProject = useProject(
423 useMemo(
424 () => scopeProjectRef(thread.environmentId, thread.projectId),
425 [thread.environmentId, thread.projectId],
426 ),
427 );
428 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
429 const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
430 const gitStatus = useEnvironmentQuery(
431 leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null
432 ? vcsEnvironment.status({
433 environmentId: thread.environmentId,
434 input: { cwd: gitCwd },
435 })
436 : null,
437 );
438 const isHighlighted = isActive || isSelected;413 const isHighlighted = isActive || isSelected;
439 const handleOpenDiscoveredPort = useCallback(414 const handleOpenDiscoveredPort = useCallback(
440 (event: React.MouseEvent<HTMLButtonElement>) => {415 (event: React.MouseEvent<HTMLButtonElement>) => {
...@@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
470 },445 },
471 });446 });
472 const linkedPullRequestStatus = useLinkedThreadPullRequest(447 const linkedPullRequestStatus = useLinkedThreadPullRequest(
473 leaseLiveStatus ? thread.environmentId : null,448 thread.environmentId,
474 leaseLiveStatus ? thread.linkedPullRequest : null,449 thread.linkedPullRequest ?? thread.branchPullRequest,
475 );450 leaseLiveStatus,
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,
494 );451 );
452 const pr = linkedPullRequestStatus?.pr ?? null;
453 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
495 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);454 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
496 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;455 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
497 const threadMetaClassName = isConfirmingArchive456 const threadMetaClassName = isConfirmingArchive
...@@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps {...@@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps {
940 showEmptyThreadState: boolean;899 showEmptyThreadState: boolean;
941 shouldShowThreadPanel: boolean;900 shouldShowThreadPanel: boolean;
942 isThreadListExpanded: boolean;901 isThreadListExpanded: boolean;
943 projectCwd: string;
944 activeRouteThreadKey: string | null;902 activeRouteThreadKey: string | null;
945 openPullRequestsInRightPanel: boolean;903 openPullRequestsInRightPanel: boolean;
946 threadJumpLabelByKey: ReadonlyMap<string, string>;904 threadJumpLabelByKey: ReadonlyMap<string, string>;
...@@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(...@@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
996 showEmptyThreadState,954 showEmptyThreadState,
997 shouldShowThreadPanel,955 shouldShowThreadPanel,
998 isThreadListExpanded,956 isThreadListExpanded,
999 projectCwd,
1000 activeRouteThreadKey,957 activeRouteThreadKey,
1001 openPullRequestsInRightPanel,958 openPullRequestsInRightPanel,
1002 threadJumpLabelByKey,959 threadJumpLabelByKey,
...@@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(...@@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
1048 <SidebarThreadRow1005 <SidebarThreadRow
1049 key={threadKey}1006 key={threadKey}
1050 thread={thread}1007 thread={thread}
1051 projectCwd={projectCwd}
1052 orderedProjectThreadKeys={orderedProjectThreadKeys}1008 orderedProjectThreadKeys={orderedProjectThreadKeys}
1053 isActive={activeRouteThreadKey === threadKey}1009 isActive={activeRouteThreadKey === threadKey}
1054 openPullRequestsInRightPanel={openPullRequestsInRightPanel}1010 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
...@@ -2444,7 +2400,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2444,7 +2400,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2444 showEmptyThreadState={showEmptyThreadState}2400 showEmptyThreadState={showEmptyThreadState}
2445 shouldShowThreadPanel={shouldShowThreadPanel}2401 shouldShowThreadPanel={shouldShowThreadPanel}
2446 isThreadListExpanded={isThreadListExpanded}2402 isThreadListExpanded={isThreadListExpanded}
2447 projectCwd={project.workspaceRoot}
2448 activeRouteThreadKey={activeRouteThreadKey}2403 activeRouteThreadKey={activeRouteThreadKey}
2449 openPullRequestsInRightPanel={openPullRequestsInRightPanel}2404 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
2450 threadJumpLabelByKey={threadJumpLabelByKey}2405 threadJumpLabelByKey={threadJumpLabelByKey}
apps/web/src/components/Sidebar.tsx+5-58
...@@ -167,15 +167,9 @@ import {...@@ -167,15 +167,9 @@ import {
167import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";167import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
168import {168import {
169 ThreadWorktreeIndicator,169 ThreadWorktreeIndicator,
170 nextThreadChangeRequestSnapshot,
171 prStatusIndicator,170 prStatusIndicator,
172 resolveDisplayedThreadPr,
173 resolveDisplayedThreadPrProvider,
174 setThreadChangeRequestSnapshot,
175 settledPrHoverColorClass,171 settledPrHoverColorClass,
176 terminalStatusFromRunningIds,172 terminalStatusFromRunningIds,
177 threadChangeRequestSnapshotsAtom,
178 type ThreadChangeRequestSnapshot,
179 type TerminalStatusIndicator,173 type TerminalStatusIndicator,
180 useLinkedThreadPullRequest,174 useLinkedThreadPullRequest,
181} from "./ThreadStatusIndicators";175} from "./ThreadStatusIndicators";
...@@ -795,16 +789,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -795,16 +789,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
795 onUnsnooze: (threadRef: ScopedThreadRef) => void;789 onUnsnooze: (threadRef: ScopedThreadRef) => void;
796 onUnpin: (threadRef: ScopedThreadRef) => void;790 onUnpin: (threadRef: ScopedThreadRef) => void;
797 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;791 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
798 changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
799 onChangeRequestSnapshot: (
800 threadKey: string,
801 snapshot: ThreadChangeRequestSnapshot | null,
802 ) => void;
803}) {792}) {
804 const {793 const {
805 isRenaming,794 isRenaming,
806 changeRequestSnapshot,
807 onChangeRequestSnapshot,
808 onCancelRename,795 onCancelRename,
809 onCommitRename,796 onCommitRename,
810 onContextMenu,797 onContextMenu,
...@@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
856843
857 const gitCwd = thread.worktreePath ?? props.projectCwd;844 const gitCwd = thread.worktreePath ?? props.projectCwd;
858 const linkedPullRequestStatus = useLinkedThreadPullRequest(845 const linkedPullRequestStatus = useLinkedThreadPullRequest(
859 leaseLiveStatus ? thread.environmentId : null,846 thread.environmentId,
860 leaseLiveStatus ? thread.linkedPullRequest : null,847 thread.linkedPullRequest ?? thread.branchPullRequest,
848 leaseLiveStatus,
861 );849 );
862 const gitStatus = useEnvironmentQuery(850 const gitStatus = useEnvironmentQuery(
863 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null851 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
...@@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
871 JSON.stringify([thread.environmentId, gitCwd]),859 JSON.stringify([thread.environmentId, gitCwd]),
872 gitStatus.data,860 gitStatus.data,
873 );861 );
874 const retainTerminalOnBranchMismatch = thread.worktreePath === null;862 const pr = linkedPullRequestStatus?.pr ?? null;
875 const pr = resolveDisplayedThreadPr({
876 threadBranch: thread.branch,
877 gitStatus: visibleGitStatus,
878 snapshot: changeRequestSnapshot,
879 retainTerminalOnBranchMismatch,
880 linkedPullRequest: thread.linkedPullRequest,
881 linkedPullRequestStatus,
882 });
883863
884 // Same semantics as the legacy sidebar (never-visited counts as read):864 // Same semantics as the legacy sidebar (never-visited counts as read):
885 // switching sidebars must not light up every historical thread as unread.865 // switching sidebars must not light up every historical thread as unread.
...@@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
972 activeThreadBranch: thread.branch,952 activeThreadBranch: thread.branch,
973 currentGitBranch: visibleGitStatus?.refName ?? null,953 currentGitBranch: visibleGitStatus?.refName ?? null,
974 });954 });
975 const prProvider = resolveDisplayedThreadPrProvider({955 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
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);
984 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;956 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
985 useEffect(() => {
986 const nextSnapshot = nextThreadChangeRequestSnapshot({
987 threadBranch: thread.branch,
988 gitStatus: visibleGitStatus,
989 snapshot: changeRequestSnapshot,
990 retainTerminalOnBranchMismatch,
991 linkedPullRequest: thread.linkedPullRequest,
992 linkedPullRequestStatus,
993 });
994 if (nextSnapshot === undefined) return;
995 onChangeRequestSnapshot(threadKey, nextSnapshot);
996 }, [
997 changeRequestSnapshot,
998 visibleGitStatus,
999 linkedPullRequestStatus,
1000 onChangeRequestSnapshot,
1001 retainTerminalOnBranchMismatch,
1002 thread.branch,
1003 thread.linkedPullRequest,
1004 threadKey,
1005 ]);
1006957
1007 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;958 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
1008 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;959 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
...@@ -2104,8 +2055,6 @@ export default function Sidebar() {...@@ -2104,8 +2055,6 @@ export default function Sidebar() {
2104 // fresh clock whenever it recomputes.2055 // fresh clock whenever it recomputes.
2105 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);2056 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
21062057
2107 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
2108
2109 // Project scope: one menu above the list. Scoping filters the list without2058 // Project scope: one menu above the list. Scoping filters the list without
2110 // making the header width depend on the number or length of project names.2059 // making the header width depend on the number or length of project names.
2111 // The selection lives in the persisted UI store next to the other sidebar2060 // The selection lives in the persisted UI store next to the other sidebar
...@@ -4092,8 +4041,6 @@ export default function Sidebar() {...@@ -4092,8 +4041,6 @@ export default function Sidebar() {
4092 onUnsnooze={attemptUnsnooze}4041 onUnsnooze={attemptUnsnooze}
4093 onUnpin={attemptUnpin}4042 onUnpin={attemptUnpin}
4094 onAcknowledgeWoke={acknowledgeWoke}4043 onAcknowledgeWoke={acknowledgeWoke}
4095 changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
4096 onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
4097 />4044 />
4098 );4045 );
4099 };4046 };
apps/web/src/components/ThreadStatusIndicators.test.ts-525
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import { AtomRegistry } from "effect/unstable/reactivity";
5import {3import {
6 GitMergeIcon,4 GitMergeIcon,
7 GitPullRequestClosedIcon,5 GitPullRequestClosedIcon,
...@@ -11,15 +9,8 @@ import {...@@ -11,15 +9,8 @@ import {
119
12import {10import {
13 ChangeRequestStatusIcon,11 ChangeRequestStatusIcon,
14 nextThreadChangeRequestSnapshot,
15 prStatusIndicator,12 prStatusIndicator,
16 resolveDisplayedThreadPr,
17 resolveDisplayedThreadPrProvider,
18 resolveThreadPr,
19 settledPrHoverColorClass,13 settledPrHoverColorClass,
20 threadChangeRequestSnapshotsEqual,
21 threadChangeRequestSnapshotsAtom,
22 type ThreadChangeRequestSnapshot,
23} from "./ThreadStatusIndicators";14} from "./ThreadStatusIndicators";
24import { newestPullRequestSummary } from "../state/pullRequests";15import { newestPullRequestSummary } from "../state/pullRequests";
2516
...@@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {...@@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
57 };48 };
58}49}
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
79function pullRequestSummary(51function pullRequestSummary(
80 state: PullRequestSummary["state"],52 state: PullRequestSummary["state"],
81 updatedAt: string,53 updatedAt: string,
...@@ -117,503 +89,6 @@ describe("shared pull request state", () => {...@@ -117,503 +89,6 @@ describe("shared pull request state", () => {
117 });89 });
118});90});
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
617describe("prStatusIndicator", () => {92describe("prStatusIndicator", () => {
618 it("formats PR tooltips with number, uppercase status, and title", () => {93 it("formats PR tooltips with number, uppercase status, and title", () => {
619 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({94 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
apps/web/src/components/ThreadStatusIndicators.tsx+8-303
...@@ -1,8 +1,4 @@...@@ -1,8 +1,4 @@
1import {1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2 scopeProjectRef,
3 scopedThreadKey,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";2import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
7import {3import {
8 type EnvironmentId,4 type EnvironmentId,
...@@ -10,17 +6,13 @@ import {...@@ -10,17 +6,13 @@ import {
10 type ThreadLinkedPullRequest,6 type ThreadLinkedPullRequest,
11 type VcsStatusResult,7 type VcsStatusResult,
12} from "@t3tools/contracts";8} from "@t3tools/contracts";
13import { Atom } from "effect/unstable/reactivity";
14import { FolderGit2Icon, TerminalIcon } from "lucide-react";9import { FolderGit2Icon, TerminalIcon } from "lucide-react";
15import { useMemo } from "react";10import { useMemo } from "react";
16import { appAtomRegistry } from "../rpc/atomRegistry";
17import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";11import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
18import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";12import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
19import { useProject } from "../state/entities";
20import { useEnvironmentQuery } from "../state/query";13import { useEnvironmentQuery } from "../state/query";
21import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";14import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
22import { useThreadRunningTerminalIds } from "../state/terminalSessions";15import { useThreadRunningTerminalIds } from "../state/terminalSessions";
23import { vcsEnvironment } from "../state/vcs";
24import { useUiStateStore } from "../uiStateStore";16import { useUiStateStore } from "../uiStateStore";
25import { resolveChangeRequestPresentation } from "../sourceControlPresentation";17import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
26import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";18import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
...@@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus {...@@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus {
51 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;43 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
52}44}
5345
46/** Keep cached summaries visible when an offscreen row stops live queries. */
54export function useLinkedThreadPullRequest(47export function useLinkedThreadPullRequest(
55 environmentId: EnvironmentId | null,48 environmentId: EnvironmentId | null,
56 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,49 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
50 enabled = true,
57): LinkedThreadPullRequestStatus | null {51): LinkedThreadPullRequestStatus | null {
58 const queried = useEnvironmentQuery(52 const queried = useEnvironmentQuery(
59 environmentId === null || linkedPullRequest == null53 !enabled || environmentId === null || linkedPullRequest == null
60 ? null54 ? null
61 : linkedPullRequestDetailAtom({55 : linkedPullRequestDetailAtom({
62 environmentId,56 environmentId,
...@@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }...@@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }
178 );172 );
179}173}
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
446export function terminalStatusFromRunningIds(175export function terminalStatusFromRunningIds(
447 runningTerminalIds: ReadonlyArray<string>,176 runningTerminalIds: ReadonlyArray<string>,
448): TerminalStatusIndicator | null {177): TerminalStatusIndicator | null {
...@@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
551 const lastVisitedAt = useUiStateStore(280 const lastVisitedAt = useUiStateStore(
552 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],281 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
553 );282 );
554 const threadProject = useProject(283 const pullRequest = useLinkedThreadPullRequest(
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(
563 thread.environmentId,284 thread.environmentId,
564 thread.linkedPullRequest,285 thread.linkedPullRequest ?? thread.branchPullRequest,
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,
583 );286 );
287 const pr = pullRequest?.pr ?? null;
288 const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);
584 const threadStatus = resolveThreadStatusPill({289 const threadStatus = resolveThreadStatusPill({
585 thread: {290 thread: {
586 ...thread,291 ...thread,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx-12
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 type PullRequestListEntry,8 type PullRequestListEntry,
9 type PullRequestUpdateMethod,9 type PullRequestUpdateMethod,
10 type PullRequestRef,10 type PullRequestRef,
11 type PullRequestState,
12 resolveEnvironmentMachineKind,11 resolveEnvironmentMachineKind,
13 type ScopedThreadRef,12 type ScopedThreadRef,
14} from "@t3tools/contracts";13} from "@t3tools/contracts";
...@@ -454,7 +453,6 @@ export function PullRequestDetailPanel({...@@ -454,7 +453,6 @@ export function PullRequestDetailPanel({
454 refreshToken: forcedRefreshToken = 0,453 refreshToken: forcedRefreshToken = 0,
455 onActed,454 onActed,
456 onClose,455 onClose,
457 onStateChange,
458 context = "page",456 context = "page",
459 composerDraftTarget,457 composerDraftTarget,
460}: {458}: {
...@@ -482,8 +480,6 @@ export function PullRequestDetailPanel({...@@ -482,8 +480,6 @@ export function PullRequestDetailPanel({
482 onActed?: () => void;480 onActed?: () => void;
483 /** Page-owned detail columns use this to clear the selected pull request. */481 /** Page-owned detail columns use this to clear the selected pull request. */
484 onClose?: () => void;482 onClose?: () => void;
485 /** Keeps surrounding inferred thread state in step with refreshed host state. */
486 onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void;
487 /**483 /**
488 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's484 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's
489 * own pull request, so the branch is already under the reader's feet — and checking it out485 * own pull request, so the branch is already under the reader's feet — and checking it out
...@@ -714,14 +710,6 @@ export function PullRequestDetailPanel({...@@ -714,14 +710,6 @@ export function PullRequestDetailPanel({
714 }710 }
715 activityRevision.current = next;711 activityRevision.current = next;
716 }, [activityQuery.refresh, coreDetail, tabScopeKey]);712 }, [activityQuery.refresh, coreDetail, tabScopeKey]);
717 useLayoutEffect(() => {
718 if (!resolvedCoreDetail) return;
719 onStateChange?.({
720 repository: resolvedCoreDetail.repository,
721 number: resolvedCoreDetail.number,
722 state: resolvedCoreDetail.state,
723 });
724 }, [onStateChange, resolvedCoreDetail]);
725 // Reuse activity and diff until core detail reports a changed revision. Keyed by713 // Reuse activity and diff until core detail reports a changed revision. Keyed by
726 // the pull request rather than by the panel, because this one panel shows a different pull714 // the pull request rather than by the panel, because this one panel shows a different pull
727 // request every time it is opened.715 // request every time it is opened.
docs/user/thread-sidebar.md+6-1
...@@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads....@@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads.
4949
50## Link a pull request50## 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
52On web and desktop, right-click a pull request link in a thread and choose56On 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.
54The linked pull request participates in automatic settlement.59The linked pull request participates in automatic settlement.
5560
56## Find and reference work61## Find and reference work
packages/client-runtime/src/state/threadReducer.test.ts+56-41
...@@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => {...@@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => {
306 }306 }
307 });307 });
308308
309 it("sets and clears a linked pull request", () => {309 it.each(["linkedPullRequest", "branchPullRequest"] as const)(
310 const linkedPullRequest = {310 "sets and clears %s without changing the other link",
311 projectId: ProjectId.make("project-1"),311 (field) => {
312 repository: "pingdotgg/t3code",312 const linkedPullRequest = {
313 number: 42,313 projectId: ProjectId.make("project-1"),
314 url: "https://github.com/pingdotgg/t3code/pull/42",314 repository: "pingdotgg/t3code",
315 };315 number: 42,
316 const linked = applyThreadDetailEvent(baseThread, {316 url: "https://github.com/pingdotgg/t3code/pull/42",
317 ...baseEventFields,317 };
318 sequence: 5,318 const otherField =
319 occurredAt: "2026-04-01T05:00:00.000Z",319 field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest";
320 aggregateKind: "thread",320 const otherPullRequest = {
321 aggregateId: ThreadId.make("thread-1"),321 ...linkedPullRequest,
322 type: "thread.meta-updated",322 number: 43,
323 payload: {323 url: "https://github.com/pingdotgg/t3code/pull/43",
324 threadId: ThreadId.make("thread-1"),324 };
325 linkedPullRequest,325 const linked = applyThreadDetailEvent(
326 updatedAt: "2026-04-01T05:00:00.000Z",326 { ...baseThread, [otherField]: otherPullRequest },
327 },327 {
328 });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");342 expect(linked.kind).toBe("updated");
331 if (linked.kind !== "updated") return;343 if (linked.kind !== "updated") return;
332 expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest);344 expect(linked.thread[field]).toEqual(linkedPullRequest);
345 expect(linked.thread[otherField]).toEqual(otherPullRequest);
333346
334 const cleared = applyThreadDetailEvent(linked.thread, {347 const cleared = applyThreadDetailEvent(linked.thread, {
335 ...baseEventFields,348 ...baseEventFields,
336 sequence: 6,349 sequence: 6,
337 occurredAt: "2026-04-01T06:00:00.000Z",350 occurredAt: "2026-04-01T06:00:00.000Z",
338 aggregateKind: "thread",351 aggregateKind: "thread",
339 aggregateId: ThreadId.make("thread-1"),352 aggregateId: ThreadId.make("thread-1"),
340 type: "thread.meta-updated",353 type: "thread.meta-updated",
341 payload: {354 payload: {
342 threadId: ThreadId.make("thread-1"),355 threadId: ThreadId.make("thread-1"),
343 linkedPullRequest: null,356 [field]: null,
344 updatedAt: "2026-04-01T06:00:00.000Z",357 updatedAt: "2026-04-01T06:00:00.000Z",
345 },358 },
346 });359 });
347360
348 expect(cleared.kind).toBe("updated");361 expect(cleared.kind).toBe("updated");
349 if (cleared.kind === "updated") {362 if (cleared.kind === "updated") {
350 expect(cleared.thread.linkedPullRequest).toBeNull();363 expect(cleared.thread[field]).toBeNull();
351 }364 expect(cleared.thread[otherField]).toEqual(otherPullRequest);
352 });365 }
366 },
367 );
353 });368 });
354369
355 describe("thread.message-sent", () => {370 describe("thread.message-sent", () => {
packages/client-runtime/src/state/threadReducer.ts+4
...@@ -97,6 +97,7 @@ export function applyThreadDetailEvent(...@@ -97,6 +97,7 @@ export function applyThreadDetailEvent(
97 interactionMode: event.payload.interactionMode,97 interactionMode: event.payload.interactionMode,
98 branch: event.payload.branch,98 branch: event.payload.branch,
99 worktreePath: event.payload.worktreePath,99 worktreePath: event.payload.worktreePath,
100 branchPullRequest: null,
100 latestTurn: null,101 latestTurn: null,
101 createdAt: event.payload.createdAt,102 createdAt: event.payload.createdAt,
102 updatedAt: event.payload.updatedAt,103 updatedAt: event.payload.updatedAt,
...@@ -240,6 +241,9 @@ export function applyThreadDetailEvent(...@@ -240,6 +241,9 @@ export function applyThreadDetailEvent(
240 ...(event.payload.linkedPullRequest !== undefined241 ...(event.payload.linkedPullRequest !== undefined
241 ? { linkedPullRequest: event.payload.linkedPullRequest }242 ? { linkedPullRequest: event.payload.linkedPullRequest }
242 : {}),243 : {}),
244 ...(event.payload.branchPullRequest !== undefined
245 ? { branchPullRequest: event.payload.branchPullRequest }
246 : {}),
243 updatedAt: event.payload.updatedAt,247 updatedAt: event.payload.updatedAt,
244 },248 },
245 };249 };
packages/contracts/src/orchestration.test.ts+42
...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";
56
6import {7import {
7 DEFAULT_PROVIDER_INTERACTION_MODE,8 DEFAULT_PROVIDER_INTERACTION_MODE,
...@@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () =>...@@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () =>
837 }),838 }),
838);839);
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
840it.effect("rejects an explicit title combined with title regeneration", () =>882it.effect("rejects an explicit title combined with title regeneration", () =>
841 Effect.gen(function* () {883 Effect.gen(function* () {
842 const result = yield* Effect.exit(884 const result = yield* Effect.exit(
packages/contracts/src/orchestration.ts+21
...@@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({
494 branch: Schema.NullOr(TrimmedNonEmptyString),494 branch: Schema.NullOr(TrimmedNonEmptyString),
495 worktreePath: Schema.NullOr(TrimmedNonEmptyString),495 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
496 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),496 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
497 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
497 latestTurn: Schema.NullOr(OrchestrationLatestTurn),498 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
498 createdAt: IsoDateTime,499 createdAt: IsoDateTime,
499 updatedAt: IsoDateTime,500 updatedAt: IsoDateTime,
...@@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({
572 branch: Schema.NullOr(TrimmedNonEmptyString),573 branch: Schema.NullOr(TrimmedNonEmptyString),
573 worktreePath: Schema.NullOr(TrimmedNonEmptyString),574 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
574 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),575 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
576 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
575 latestTurn: Schema.NullOr(OrchestrationLatestTurn),577 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
576 createdAt: IsoDateTime,578 createdAt: IsoDateTime,
577 updatedAt: IsoDateTime,579 updatedAt: IsoDateTime,
...@@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({...@@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
1183 title: Schema.optional(TrimmedNonEmptyString),1185 title: Schema.optional(TrimmedNonEmptyString),
1184});1186});
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
1186const InternalOrchestrationCommand = Schema.Union([1205const InternalOrchestrationCommand = Schema.Union([
1187 ThreadAutoSettleCommand,1206 ThreadAutoSettleCommand,
1188 ThreadSessionSetCommand,1207 ThreadSessionSetCommand,
...@@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([...@@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([
1194 ThreadActivityAppendCommand,1213 ThreadActivityAppendCommand,
1195 ThreadRevertCompleteCommand,1214 ThreadRevertCompleteCommand,
1196 ThreadTitleRegenerationCompleteCommand,1215 ThreadTitleRegenerationCompleteCommand,
1216 ThreadPullRequestSyncCommand,
1197]);1217]);
1198export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;1218export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
11991219
...@@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({...@@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
1367 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1387 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1368 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1388 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1369 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),1389 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1390 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1370 updatedAt: IsoDateTime,1391 updatedAt: IsoDateTime,
1371});1392});
13721393
packages/shared/src/threadReference.test.ts+2-17
...@@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => {...@@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => {
9 threadId: "thread-1",9 threadId: "thread-1",
10 openPanelPullRequestUrl: null,10 openPanelPullRequestUrl: null,
11 linkedPullRequestUrl: "https://github.com/t3/pr/12",11 linkedPullRequestUrl: "https://github.com/t3/pr/12",
12 detectedPullRequestUrl: "https://github.com/t3/pr/13",
13 }),12 }),
14 ).toBeNull();13 ).toBeNull();
15 });14 });
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", () => {
18 expect(17 expect(
19 resolveThreadReferenceCopyTarget({18 resolveThreadReferenceCopyTarget({
20 threadId: "thread-1",19 threadId: "thread-1",
21 openPanelPullRequestUrl: "https://github.com/t3/pr/14",20 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
22 linkedPullRequestUrl: "https://github.com/t3/pr/12",21 linkedPullRequestUrl: "https://github.com/t3/pr/12",
23 detectedPullRequestUrl: "https://github.com/t3/pr/13",
24 }),22 }),
25 ).toMatchObject({23 ).toMatchObject({
26 kind: "pull-request",24 kind: "pull-request",
...@@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => {...@@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => {
29 });27 });
30 });28 });
3129
32 it("prefers a durable linked pull request", () => {30 it("uses the thread pull request when no panel is open", () => {
33 expect(31 expect(
34 resolveThreadReferenceCopyTarget({32 resolveThreadReferenceCopyTarget({
35 threadId: "thread-1",33 threadId: "thread-1",
36 linkedPullRequestUrl: "https://github.com/t3/pr/12",34 linkedPullRequestUrl: "https://github.com/t3/pr/12",
37 detectedPullRequestUrl: "https://github.com/t3/pr/13",
38 }),35 }),
39 ).toMatchObject({36 ).toMatchObject({
40 kind: "pull-request",37 kind: "pull-request",
...@@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => {...@@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => {
43 });40 });
44 });41 });
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
58 it("falls back to the thread ID", () => {43 it("falls back to the thread ID", () => {
59 expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({44 expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({
60 kind: "thread",45 kind: "thread",
packages/shared/src/threadReference.ts+1-3
...@@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: {...@@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: {
11 /** Undefined means no PR panel; null means its URL is not available yet. */11 /** Undefined means no PR panel; null means its URL is not available yet. */
12 readonly openPanelPullRequestUrl?: string | null | undefined;12 readonly openPanelPullRequestUrl?: string | null | undefined;
13 readonly linkedPullRequestUrl?: string | null;13 readonly linkedPullRequestUrl?: string | null;
14 readonly detectedPullRequestUrl?: string | null;
15}): ThreadReferenceCopyTarget | null {14}): ThreadReferenceCopyTarget | null {
16 if (input.openPanelPullRequestUrl === null) return null;15 if (input.openPanelPullRequestUrl === null) return null;
17 const pullRequestUrl =16 const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl;
18 input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
19 return pullRequestUrl17 return pullRequestUrl
20 ? {18 ? {
21 kind: "pull-request",19 kind: "pull-request",