Skip to content

Commit b3a238b

Browse files
feat(web,mobile): Continue on… a linked environment
Moving a thread to a linked environment was reachable only through the t3_thread_handoff MCP tool. Users now pick "Continue on…" from the web sidebar and chat-header thread menus, the command palette, and mobile's long-press and thread header menus. Targets are read from threadHandoff.options when the menu opens. The thread shows the move's state above the composer: waiting for the turn to end (with Keep it here), under way, moved (with Open on <machine>), or failed. While the thread is departing or departed, the composer is closed on both clients. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 27a6351 commit b3a238b

26 files changed

Lines changed: 790 additions & 18 deletions

‎apps/mobile/src/Stack.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
4949
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
5050
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
5151
import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
52+
import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet";
5253
import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
5354
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
5455
import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
@@ -547,6 +548,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
547548
"SettingsLegal",
548549
"SettingsSheet",
549550
"ThreadAgents",
551+
"ThreadHandoff",
550552
"ThreadQueue",
551553
"ThreadReviewComment",
552554
"ThreadDevicePreview",
@@ -802,6 +804,15 @@ const RootStackConfig = createNativeStackNavigator({
802804
sheetGrabberVisible: true,
803805
},
804806
}),
807+
ThreadHandoff: createNativeStackScreen({
808+
screen: ThreadHandoffSheet,
809+
options: {
810+
...FORM_SHEET_PRESENTATION_OPTIONS,
811+
headerShown: false,
812+
sheetAllowedDetents: [0.5, 0.9],
813+
sheetGrabberVisible: true,
814+
},
815+
}),
805816
GitOverview: createNativeStackScreen({
806817
screen: GitOverviewSheet,
807818
linking: `${THREAD_LINKING_PREFIX}/git`,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ export function HomeScreen(props: HomeScreenProps) {
501501
pinReorderEnvironmentIds,
502502
activeReorderEnvironmentIds,
503503
titleRegenerationEnvironmentIds,
504+
handoffEnvironmentIds,
504505
} = listEnvironments;
505506
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
506507
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
@@ -752,6 +753,7 @@ export function HomeScreen(props: HomeScreenProps) {
752753
onRenameThread={handleRenameThread}
753754
onRegenerateThreadTitle={handleRegenerateThreadTitle}
754755
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
756+
handoffSupported={handoffEnvironmentIds.has(thread.environmentId)}
755757
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
756758
onSettleThread={handleSettleThread}
757759
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
@@ -810,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) {
810812
snoozeEnvironmentIds,
811813
threadSearchMatchByKey,
812814
titleRegenerationEnvironmentIds,
815+
handoffEnvironmentIds,
813816
toggleSettledShelf,
814817
toggleSnoozedShelf,
815818
toggleWorkingShelf,

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import { useThreadReportedModelSelection } from "../../state/entities";
33
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
4+
import { ThreadHandoffCard, threadHandoffClosesComposer } from "./ThreadHandoffCard";
45
import { useNavigation } from "@react-navigation/native";
56
import type { WorktreeSetupCardProps } from "./worktree-setup-card";
67
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
@@ -1233,6 +1234,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
12331234
/>
12341235
</Animated.View>
12351236
) : null}
1237+
<View
1238+
// With the composer closed, the card pads the home indicator itself.
1239+
style={
1240+
threadHandoffClosesComposer(props.selectedThread)
1241+
? { paddingBottom: composerBottomInset }
1242+
: undefined
1243+
}
1244+
>
1245+
<ThreadHandoffCard thread={props.selectedThread} />
1246+
</View>
12361247
<UsageLimitRecoveryCard
12371248
key={props.selectedThread.latestRun?.runId}
12381249
thread={props.selectedThread}
@@ -1331,10 +1342,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
13311342
{/* Hidden (not unmounted) while a user-input request owns the
13321343
composer slot, so composer drafts and editor state survive.
13331344
A rejected creation has no thread to send to; the failure card
1334-
owns the slot instead. */}
1345+
owns the slot instead. A thread that moved away continues on
1346+
the other environment, so its card owns the slot too. */}
13351347
<View
13361348
style={
1337-
activeUserInputRequestId !== null || props.creationState?.kind === "failed"
1349+
activeUserInputRequestId !== null ||
1350+
props.creationState?.kind === "failed" ||
1351+
threadHandoffClosesComposer(props.selectedThread)
13381352
? { display: "none" }
13391353
: undefined
13401354
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export type ThreadGitMenuProps = {
9292
readonly onOpenGitInspector?: () => void;
9393
/** Present only on a thread whose work can be merged into the one it came from. */
9494
readonly onMergeBack?: () => void;
95+
/** Present only when the thread can move to a linked environment. */
96+
readonly onContinueOn?: () => void;
9597
readonly onPull: () => Promise<void>;
9698
readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
9799
};
@@ -392,6 +394,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
392394
},
393395
]
394396
: []),
397+
...(props.onContinueOn
398+
? [
399+
{
400+
description: "Move this thread and its work to a linked environment",
401+
icon: { name: "arrow.up.right", type: "sfSymbol" as const },
402+
label: "Continue on…",
403+
onPress: props.onContinueOn,
404+
type: "action" as const,
405+
},
406+
]
407+
: []),
395408
{
396409
description: "Commit, files, branches",
397410
icon: { name: "ellipsis", type: "sfSymbol" },
@@ -422,6 +435,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
422435
props.canOpenTerminal,
423436
props.canOperateTerminal,
424437
props.gitStatus,
438+
props.onContinueOn,
425439
props.onMergeBack,
426440
props.onOpenNewTerminal,
427441
props.onOpenTerminal,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { threadHandoffNotice } from "@t3tools/client-runtime/state/peerLinks";
2+
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3+
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4+
import { useNavigation } from "@react-navigation/native";
5+
import { useState } from "react";
6+
import { View } from "react-native";
7+
8+
import { AppText as Text } from "../../components/AppText";
9+
import { cn } from "../../lib/cn";
10+
import { useEnvironments } from "../../state/environments";
11+
import { threadHandoffEnvironment } from "../../state/threadHandoff";
12+
import { useAtomCommand } from "../../state/use-atom-command";
13+
import { RequestActionButton } from "./RequestActionButton";
14+
15+
/** Whether the composer is closed because the thread is moving or has moved away. */
16+
export function threadHandoffClosesComposer(
17+
thread: Pick<EnvironmentThreadShell, "handoff">,
18+
): boolean {
19+
return thread.handoff?.state === "departing" || thread.handoff?.state === "departed";
20+
}
21+
22+
/**
23+
* A thread's move to a linked environment: waiting for its turn to end (with
24+
* Keep it here), under way, moved (with a link there when this app is
25+
* connected to it), or failed.
26+
*/
27+
export function ThreadHandoffCard({ thread }: { thread: EnvironmentThreadShell }) {
28+
const navigation = useNavigation();
29+
const cancel = useAtomCommand(threadHandoffEnvironment.cancel, {
30+
label: "cancel thread move",
31+
reportFailure: false,
32+
});
33+
const [cancelling, setCancelling] = useState(false);
34+
const [error, setError] = useState<string | null>(null);
35+
const { presentationById } = useEnvironments();
36+
const handoff = thread.handoff;
37+
const notice = threadHandoffNotice(handoff);
38+
if (handoff === null || notice === null) return null;
39+
40+
const keepHere = async () => {
41+
setCancelling(true);
42+
setError(null);
43+
const result = await cancel({
44+
environmentId: thread.environmentId,
45+
input: { threadId: thread.id },
46+
});
47+
setCancelling(false);
48+
if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result)));
49+
};
50+
51+
return (
52+
<View className="mx-3 mb-2 gap-2 rounded-xl border border-border-subtle bg-screen p-3">
53+
<Text
54+
className={cn(
55+
"text-sm font-t3-bold",
56+
notice.tone === "error" ? "text-danger-foreground" : "text-foreground",
57+
)}
58+
>
59+
{notice.title}
60+
</Text>
61+
{notice.detail !== null ? (
62+
<Text className="text-sm text-foreground-secondary">{notice.detail}</Text>
63+
) : null}
64+
{handoff.state === "pending" ? (
65+
<View className="flex-row">
66+
<RequestActionButton
67+
label={cancelling ? "Cancelling…" : "Keep it here"}
68+
tone="secondary"
69+
disabled={cancelling}
70+
onPress={() => void keepHere()}
71+
/>
72+
</View>
73+
) : handoff.state === "departed" && presentationById.has(handoff.environmentId) ? (
74+
<View className="flex-row">
75+
<RequestActionButton
76+
label={`Open on ${handoff.label}`}
77+
tone="secondary"
78+
onPress={() =>
79+
navigation.navigate("Thread", {
80+
environmentId: handoff.environmentId,
81+
threadId: handoff.threadId,
82+
})
83+
}
84+
/>
85+
</View>
86+
) : null}
87+
{error !== null ? (
88+
<Text accessibilityRole="alert" className="text-sm text-danger-foreground">
89+
{error}
90+
</Text>
91+
) : null}
92+
</View>
93+
);
94+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2+
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3+
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
4+
import * as Haptics from "expo-haptics";
5+
import { useState } from "react";
6+
import { Platform, Pressable, ScrollView, View } from "react-native";
7+
import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
8+
import { useSafeAreaInsets } from "react-native-safe-area-context";
9+
10+
import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
11+
import { AppText as Text } from "../../components/AppText";
12+
import { useUniwindTheme } from "../../lib/useUniwindTheme";
13+
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
14+
import { useEnvironmentQuery } from "../../state/query";
15+
import { threadHandoffEnvironment } from "../../state/threadHandoff";
16+
import { useAtomCommand } from "../../state/use-atom-command";
17+
18+
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
19+
20+
type HandoffTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
21+
22+
/**
23+
* "Continue on…": the linked environments this thread can move to. Picking
24+
* one starts the move and closes the sheet; the thread's card shows how it goes.
25+
*/
26+
export function ThreadHandoffSheet({ route }: StaticScreenProps<HandoffTarget>) {
27+
const target = route.params;
28+
const navigation = useNavigation();
29+
const insets = useSafeAreaInsets();
30+
const theme = useUniwindTheme();
31+
const options = useEnvironmentQuery(
32+
threadHandoffEnvironment.options({
33+
environmentId: target.environmentId,
34+
input: { threadId: target.threadId },
35+
}),
36+
);
37+
const start = useAtomCommand(threadHandoffEnvironment.start, {
38+
label: "move thread",
39+
reportFailure: false,
40+
});
41+
const [starting, setStarting] = useState<EnvironmentId | null>(null);
42+
const [error, setError] = useState<string | null>(null);
43+
44+
const choose = async (environmentId: EnvironmentId) => {
45+
void Haptics.selectionAsync();
46+
setStarting(environmentId);
47+
setError(null);
48+
const result = await start({
49+
environmentId: target.environmentId,
50+
input: { threadId: target.threadId, environmentId },
51+
});
52+
setStarting(null);
53+
if (result._tag === "Failure") {
54+
setError(String(squashAtomCommandFailure(result)));
55+
return;
56+
}
57+
navigation.goBack();
58+
};
59+
60+
const targets = options.data?.options ?? [];
61+
const content = (
62+
<ScrollView
63+
className="flex-1"
64+
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
65+
contentContainerClassName="px-5 pb-6"
66+
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
67+
>
68+
<Text className="pb-2 pt-3 text-sm text-foreground-muted">
69+
Moves the conversation, unpushed commits, and uncommitted changes. This thread becomes
70+
read-only here.
71+
</Text>
72+
{options.error ? (
73+
<Text className="pt-4 text-sm text-danger-foreground">{options.error}</Text>
74+
) : options.data === null ? (
75+
<Text className="pt-4 text-sm text-foreground-muted">Asking linked environments…</Text>
76+
) : targets.length === 0 ? (
77+
<Text className="pt-4 text-sm text-foreground-muted">
78+
No linked environments. Link one from a desktop or with `t3 environment link`.
79+
</Text>
80+
) : (
81+
targets.map((option) => (
82+
<Pressable
83+
key={option.environmentId}
84+
accessibilityRole="button"
85+
accessibilityState={{ disabled: option.reason !== null || starting !== null }}
86+
disabled={option.reason !== null || starting !== null}
87+
onPress={() => void choose(option.environmentId)}
88+
className="border-b border-border py-3.5 active:opacity-70 disabled:opacity-50"
89+
>
90+
<Text className="text-base font-t3-bold text-foreground">
91+
{starting === option.environmentId ? `Moving to ${option.label}…` : option.label}
92+
</Text>
93+
{option.reason !== null ? (
94+
<Text className="text-xs text-foreground-muted">{option.reason}</Text>
95+
) : null}
96+
</Pressable>
97+
))
98+
)}
99+
{error !== null ? (
100+
<Text accessibilityRole="alert" className="pt-3 text-sm text-danger-foreground">
101+
{error}
102+
</Text>
103+
) : null}
104+
</ScrollView>
105+
);
106+
107+
if (Platform.OS === "ios") {
108+
// A plain formSheet screen never renders a stack header, so it comes from
109+
// a nested native stack inside the sheet (same shape as the agents sheet).
110+
return (
111+
<View collapsable={false} className="flex-1 bg-sheet">
112+
<ScreenStack style={{ flex: 1 }}>
113+
<Screen
114+
activityState={2}
115+
enabled
116+
isNativeStack
117+
screenId="thread-handoff-sheet-native"
118+
scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
119+
style={{ backgroundColor: theme["--color-sheet"], flex: 1 }}
120+
>
121+
{content}
122+
<ScreenStackHeaderConfig
123+
backgroundColor="rgba(0,0,0,0)"
124+
color={theme["--color-foreground"]}
125+
hideBackButton
126+
hideShadow={false}
127+
title="Continue on…"
128+
titleColor={theme["--color-foreground"]}
129+
titleFontSize={18}
130+
titleFontWeight="800"
131+
translucent
132+
/>
133+
</Screen>
134+
</ScreenStack>
135+
</View>
136+
);
137+
}
138+
139+
return (
140+
<View collapsable={false} className="flex-1 bg-sheet">
141+
<AndroidSheetHeader title="Continue on…" onBack={() => navigation.goBack()} />
142+
{content}
143+
</View>
144+
);
145+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ function ThreadNavigationSidebarPane(
324324
pinReorderEnvironmentIds,
325325
activeReorderEnvironmentIds,
326326
titleRegenerationEnvironmentIds,
327+
handoffEnvironmentIds,
327328
} = listEnvironments;
328329
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
329330
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
@@ -727,6 +728,7 @@ function ThreadNavigationSidebarPane(
727728
onRenameThread={renameThread}
728729
onRegenerateThreadTitle={regenerateThreadTitle}
729730
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
731+
handoffSupported={handoffEnvironmentIds.has(thread.environmentId)}
730732
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
731733
onSettleThread={settleThread}
732734
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
@@ -822,6 +824,7 @@ function ThreadNavigationSidebarPane(
822824
providersByEnvironmentId,
823825
threadSearchMatchByKey,
824826
titleRegenerationEnvironmentIds,
827+
handoffEnvironmentIds,
825828
settleThread,
826829
settlementEnvironmentIds,
827830
showMoreSettled,

0 commit comments

Comments
 (0)