From 3828dc706247ce9c57cf4e007116f96a715024fd Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:16:32 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(web,mobile):=20Continue=20on=E2=80=A6?= =?UTF-8?q?=20a=20linked=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ), 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) --- apps/mobile/src/Stack.tsx | 10 ++ apps/mobile/src/features/home/HomeScreen.tsx | 2 + .../layout/workspace-overlay-routes.ts | 1 + .../features/threads/ThreadDetailScreen.tsx | 17 +- .../features/threads/ThreadGitControls.tsx | 13 ++ .../features/threads/ThreadHandoffCard.tsx | 94 ++++++++++++ .../features/threads/ThreadHandoffSheet.tsx | 145 ++++++++++++++++++ .../src/features/threads/ThreadHeader.tsx | 10 +- .../threads/ThreadNavigationSidebar.tsx | 2 + .../features/threads/ThreadRouteScreen.tsx | 11 ++ .../features/threads/thread-list-v2-items.tsx | 19 ++- .../src/state/thread-list-environments.ts | 4 + apps/mobile/src/state/threadHandoff.ts | 5 + apps/web/src/components/ChatView.tsx | 32 ++-- apps/web/src/components/CommandPalette.tsx | 45 ++++++ apps/web/src/components/Sidebar.tsx | 20 +++ .../components/chat/ThreadHandoffBanner.tsx | 100 ++++++++++++ .../components/threadActionMenu.logic.test.ts | 42 +++++ .../src/components/threadActionMenu.logic.ts | 26 ++++ apps/web/src/components/threadHandoffMenu.ts | 79 ++++++++++ .../web/src/hooks/useThreadActionMenu.test.ts | 5 + apps/web/src/hooks/useThreadActionMenu.ts | 20 +++ apps/web/src/state/threadHandoff.ts | 5 + docs/user/remote-access.md | 4 +- packages/client-runtime/src/state/models.ts | 3 + .../src/state/peerLinks.test.ts | 30 ++++ .../client-runtime/src/state/peerLinks.ts | 62 +++++++- vite.config.ts | 1 + 28 files changed, 790 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadHandoffCard.tsx create mode 100644 apps/mobile/src/features/threads/ThreadHandoffSheet.tsx create mode 100644 apps/mobile/src/state/threadHandoff.ts create mode 100644 apps/web/src/components/chat/ThreadHandoffBanner.tsx create mode 100644 apps/web/src/components/threadHandoffMenu.ts create mode 100644 apps/web/src/state/threadHandoff.ts create mode 100644 packages/client-runtime/src/state/peerLinks.test.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..2290e4b463ab5 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -67,6 +67,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; +import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; import { FullscreenThreadRouteScreen, @@ -911,6 +912,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ 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`, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 37de121c31f1a..8dd360cc159a1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -435,6 +435,7 @@ export function HomeScreen(props: HomeScreenProps) { pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + handoffEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(); @@ -596,6 +597,7 @@ export function HomeScreen(props: HomeScreenProps) { 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)} diff --git a/apps/mobile/src/features/layout/workspace-overlay-routes.ts b/apps/mobile/src/features/layout/workspace-overlay-routes.ts index 0e8a009995063..646bad1b6f0a6 100644 --- a/apps/mobile/src/features/layout/workspace-overlay-routes.ts +++ b/apps/mobile/src/features/layout/workspace-overlay-routes.ts @@ -30,6 +30,7 @@ const LISTED_OVERLAY_ROUTES: ReadonlySet = new Set([ "ThreadAgents", "ThreadBackgroundWork", "ThreadPullRequests", + "ThreadHandoff", "ThreadQueue", "ThreadReviewComment", "ThreadDevicePreview", diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3694a2ba9f439..140f58916e08b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,13 +14,14 @@ import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-v import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice"; -import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { effectiveSnoozed, threadWokeAt, isThreadWokeUnseen, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; +import { ThreadHandoffCard, threadHandoffClosesComposer } from "./ThreadHandoffCard"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; @@ -782,7 +783,9 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { } | 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 pendingMessageEditBlocked = queuedRunEditBlockedReason({ openQuestionCount: activeUserInputRequestId === null ? 0 : 1 }) !== null; @@ -1436,6 +1439,16 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { /> ) : null} + + + void; readonly onOpenGitInspector?: () => void; readonly onMergeBack?: () => void; + /** Present only when the thread can move to a linked environment. */ + readonly onContinueOn?: () => void; readonly onPull: () => Promise; readonly onRunAction: (input: GitActionRequestInput) => Promise; }; @@ -396,6 +398,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit }, ] : []), + ...(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: props.hasThread === false diff --git a/apps/mobile/src/features/threads/ThreadHandoffCard.tsx b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx new file mode 100644 index 0000000000000..bcd05e4b1e6b3 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx @@ -0,0 +1,94 @@ +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, +): 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(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 ( + + + {notice.title} + + {notice.detail !== null ? ( + {notice.detail} + ) : null} + {handoff.state === "pending" ? ( + + void keepHere()} + /> + + ) : handoff.state === "departed" && presentationById.has(handoff.environmentId) ? ( + + + navigation.navigate("Thread", { + environmentId: handoff.environmentId, + threadId: handoff.threadId, + }) + } + /> + + ) : null} + {error !== null ? ( + + {error} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadHandoffSheet.tsx b/apps/mobile/src/features/threads/ThreadHandoffSheet.tsx new file mode 100644 index 0000000000000..71a329b37ed80 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadHandoffSheet.tsx @@ -0,0 +1,145 @@ +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 { 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"; + +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) { + 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(null); + const [error, setError] = useState(null); + + const choose = async (environmentId: EnvironmentId) => { + 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 = ( + + + Moves the conversation, unpushed commits, and uncommitted changes. This thread becomes + read-only here. + + {options.error ? ( + {options.error} + ) : options.data === null ? ( + Asking linked environments… + ) : targets.length === 0 ? ( + + No linked environments. Link one from a desktop or with `t3 environment link`. + + ) : ( + targets.map((option) => ( + void choose(option.environmentId)} + className="border-b border-border py-3.5 active:opacity-70 disabled:opacity-50" + > + + {starting === option.environmentId ? `Moving to ${option.label}…` : option.label} + + {option.reason !== null ? ( + {option.reason} + ) : null} + + )) + )} + {error !== null ? ( + + {error} + + ) : null} + + ); + + 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 ( + + + + {content} + + + + + ); + } + + return ( + + navigation.goBack()} /> + {content} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index acdbaea83efac..dc5fd32899da1 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -22,7 +22,7 @@ export function ThreadHeader( 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>(() => { const actions: AppBarAction[] = []; @@ -61,6 +61,13 @@ export function ThreadHeader( 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 @@ export function ThreadHeader( props.onOpenFilesInspector, onOpenTerminal, onMergeBack, + onContinueOn, props.onOpenGitInspector, toggleAuxiliaryPane, props.onReturnToThread, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 163824526000f..e26e7c79a8c26 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -286,6 +286,7 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + handoffEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(); @@ -585,6 +586,7 @@ function ThreadNavigationSidebarPane( 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)} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 1559adb60e1f5..5f1f7ae653eed 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -809,6 +809,17 @@ function ThreadRouteContent( mergeBackTargetThreadId !== null && mergeBackRun !== null ? () => void handleMergeBack() : undefined, + onContinueOn: + selectedThread !== null && + selectedThread !== undefined && + routeEnvironmentRuntime?.serverConfig?.environment.capabilities.peerLinks === true && + (selectedThread.handoff === null || selectedThread.handoff.state === "failed") + ? () => + navigation.navigate("ThreadHandoff", { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + }) + : undefined, currentBranch: selectedThread?.branch ?? null, gitStatus: gitStatus.data, gitOperationLabel: gitState.gitOperationLabel, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8935877d28b29..b2a7fbd255399 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -28,6 +28,7 @@ import { import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import { useNavigation } from "@react-navigation/native"; import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -612,6 +613,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; readonly autoSettleOptOutSupported: boolean; readonly titleRegenerationSupported: boolean; + readonly handoffSupported?: boolean; readonly reorderSupported?: boolean; readonly onMoveThread?: ( thread: EnvironmentThreadShell, @@ -651,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSetThreadAutoSettle, onMoveThread, } = props; + const navigation = useNavigation(); const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const dormant = useSwipeRowDormant(props.activationKey); @@ -832,6 +835,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : [], [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); + // The targets are read in the sheet it opens; a native menu is built ahead of time. + const canContinueOn = + props.handoffSupported === true && + (thread.handoff === null || thread.handoff.state === "failed"); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -839,8 +846,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...(canContinueOn + ? [{ id: "continue-on", title: "Continue on…", image: "arrow.up.right" }] + : []), ], - [props.titleRegenerationSupported, thread.titleRegeneration], + [canContinueOn, props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( () => [ @@ -918,6 +928,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { 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" }); } @@ -942,6 +958,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [ onNewThreadOnBranch, + navigation, thread, handleArchive, handleDelete, diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 04389802f1a85..ee253d3c533ea 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -20,6 +20,7 @@ const capabilityKeys = [ "threadPinReorder", "threadActiveReorder", "threadTitleRegeneration", + "peerLinks", ] as const; function selectEnvironment(config: ServerConfig) { @@ -71,6 +72,7 @@ function collectEnvironments(environments: ReadonlyMap(); const activeReorderEnvironmentIds = new Set(); const titleRegenerationEnvironmentIds = new Set(); + const handoffEnvironmentIds = new Set(); for (const [id, { providers, machineKind, capabilities }] of environments) { providersByEnvironmentId.set(id, providers); machineByEnvironmentId.set(id, machineKind); @@ -81,6 +83,7 @@ function collectEnvironments(environments: ReadonlyMap { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; + // The user asked for this one, so it leads the notice tier instead of trailing it. + const handoffItems = threadHandoffBannerItem === null ? [] : [threadHandoffBannerItem]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...handoffItems, ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, @@ -2542,6 +2550,7 @@ export default function ChatView(props: ChatViewProps) { ]; } return [ + ...handoffItems, ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, @@ -3474,17 +3483,18 @@ export default function ChatView(props: ChatViewProps) { sendDisabledReason={ !canOperateThread ? "This connection cannot change threads." - : isEnvironmentChanging - ? "Preparing machine" - : isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + : (threadHandoffSendBlockReason(activeThreadShell) ?? + (isEnvironmentChanging + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason)) } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5fbd9b4eaa201..3c951ea8ebf84 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -27,6 +27,7 @@ import { import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; +import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -63,6 +64,8 @@ import { MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, + MonitorIcon, + MonitorUpIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, @@ -116,6 +119,7 @@ import { } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; +import { readThreadHandoffTargets, startThreadHandoff } from "./threadHandoffMenu"; import { appendBrowsePathSegment, ensureBrowseDirectoryPath, @@ -1308,6 +1312,47 @@ function OpenCommandPaletteDialog(props: { } } + 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), + icon: , + keepOpen: true, + run: async () => { + const targets = await readThreadHandoffTargets(thread.environmentId, thread.id); + pushPaletteView({ + addonIcon: , + 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: , + run: () => + startThreadHandoff(thread.environmentId, thread.id, target.environmentId), + })), + }, + ], + }); + }, + }); + } + if (activeThread !== null && canShareActiveThreadWithGuest) { const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); actionItems.push({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 748b006b3809e..dfd5c3fb9f224 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -157,6 +157,11 @@ import { buildThreadActionMenuItems, threadActionRequiresOperate, } from "./threadActionMenu.logic"; +import { + readThreadHandoffSupported, + readThreadHandoffTargets, + startThreadHandoff, +} from "./threadHandoffMenu"; import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, @@ -2390,6 +2395,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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) + : []; const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ @@ -2422,12 +2433,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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" diff --git a/apps/web/src/components/chat/ThreadHandoffBanner.tsx b/apps/web/src/components/chat/ThreadHandoffBanner.tsx new file mode 100644 index 0000000000000..39ac771d4d5ee --- /dev/null +++ b/apps/web/src/components/chat/ThreadHandoffBanner.tsx @@ -0,0 +1,100 @@ +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 { useMemo, useState } from "react"; + +import { useEnvironmentIds } 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 | 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 | 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 environmentIds = useEnvironmentIds(); + const canOpenThere = + handoff?.state === "departed" && environmentIds.includes(handoff.environmentId); + return useMemo(() => { + const notice = threadHandoffNotice(handoff); + if (shell == null || handoff === null || notice === null) return null; + const actions = + handoff.state === "pending" ? ( + + ) : handoff.state === "departed" && canOpenThere ? ( + + ) : undefined; + return { + id: `thread-handoff:${shell.id}:${handoff.handoffId}:${handoff.state}`, + variant: notice.tone, + icon: notice.tone === "error" ? : , + title: notice.title, + ...(notice.detail === null ? {} : { description: notice.detail }), + ...(actions === undefined ? {} : { actions }), + }; + }, [canOpenThere, cancel, cancelling, handoff, navigate, shell]); +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 039c684dcdc36..abc829be11f15 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,6 +65,48 @@ describe("buildThreadActionMenuItems", () => { }, ); + 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("disables archive while the thread is running", () => { const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( (item) => item.id === "archive", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index eab2dafd91f46..66283026ca3a5 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -23,6 +23,8 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "continue-on" + | `continue-on:${string}` | "archive" | "delete"; @@ -89,6 +91,15 @@ export interface ThreadActionMenuState { readonly snoozePresets: ReadonlyArray; /** Guest links need their own grant, so the caller checks it instead of `canOperate`. */ readonly canShareWithGuest?: boolean; + /** + * 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. */ @@ -151,6 +162,21 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(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 ? [ diff --git a/apps/web/src/components/threadHandoffMenu.ts b/apps/web/src/components/threadHandoffMenu.ts new file mode 100644 index 0000000000000..dce8a0066feac --- /dev/null +++ b/apps/web/src/components/threadHandoffMenu.ts @@ -0,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 { + 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)), + }), + ); + } +} diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 899cf38ee51bb..e289e76f0e973 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -35,6 +35,11 @@ function recordEffect(action: string) { } vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); +vi.mock("../components/threadHandoffMenu", () => ({ + readThreadHandoffSupported: () => false, + readThreadHandoffTargets: async () => [], + startThreadHandoff: async () => recordEffect("continue-on"), +})); vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0804fc007e1a6..d05f3f02d9696 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -21,6 +21,11 @@ import { threadActionRequiresOperate, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; +import { + readThreadHandoffSupported, + readThreadHandoffTargets, + startThreadHandoff, +} from "../components/threadHandoffMenu"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -130,6 +135,12 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + // Only an environment that can move threads makes the menu wait for targets. + const handoffTargets = + readThreadHandoffSupported(threadRef.environmentId) && + (thread.handoff == null || thread.handoff.state === "failed") + ? await readThreadHandoffTargets(threadRef.environmentId, threadRef.threadId) + : []; const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, @@ -144,6 +155,7 @@ export function useThreadActionMenu(input: { supports, snoozePresets, canShareWithGuest, + handoffTargets, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -158,6 +170,14 @@ export function useThreadActionMenu(input: { ); return; } + if (action.startsWith("continue-on:")) { + await startThreadHandoff( + threadRef.environmentId, + threadRef.threadId, + action.slice("continue-on:".length), + ); + return; + } if (action.startsWith("snooze:")) { const preset = action === "snooze:custom" diff --git a/apps/web/src/state/threadHandoff.ts b/apps/web/src/state/threadHandoff.ts new file mode 100644 index 0000000000000..be386fbdf6f36 --- /dev/null +++ b/apps/web/src/state/threadHandoff.ts @@ -0,0 +1,5 @@ +import { createThreadHandoffEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const threadHandoffEnvironment = createThreadHandoffEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 53eba398872cd..eff06d86e9810 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -286,7 +286,9 @@ and the access, then approve. ### Continue a thread on another machine A thread can move to a linked machine with its conversation and its code: -commits you have not pushed, uncommitted edits and new files. It continues +commits you have not pushed, uncommitted edits and new files. Choose +**Continue on…** from the thread's menu or the command palette, or from the +thread's long-press or header menu on mobile, then pick the machine. It continues there in a new worktree of the project with the same repository, and the copy here becomes read-only with a link to it. Ignored files such as `.env` stay behind, and a move larger than 50 MB asks you to push the branch first. diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ac40471a853ba..28ca087348f74 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -150,6 +150,8 @@ export interface EnvironmentThreadShell { readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null; /** The thread in a linked environment that delegated this one, if one did. */ readonly delegatedFrom: OrchestrationV2ThreadShell["delegatedFrom"] | null; + /** A move to a linked environment: waiting, under way, done, or failed. */ + readonly handoff: NonNullable | null; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; @@ -303,6 +305,7 @@ export function presentThreadShell( forkedFrom: thread.forkedFrom, linkOrigin: thread.linkOrigin ?? null, delegatedFrom: thread.delegatedFrom ?? null, + handoff: thread.handoff ?? null, activeProviderThreadId: thread.activeProviderThreadId, latestRun, runtime, diff --git a/packages/client-runtime/src/state/peerLinks.test.ts b/packages/client-runtime/src/state/peerLinks.test.ts new file mode 100644 index 0000000000000..96c09fb674704 --- /dev/null +++ b/packages/client-runtime/src/state/peerLinks.test.ts @@ -0,0 +1,30 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { threadHandoffNotice } from "./peerLinks.ts"; + +describe("threadHandoffNotice", () => { + const base = { + handoffId: "h", + environmentId: EnvironmentId.make("environment-box"), + label: "Box", + }; + it("tells the user a waiting move can still be cancelled", () => { + expect(threadHandoffNotice({ ...base, state: "pending" })?.detail).toBe( + "Send a message to keep it here instead.", + ); + }); + it("marks the copy left behind as read-only, and a failure with its reason", () => { + expect( + threadHandoffNotice({ ...base, state: "departed", threadId: "thread:x" as never }), + ).toMatchObject({ title: "This thread continues on Box", detail: "This copy is read-only." }); + expect( + threadHandoffNotice({ ...base, state: "failed", lastError: "Push the branch." }), + ).toEqual({ + tone: "error", + title: "Could not move to Box", + detail: "Push the branch.", + }); + expect(threadHandoffNotice(null)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/peerLinks.ts b/packages/client-runtime/src/state/peerLinks.ts index 6e65f62eeb748..f517df5b6ed42 100644 --- a/packages/client-runtime/src/state/peerLinks.ts +++ b/packages/client-runtime/src/state/peerLinks.ts @@ -1,4 +1,8 @@ -import { type PeerLinkSummary, WS_METHODS } from "@t3tools/contracts"; +import { + type OrchestrationV2ThreadShell, + type PeerLinkSummary, + WS_METHODS, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -33,6 +37,62 @@ export function createPeerLinkEnvironmentAtoms( }; } +/** Moving threads to linked environments, as one environment's server does it. */ +export function createThreadHandoffEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + options: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:thread-handoff:options", + tag: WS_METHODS.threadHandoffOptions, + // Each read probes every linked environment for a matching project. + staleTimeMs: 15_000, + }), + start: createEnvironmentRpcCommand(runtime, { + label: "environment-data:thread-handoff:start", + tag: WS_METHODS.threadHandoffStart, + }), + cancel: createEnvironmentRpcCommand(runtime, { + label: "environment-data:thread-handoff:cancel", + tag: WS_METHODS.threadHandoffCancel, + }), + }; +} + +/** What a thread's banner says about its move, or nothing when there is none to show. */ +export function threadHandoffNotice( + handoff: NonNullable | null | undefined, +): { + readonly tone: "info" | "warning" | "error"; + readonly title: string; + readonly detail: string | null; +} | null { + if (handoff === null || handoff === undefined) return null; + switch (handoff.state) { + case "pending": + return { + tone: "info", + title: `Moving to ${handoff.label} when this turn ends`, + detail: "Send a message to keep it here instead.", + }; + case "departing": + return { tone: "info", title: `Moving to ${handoff.label}…`, detail: null }; + case "departed": + return { + tone: "info", + title: `This thread continues on ${handoff.label}`, + detail: "This copy is read-only.", + }; + case "failed": + return { + tone: "error", + title: `Could not move to ${handoff.label}`, + detail: handoff.lastError, + }; + } +} + +/** Links expire after 30 days and cannot be renewed yet, so warn this long before. */ const EXPIRY_WARNING_DAYS = 5; export type PeerLinkHealth = diff --git a/vite.config.ts b/vite.config.ts index 9492eb7f350cc..b37bd502c4c14 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -626,6 +626,7 @@ export default defineConfig({ "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx", "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx", "apps/mobile/src/features/threads/ThreadQueueControl.tsx", + "apps/mobile/src/features/threads/ThreadHandoffSheet.tsx", "apps/mobile/src/features/review/ReviewCommentCard.tsx", "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx", "apps/mobile/src/features/threads/thread-list-items.tsx", From 8786a281198b7ccf41d808e2396bc24bbe0dc530 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:11:19 -0700 Subject: [PATCH 2/4] fix(web): handoff menus, sends and banners respect the move's state "Open on " showed for a destination this client knew about but was not connected to; it now needs a connected environment. The thread menus waited for move targets and then showed the menu even if the user had since closed it, opened another, or switched threads. Each open now takes a token, and a stale one is dropped before showing. Preview annotations and app messages reached onSend and sendAppMessage directly, past the closed send button, so they could clear the composer for a thread that is moving away. Both now check the handoff first. The handoff notice sat ahead of the system notices, so on a priority tie it hid an update or reconnect notice in the collapsed stack. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/Sidebar.tsx | 4 + .../components/chat/ThreadHandoffBanner.tsx | 114 +++++++++--------- .../src/components/chat/useComposerSend.ts | 3 + .../web/src/hooks/useThreadActionMenu.test.ts | 45 ++++++- apps/web/src/hooks/useThreadActionMenu.ts | 14 +++ 6 files changed, 124 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c9626f3a3cce..6054646cbb5b1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1809,6 +1809,8 @@ export default function ChatView(props: ChatViewProps) { 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: { @@ -2535,27 +2537,27 @@ export default function ChatView(props: ChatViewProps) { const threadHandoffBannerItem = useThreadHandoffBannerItem(activeThreadShell); const composerBannerItems = (() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - // The user asked for this one, so it leads the notice tier instead of trailing it. const handoffItems = threadHandoffBannerItem === null ? [] : [threadHandoffBannerItem]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ - ...handoffItems, ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, ...systemComposerBannerItems, + // After the system notices, so a reconnect or update notice wins a priority tie. + ...handoffItems, ]; } return [ - ...handoffItems, ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, ...systemComposerBannerItems, + ...handoffItems, branchMismatchBannerItem({ mismatchKey: activeBranchMismatchKey, mismatch: localCheckoutBranchMismatch, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dfd5c3fb9f224..6d7f31eb24854 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2352,8 +2352,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); + // 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; @@ -2401,6 +2404,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve (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({ diff --git a/apps/web/src/components/chat/ThreadHandoffBanner.tsx b/apps/web/src/components/chat/ThreadHandoffBanner.tsx index 39ac771d4d5ee..74111583e92e8 100644 --- a/apps/web/src/components/chat/ThreadHandoffBanner.tsx +++ b/apps/web/src/components/chat/ThreadHandoffBanner.tsx @@ -7,9 +7,9 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { useNavigate } from "@tanstack/react-router"; import { ArrowUpRightIcon, MonitorUpIcon, TriangleAlertIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; -import { useEnvironmentIds } from "~/state/environments"; +import { useConnectedEnvironmentIds } from "~/state/environments"; import { threadHandoffEnvironment } from "~/state/threadHandoff"; import { useAtomCommand } from "~/state/use-atom-command"; import { buildThreadRouteParams } from "../../threadRoutes"; @@ -39,62 +39,58 @@ export function useThreadHandoffBannerItem( const cancel = useAtomCommand(threadHandoffEnvironment.cancel, { label: "cancel thread move" }); const [cancelling, setCancelling] = useState(false); const handoff = shell?.handoff ?? null; - const environmentIds = useEnvironmentIds(); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); const canOpenThere = - handoff?.state === "departed" && environmentIds.includes(handoff.environmentId); - return useMemo(() => { - const notice = threadHandoffNotice(handoff); - if (shell == null || handoff === null || notice === null) return null; - const actions = - handoff.state === "pending" ? ( - - ) : handoff.state === "departed" && canOpenThere ? ( - - ) : undefined; - return { - id: `thread-handoff:${shell.id}:${handoff.handoffId}:${handoff.state}`, - variant: notice.tone, - icon: notice.tone === "error" ? : , - title: notice.title, - ...(notice.detail === null ? {} : { description: notice.detail }), - ...(actions === undefined ? {} : { actions }), - }; - }, [canOpenThere, cancel, cancelling, handoff, navigate, shell]); + 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" ? ( + + ) : handoff.state === "departed" && canOpenThere ? ( + + ) : undefined; + return { + id: `thread-handoff:${shell.id}:${handoff.handoffId}:${handoff.state}`, + variant: notice.tone, + icon: notice.tone === "error" ? : , + title: notice.title, + ...(notice.detail === null ? {} : { description: notice.detail }), + ...(actions === undefined ? {} : { actions }), + }; } diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d932c4c0b4a5b..14cb45b8344da 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -137,6 +137,7 @@ import type { usePendingThreadRequests } from "./usePendingThreadRequests"; import type { useQueuedRunEdit } from "./useQueuedRunEdit"; import type { useTimelineFollow } from "./useTimelineFollow"; import type { useUsageLimitsPanel } from "./useUsageLimitsPanel"; +import { threadHandoffSendBlockReason } from "./ThreadHandoffBanner"; import type { useWorktreeSetup } from "./useWorktreeSetup"; const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; @@ -432,6 +433,8 @@ export function useComposerSend(input: ComposerSendInput) { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || !activeThread || + // The send button is closed for this too, but annotations reach onSend directly. + threadHandoffSendBlockReason(activeThread) !== null || (isSendBusy && !sendQueuesBehindSetup) || isConnecting || isRevertingCheckpoint || diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index e289e76f0e973..978eb7399fe40 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -21,6 +21,9 @@ const state = vi.hoisted(() => ({ granted: new Set(), effects: [] as string[], completed: deferred(), + handoffTargets: null as Promise< + ReadonlyArray<{ environmentId: string; label: string; reason: string | null }> + > | null, show: vi.fn< ( items: ReadonlyArray>, @@ -36,13 +39,15 @@ function recordEffect(action: string) { vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); vi.mock("../components/threadHandoffMenu", () => ({ - readThreadHandoffSupported: () => false, - readThreadHandoffTargets: async () => [], + 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") }), @@ -168,9 +173,45 @@ beforeEach(() => { state.granted = new Set(["primary"]); state.effects = []; state.completed = deferred(); + 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(); + 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(); + 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.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( "%s rechecks after the native menu closes", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d05f3f02d9696..8f3986a48c753 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -14,6 +14,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { @@ -118,8 +119,19 @@ export function useThreadActionMenu(input: { onError: (error) => failureToast("Failed to copy thread ID", error), }); + // Waiting for move targets can outlast the open that asked for them: a newer + // open, a close, or a switch to another thread each make the pending one stale. + const latestOpenRef = useRef(0); + useEffect(() => { + if (threadRef === null) return; + return () => { + latestOpenRef.current += 1; + }; + }, [threadRef]); + const openMenu = (position: { x: number; y: number }) => { if (threadRef === null) return; + const open = ++latestOpenRef.current; void (async () => { const api = readLocalApi(); if (!api) return; @@ -141,6 +153,7 @@ export function useThreadActionMenu(input: { (thread.handoff == null || thread.handoff.state === "failed") ? await readThreadHandoffTargets(threadRef.environmentId, threadRef.threadId) : []; + if (open !== latestOpenRef.current) return; const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, @@ -344,6 +357,7 @@ export function useThreadActionMenu(input: { }; const closeMenu = () => { + latestOpenRef.current += 1; void readLocalApi()?.contextMenu.close(); }; From 6e1de57a59ffcb4a17962dc6a65fbfbec32d5fea Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:23:23 -0700 Subject: [PATCH 3/4] fix(mobile): offer a thread's move only when it can work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Open on " showed for any destination in the catalog; it now needs that environment to be connected. The thread header offered "Continue on…" to a connection that cannot change threads, and both it and the row menu offered it while a turn was live or queued, which the server refuses. Both now check that first. A message waiting in this device's outbox would be refused once its thread starts moving, losing it. The sheet now says so and holds the move until that message is sent or discarded. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadHandoffCard.tsx | 3 ++- .../features/threads/ThreadHandoffSheet.tsx | 19 +++++++++++++++++-- .../features/threads/ThreadRouteScreen.tsx | 6 +++++- .../features/threads/thread-list-v2-items.tsx | 11 +++++++---- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadHandoffCard.tsx b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx index bcd05e4b1e6b3..6eb1c0537383c 100644 --- a/apps/mobile/src/features/threads/ThreadHandoffCard.tsx +++ b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx @@ -70,7 +70,8 @@ export function ThreadHandoffCard({ thread }: { thread: EnvironmentThreadShell } onPress={() => void keepHere()} /> - ) : handoff.state === "departed" && presentationById.has(handoff.environmentId) ? ( + ) : handoff.state === "departed" && + presentationById.get(handoff.environmentId)?.connection.phase === "connected" ? ( ) }); const [starting, setStarting] = useState(null); const [error, setError] = useState(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); @@ -69,6 +77,11 @@ export function ThreadHandoffSheet({ route }: StaticScreenProps) Moves the conversation, unpushed commits, and uncommitted changes. This thread becomes read-only here. + {outboxWaiting ? ( + + A message is still waiting to send from this device. Send or discard it first. + + ) : null} {options.error ? ( {options.error} ) : options.data === null ? ( @@ -82,8 +95,10 @@ export function ThreadHandoffSheet({ route }: StaticScreenProps) void choose(option.environmentId)} className="border-b border-border py-3.5 active:opacity-70 disabled:opacity-50" > diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 5f1f7ae653eed..858d30776fc59 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -79,6 +79,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s 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, useThreadFamily } from "../../state/threads"; @@ -812,8 +813,11 @@ function ThreadRouteContent( onContinueOn: selectedThread !== null && selectedThread !== undefined && + canOperateThread && routeEnvironmentRuntime?.serverConfig?.environment.capabilities.peerLinks === true && - (selectedThread.handoff === null || selectedThread.handoff.state === "failed") + (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, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index b2a7fbd255399..7df54e7de56d0 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -16,9 +16,10 @@ import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useVisibleSecondClock } from "./use-visible-second-clock"; import type { ThreadMoveDestination } from "./threadOrder"; -import type { - EnvironmentProject, - EnvironmentThreadShell, +import { + threadRuntimeIsActive, + type EnvironmentProject, + type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { @@ -836,9 +837,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [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"); + (thread.handoff === null || thread.handoff.state === "failed") && + !threadRuntimeIsActive(thread.runtime); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, From 5558f85e84a91d98873d5cefd5c307db680f8312 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:39:24 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(web,mobile):=20Continue=20on=E2=80=A6?= =?UTF-8?q?=20needs=20operate=20access,=20and=20the=20mobile=20fallback=20?= =?UTF-8?q?menu=20offers=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command palette offered Continue on… on a read-only connection; it now needs operate access, like the thread menus. On mobile layouts without native header glass the header falls back to a menu that did not list the action. Co-Authored-By: Claude Opus 5.5 --- .../mobile/src/features/threads/ThreadGitControls.tsx | 11 +++++++++++ apps/web/src/components/CommandPalette.tsx | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index ccd96371db58c..2c7b0f84e2f53 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -577,6 +577,17 @@ function threadGitMenuDefinition( : "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", diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3c951ea8ebf84..ce07430b3f272 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1324,7 +1324,9 @@ function OpenCommandPaletteDialog(props: { 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), + disabled: + !threadRuntimeCanArchive(thread.runtime) || + !readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), icon: , keepOpen: true, run: async () => {