Skip to content

Commit 0a7acb5

Browse files
PR Batch Testerclaude
andcommitted
fix(mobile): Back from a linked thread returns to the thread it was opened from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 807319c commit 0a7acb5

6 files changed

Lines changed: 77 additions & 17 deletions

File tree

‎apps/mobile/src/components/ComposerEditor.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { ComposerContextId } from "@t3tools/contracts";
33
import { useAtomValue } from "@effect/atom-react";
44
import { AsyncResult } from "effect/reactivity";
55
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6-
import { useNavigation } from "@react-navigation/native";
76
import { useEffect, useRef, useState } from "react";
87
import type { EnvironmentId } from "@t3tools/contracts";
98
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
@@ -24,6 +23,7 @@ import {
2423
} from "../state/use-composer-drafts";
2524
import { importComposerContextClipboard } from "../lib/composerContextClipboard";
2625
import { mobilePreferencesAtom } from "../state/preferences";
26+
import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread";
2727
import { ComposerContextSheet } from "./ComposerContextSheet";
2828
import { AppText as Text } from "./ui/AppText";
2929
import {
@@ -154,7 +154,7 @@ export function ComposerEditor({
154154
}),
155155
})
156156
: "";
157-
const navigation = useNavigation();
157+
const openLinkedThread = useOpenLinkedThread();
158158
const selectedReference = selected
159159
? collectComposerContextReferences(selected.source)[0]
160160
: undefined;
@@ -236,7 +236,7 @@ export function ComposerEditor({
236236
: {})}
237237
onOpenThread={(thread) => {
238238
setSelected(null);
239-
navigation.navigate("Thread", {
239+
openLinkedThread({
240240
environmentId: String(thread.environmentId),
241241
threadId: String(thread.threadId),
242242
});

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat";
129129
import { useThreadServerBrowserTabs } from "../../state/preview";
130130
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
131131
import { scopedThreadKey } from "../../lib/scopedEntities";
132-
import { parentThreadIsBelow } from "../../lib/android-workspace-navigation";
133132
import {
134133
clearThreadComposerError,
135134
threadComposerErrorsAtom,
@@ -173,6 +172,7 @@ import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
173172
import { useThreadQueuedCount } from "./ThreadQueueControl";
174173
import type { ThreadContentPresentation } from "./threadContentPresentation";
175174
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
175+
import { useOpenLinkedThread } from "./useOpenLinkedThread";
176176
import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";
177177

178178
export interface ThreadDetailScreenProps {
@@ -456,6 +456,7 @@ function ThreadVisitWatermark(props: {
456456
export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetailScreenProps) {
457457
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
458458
const navigation = useNavigation();
459+
const openLinkedThread = useOpenLinkedThread();
459460
const { session: voiceInputSession } = useGlobalVoiceInput();
460461
const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef);
461462
const deviceState = useEnvironmentQuery(
@@ -1644,17 +1645,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
16441645
onOpenParent={
16451646
props.selectedThread.lineage.parentThreadId === null
16461647
? null
1647-
: () => {
1648-
const parent = {
1648+
: () =>
1649+
openLinkedThread({
16491650
environmentId: String(props.environmentId),
16501651
threadId: String(props.selectedThread.lineage.parentThreadId),
1651-
};
1652-
navigation.dispatch((state) =>
1653-
parentThreadIsBelow(state, parent)
1654-
? StackActions.pop()
1655-
: StackActions.push("Thread", parent),
1656-
);
1657-
}
1652+
})
16581653
}
16591654
/>
16601655
</View>

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ import { waitForThreadShellReady } from "./threadForkNavigation";
261261
import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
262262
import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu";
263263
import { useFileChipShare } from "./useFileChipShare";
264+
import { useOpenLinkedThread } from "./useOpenLinkedThread";
264265
import {
265266
MarkdownImageAvailableWidthContext,
266267
ThreadMarkdownImage,
@@ -1025,13 +1026,13 @@ function OpenSenderThreadButton(props: {
10251026
readonly environmentId: EnvironmentId;
10261027
readonly senderThreadId: ThreadId;
10271028
}) {
1028-
const navigation = useNavigation();
1029+
const openLinkedThread = useOpenLinkedThread();
10291030
return (
10301031
<Pressable
10311032
accessibilityRole="link"
10321033
hitSlop={6}
10331034
onPress={() =>
1034-
navigation.navigate("Thread", {
1035+
openLinkedThread({
10351036
environmentId: String(props.environmentId),
10361037
threadId: String(props.senderThreadId),
10371038
})
@@ -1767,6 +1768,7 @@ type UserMessageContentProps = {
17671768
function UserMessageContent(props: UserMessageContentProps) {
17681769
const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
17691770
const navigation = useNavigation();
1771+
const openLinkedThread = useOpenLinkedThread();
17701772
const { selectedThread } = useThreadSelection();
17711773
const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
17721774
const text = replaceComposerContextReferences(liveText, (ref) => {
@@ -1784,7 +1786,7 @@ function UserMessageContent(props: UserMessageContentProps) {
17841786
return;
17851787
}
17861788
if (record?.kind === "thread" && "threadId" in record) {
1787-
navigation.navigate("Thread", {
1789+
openLinkedThread({
17881790
environmentId: String(record.environmentId),
17891791
threadId: String(record.threadId),
17901792
});
@@ -1992,6 +1994,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19921994
const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType);
19931995
const openPullRequest = useOpenPullRequest();
19941996
const navigation = useNavigation();
1997+
const openLinkedThread = useOpenLinkedThread();
19951998
const onOpenTurnDiff = useCallback(
19961999
(checkpoint: ThreadCheckpointSummary) => {
19972000
setReviewSelectedSectionId(
@@ -2176,7 +2179,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21762179
}
21772180
const target = resolveLinkedThread(href);
21782181
if (target) {
2179-
navigation.navigate("Thread", {
2182+
openLinkedThread({
21802183
environmentId: String(target.environmentId),
21812184
threadId: String(target.threadId),
21822185
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { StackActions, useNavigation } from "@react-navigation/native";
2+
import { useCallback } from "react";
3+
import { linkedThreadStep } from "../../lib/android-workspace-navigation";
4+
5+
export function useOpenLinkedThread() {
6+
const navigation = useNavigation();
7+
return useCallback(
8+
(target: { readonly environmentId: string; readonly threadId: string }) => {
9+
const state = navigation.getState();
10+
const step = state ? linkedThreadStep(state, target) : "push";
11+
if (step === "pop") navigation.dispatch(StackActions.pop());
12+
else if (step === "push") navigation.dispatch(StackActions.push("Thread", target));
13+
},
14+
[navigation],
15+
);
16+
}

‎apps/mobile/src/lib/android-workspace-navigation.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../features/workspace/android-workspace-tabs";
88
import {
99
androidWorkspaceNavigationState,
10+
linkedThreadStep,
1011
parentThreadIsBelow,
1112
resetAndroidWorkspace,
1213
returnToAndroidWorkspaceList,
@@ -202,6 +203,30 @@ describe("Android workspace navigation", () => {
202203
});
203204
});
204205

206+
it("opens a linked thread above the current one so Back returns to it", () => {
207+
const parent = select(router.getInitialState(options), thread);
208+
const child = { ...thread.params, threadId: "child-a" };
209+
const open = (
210+
state: ReturnType<typeof router.getInitialState>,
211+
target: ReactNavigation.RootParamList["Thread"],
212+
) => {
213+
const step = linkedThreadStep(state, target);
214+
if (step === "stay") return state;
215+
return apply(
216+
state,
217+
step === "pop" ? StackActions.pop() : StackActions.push("Thread", target),
218+
);
219+
};
220+
221+
const inChild = open(parent, child);
222+
expect(inChild.routes.map((route) => route.params)).toEqual([undefined, thread.params, child]);
223+
expect(open(inChild, child)).toBe(inChild);
224+
225+
const backInParent = open(inChild, thread.params);
226+
expect(backInParent.routes).toEqual(parent.routes);
227+
expect(backInParent.index).toBe(parent.index);
228+
});
229+
205230
it("recognizes a parent only when its environment and thread are immediately below", () => {
206231
const parent = select(router.getInitialState(options), thread);
207232
const child = apply(

‎apps/mobile/src/lib/android-workspace-navigation.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ export function parentThreadIsBelow(
9292
);
9393
}
9494

95+
/**
96+
* How a thread link opened from inside a thread reaches its target. `navigate` would reuse the
97+
* current Thread route, so Back would skip the thread the link was in and land on the list.
98+
*/
99+
export function linkedThreadStep(
100+
state: NavigationState,
101+
target: ReactNavigation.RootParamList["Thread"],
102+
): "stay" | "pop" | "push" {
103+
const params = state.routes[state.index]?.params as
104+
| ReactNavigation.RootParamList["Thread"]
105+
| undefined;
106+
if (
107+
state.routes[state.index]?.name === "Thread" &&
108+
params?.environmentId === target?.environmentId &&
109+
params?.threadId === target?.threadId
110+
) {
111+
return "stay";
112+
}
113+
return parentThreadIsBelow(state, target) ? "pop" : "push";
114+
}
115+
95116
export function returnToAndroidWorkspaceList(
96117
navigation: WorkspaceNavigation & { readonly goBack: () => void },
97118
selectTab: () => void,

0 commit comments

Comments
 (0)