# Files changed · PR \#16758 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16758/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web,mobile\): Continue on… a linked environment](https://git.cafe/pingdotgg/t3code/pull/16758.md)

Version: 29

Head commit: bae6ee0b2e3853df96ec3b279e68ca358169f2dc

Base commit: be89710151b212a24dc62721e0b3c46e67441475

Comparison base: be89710151b212a24dc62721e0b3c46e67441475

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/Stack.tsx

Status: modified; +11; −0

```diff
@@ -71,6 +71,7 @@
 import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
 import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet";
 import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
+import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet";
 import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
 import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
 import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
@@ -559,6 +560,7 @@
   "SettingsSheet",
   "ThreadAgents",
   "ThreadPullRequests",
+  "ThreadHandoff",
   "ThreadQueue",
   "ThreadReviewComment",
   "ThreadDevicePreview",
@@ -878,6 +880,15 @@
         sheetGrabberVisible: true,
       },
     }),
+    ThreadHandoff: createNativeStackScreen({
+      screen: ThreadHandoffSheet,
+      options: {
+        ...FORM_SHEET_PRESENTATION_OPTIONS,
+        headerShown: false,
+        sheetAllowedDetents: [0.5, 0.9],
+        sheetGrabberVisible: true,
+      },
+    }),
     GitOverview: createNativeStackScreen({
       screen: GitOverviewSheet,
       linking: `${THREAD_LINKING_PREFIX}/git`,
```

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

Status: modified; +2; −0

```diff
@@ -468,6 +468,7 @@
     pinReorderEnvironmentIds,
     activeReorderEnvironmentIds,
     titleRegenerationEnvironmentIds,
+    handoffEnvironmentIds,
   } = listEnvironments;
   const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
   const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
@@ -666,6 +667,7 @@
         onRenameThread={handleRenameThread}
         onRegenerateThreadTitle={handleRegenerateThreadTitle}
         titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
+        handoffSupported={handoffEnvironmentIds.has(thread.environmentId)}
         settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
         onSettleThread={handleSettleThread}
         snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
```

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

Status: modified; +14; −1

```diff
@@ -8,6 +8,7 @@
   useThreadShell,
 } from "../../state/entities";
 import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
+import { ThreadHandoffCard, threadHandoffClosesComposer } from "./ThreadHandoffCard";
 import { StackActions, useNavigation } from "@react-navigation/native";
 import type { WorktreeSetupCardProps } from "./worktree-setup-card";
 import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
@@ -617,7 +618,9 @@
     useState<RuntimeRequestId | null>(null);
   const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
   const composerSlotHidden =
-    activeUserInputRequestId !== null || props.creationState?.kind === "failed";
+    activeUserInputRequestId !== null ||
+    props.creationState?.kind === "failed" ||
+    threadHandoffClosesComposer(props.selectedThread);
   const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
     readonly key: string;
     readonly threadKey: string;
@@ -1261,6 +1264,16 @@
                       />
                     </Animated.View>
                   ) : null}
+                  <View
+                    // With the composer closed, the card pads the home indicator itself.
+                    style={
+                      threadHandoffClosesComposer(props.selectedThread)
+                        ? { paddingBottom: composerBottomInset }
+                        : undefined
+                    }
+                  >
+                    <ThreadHandoffCard thread={props.selectedThread} />
+                  </View>
                   <UsageLimitRecoveryCard
                     key={props.selectedThread.latestRun?.runId}
                     thread={props.selectedThread}
```

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

Status: modified; +24; −0

```diff
@@ -91,6 +91,8 @@
   readonly onOpenFilesInspector?: () => void;
   readonly onOpenGitInspector?: () => void;
   readonly onMergeBack?: () => void;
+  /** Present only when the thread can move to a linked environment. */
+  readonly onContinueOn?: () => void;
   readonly onPull: () => Promise<void>;
   readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
 };
@@ -378,6 +380,17 @@
                 },
               ]
             : []),
+          ...(props.onContinueOn
+            ? [
+                {
+                  description: "Move this thread and its work to a linked environment",
+                  icon: { name: "arrow.up.right", type: "sfSymbol" as const },
+                  label: "Continue on…",
+                  onPress: props.onContinueOn,
+                  type: "action" as const,
+                },
+              ]
+            : []),
           {
             description: "Commit, files, branches",
             icon: { name: "ellipsis", type: "sfSymbol" },
@@ -544,6 +557,17 @@
         subtitle: "Turn diffs and worktree changes",
         onPress: model.openReview,
       },
+      ...(props.onContinueOn
+        ? [
+            {
+              id: "git-continue-on",
+              title: "Continue on…",
+              icon: "arrow.up.right",
+              subtitle: "Move this thread and its work to a linked environment",
+              onPress: props.onContinueOn,
+            },
+          ]
+        : []),
       {
         id: "git-more",
         title: "More",
```

## apps/mobile/src/features/threads/ThreadHandoffCard.tsx

Status: added; +95; −0

```diff
@@ -1,0 +1,95 @@
+import { threadHandoffNotice } from "@t3tools/client-runtime/state/peerLinks";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { useNavigation } from "@react-navigation/native";
+import { useState } from "react";
+import { View } from "react-native";
+
+import { AppText as Text } from "../../components/ui/AppText";
+import { cn } from "../../lib/cn";
+import { useEnvironments } from "../../state/environments";
+import { threadHandoffEnvironment } from "../../state/threadHandoff";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { RequestActionButton } from "./RequestActionButton";
+
+/** Whether the composer is closed because the thread is moving or has moved away. */
+export function threadHandoffClosesComposer(
+  thread: Pick<EnvironmentThreadShell, "handoff">,
+): boolean {
+  return thread.handoff?.state === "departing" || thread.handoff?.state === "departed";
+}
+
+/**
+ * A thread's move to a linked environment: waiting for its turn to end (with
+ * Keep it here), under way, moved (with a link there when this app is
+ * connected to it), or failed.
+ */
+export function ThreadHandoffCard({ thread }: { thread: EnvironmentThreadShell }) {
+  const navigation = useNavigation();
+  const cancel = useAtomCommand(threadHandoffEnvironment.cancel, {
+    label: "cancel thread move",
+    reportFailure: false,
+  });
+  const [cancelling, setCancelling] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const { presentationById } = useEnvironments();
+  const handoff = thread.handoff;
+  const notice = threadHandoffNotice(handoff);
+  if (handoff === null || notice === null) return null;
+
+  const keepHere = async () => {
+    setCancelling(true);
+    setError(null);
+    const result = await cancel({
+      environmentId: thread.environmentId,
+      input: { threadId: thread.id },
+    });
+    setCancelling(false);
+    if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result)));
+  };
+
+  return (
+    <View className="mx-3 mb-2 gap-2 rounded-xl border border-border-subtle bg-screen p-3">
+      <Text
+        className={cn(
+          "text-sm font-t3-bold",
+          notice.tone === "error" ? "text-danger-foreground" : "text-foreground",
+        )}
+      >
+        {notice.title}
+      </Text>
+      {notice.detail !== null ? (
+        <Text className="text-sm text-foreground-secondary">{notice.detail}</Text>
+      ) : null}
+      {handoff.state === "pending" ? (
+        <View className="flex-row">
+          <RequestActionButton
+            label={cancelling ? "Cancelling…" : "Keep it here"}
+            tone="secondary"
+            disabled={cancelling}
+            onPress={() => void keepHere()}
+          />
+        </View>
+      ) : handoff.state === "departed" &&
+        presentationById.get(handoff.environmentId)?.connection.phase === "connected" ? (
+        <View className="flex-row">
+          <RequestActionButton
+            label={`Open on ${handoff.label}`}
+            tone="secondary"
+            onPress={() =>
+              navigation.navigate("Thread", {
+                environmentId: handoff.environmentId,
+                threadId: handoff.threadId,
+              })
+            }
+          />
+        </View>
+      ) : null}
+      {error !== null ? (
+        <Text accessibilityRole="alert" className="text-sm text-danger-foreground">
+          {error}
+        </Text>
+      ) : null}
+    </View>
+  );
+}
```

## apps/mobile/src/features/threads/ThreadHandoffSheet.tsx

Status: added; +160; −0

```diff
@@ -1,0 +1,160 @@
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import * as Haptics from "expo-haptics";
+import { useState } from "react";
+import { Platform, Pressable, ScrollView, View } from "react-native";
+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader";
+import { AppText as Text } from "../../components/ui/AppText";
+import { scopedThreadKey } from "../../lib/scopedEntities";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
+import { useEnvironmentQuery } from "../../state/query";
+import { threadHandoffEnvironment } from "../../state/threadHandoff";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
+
+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
+
+type HandoffTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
+
+/**
+ * "Continue on…": the linked environments this thread can move to. Picking
+ * one starts the move and closes the sheet; the thread's card shows how it goes.
+ */
+export function ThreadHandoffSheet({ route }: StaticScreenProps<HandoffTarget>) {
+  const target = route.params;
+  const navigation = useNavigation();
+  const insets = useSafeAreaInsets();
+  const theme = useUniwindTheme();
+  const options = useEnvironmentQuery(
+    threadHandoffEnvironment.options({
+      environmentId: target.environmentId,
+      input: { threadId: target.threadId },
+    }),
+  );
+  const start = useAtomCommand(threadHandoffEnvironment.start, {
+    label: "move thread",
+    reportFailure: false,
+  });
+  const [starting, setStarting] = useState<EnvironmentId | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  // A message waiting in the outbox exists only on this device. Once the thread
+  // starts moving, the server refuses it, so it would be lost.
+  const outboxWaiting = useQueuedThreadKeys().has(
+    scopedThreadKey(target.environmentId, target.threadId),
+  );
+
+  const choose = async (environmentId: EnvironmentId) => {
+    if (outboxWaiting) return;
+    void Haptics.selectionAsync();
+    setStarting(environmentId);
+    setError(null);
+    const result = await start({
+      environmentId: target.environmentId,
+      input: { threadId: target.threadId, environmentId },
+    });
+    setStarting(null);
+    if (result._tag === "Failure") {
+      setError(String(squashAtomCommandFailure(result)));
+      return;
+    }
+    navigation.goBack();
+  };
+
+  const targets = options.data?.options ?? [];
+  const content = (
+    <ScrollView
+      className="flex-1"
+      contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
+      contentContainerClassName="px-5 pb-6"
+      contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
+    >
+      <Text className="pb-2 pt-3 text-sm text-foreground-muted">
+        Moves the conversation, unpushed commits, and uncommitted changes. This thread becomes
+        read-only here.
+      </Text>
+      {outboxWaiting ? (
+        <Text accessibilityRole="alert" className="pt-2 text-sm text-danger-foreground">
+          A message is still waiting to send from this device. Send or discard it first.
+        </Text>
+      ) : null}
+      {options.error ? (
+        <Text className="pt-4 text-sm text-danger-foreground">{options.error}</Text>
+      ) : options.data === null ? (
+        <Text className="pt-4 text-sm text-foreground-muted">Asking linked environments…</Text>
+      ) : targets.length === 0 ? (
+        <Text className="pt-4 text-sm text-foreground-muted">
+          No linked environments. Link one from a desktop or with `t3 environment link`.
+        </Text>
+      ) : (
+        targets.map((option) => (
+          <Pressable
+            key={option.environmentId}
+            accessibilityRole="button"
+            accessibilityState={{
+              disabled: option.reason !== null || starting !== null || outboxWaiting,
+            }}
+            disabled={option.reason !== null || starting !== null || outboxWaiting}
+            onPress={() => void choose(option.environmentId)}
+            className="border-b border-border py-3.5 active:opacity-70 disabled:opacity-50"
+          >
+            <Text className="text-base font-t3-bold text-foreground">
+              {starting === option.environmentId ? `Moving to ${option.label}…` : option.label}
+            </Text>
+            {option.reason !== null ? (
+              <Text className="text-xs text-foreground-muted">{option.reason}</Text>
+            ) : null}
+          </Pressable>
+        ))
+      )}
+      {error !== null ? (
+        <Text accessibilityRole="alert" className="pt-3 text-sm text-danger-foreground">
+          {error}
+        </Text>
+      ) : null}
+    </ScrollView>
+  );
+
+  if (Platform.OS === "ios") {
+    // A plain formSheet screen never renders a stack header, so it comes from
+    // a nested native stack inside the sheet (same shape as the agents sheet).
+    return (
+      <View collapsable={false} className="flex-1 bg-sheet">
+        <ScreenStack style={{ flex: 1 }}>
+          <Screen
+            activityState={2}
+            enabled
+            isNativeStack
+            screenId="thread-handoff-sheet-native"
+            scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
+            style={{ backgroundColor: theme["--color-sheet"], flex: 1 }}
+          >
+            {content}
+            <ScreenStackHeaderConfig
+              backgroundColor="rgba(0,0,0,0)"
+              color={theme["--color-foreground"]}
+              hideBackButton
+              hideShadow={false}
+              title="Continue on…"
+              titleColor={theme["--color-foreground"]}
+              titleFontSize={18}
+              titleFontWeight="800"
+              translucent
+            />
+          </Screen>
+        </ScreenStack>
+      </View>
+    );
+  }
+
+  return (
+    <View collapsable={false} className="flex-1 bg-sheet">
+      <AndroidSheetHeader title="Continue on…" onBack={() => navigation.goBack()} />
+      {content}
+    </View>
+  );
+}
```

## apps/mobile/src/features/threads/ThreadHeader.tsx

Status: modified; +9; −1

```diff
@@ -22,7 +22,7 @@
   const goBack = useWorkspaceBackNavigation("threads");
   const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } =
     useAdaptiveWorkspaceLayout();
-  const { onOpenTerminal, onMergeBack } = props.gitControls;
+  const { onOpenTerminal, onMergeBack, onContinueOn } = props.gitControls;
   const native = useThreadHeaderOptions(props);
   const androidHeaderActions = useMemo<ReadonlyArray<AppBarAction>>(() => {
     const actions: AppBarAction[] = [];
@@ -61,6 +61,13 @@
         onPress: onMergeBack,
       });
     }
+    if (onContinueOn) {
+      actions.push({
+        accessibilityLabel: "Continue on another environment",
+        icon: "arrow.up.right",
+        onPress: onContinueOn,
+      });
+    }
     return actions;
   }, [
     props.inspectorMode,
@@ -68,6 +75,7 @@
     props.onOpenFilesInspector,
     onOpenTerminal,
     onMergeBack,
+    onContinueOn,
     props.onOpenGitInspector,
     toggleAuxiliaryPane,
     props.onReturnToThread,
```

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

Status: modified; +2; −0

```diff
@@ -293,6 +293,7 @@
     pinReorderEnvironmentIds,
     activeReorderEnvironmentIds,
     titleRegenerationEnvironmentIds,
+    handoffEnvironmentIds,
   } = listEnvironments;
   const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
   const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
@@ -628,6 +629,7 @@
             onRenameThread={renameThread}
             onRegenerateThreadTitle={regenerateThreadTitle}
             titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
+            handoffSupported={handoffEnvironmentIds.has(thread.environmentId)}
             settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
             onSettleThread={settleThread}
             snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
```

## apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Status: modified; +15; −0

```diff
@@ -74,6 +74,7 @@
 import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
 import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
 import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/models";
 import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
 import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
 import { threadEnvironment } from "../../state/threads";
@@ -725,6 +726,20 @@
       mergeBackTargetThreadId !== null && mergeBackRun !== null
         ? () => void handleMergeBack()
         : undefined,
+    onContinueOn:
+      selectedThread !== null &&
+      selectedThread !== undefined &&
+      canOperateThread &&
+      routeEnvironmentRuntime?.serverConfig?.environment.capabilities.peerLinks === true &&
+      (selectedThread.handoff === null || selectedThread.handoff.state === "failed") &&
+      // The server refuses to move a thread while any of its turns is live or queued.
+      !threadRuntimeIsActive(selectedThread.runtime)
+        ? () =>
+            navigation.navigate("ThreadHandoff", {
+              environmentId: selectedThread.environmentId,
+              threadId: selectedThread.id,
+            })
+        : undefined,
     currentBranch: selectedThread?.branch ?? null,
     gitStatus: gitStatus.data,
     gitOperationLabel: gitState.gitOperationLabel,
```

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

Status: modified; +24; −4

```diff
@@ -13,14 +13,16 @@
 import { appAtomRegistry } from "../../state/atom-registry";
 import { threadArrangementOpenAtom } from "../../state/thread-order";
 import type { ThreadMoveDestination } from "./threadOrder";
+import {
+  threadRuntimeIsActive,
+  type EnvironmentProject,
+  type EnvironmentThreadShell,
-import type {
-  EnvironmentProject,
-  EnvironmentThreadShell,
 } from "@t3tools/client-runtime/state/shell";
 import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
 import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
 import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
 import type { MenuAction } from "@react-native-menu/menu";
+import { useNavigation } from "@react-navigation/native";
 import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
 import { Pressable, useWindowDimensions, View } from "react-native";
 import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
@@ -512,6 +514,7 @@
   readonly pinningSupported: boolean;
   readonly autoSettleOptOutSupported: boolean;
   readonly titleRegenerationSupported: boolean;
+  readonly handoffSupported?: boolean;
   readonly reorderSupported?: boolean;
   readonly onMoveThread?: (
     thread: EnvironmentThreadShell,
@@ -546,6 +549,7 @@
     onSetThreadAutoSettle,
     onMoveThread,
   } = props;
+  const navigation = useNavigation();
   const snoozedRow = props.snoozed === true;
   const pinnedRow = props.pinned === true;
   const dormant = useSwipeRowDormant(props.activationKey);
@@ -726,6 +730,12 @@
         : [],
     [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt],
   );
+  // The targets are read in the sheet it opens; a native menu is built ahead of time.
+  // The server refuses to move a thread while any of its turns is live or queued.
+  const canContinueOn =
+    props.handoffSupported === true &&
+    (thread.handoff === null || thread.handoff.state === "failed") &&
+    !threadRuntimeIsActive(thread.runtime);
   const titleMenuItems = useMemo<MenuAction[]>(
     () => [
       { id: "rename", title: "Rename", image: "square.and.pencil" },
@@ -733,8 +743,11 @@
         supported: props.titleRegenerationSupported,
         isRegenerating: thread.titleRegeneration != null,
       }),
+      ...(canContinueOn
+        ? [{ id: "continue-on", title: "Continue on…", image: "arrow.up.right" }]
+        : []),
     ],
+    [canContinueOn, props.titleRegenerationSupported, thread.titleRegeneration],
-    [props.titleRegenerationSupported, thread.titleRegeneration],
   );
   const snoozableCardMenuActions = useMemo<MenuAction[]>(
     () => [
@@ -808,6 +821,12 @@
       if (nativeEvent.event === "archive") handleArchive();
       if (nativeEvent.event === "rename") handleRename();
       if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
+      if (nativeEvent.event === "continue-on") {
+        navigation.navigate("ThreadHandoff", {
+          environmentId: thread.environmentId,
+          threadId: thread.id,
+        });
+      }
       if (nativeEvent.event === "copy-thread-id") {
         copyTextWithHaptic(thread.id, { target: "thread-id" });
       }
@@ -832,6 +851,7 @@
     },
     [
       onNewThreadOnBranch,
+      navigation,
       thread,
       handleArchive,
       handleDelete,
```

## apps/mobile/src/state/thread-list-environments.ts

Status: modified; +4; −0

```diff
@@ -20,6 +20,7 @@
   "threadPinReorder",
   "threadActiveReorder",
   "threadTitleRegeneration",
+  "peerLinks",
 ] as const;
 
 function selectEnvironment(config: ServerConfig) {
@@ -71,6 +72,7 @@
   const pinReorderEnvironmentIds = new Set<EnvironmentId>();
   const activeReorderEnvironmentIds = new Set<EnvironmentId>();
   const titleRegenerationEnvironmentIds = new Set<EnvironmentId>();
+  const handoffEnvironmentIds = new Set<EnvironmentId>();
   for (const [id, { providers, machineKind, capabilities }] of environments) {
     providersByEnvironmentId.set(id, providers);
     machineByEnvironmentId.set(id, machineKind);
@@ -81,6 +83,7 @@
     if (capabilities.threadPinReorder === true) pinReorderEnvironmentIds.add(id);
     if (capabilities.threadActiveReorder === true) activeReorderEnvironmentIds.add(id);
     if (capabilities.threadTitleRegeneration === true) titleRegenerationEnvironmentIds.add(id);
+    if (capabilities.peerLinks === true) handoffEnvironmentIds.add(id);
   }
   return {
     providersByEnvironmentId,
@@ -92,6 +95,7 @@
     pinReorderEnvironmentIds,
     activeReorderEnvironmentIds,
     titleRegenerationEnvironmentIds,
+    handoffEnvironmentIds,
   };
 }
 
```

## apps/mobile/src/state/threadHandoff.ts

Status: added; +5; −0

```diff
@@ -1,0 +1,5 @@
+import { createThreadHandoffEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const threadHandoffEnvironment = createThreadHandoffEnvironmentAtoms(connectionAtomRuntime);
```

## apps/web/src/components/ChatView.tsx

Status: modified; +25; −11

```diff
@@ -263,6 +263,10 @@
 } from "../previewMiniPlayerStore";
 import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.logic";
 import { RightPanelTabs } from "./RightPanelTabs";
+import {
+  threadHandoffSendBlockReason,
+  useThreadHandoffBannerItem,
+} from "./chat/ThreadHandoffBanner";
 import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
 import { useDeviceState } from "~/state/device";
 import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
@@ -3542,6 +3546,8 @@
     if (!isServerThread || activeThreadId === null) {
       throw new Error("Messages from apps need a started thread.");
     }
+    const handoffBlockReason = threadHandoffSendBlockReason(activeThreadShell);
+    if (handoffBlockReason !== null) throw new Error(handoffBlockReason);
     const result = await startThreadTurn({
       environmentId,
       input: {
@@ -5559,6 +5565,7 @@
       onAction={acknowledgeActiveThreadWokeStable}
     />
   ) : null;
+  const threadHandoffBannerItem = useThreadHandoffBannerItem(activeThreadShell);
   const activeThreadHasCompactableConversation = serverVisibleTurnItems.some(
     ({ item }) =>
       item.type === "user_message" &&
@@ -5679,6 +5686,7 @@
       goalBannerItem,
       backgroundWorkBannerItem,
     ].filter((item) => item !== null);
+    const handoffItems = threadHandoffBannerItem === null ? [] : [threadHandoffBannerItem];
     const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
     const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
     if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
@@ -5688,6 +5696,8 @@
         ...usageLimitsItems,
         ...projectCloneItems,
         ...systemComposerBannerItems,
+        // After the system notices, so a reconnect or update notice wins a priority tie.
+        ...handoffItems,
         ...backgroundWorkItems,
       ];
     }
@@ -5697,6 +5707,7 @@
       ...usageLimitsItems,
       ...projectCloneItems,
       ...systemComposerBannerItems,
+      ...handoffItems,
       ...backgroundWorkItems,
       {
         id: `branch-mismatch:${activeBranchMismatchKey}`,
@@ -6285,6 +6296,8 @@
     if (
       !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
       !activeThread ||
+      // The send button is closed for this too, but annotations reach onSend directly.
+      threadHandoffSendBlockReason(activeThreadShell) !== null ||
       (isSendBusy && !sendQueuesBehindSetup) ||
       isConnecting ||
       isRevertingCheckpoint ||
@@ -8537,17 +8550,18 @@
                               sendDisabledReason={
                                 !canOperateThread
                                   ? "This connection cannot change threads."
-                                  : isEnvironmentChanging
-                                    ? "Preparing machine"
-                                    : isRevertingCheckpoint
-                                      ? "Rewinding conversation"
-                                      : feedbackUploading
+                                  : (threadHandoffSendBlockReason(activeThreadShell) ??
+                                    (isEnvironmentChanging
+                                      ? "Preparing machine"
-                                        ? "Sending feedback"
-                                        : threadDetailLoading
-                                          ? "Messages loading"
-                                          : worktreeSetupBlocksSend
-                                            ? "Preparing worktree"
-                                            : projectCloneSendBlockReason
+                                      : isRevertingCheckpoint
+                                        ? "Rewinding conversation"
+                                        : feedbackUploading
+                                          ? "Sending feedback"
+                                          : threadDetailLoading
+                                            ? "Messages loading"
+                                            : worktreeSetupBlocksSend
+                                              ? "Preparing worktree"
+                                              : projectCloneSendBlockReason))
                               }
                               isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
                               queuedRunsControl={queuedRunsControl}
```

## apps/web/src/components/CommandPalette.tsx

Status: modified; +46; −0

```diff
@@ -26,6 +26,7 @@
 } from "@t3tools/client-runtime/operations/projects";
 import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
 import { connectionStatusText } from "@t3tools/client-runtime/connection";
+import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
 import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
 import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
 import {
@@ -70,6 +71,7 @@
   LinkIcon,
   MessageSquareIcon,
   MonitorIcon,
+  MonitorUpIcon,
   MoonIcon,
   PaletteIcon,
   RotateCcwIcon,
@@ -137,6 +139,7 @@
 import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
 import { useThreadSearch } from "../state/queries";
 import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
+import { readThreadHandoffTargets, startThreadHandoff } from "./threadHandoffMenu";
 import {
   appendBrowsePathSegment,
   ensureBrowseDirectoryPath,
@@ -1743,6 +1746,49 @@
     }
   }
 
+  if (
+    activeThread !== null &&
+    activeThreadServerConfig?.environment.capabilities.peerLinks === true &&
+    (activeThread.handoff == null || activeThread.handoff.state === "failed")
+  ) {
+    const thread = activeThread;
+    actionItems.push({
+      kind: "action",
+      value: "action:continue-on",
+      searchTerms: ["continue on", "move", "hand off", "handoff", "environment", "machine"],
+      title: "Continue on…",
+      description: "Move this thread and its work to a linked environment",
+      disabled:
+        !threadRuntimeCanArchive(thread.runtime) ||
+        !readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope),
+      icon: <MonitorUpIcon className={ITEM_ICON_CLASS} />,
+      keepOpen: true,
+      run: async () => {
+        const targets = await readThreadHandoffTargets(thread.environmentId, thread.id);
+        pushPaletteView({
+          addonIcon: <MonitorUpIcon className={ADDON_ICON_CLASS} />,
+          groups: [
+            {
+              value: "handoff-targets",
+              label: targets.length === 0 ? "No linked environments answered" : "Continue on",
+              items: targets.map((target) => ({
+                kind: "action" as const,
+                value: `action:continue-on:${target.environmentId}`,
+                searchTerms: [target.label],
+                title: target.label,
+                ...(target.reason === null ? {} : { description: target.reason }),
+                disabled: target.reason !== null,
+                icon: <MonitorIcon className={ITEM_ICON_CLASS} />,
+                run: () =>
+                  startThreadHandoff(thread.environmentId, thread.id, target.environmentId),
+              })),
+            },
+          ],
+        });
+      },
+    });
+  }
+
   if (activeThread !== null) {
     const thread = activeThread;
     actionItems.push({
```

## apps/web/src/components/Sidebar.tsx

Status: modified; +24; −0

```diff
@@ -189,6 +189,11 @@
   threadActionRequiresOperate,
 } from "./threadActionMenu.logic";
 import {
+  readThreadHandoffSupported,
+  readThreadHandoffTargets,
+  startThreadHandoff,
+} from "./threadHandoffMenu";
+import {
   animateSidebarLayoutChanges,
   applySidebarThreadDrop,
   filterSidebarV2VisibleThreads,
@@ -4207,8 +4212,11 @@
     },
   );
 
+  // Waiting for move targets can outlast the right-click that asked for them.
+  const latestThreadContextMenuRef = useRef(0);
   const handleThreadContextMenu = useStableCallback(
     (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
+      const open = ++latestThreadContextMenuRef.current;
       void (async () => {
         const api = readLocalApi();
         if (!api) return;
@@ -4250,6 +4258,13 @@
                 projectRef.projectId === thread.projectId,
             ),
           ) ?? null;
+        // Only an environment that can move threads makes the menu wait for targets.
+        const handoffTargets =
+          readThreadHandoffSupported(thread.environmentId) &&
+          (thread.handoff == null || thread.handoff.state === "failed")
+            ? await readThreadHandoffTargets(thread.environmentId, thread.id)
+            : [];
+        if (open !== latestThreadContextMenuRef.current) return;
         const clicked = await settlePromise(() =>
           api.contextMenu.show(
             buildThreadActionMenuItems({
@@ -4279,12 +4294,21 @@
                 titleRegeneration: supportsTitleRegeneration,
               },
               snoozePresets,
+              handoffTargets,
             }),
             position,
           ),
         );
         if (clicked._tag === "Failure" || clicked.value === null) return;
         if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return;
+        if (clicked.value?.startsWith("continue-on:")) {
+          await startThreadHandoff(
+            thread.environmentId,
+            thread.id,
+            clicked.value.slice("continue-on:".length),
+          );
+          return;
+        }
         if (clicked.value?.startsWith("snooze:")) {
           const preset =
             clicked.value === "snooze:custom"
```

## apps/web/src/components/chat/ThreadHandoffBanner.tsx

Status: added; +96; −0

```diff
@@ -1,0 +1,96 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { threadHandoffNotice } from "@t3tools/client-runtime/state/peerLinks";
+import {
+  isAtomCommandInterrupted,
+  squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { useNavigate } from "@tanstack/react-router";
+import { ArrowUpRightIcon, MonitorUpIcon, TriangleAlertIcon } from "lucide-react";
+import { useState } from "react";
+
+import { useConnectedEnvironmentIds } from "~/state/environments";
+import { threadHandoffEnvironment } from "~/state/threadHandoff";
+import { useAtomCommand } from "~/state/use-atom-command";
+import { buildThreadRouteParams } from "../../threadRoutes";
+import { Button } from "../ui/button";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import type { ComposerBannerStackItem } from "./ComposerBannerStack";
+
+/** Why the composer is closed while a thread moves away, or null when it is open. */
+export function threadHandoffSendBlockReason(
+  shell: Pick<EnvironmentThreadShell, "handoff"> | null | undefined,
+): string | null {
+  const handoff = shell?.handoff;
+  if (handoff?.state === "departing") return `Moving to ${handoff.label}`;
+  if (handoff?.state === "departed") return `Continues on ${handoff.label}`;
+  return null;
+}
+
+/**
+ * The banner for a thread's move to a linked environment: waiting (with
+ * Cancel), under way, moved (with a link to it there when this client is
+ * connected to that environment), or failed.
+ */
+export function useThreadHandoffBannerItem(
+  shell: Pick<EnvironmentThreadShell, "environmentId" | "id" | "handoff"> | null | undefined,
+): ComposerBannerStackItem | null {
+  const navigate = useNavigate();
+  const cancel = useAtomCommand(threadHandoffEnvironment.cancel, { label: "cancel thread move" });
+  const [cancelling, setCancelling] = useState(false);
+  const handoff = shell?.handoff ?? null;
+  const connectedEnvironmentIds = useConnectedEnvironmentIds();
+  const canOpenThere =
+    handoff?.state === "departed" && connectedEnvironmentIds.includes(handoff.environmentId);
+  const notice = threadHandoffNotice(handoff);
+  if (shell == null || handoff === null || notice === null) return null;
+  const actions =
+    handoff.state === "pending" ? (
+      <Button
+        size="xs"
+        variant="ghost"
+        disabled={cancelling}
+        onClick={() => {
+          setCancelling(true);
+          void cancel({ environmentId: shell.environmentId, input: { threadId: shell.id } }).then(
+            (result) => {
+              setCancelling(false);
+              if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+                toastManager.add(
+                  stackedThreadToast({
+                    type: "error",
+                    title: "Could not cancel the move",
+                    description: String(squashAtomCommandFailure(result)),
+                  }),
+                );
+              }
+            },
+          );
+        }}
+      >
+        {cancelling ? "Cancelling…" : "Keep it here"}
+      </Button>
+    ) : handoff.state === "departed" && canOpenThere ? (
+      <Button
+        size="xs"
+        variant="ghost"
+        onClick={() =>
+          void navigate({
+            to: "/$environmentId/$threadId",
+            params: buildThreadRouteParams(scopeThreadRef(handoff.environmentId, handoff.threadId)),
+          })
+        }
+      >
+        Open on {handoff.label}
+        <ArrowUpRightIcon />
+      </Button>
+    ) : undefined;
+  return {
+    id: `thread-handoff:${shell.id}:${handoff.handoffId}:${handoff.state}`,
+    variant: notice.tone,
+    icon: notice.tone === "error" ? <TriangleAlertIcon /> : <MonitorUpIcon />,
+    title: notice.title,
+    ...(notice.detail === null ? {} : { description: notice.detail }),
+    ...(actions === undefined ? {} : { actions }),
+  };
+}
```

## apps/web/src/components/threadActionMenu.logic.test.ts

Status: modified; +42; −0

```diff
@@ -91,6 +91,48 @@
     expect(allowed.every((item) => !item.disabled)).toBe(true);
   });
 
+  it("offers each linked environment, disabled with its reason when it cannot take the thread", () => {
+    const items = buildThreadActionMenuItems({
+      ...baseState,
+      handoffTargets: [
+        { environmentId: "environment-box", label: "Box", reason: null },
+        {
+          environmentId: "environment-vps",
+          label: "VPS",
+          reason: "No project with this repository",
+        },
+      ],
+    });
+    const continueOn = items.find((item) => item.id === "continue-on");
+    expect(continueOn?.children).toEqual([
+      { id: "continue-on:environment-box", label: "Box", disabled: false },
+      {
+        id: "continue-on:environment-vps",
+        label: "VPS (No project with this repository)",
+        disabled: true,
+      },
+    ]);
+    // Without targets there is nothing to offer, and a running thread cannot move.
+    expect(buildThreadActionMenuItems(baseState).some((item) => item.id === "continue-on")).toBe(
+      false,
+    );
+    expect(
+      buildThreadActionMenuItems({
+        ...baseState,
+        isRunning: true,
+        handoffTargets: [{ environmentId: "environment-box", label: "Box", reason: null }],
+      }).find((item) => item.id === "continue-on")?.disabled,
+    ).toBe(true);
+    // A connection that cannot change threads cannot move one either.
+    expect(
+      buildThreadActionMenuItems({
+        ...baseState,
+        canOperate: false,
+        handoffTargets: [{ environmentId: "environment-box", label: "Box", reason: null }],
+      }).find((item) => item.id === "continue-on")?.disabled,
+    ).toBe(true);
+  });
+
   it("hides lifecycle items when the environment lacks the capabilities", () => {
     expect(
       ids({
```

## apps/web/src/components/threadActionMenu.logic.ts

Status: modified; +26; −0

```diff
@@ -22,6 +22,8 @@
   | "copy-path"
   | "copy-branch"
   | "copy-thread-id"
+  | "continue-on"
+  | `continue-on:${string}`
   | "archive"
   | "delete";
 
@@ -86,6 +88,15 @@
     readonly titleRegeneration: boolean;
   };
   readonly snoozePresets: ReadonlyArray<SnoozePreset>;
+  /**
+   * Linked environments the thread can move to, read when the menu opens;
+   * empty or absent hides "Continue on…". `reason` disables one with a hint.
+   */
+  readonly handoffTargets?: ReadonlyArray<{
+    readonly environmentId: string;
+    readonly label: string;
+    readonly reason: string | null;
+  }>;
 }
 
 /** Local navigation, read markers, and copying remain available to read-only clients. */
@@ -147,6 +158,21 @@
               },
         ]
       : []),
+    ...(state.handoffTargets !== undefined && state.handoffTargets.length > 0
+      ? [
+          {
+            id: "continue-on" as const,
+            label: "Continue on…",
+            icon: "folder-tree",
+            disabled: state.isRunning,
+            children: state.handoffTargets.map((target) => ({
+              id: `continue-on:${target.environmentId}` as const,
+              label: target.reason === null ? target.label : `${target.label} (${target.reason})`,
+              disabled: target.reason !== null,
+            })),
+          },
+        ]
+      : []),
     { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
     ...(state.supports.titleRegeneration
       ? [
```

## apps/web/src/components/threadHandoffMenu.ts

Status: added; +79; −0

```diff
@@ -1,0 +1,79 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import {
+  runAtomCommand,
+  squashAtomCommandFailure,
+  isAtomCommandInterrupted,
+} from "@t3tools/client-runtime/state/runtime";
+import * as Effect from "effect/Effect";
+import { AtomRegistry } from "effect/reactivity";
+
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { environmentServerConfigsAtom } from "../state/server";
+import { threadHandoffEnvironment } from "../state/threadHandoff";
+import { stackedThreadToast, toastManager } from "./ui/toast";
+
+/** How long a menu waits for the move targets before opening without them. */
+const TARGETS_TIMEOUT_MS = 1_500;
+
+/** Whether this environment can move threads, so its menus should ask where to. */
+export function readThreadHandoffSupported(environmentId: EnvironmentId): boolean {
+  return (
+    appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+      .peerLinks === true
+  );
+}
+
+/**
+ * Where a thread can move, read as its menu opens. Empty when the environment
+ * cannot move threads, or does not answer in time; the menu then shows no
+ * "Continue on…".
+ */
+export async function readThreadHandoffTargets(
+  environmentId: EnvironmentId,
+  threadId: ThreadId,
+): Promise<
+  ReadonlyArray<{
+    readonly environmentId: string;
+    readonly label: string;
+    readonly reason: string | null;
+  }>
+> {
+  if (!readThreadHandoffSupported(environmentId)) return [];
+  const result = await Effect.runPromise(
+    AtomRegistry.getResult(
+      appAtomRegistry,
+      threadHandoffEnvironment.options({ environmentId, input: { threadId } }),
+      { suspendOnWaiting: true },
+    ).pipe(
+      Effect.timeoutOption(TARGETS_TIMEOUT_MS),
+      Effect.map((found) => (found._tag === "Some" ? found.value.options : [])),
+      Effect.orElseSucceed(() => []),
+    ),
+  );
+  return result.map((option) => ({
+    environmentId: option.environmentId,
+    label: option.label,
+    reason: option.reason,
+  }));
+}
+
+/** Starts the move a "Continue on…" choice asked for; the thread's banner shows how it goes. */
+export async function startThreadHandoff(
+  environmentId: EnvironmentId,
+  threadId: ThreadId,
+  targetEnvironmentId: string,
+): Promise<void> {
+  const result = await runAtomCommand(appAtomRegistry, threadHandoffEnvironment.start, {
+    environmentId,
+    input: { threadId, environmentId: targetEnvironmentId as EnvironmentId },
+  });
+  if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+    toastManager.add(
+      stackedThreadToast({
+        type: "error",
+        title: "Could not move the thread",
+        description: String(squashAtomCommandFailure(result)),
+      }),
+    );
+  }
+}
```

## apps/web/src/hooks/useThreadActionMenu.test.ts

Status: modified; +46; −0

```diff
@@ -21,6 +21,9 @@
   granted: new Set<string>(),
   effects: [] as string[],
   completed: deferred<void>(),
+  handoffTargets: null as Promise<
+    ReadonlyArray<{ environmentId: string; label: string; reason: string | null }>
+  > | null,
   show: vi.fn<
     (
       items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>>,
@@ -35,9 +38,16 @@
 }
 
 vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() }));
+vi.mock("../components/threadHandoffMenu", () => ({
+  readThreadHandoffSupported: () => state.handoffTargets !== null,
+  readThreadHandoffTargets: () => state.handoffTargets ?? Promise.resolve([]),
+  startThreadHandoff: async () => recordEffect("continue-on"),
+}));
 vi.mock("react", () => ({
   useCallback: (callback: unknown) => callback,
+  useEffect: () => {},
   useMemo: (factory: () => unknown) => factory(),
+  useRef: (current: unknown) => ({ current }),
 }));
 vi.mock("@tanstack/react-router", () => ({
   useRouter: () => ({ navigate: async () => recordEffect("project-settings") }),
@@ -162,9 +172,45 @@
   state.granted = new Set(["primary"]);
   state.effects = [];
   state.completed = deferred<void>();
+  state.handoffTargets = null;
   state.show.mockReset().mockResolvedValue(null);
 });
 
+describe("thread menu while move targets load", () => {
+  const targets = [{ environmentId: "box", label: "Box", reason: null }];
+
+  it("shows the menu once the targets arrive", async () => {
+    state.handoffTargets = Promise.resolve(targets);
+    createMenu().openMenu(position);
+    await vi.waitFor(() => expect(state.show).toHaveBeenCalledTimes(1));
+    expect(state.show.mock.calls[0]![0].some((item) => item.id === "continue-on")).toBe(true);
+  });
+
+  it("drops an open the user closed or replaced before the targets arrived", async () => {
+    const first = deferred<typeof targets>();
+    state.handoffTargets = first.promise;
+    const menu = createMenu();
+    menu.openMenu(position);
+    menu.closeMenu();
+    first.resolve(targets);
+    await first.promise;
+    await Promise.resolve();
+    expect(state.show).not.toHaveBeenCalled();
+
+    const stale = deferred<typeof targets>();
+    state.handoffTargets = stale.promise;
+    menu.openMenu(position);
+    state.handoffTargets = Promise.resolve(targets);
+    menu.openMenu({ x: 30, y: 40 });
+    stale.resolve(targets);
+    await vi.waitFor(() => expect(state.show).toHaveBeenCalledTimes(1));
+    await stale.promise;
+    await Promise.resolve();
+    expect(state.show).toHaveBeenCalledTimes(1);
+    expect(state.show.mock.calls[0]![1]).toEqual({ x: 30, y: 40 });
+  });
+});
+
 describe("thread menu permissions", () => {
   it("disables mutations for a denied secondary environment", () => {
     createMenu().openMenu(position);
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/16758/diffs?after=ImFwcHMvd2ViL3NyYy9ob29rcy91c2VUaHJlYWRBY3Rpb25NZW51LnRlc3QudHMi&expectedVersion=29&format=markdown)
