Skip to content

Commit 223ff44

Browse files
t3dotggclaude
andauthored
fix(server): link thread PRs without an open client (#10101)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5fa35d2 commit 223ff44

50 files changed

Lines changed: 2309 additions & 1486 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/mobile/src/features/home/HomeScreen.tsx

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) {
416416
[threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
417417
);
418418

419-
const projectCwdByKey = useMemo(() => {
420-
const map = new Map<string, string>();
421-
for (const project of props.projects) {
422-
map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
423-
}
424-
return map;
425-
}, [props.projects]);
426-
427419
const projectByKey = useMemo(() => {
428420
const map = new Map<string, EnvironmentProject>();
429421
for (const project of props.projects) {
@@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) {
850842
onPinThread={handlePinThread}
851843
onUnpinThread={handleUnpinThread}
852844
onMovePinnedThread={handleMovePinnedThread}
853-
projectCwd={
854-
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
855-
}
856845
onSwipeableClose={handleSwipeableClose}
857846
onSwipeableWillOpen={handleSwipeableWillOpen}
858847
/>
@@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) {
875864
machineByEnvironmentId,
876865
pinReorderEnvironmentIds,
877866
projectByKey,
878-
projectCwdByKey,
879867
props.onArchiveThread,
880868
props.onDeletePendingTask,
881869
props.onSelectPendingTask,
@@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) {
903891
const v2ExtraData = useMemo(
904892
() => ({
905893
projectByKey,
906-
projectCwdByKey,
907894
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
908895
serverConfigs,
909896
savedConnectionsById: props.savedConnectionsById,
@@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) {
913900
}),
914901
[
915902
projectByKey,
916-
projectCwdByKey,
917903
props.searchQuery,
918904
props.savedConnectionsById,
919905
serverConfigs,
@@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) {
925911

926912
const extraData = useMemo(
927913
() => ({
928-
projectCwdByKey,
929914
savedConnectionsById: props.savedConnectionsById,
930915
searchQuery: props.searchQuery,
931916
threadSearchMatchByKey,
932917
}),
933-
[projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
918+
[props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
934919
);
935920

936921
const renderItem = useCallback(
@@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) {
982967
props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
983968
}
984969
environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
985-
projectCwd={
986-
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
987-
null
988-
}
989970
isLast={item.isLast}
990971
searchMatch={threadSearchMatchByKey.get(
991972
threadSearchMatchKey({
@@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) {
10211002
handleSwipeableWillOpen,
10221003
handleRegenerateThreadTitle,
10231004
machineByEnvironmentId,
1024-
projectCwdByKey,
10251005
props.onArchiveThread,
10261006
props.onDeletePendingTask,
10271007
props.onDeleteThread,

apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import {
1212

1313
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1414
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15-
import { useProject, useThreadShell } from "../../state/entities";
16-
import { useEnvironmentQuery } from "../../state/query";
15+
import { useThreadShell } from "../../state/entities";
1716
import type { GitActionProgress } from "../../state/use-vcs-action-state";
18-
import { vcsEnvironment } from "../../state/vcs";
1917
import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
2018
import {
2119
dispatchHardwareKeyboardCommand,
@@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({
4038
const navigation = useNavigation();
4139
const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
4240
const activeThread = useThreadShell(activeThreadRef);
43-
const activeProjectRef = useMemo(
44-
() =>
45-
activeThread === null
46-
? null
47-
: {
48-
environmentId: activeThread.environmentId,
49-
projectId: activeThread.projectId,
50-
},
51-
[activeThread],
52-
);
53-
const activeProject = useProject(activeProjectRef);
54-
const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null;
55-
const gitStatus = useEnvironmentQuery(
56-
activeThread !== null &&
57-
activeThread.linkedPullRequest == null &&
58-
activeThread.branch !== null &&
59-
activeThreadCwd !== null
60-
? vcsEnvironment.status({
61-
environmentId: activeThread.environmentId,
62-
input: { cwd: activeThreadCwd },
63-
})
64-
: null,
65-
).data;
66-
const detectedPullRequestUrl =
67-
activeThread?.branch != null && gitStatus?.refName === activeThread.branch
68-
? (gitStatus.pr?.url ?? null)
69-
: null;
7041
const copyTarget = useMemo(
7142
() =>
7243
activeThreadRef === null
7344
? null
7445
: resolveThreadReferenceCopyTarget({
7546
threadId: activeThread?.id ?? activeThreadRef.threadId,
76-
linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null,
77-
detectedPullRequestUrl,
47+
linkedPullRequestUrl:
48+
(activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
7849
}),
79-
[activeThread, activeThreadRef, detectedPullRequestUrl],
50+
[activeThread, activeThreadRef],
8051
);
8152
const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
8253
const copyRequestIdRef = useRef(0);

apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane(
353353
}),
354354
[threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery],
355355
);
356-
const projectCwdByKey = useMemo(() => {
357-
const map = new Map<string, string>();
358-
for (const project of projects) {
359-
map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot);
360-
}
361-
return map;
362-
}, [projects]);
363356
const projectByKey = useMemo(() => {
364357
const map = new Map<string, EnvironmentProject>();
365358
for (const project of projects) {
@@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane(
742735
() => ({
743736
selectedThreadKey: props.selectedThreadKey ?? "",
744737
projectByKey,
745-
projectCwdByKey,
746738
projectTitleByProjectKey,
747739
savedConnectionsById,
748740
serverConfigs,
@@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane(
752744
[
753745
props.selectedThreadKey,
754746
projectByKey,
755-
projectCwdByKey,
756747
projectTitleByProjectKey,
757748
savedConnectionsById,
758749
serverConfigs,
@@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane(
914905
onPinThread={pinThread}
915906
onUnpinThread={unpinThread}
916907
onMovePinnedThread={movePinnedThread}
917-
projectCwd={projectCwdByKey.get(scopeKey) ?? null}
918908
onSwipeableClose={handleSwipeableClose}
919909
onSwipeableWillOpen={handleSwipeableWillOpen}
920910
simultaneousSwipeGesture={sidebarScrollGesture}
@@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane(
1000990
savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
1001991
}
1002992
environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
1003-
projectCwd={
1004-
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
1005-
null
1006-
}
1007993
isLast={item.isLast}
1008994
searchMatch={threadSearchMatchByKey.get(
1009995
threadSearchMatchKey({
@@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane(
10541040
pinThread,
10551041
pinningEnvironmentIds,
10561042
projectByKey,
1057-
projectCwdByKey,
10581043
projectTitleByProjectKey,
10591044
regenerateThreadTitle,
10601045
props.onNewThreadInProject,

apps/mobile/src/features/threads/thread-list-items.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
416416
readonly thread: EnvironmentThreadShell;
417417
readonly environmentLabel: string | null;
418418
readonly environmentMachine?: EnvironmentMachineKind;
419-
readonly projectCwd: string | null;
420419
readonly searchMatch?: EnvironmentThreadSearchMatch;
421420
readonly searchQuery?: string;
422421
readonly isLast: boolean;
@@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453452
const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
454453
props;
455454
const status = resolveThreadStatus(thread);
456-
const pr = useThreadPr(thread, props.projectCwd);
455+
const pr = useThreadPr(thread);
457456
const timestamp = relativeTime(
458457
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
459458
);

apps/mobile/src/features/threads/thread-list-v2-items.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
377377
readonly canMovePinnedDown?: boolean;
378378
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
379379
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
380-
readonly projectCwd?: string | null;
381380
readonly searchMatch?: EnvironmentThreadSearchMatch;
382381
readonly searchQuery?: string;
383382
readonly simultaneousSwipeGesture?: ComponentProps<
@@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
403402
const snoozedRow = props.snoozed === true;
404403
const pinnedRow = props.pinned === true;
405404

406-
const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
405+
const pr = useThreadPr(thread);
407406

408407
const theme = useUniwindTheme();
409408
const screenColor = theme["--color-screen"];

apps/mobile/src/state/use-thread-pr.ts

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime";
1212
import { appAtomRegistry } from "./atom-registry";
1313
import { useEnvironmentQuery } from "./query";
1414
import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
15-
import { vcsEnvironment } from "./vcs";
1615

17-
const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
16+
const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1817
const MAX_THREAD_PR_SNAPSHOTS = 500;
1918

2019
interface ThreadPrSnapshot {
@@ -23,7 +22,7 @@ interface ThreadPrSnapshot {
2322
}
2423

2524
// One bounded cache survives row virtualization without retaining one live
26-
// atom for every thread, branch, directory, or linked pull request ever seen.
25+
// atom for every thread or pull request ever seen.
2726
const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe(
2827
Atom.keepAlive,
2928
Atom.withLabel("mobile:thread-pr-snapshots"),
@@ -36,20 +35,13 @@ export {
3635
} from "./thread-pr-presentation";
3736

3837
/**
39-
* Live PR status for a thread's branch. Subscriptions are deduplicated per
40-
* (environmentId, cwd) by the atom family, so many rows on the same worktree
41-
* or project root share one stream — and virtualization means only visible
42-
* rows subscribe at all.
38+
* Live status for a thread's server-provided PR. Visible rows share a summary
39+
* request for the same PR in the same environment.
4340
*/
44-
export function useThreadPr(
45-
thread: EnvironmentThreadShell,
46-
projectCwd: string | null,
47-
): ThreadPrPresentation | null {
48-
const cwd = thread.worktreePath ?? projectCwd;
41+
export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
42+
const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null;
4943
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
50-
const snapshotIdentity = JSON.stringify(
51-
thread.linkedPullRequest ?? { branch: thread.branch, cwd },
52-
);
44+
const snapshotIdentity = JSON.stringify(pullRequestRef);
5345
// Select this row's entry so writes for other rows do not re-render it.
5446
const snapshotEntry = useAtomValue(
5547
threadPrSnapshotsAtom,
@@ -59,45 +51,30 @@ export function useThreadPr(
5951
),
6052
);
6153
const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null;
62-
const gitStatus = useEnvironmentQuery(
63-
thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
64-
? vcsEnvironment.status({
65-
environmentId: thread.environmentId,
66-
input: { cwd },
67-
})
68-
: null,
69-
);
70-
const linkedPullRequest = useEnvironmentQuery(
71-
thread.linkedPullRequest == null
54+
const pullRequestSummary = useEnvironmentQuery(
55+
pullRequestRef === null
7256
? null
73-
: linkedPullRequestDetailAtom({
57+
: pullRequestSummaryAtom({
7458
environmentId: thread.environmentId,
7559
input: {
76-
projectId: thread.linkedPullRequest.projectId,
77-
repository: thread.linkedPullRequest.repository,
78-
number: thread.linkedPullRequest.number,
60+
projectId: pullRequestRef.projectId,
61+
repository: pullRequestRef.repository,
62+
number: pullRequestRef.number,
7963
},
8064
}),
8165
);
8266

8367
const live = useMemo<ThreadPrPresentation | null | undefined>(() => {
84-
if (thread.linkedPullRequest != null) {
85-
const detail = linkedPullRequest.data;
86-
return detail === null
87-
? undefined
88-
: presentThreadPr(pullRequestDetailToVcsStatus(detail), {
89-
kind: detail.provider,
90-
name: detail.provider,
91-
baseUrl: "",
92-
});
93-
}
94-
95-
const status = gitStatus.data;
96-
if (thread.branch === null) return null;
97-
if (status === null) return undefined;
98-
if (status.refName !== thread.branch || !status.pr) return null;
99-
return presentThreadPr(status.pr, status.sourceControlProvider);
100-
}, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]);
68+
if (pullRequestRef === null) return null;
69+
const summary = pullRequestSummary.data;
70+
return summary === null
71+
? undefined
72+
: presentThreadPr(pullRequestDetailToVcsStatus(summary), {
73+
kind: summary.provider,
74+
name: summary.provider,
75+
baseUrl: "",
76+
});
77+
}, [pullRequestRef, pullRequestSummary.data]);
10178

10279
useEffect(() => {
10380
if (live === undefined) return;

apps/mobile/src/state/use-thread-selection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function threadDetailToShell(
5555
branch: thread.branch,
5656
worktreePath: thread.worktreePath,
5757
linkedPullRequest: thread.linkedPullRequest ?? null,
58+
branchPullRequest: thread.branchPullRequest ?? null,
5859
latestTurn: thread.latestTurn,
5960
createdAt: thread.createdAt,
6061
updatedAt: thread.updatedAt,

apps/server/integration/OrchestrationEngineHarness.integration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
} from "../src/orchestration/Services/OrchestrationEngine.ts";
6868
import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
6969
import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70+
import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
7071
import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
7172
import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
7273
import {
@@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = (
394395
drainThrough: () => Effect.void,
395396
}),
396397
),
398+
Layer.provideMerge(
399+
Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, {
400+
start: () => Effect.void,
401+
drain: Effect.void,
402+
}),
403+
),
397404
Layer.provideMerge(
398405
Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
399406
start: () => Effect.void,

0 commit comments

Comments
 (0)