From f921b9b3be1972d2fb33c5b093ae10f2b76d13af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 02:02:19 +0000 Subject: [PATCH 1/2] feat(web,mobile): continue a thread on a linked environment --- apps/mobile/src/Stack.tsx | 11 + apps/mobile/src/features/home/HomeScreen.tsx | 13 +- .../layout/AdaptiveWorkspaceLayout.tsx | 12 +- .../features/threads/ThreadDetailScreen.tsx | 7 +- .../features/threads/ThreadGitControls.tsx | 34 +- .../features/threads/ThreadHandoffCard.tsx | 159 +++++++ .../features/threads/ThreadHandoffSheet.tsx | 159 +++++++ .../src/features/threads/ThreadHeader.tsx | 7 + .../threads/ThreadNavigationSidebar.tsx | 3 + .../features/threads/ThreadRouteScreen.tsx | 8 + .../features/threads/thread-list-v2-items.tsx | 19 +- .../threads/useThreadHandoffAction.ts | 34 ++ apps/mobile/src/lib/attachmentUpload.ts | 14 +- .../mobile/src/state/thread-outbox-manager.ts | 6 + apps/mobile/src/state/threadHandoff.ts | 41 ++ .../src/state/use-thread-composer-state.ts | 21 + ...se-thread-outbox-drain.permissions.test.ts | 117 ++++- .../src/state/use-thread-outbox-drain.ts | 22 +- apps/web/src/components/ChatView.tsx | 30 +- apps/web/src/components/CommandPalette.tsx | 14 + apps/web/src/components/Sidebar.tsx | 6 + .../src/components/ThreadHandoffBanner.tsx | 169 +++++++ .../src/components/ThreadHandoffDialog.tsx | 163 +++++++ .../src/components/threadActionMenu.logic.ts | 5 + .../web/src/components/threadHandoffAction.ts | 37 ++ .../web/src/hooks/useThreadActionMenu.test.ts | 39 ++ apps/web/src/hooks/useThreadActionMenu.ts | 6 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/state/threadHandoff.ts | 30 ++ docs/user/remote-access.md | 8 + .../client-runtime/src/operations/commands.ts | 153 +++--- packages/client-runtime/src/rpc/client.ts | 16 +- packages/client-runtime/src/state/models.ts | 2 + packages/client-runtime/src/state/runtime.ts | 7 +- .../src/state/threadCommands.test.ts | 435 +++++++++++++++++- .../src/state/threadCommands.ts | 50 +- .../client-runtime/src/state/threadHandoff.ts | 175 +++++++ .../src/state/threadShell.test.ts | 43 ++ packages/client-runtime/src/state/threads.ts | 2 + 39 files changed, 1987 insertions(+), 93 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/features/threads/useThreadHandoffAction.ts create mode 100644 apps/mobile/src/state/threadHandoff.ts create mode 100644 apps/web/src/components/ThreadHandoffBanner.tsx create mode 100644 apps/web/src/components/ThreadHandoffDialog.tsx create mode 100644 apps/web/src/components/threadHandoffAction.ts create mode 100644 apps/web/src/state/threadHandoff.ts create mode 100644 packages/client-runtime/src/state/threadHandoff.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c35d9911ca41b..d4cf5217add39 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,4 @@ +import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; @@ -550,6 +551,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "NewTaskSheet", "SettingsLegal", "SettingsSheet", + "ThreadHandoff", "ThreadAgents", "ThreadPullRequests", "ThreadQueue", @@ -855,6 +857,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ sheetGrabberVisible: true, }, }), + ThreadHandoff: createNativeStackScreen({ + screen: ThreadHandoffSheet, + options: { + ...FORM_SHEET_PRESENTATION_OPTIONS, + headerShown: false, + sheetAllowedDetents: [0.65, 0.95], + sheetGrabberVisible: true, + }, + }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e90fc1672bc7d..eb57cb3fda960 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,9 +11,13 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + type EnvironmentId, + type SidebarProjectGroupingMode, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { use, useEffect, useRef, useState, type ComponentProps } from "react"; import { Platform, @@ -250,6 +254,10 @@ function renderHomeScrollView(props: ScrollViewProps) { } export function HomeScreen(props: HomeScreenProps) { + const navigation = useNavigation(); + const handleContinueOn = (ref: ScopedThreadRef) => { + navigation.navigate("ThreadHandoff", ref); + }; const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const workspace = useWorkspaceTabs(); const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0); @@ -631,6 +639,7 @@ export function HomeScreen(props: HomeScreenProps) { selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey} fullSwipeWidth={fullSwipeWidth} onNewThreadOnBranch={props.onNewThreadOnBranch} + onContinueOn={handleContinueOn} thread={thread} variant={item.item.variant} hasQueuedMessages={item.hasQueuedMessages} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 82bd286cbdbdf..a488ea68042b2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -2,7 +2,12 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + type SidebarProjectGroupingMode, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { @@ -416,6 +421,10 @@ function AdaptiveWorkspaceLayoutContent( }); }; + const handleContinueOn = (ref: ScopedThreadRef) => { + navigation.navigate("ThreadHandoff", ref); + }; + const handleOpenThreadPullRequests = useOpenThreadPullRequests(); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { @@ -683,6 +692,7 @@ function AdaptiveWorkspaceLayoutContent( onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} onNewThreadOnBranch={handleNewThreadOnBranch} + onContinueOn={handleContinueOn} onSelectThread={handleSelectThread} onOpenThreadPullRequests={handleOpenThreadPullRequests} onSearchQueryChange={setPrimarySidebarSearchQuery} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a456387a31cf3..46820234a7730 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,5 @@ +import { ThreadHandoffCard } from "./ThreadHandoffCard"; +import { threadHandoffIsReadOnly } from "@t3tools/client-runtime/state/threads"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import * as Dialog from "../../components/ui/Dialog"; @@ -612,7 +614,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; const composerSlotHidden = - activeUserInputRequestId !== null || props.creationState?.kind === "failed"; + activeUserInputRequestId !== null || + props.creationState?.kind === "failed" || + threadHandoffIsReadOnly(props.selectedThread); const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly threadKey: string; @@ -1284,6 +1288,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ) : null} + {/* * Hidden (not unmounted) while a user-input request owns the composer slot, so * composer drafts and editor state survive. diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index c7cf8a61e300b..671692ca28c51 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -72,6 +72,7 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { type HeaderItem = Record; type HeaderItems = HeaderItem[]; type ThreadGitHeaderActionItems = { + readonly handoff: HeaderItem | null; readonly terminal: HeaderItem; readonly files: HeaderItem; readonly git: HeaderItem; @@ -96,6 +97,7 @@ export type ThreadGitMenuProps = { }; type ThreadGitControlsProps = ThreadGitMenuProps & { + readonly onContinueOn?: () => void; readonly auxiliaryPaneControl?: { readonly accessibilityLabel: string; readonly onPress: () => void; @@ -264,6 +266,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit const model = useThreadGitControlModel(props); return { + handoff: + props.onContinueOn !== undefined + ? { + type: "button" as const, + identifier: "thread-continue-on", + accessibilityLabel: "Continue on…", + label: "Continue on…", + icon: { name: "arrow.right", type: "sfSymbol" as const }, + onPress: props.onContinueOn, + } + : null, terminal: { accessibilityLabel: "Open terminal", disabled: !props.canOpenTerminal, @@ -397,12 +410,22 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems { const actionItems = useThreadGitHeaderActionItems(props); - return [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems; + return [ + ...(actionItems.handoff ? [actionItems.handoff] : []), + actionItems.git, + actionItems.files, + actionItems.terminal, + ] as HeaderItems; } export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems { const actionItems = useThreadGitHeaderActionItems(props); - return [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems; + return [ + ...(actionItems.handoff ? [actionItems.handoff] : []), + actionItems.files, + actionItems.git, + actionItems.terminal, + ] as HeaderItems; } export function ThreadGitControls(props: ThreadGitControlsProps) { @@ -415,6 +438,13 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { return ( + {props.onContinueOn !== undefined ? ( + + ) : null} {showActionControls && props.auxiliaryPaneControl ? ( + ) : null; +} +function Notice({ thread }: { thread: EnvironmentThreadShell }) { + const handoff = thread.handoff!; + const ref = { environmentId: thread.environmentId, threadId: thread.id }; + const notice = threadHandoffNotice(handoff)!; + const canCancel = useAtomValue( + threadHandoffEnvironment.cancel.permissionAtom(thread.environmentId), + ); + const canRetry = useAtomValue( + threadHandoffEnvironment.start.permissionAtom(thread.environmentId), + ); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const sourceConnected = presentations.get(thread.environmentId)?.connection.phase === "connected"; + const navigation = useNavigation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const working = useRef(false); + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + const current = () => { + const live = appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.handoff; + return alive.current && live?.handoffId === handoff.handoffId && live.state === handoff.state; + }; + const act = async (action: "cancel" | "retry" | "abandon") => { + if (!current() || working.current) return; + const command = + action === "retry" ? threadHandoffEnvironment.start : threadHandoffEnvironment.cancel; + if (!appAtomRegistry.get(command.permissionAtom(thread.environmentId))) return; + working.current = true; + setBusy(true); + setError(null); + try { + const result = + action === "retry" + ? await threadHandoffEnvironment.start.run(appAtomRegistry, { + environmentId: thread.environmentId, + input: { + threadId: thread.id, + environmentId: handoff.environmentId, + projectId: handoff.projectId, + handoffId: handoff.handoffId, + isCurrent: current, + }, + }) + : await threadHandoffEnvironment.cancel.run(appAtomRegistry, { + environmentId: thread.environmentId, + input: { + threadId: thread.id, + handoffId: handoff.handoffId, + isCurrent: current, + ...(action === "abandon" ? { abandon: true } : {}), + }, + }); + if (current() && result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Could not update the move. Try again.", + ); + } + } finally { + if (alive.current) { + working.current = false; + setBusy(false); + } + } + }; + const abandon = () => + Dialog.confirm({ + title: "Continue here?", + message: + "The move outcome is unknown. The destination may already have a copy or finish accepting it later. Continue working here anyway?", + confirmText: "Continue here", + destructive: true, + onConfirm: () => { + if (current()) void act("abandon"); + }, + }); + return ( + + {notice.title} + {notice.detail} + {handoff.state === "pending" ? ( + + ) : null} + {handoff.state === "departing" ? ( + <> + + + + ) : null} + {handoff.state === "departed" && connected.includes(handoff.environmentId) ? ( + + ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + ); +} diff --git a/apps/web/src/components/ThreadHandoffDialog.tsx b/apps/web/src/components/ThreadHandoffDialog.tsx new file mode 100644 index 0000000000000..0bbd456fbd037 --- /dev/null +++ b/apps/web/src/components/ThreadHandoffDialog.tsx @@ -0,0 +1,163 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useRouter } from "@tanstack/react-router"; +import { AsyncResult } from "effect/reactivity"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; +import { + configureThreadHandoffActions, + updateThreadHandoffSelection, + isCurrentThreadHandoffRequest, + threadHandoffRequest, + type ThreadHandoffRequest, +} from "./threadHandoffAction"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { threadHandoffEnvironment } from "../state/threadHandoff"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "./ui/dialog"; + +configureThreadHandoffActions((ref) => + appAtomRegistry.get(threadHandoffEnvironment.availableAtom(ref)), +); + +export function ThreadHandoffDialogHost() { + const pathname = useLocation({ select: (location) => location.pathname }); + const router = useRouter(); + updateThreadHandoffSelection(pathname); + useEffect( + () => + router.subscribe("onBeforeNavigate", (event) => + updateThreadHandoffSelection(event.toLocation.pathname), + ), + [router], + ); + const request = useStore(threadHandoffRequest, (state) => state.request); + return request && isCurrentThreadHandoffRequest(request) ? ( + + ) : null; +} + +function ThreadHandoffDialog({ request }: { request: ThreadHandoffRequest }) { + const { ref } = request; + const atom = threadHandoffEnvironment.options({ + environmentId: ref.environmentId, + input: { threadId: ref.threadId }, + }); + const options = useAtomValue(atom); + const available = useAtomValue(threadHandoffEnvironment.availableAtom(ref)); + const [busy, setBusy] = useState(false); + const sending = useRef(false); + const [error, setError] = useState(null); + const current = () => isCurrentThreadHandoffRequest(request); + const close = () => { + if (current()) threadHandoffRequest.setState({ request: null }); + }; + const start = async ( + option: Extract["value"]["options"][number], + ) => { + if ( + !current() || + sending.current || + option.reason !== null || + option.projectId === null || + !appAtomRegistry.get(threadHandoffEnvironment.availableAtom(ref)) + ) + return; + sending.current = true; + setBusy(true); + setError(null); + const result = await threadHandoffEnvironment.start.run(appAtomRegistry, { + environmentId: ref.environmentId, + input: { + threadId: ref.threadId, + environmentId: option.environmentId, + projectId: option.projectId, + isCurrent: current, + }, + }); + if (!current()) return; + sending.current = false; + setBusy(false); + if (AsyncResult.isSuccess(result)) close(); + else if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "Could not start the move. Try again."); + } + }; + return ( + { + if (!open) close(); + }} + > + + + Continue on… + + Move this conversation and Git working changes to a linked environment with the same + repository. A fresh agent continues there. + + + +
+ {!available && !busy ? ( +

This thread can no longer move. Stop any work before trying again.

+ ) : null} + {options.waiting || options._tag === "Initial" ? ( +

Finding linked environments…

+ ) : null} + {AsyncResult.isFailure(options) ? ( +
+

Could not load destinations.

+ +
+ ) : null} + {AsyncResult.isSuccess(options) ? ( + options.value.options.length === 0 ? ( +

No linked environments found. Link an environment in Connections first.

+ ) : ( + options.value.options.map((option) => ( +
+ + {option.reason ? ( +

{option.reason}

+ ) : null} +
+ )) + ) + ) : null} + {busy ?

Starting the move…

: null} + {error ?

{error}

: null} +
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index cca7bcf20ed2c..33acb9c333a64 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -2,6 +2,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; export type ThreadActionMenuId = + | "continue-on" | "new-thread-on-branch" | "filter-by-project" | "project-settings" @@ -64,6 +65,7 @@ export function buildDraftActionMenuItems(options: { } export interface ThreadActionMenuState { + readonly canContinueOn?: boolean; readonly canOperate: boolean; readonly branch: string | null; readonly projectFilter: { @@ -105,6 +107,9 @@ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { const items: ReadonlyArray> = [ + ...(state.canContinueOn + ? [{ id: "continue-on" as const, label: "Continue on…", icon: "arrow-right-left" }] + : []), ...(state.branch ? [ { diff --git a/apps/web/src/components/threadHandoffAction.ts b/apps/web/src/components/threadHandoffAction.ts new file mode 100644 index 0000000000000..49411c16b3067 --- /dev/null +++ b/apps/web/src/components/threadHandoffAction.ts @@ -0,0 +1,37 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { createStore } from "zustand/vanilla"; + +let selection: string | null = null; +let generation = 0; +let requestId = 0; +let isAvailable: (ref: ScopedThreadRef) => boolean = () => false; +export type ThreadHandoffRequest = { ref: ScopedThreadRef; generation: number; id: number }; +export const threadHandoffRequest = createStore<{ request: ThreadHandoffRequest | null }>(() => ({ + request: null, +})); + +export function configureThreadHandoffActions(readAvailable: typeof isAvailable) { + isAvailable = readAvailable; +} +export function updateThreadHandoffSelection(pathname: string) { + if (selection !== pathname) { + selection = pathname; + generation++; + } +} +export function isCurrentThreadHandoffRequest(request: ThreadHandoffRequest) { + return ( + request.generation === generation && threadHandoffRequest.getState().request?.id === request.id + ); +} +export function threadHandoffAction(ref: ScopedThreadRef) { + const capturedGeneration = generation; + const available = () => capturedGeneration === generation && isAvailable(ref); + return { + available: available(), + run: () => { + if (available()) + threadHandoffRequest.setState({ request: { ref, generation, id: ++requestId } }); + }, + }; +} diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 4b8054c986fae..68bc104f1366d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -145,6 +145,7 @@ vi.mock("./useThreadActions", () => ({ })); import { useThreadActionMenu } from "./useThreadActionMenu"; +import * as HandoffAction from "../components/threadHandoffAction"; const target = { environmentId: EnvironmentId.make("secondary"), @@ -219,3 +220,41 @@ describe("thread menu permissions", () => { expect(state.effects).toEqual([effect]); }); }); + +describe("thread handoff menu navigation", () => { + it("ignores delayed native menu choices from before draft navigation and accepts a fresh choice", async () => { + state.granted.add("secondary"); + HandoffAction.threadHandoffRequest.setState({ request: null }); + HandoffAction.configureThreadHandoffActions(() => true); + HandoffAction.updateThreadHandoffSelection("/secondary/thread"); + const requested = deferred(); + const requests: Array = []; + const unsubscribe = HandoffAction.threadHandoffRequest.subscribe(({ request }) => { + if (request) { + requests.push(request); + requested.resolve(); + } + }); + try { + const oldChoice = deferred(); + state.show.mockReturnValueOnce(oldChoice.promise); + createMenu().openMenu(position); + HandoffAction.updateThreadHandoffSelection("/secondary/draft"); + HandoffAction.updateThreadHandoffSelection("/secondary/thread"); + const freshChoice = deferred(); + state.show.mockReturnValueOnce(freshChoice.promise); + createMenu().openMenu(position); + oldChoice.resolve("continue-on"); + // Both menu continuations run in resolution order; the valid request is the milestone. + freshChoice.resolve("continue-on"); + await requested.promise; + expect(requests).toHaveLength(1); + expect(requests[0]?.ref).toEqual(target); + expect(HandoffAction.isCurrentThreadHandoffRequest(requests[0]!)).toBe(true); + } finally { + unsubscribe(); + HandoffAction.threadHandoffRequest.setState({ request: null }); + HandoffAction.configureThreadHandoffActions(() => false); + } + }); +}); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 08ed2b24e4cfe..6738f4b13f018 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,3 +1,4 @@ +import { threadHandoffAction } from "../components/threadHandoffAction"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { @@ -128,7 +129,9 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const handoff = threadHandoffAction(threadRef); const items = buildThreadActionMenuItems({ + canContinueOn: handoff.available, canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, projectFilter: null, @@ -177,6 +180,9 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "continue-on": + handoff.run(); + return; case "project-settings": { const project = projects.find( (candidate) => diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ece2903be3d3e..23614e5ef9489 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { ThreadHandoffDialogHost } from "../components/ThreadHandoffDialog"; import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -186,6 +187,7 @@ function RootRouteView() { + @@ -241,6 +243,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/state/threadHandoff.ts b/apps/web/src/state/threadHandoff.ts new file mode 100644 index 0000000000000..6e2d9b8d27e86 --- /dev/null +++ b/apps/web/src/state/threadHandoff.ts @@ -0,0 +1,30 @@ +import { createThreadHandoffEnvironmentAtoms } from "@t3tools/client-runtime/state/threads"; +import { type ScopedThreadRef, type EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentThreadShells } from "./threads"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +const thread = Atom.family((key: string) => { + const ref = JSON.parse(key) as ScopedThreadRef; + return Atom.make((get) => get(environmentThreadShells.threadShellAtom(ref))?.source ?? null); +}); +const connectedAtom = Atom.family((id: EnvironmentId) => + Atom.make( + (get) => get(environmentPresentations.presentationAtom(id))?.connection.phase === "connected", + ), +); +const supportedAtom = Atom.family((id: EnvironmentId) => + Atom.make( + (get) => + get(serverEnvironment.configValueAtom(id))?.environment.capabilities.threadHandoff === true, + ), +); +const noLocalWork = Atom.make(false); +export const threadHandoffEnvironment = createThreadHandoffEnvironmentAtoms(connectionAtomRuntime, { + threadAtom: (ref) => thread(JSON.stringify(ref)), + connectedAtom, + supportedAtom, + hasLocalWorkAtom: () => noLocalWork, +}); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index fbdf02ad3f43c..6ce435fbfd433 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -35,6 +35,14 @@ If an older saved link asks you to pair again, update both environments and repe the previous pairing unless it has full access. Unlinking forgets the credential here; revoke the client in the destination's **Settings → Connections** to end its session there. +To continue a stopped thread on another linked environment, choose **Continue on…** +from its actions and select a destination with the same repository. The conversation +and Git working changes move to a fresh agent there; provider sessions and chat +attachment files stay on the source. Finish queued work first. The source becomes +read-only during the move. Retry an interrupted move, or choose **Continue here…** +if you accept that the destination may already have a copy. Threads started by a +restricted link cannot move. + ## T3 Connect T3 Connect makes an environment available to your other devices without setting diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index b16a2f5b4895a..ac9e54b606216 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -33,7 +33,11 @@ import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import { getInitialServerConfig, request } from "../rpc/client.ts"; +import { + getInitialServerConfig, + request, + type EnvironmentRpcUnavailableError, +} from "../rpc/client.ts"; interface CommandMetadata { readonly commandId?: CommandId; @@ -259,8 +263,10 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)); }); -const dispatch = (command: OrchestrationV2Command) => - request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); +const dispatch = ( + command: OrchestrationV2Command, + beforeDispatch: Effect.Effect = Effect.void, +) => request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command, beforeDispatch); const getProjection = (threadId: ThreadId) => request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); @@ -276,6 +282,7 @@ const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(f threadId: ThreadId, messageId: MessageId, attachments: ReadonlyArray, + beforeDispatch: Effect.Effect = Effect.void, ) { const stored = attachments.filter( (attachment): attachment is ChatAttachment => !("dataUrl" in attachment), @@ -284,11 +291,15 @@ const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(f (attachment): attachment is UploadChatAttachment => "dataUrl" in attachment, ); if (uploads.length === 0) return stored; - const result = yield* request(WS_METHODS.assetsPersistChatAttachments, { - threadId, - messageId, - attachments: uploads, - }); + const result = yield* request( + WS_METHODS.assetsPersistChatAttachments, + { + threadId, + messageId, + attachments: uploads, + }, + beforeDispatch, + ); if (stored.length === 0) return result.attachments; const byUpload = new Map( uploads.map((attachment, index) => [attachment, result.attachments[index]]), @@ -619,12 +630,16 @@ export const setThreadInteractionMode = Effect.fn("EnvironmentCommands.setThread export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* ( input: StartThreadTurnInput, + beforeDispatch: Effect.Effect = Effect.void, ) { + yield* beforeDispatch; const commandId = yield* allocateCommandId(input); + yield* beforeDispatch; const attachments = yield* persistAttachments( input.threadId, input.message.messageId, input.message.attachments, + beforeDispatch, ); const context = remapComposerContextAttachments( input.message.context, @@ -659,48 +674,55 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ? {} : { branch: bootstrap.branch }), }; - return yield* request(ORCHESTRATION_V2_WS_METHODS.launchThread, { - commandId, - creationSource: input.creationSource ?? "web", - threadId: input.threadId, - ...(bootstrap === undefined ? { reuseExistingThread: true } : {}), - projectId: thread.projectId, - title: input.titleSeed ?? thread.title, - generateTitle: input.titleSeed !== undefined, - modelSelection: input.modelSelection ?? thread.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - workspaceStrategy, - initialMessage: { - messageId: input.message.messageId, - text: input.message.text, - ...(context ? { context } : {}), - attachments, + return yield* request( + ORCHESTRATION_V2_WS_METHODS.launchThread, + { + commandId, + creationSource: input.creationSource ?? "web", + threadId: input.threadId, + ...(bootstrap === undefined ? { reuseExistingThread: true } : {}), + projectId: thread.projectId, + title: input.titleSeed ?? thread.title, + generateTitle: input.titleSeed !== undefined, + modelSelection: input.modelSelection ?? thread.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + workspaceStrategy, + initialMessage: { + messageId: input.message.messageId, + text: input.message.text, + ...(context ? { context } : {}), + attachments, + }, }, - }); + beforeDispatch, + ); } const requestedMode = input.dispatchMode ?? "auto"; if (requestedMode === "start") { - return yield* dispatch({ - type: "message.dispatch", - commandId, - createdBy: "user", - creationSource: input.creationSource ?? "web", - threadId: input.threadId, - messageId: input.message.messageId, - ...(input.manualContinuationOfRunId === undefined - ? {} - : { manualContinuationOfRunId: input.manualContinuationOfRunId }), - text: input.message.text, - ...(context ? { context } : {}), - attachments, - ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), - ...(input.sourceProposedPlan === undefined - ? {} - : { sourcePlanRef: input.sourceProposedPlan }), - dispatchMode: { type: "start_immediately" }, - }); + return yield* dispatch( + { + type: "message.dispatch", + commandId, + createdBy: "user", + creationSource: input.creationSource ?? "web", + threadId: input.threadId, + messageId: input.message.messageId, + ...(input.manualContinuationOfRunId === undefined + ? {} + : { manualContinuationOfRunId: input.manualContinuationOfRunId }), + text: input.message.text, + ...(context ? { context } : {}), + attachments, + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + ...(input.sourceProposedPlan === undefined + ? {} + : { sourcePlanRef: input.sourceProposedPlan }), + dispatchMode: { type: "start_immediately" }, + }, + beforeDispatch, + ); } const serverResolvesCommandContext = yield* supportsServerResolvedCommandContext(); @@ -746,24 +768,29 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( const shouldSendTitleSeed = input.titleSeed !== undefined && (serverResolvesCommandContext || projection?.messages.length === 0); - return yield* dispatch({ - type: "message.dispatch", - commandId, - createdBy: "user", - creationSource: input.creationSource ?? "web", - threadId: input.threadId, - messageId: input.message.messageId, - text: input.message.text, - ...(context ? { context } : {}), - attachments, - ...(shouldSendTitleSeed ? { titleSeed: input.titleSeed } : {}), - ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), - ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }), - ...(serverResolvesCommandContext && requestedMode !== "queue" - ? { deliveryIntent: requestedMode } - : {}), - dispatchMode, - }); + return yield* dispatch( + { + type: "message.dispatch", + commandId, + createdBy: "user", + creationSource: input.creationSource ?? "web", + threadId: input.threadId, + messageId: input.message.messageId, + text: input.message.text, + ...(context ? { context } : {}), + attachments, + ...(shouldSendTitleSeed ? { titleSeed: input.titleSeed } : {}), + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + ...(input.sourceProposedPlan === undefined + ? {} + : { sourcePlanRef: input.sourceProposedPlan }), + ...(serverResolvesCommandContext && requestedMode !== "queue" + ? { deliveryIntent: requestedMode } + : {}), + dispatchMode, + }, + beforeDispatch, + ); }); export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 5b670fd622a5f..7265cb6779df8 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -189,7 +189,11 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { +>( + tag: TTag, + input: EnvironmentRpcInput, + beforeDispatch: Effect.Effect = Effect.void, +) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, @@ -205,7 +209,12 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < environmentId: supervisor.target.environmentId, method: tag, }); - return yield* method(input).pipe(Effect.ensuring(completeObservation)); + // Observation can suspend while the connection grants change. + return yield* authorizeRequest(tag, input).pipe( + Effect.andThen(beforeDispatch), + Effect.andThen(() => method(input)), + Effect.ensuring(completeObservation), + ); }); export function runStreamGuarded( @@ -433,7 +442,8 @@ export function subscribe( export const request = >( tag: TTag, input: EnvironmentRpcInput, -) => requestGuarded(tag, input); + beforeDispatch: Effect.Effect = Effect.void, +) => requestGuarded(tag, input, beforeDispatch); export const runStream = < TTag extends Exclude, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 36535a273cc0b..412a8b06f468c 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -99,6 +99,7 @@ export interface EnvironmentThreadShell { readonly branch: string | null; readonly worktreePath: string | null; readonly lineage: OrchestrationV2ThreadShell["lineage"]; + readonly handoff?: OrchestrationV2ThreadShell["handoff"] | null; readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; readonly latestRun: ThreadRunSummary | null; @@ -238,6 +239,7 @@ export function presentThreadShell( linkedPullRequest: thread.linkedPullRequest ?? null, branchPullRequest: thread.branchPullRequest ?? null, lineage: thread.lineage, + handoff: thread.handoff ?? null, forkedFrom: thread.forkedFrom, activeProviderThreadId: thread.activeProviderThreadId, latestRun, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 94530b88113a4..ace72dea0e593 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -849,6 +849,8 @@ export function createEnvironmentRpcCommand< readonly tag: TTag; readonly execute?: ( input: Input, + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentIdType, ) => Effect.Effect< EnvironmentRpcSuccess, EnvironmentRpcFailure | EnvironmentRpcUnavailableError, @@ -889,7 +891,10 @@ export function createEnvironmentRpcCommand< // Routing requires consent on the origin as well as the actual destination. // The transport check below deliberately checks the destination again. return permissions.authorize(registry, environmentId, input).pipe( - Effect.andThen(() => options.execute?.(input) ?? requestGuarded(options.tag, input)), + Effect.andThen( + () => + options.execute?.(input, registry, environmentId) ?? requestGuarded(options.tag, input), + ), Effect.provideService(RpcPermissionGuard, { authorize: (id, method, payload) => createCommandPermissions(runtime, method).authorize(registry, id, payload), diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index d6f81964bdeee..1b46e3d988da3 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, + WS_METHODS, + type AuthSessionState, CommandId, + MessageId, RuntimeRequestId, EnvironmentId, ORCHESTRATION_V2_WS_METHODS, @@ -9,7 +13,11 @@ import { ThreadId, type OrchestrationV2Command, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadHandoff, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; +import { vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -18,12 +26,23 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; +import * as ThreadCommands from "./threadCommands.ts"; +import { createThreadHandoffEnvironmentAtoms } from "./threadHandoff.ts"; +import * as HandoffAction from "../../../../apps/web/src/components/threadHandoffAction.ts"; +import { EnvironmentRpcRequestObserver, type EnvironmentRpcInput } from "../rpc/client.ts"; + +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), +})); +const sessions = Atom.family((_id: EnvironmentId) => + Atom.make>(AsyncResult.initial()), +); const ENVIRONMENT_ID = EnvironmentId.make("remote"); const THREAD_ID = ThreadId.make("thread"); @@ -80,7 +99,9 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = { ], }; -const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { +const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* ( + replyImmediately = false, +) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; reply: Deferred.Deferred<{ sequence: number }, Error>; @@ -94,6 +115,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { Effect.gen(function* () { const reply = yield* Deferred.make<{ sequence: number }, Error>(); yield* Queue.offer(requests, { command, reply }); + if (replyImmediately) yield* Deferred.succeed(reply, { sequence: 2 }); return yield* Deferred.await(reply); }), }, @@ -397,3 +419,412 @@ describe("remote thread lifecycle commands", () => { }), ); }); + +const handoffTarget = { + handoffId: "move-one", + environmentId: EnvironmentId.make("destination"), + projectId: ProjectId.make("destination-project"), + label: "Destination", +}; +const handoffStates: ReadonlyArray<{ + label: string; + handoff: OrchestrationV2ThreadHandoff | null; + sendable: boolean; +}> = [ + { label: "no move", handoff: null, sendable: true }, + { + label: "pending", + handoff: { ...handoffTarget, state: "pending", requestCommandId: CommandId.make("request") }, + sendable: true, + }, + { label: "departing", handoff: { ...handoffTarget, state: "departing" }, sendable: false }, + { + label: "departed", + handoff: { ...handoffTarget, state: "departed", threadId: ThreadId.make("destination-thread") }, + sendable: false, + }, + { + label: "failed", + handoff: { ...handoffTarget, state: "failed", lastError: "Destination disconnected" }, + sendable: true, + }, +]; + +describe("thread move commands", () => { + it("offers Move only for a connected, supported, stopped source without local work", () => { + const source = SNAPSHOT.threads[0]!; + const context = { connected: true, supported: true, hasLocalWork: false }; + const canStart = (thread: OrchestrationV2ThreadShell | null, overrides = {}) => + ThreadCommands.canStartThreadHandoff({ ...context, ...overrides, thread }); + + expect(canStart(source)).toBe(true); + expect(canStart(null)).toBe(false); + expect(canStart(source, { connected: false })).toBe(false); + expect(canStart(source, { supported: false })).toBe(false); + expect(canStart(source, { hasLocalWork: true })).toBe(false); + expect(canStart({ ...source, archivedAt: NOW })).toBe(false); + for (const status of ["preparing", "queued", "starting", "running", "waiting"] as const) { + expect(canStart({ ...source, status, activeRunId: null }), status).toBe(false); + } + for (const creationSource of ["provider", "mcp"] as const) { + expect( + canStart({ + ...source, + creationSource, + createdBy: "agent", + lineage: { + ...source.lineage, + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent", + }, + }), + creationSource, + ).toBe(false); + } + for (const { label, handoff } of handoffStates) { + expect(canStart({ ...source, handoff }), label).toBe( + handoff === null || handoff.state === "failed", + ); + } + }); + + it.effect.each(handoffStates)( + "rechecks the source before dispatching a turn ($label)", + ({ handoff, sendable }) => + Effect.gen(function* () { + const h = yield* makeHarness(true); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { + ...SNAPSHOT, + threads: [{ ...SNAPSHOT.threads[0]!, handoff }], + }); + const result = yield* Effect.promise(() => + h.commands.startTurn.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + commandId: CommandId.make("send"), + dispatchMode: "start", + runtimeMode: "full-access", + interactionMode: "default", + message: { + messageId: MessageId.make("message"), + role: "user", + text: "Keep working here", + attachments: [], + }, + }, + }), + ); + expect(yield* Queue.size(h.requests)).toBe(sendable ? 1 : 0); + expect(result._tag).toBe(sendable ? "Success" : "Failure"); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.handoff).toEqual(handoff); + }), + ); +}); + +const makeHandoffHarness = Effect.fn("TestThreadCommands.makeHandoffHarness")(function* () { + const gates = yield* Queue.unbounded>(); + const sent: Array<{ method: string; input: unknown }> = []; + const record = (method: string, input: unknown) => + Effect.sync(() => { + sent.push({ method, input }); + }); + const supervisor = { + target: { environmentId: ENVIRONMENT_ID }, + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.threadHandoffStart]: ( + input: EnvironmentRpcInput, + ) => + record(WS_METHODS.threadHandoffStart, input).pipe( + Effect.as({ ...handoffTarget, state: "departing" as const }), + ), + [WS_METHODS.threadHandoffCancel]: ( + input: EnvironmentRpcInput, + ) => record(WS_METHODS.threadHandoffCancel, input).pipe(Effect.as({})), + }, + } as unknown as RpcSession), + ), + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + Layer.succeed(EnvironmentRpcRequestObserver, { + observe: () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + yield* Queue.offer(gates, release); + yield* Deferred.await(release); + return Effect.void; + }), + }), + ), + ); + const thread = Atom.make(SNAPSHOT.threads[0]!); + const localWork = Atom.make(false); + const commands = createThreadHandoffEnvironmentAtoms(runtime, { + threadAtom: () => thread, + connectedAtom: () => Atom.make(true), + supportedAtom: () => Atom.make(true), + hasLocalWorkAtom: () => localWork, + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const session = sessions(ENVIRONMENT_ID); + registry.mount(session); + const grant: AuthSessionState = { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthOrchestrationOperateScope], + permissions: [AuthOrchestrationOperateScope], + }; + registry.set(session, AsyncResult.success(grant)); + return { registry, commands, thread, localWork, gates, sent, session, grant }; +}); + +describe("handoff dispatch boundaries", () => { + it.effect("moves full-access linked threads but fences origins without recorded access", () => + Effect.gen(function* () { + const h = yield* makeHandoffHarness(); + const ref = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }; + const input = { threadId: THREAD_ID, environmentId: handoffTarget.environmentId }; + const linkOrigin = { sessionId: "linked-session", label: "Linked source" }; + yield* Queue.take(h.gates).pipe( + Effect.flatMap((gate) => Deferred.succeed(gate, undefined)), + Effect.forever, + Effect.forkChild, + ); + + h.registry.set(h.thread, { + ...SNAPSHOT.threads[0]!, + linkOrigin: { ...linkOrigin, access: "full-access" }, + }); + expect(h.registry.get(h.commands.availableAtom(ref))).toBe(true); + const accepted = yield* Effect.promise(() => + h.commands.start.run(h.registry, { environmentId: ENVIRONMENT_ID, input }), + ); + expect(accepted._tag).toBe("Success"); + expect(h.sent).toEqual([{ method: WS_METHODS.threadHandoffStart, input }]); + + h.registry.set(h.thread, { ...SNAPSHOT.threads[0]!, linkOrigin }); + const available = h.registry.get(h.commands.availableAtom(ref)); + const denied = yield* Effect.promise(() => + h.commands.start.run(h.registry, { environmentId: ENVIRONMENT_ID, input }), + ); + expect({ available, result: denied._tag, sent: h.sent }).toEqual({ + available: false, + result: "Failure", + sent: [{ method: WS_METHODS.threadHandoffStart, input }], + }); + }), + ); + + it.effect( + "revalidates local work and the source shell after preparation, then sends only current wire input", + () => + Effect.gen(function* () { + const h = yield* makeHandoffHarness(); + const ref = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }; + const input = { + threadId: THREAD_ID, + environmentId: handoffTarget.environmentId, + projectId: handoffTarget.projectId, + isCurrent: () => true, + }; + expect(h.registry.get(h.commands.availableAtom(ref))).toBe(true); + const queued = h.commands.start.run(h.registry, { environmentId: ENVIRONMENT_ID, input }); + const queueGate = yield* Queue.take(h.gates); + h.registry.set(h.localWork, true); + expect(h.registry.get(h.commands.availableAtom(ref))).toBe(false); + yield* Deferred.succeed(queueGate, undefined); + expect((yield* Effect.promise(() => queued))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + + h.registry.set(h.localWork, false); + const running = h.commands.start.run(h.registry, { environmentId: ENVIRONMENT_ID, input }); + const runGate = yield* Queue.take(h.gates); + h.registry.set(h.thread, { ...SNAPSHOT.threads[0]!, status: "running" }); + yield* Deferred.succeed(runGate, undefined); + expect((yield* Effect.promise(() => running))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + + h.registry.set(h.thread, SNAPSHOT.threads[0]!); + const valid = h.commands.start.run(h.registry, { environmentId: ENVIRONMENT_ID, input }); + yield* Deferred.succeed(yield* Queue.take(h.gates), undefined); + expect((yield* Effect.promise(() => valid))._tag).toBe("Success"); + expect(h.sent).toEqual([ + { + method: WS_METHODS.threadHandoffStart, + input: { + threadId: THREAD_ID, + environmentId: handoffTarget.environmentId, + projectId: handoffTarget.projectId, + }, + }, + ]); + }), + ); + + it.effect( + "does not let pending cancellation, retry or abandonment cross a replacement handoff", + () => + Effect.gen(function* () { + const h = yield* makeHandoffHarness(); + const pending = { + ...handoffTarget, + state: "pending" as const, + requestCommandId: CommandId.make("move-request"), + }; + h.registry.set(h.thread, { ...SNAPSHOT.threads[0]!, handoff: pending }); + const cancelled = h.commands.cancel.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, handoffId: pending.handoffId, isCurrent: () => true }, + }); + const cancelGate = yield* Queue.take(h.gates); + const departing = { + ...handoffTarget, + handoffId: "replacement", + state: "departing" as const, + }; + h.registry.set(h.thread, { ...SNAPSHOT.threads[0]!, handoff: departing }); + yield* Deferred.succeed(cancelGate, undefined); + expect((yield* Effect.promise(() => cancelled))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + + const retry = h.commands.start.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + environmentId: departing.environmentId, + handoffId: departing.handoffId, + isCurrent: () => true, + }, + }); + const retryGate = yield* Queue.take(h.gates); + const next = { ...departing, handoffId: "newest" }; + h.registry.set(h.thread, { ...SNAPSHOT.threads[0]!, handoff: next }); + yield* Deferred.succeed(retryGate, undefined); + expect((yield* Effect.promise(() => retry))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + + let current = true; + const abandonInput = { + threadId: THREAD_ID, + handoffId: next.handoffId, + abandon: true, + isCurrent: () => current, + }; + const stale = h.commands.cancel.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: abandonInput, + }); + const abandonGate = yield* Queue.take(h.gates); + current = false; + yield* Deferred.succeed(abandonGate, undefined); + expect((yield* Effect.promise(() => stale))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + + current = true; + const accepted = h.commands.cancel.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: abandonInput, + }); + yield* Deferred.succeed(yield* Queue.take(h.gates), undefined); + expect((yield* Effect.promise(() => accepted))._tag).toBe("Success"); + expect(h.sent).toEqual([ + { + method: WS_METHODS.threadHandoffCancel, + input: { threadId: THREAD_ID, handoffId: next.handoffId, abandon: true }, + }, + ]); + }), + ); + + it.effect( + "rejects an old selection after source to draft to the same source, while a fresh selection can move", + () => + Effect.gen(function* () { + const h = yield* makeHandoffHarness(); + const ref = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }; + HandoffAction.configureThreadHandoffActions((source) => + h.registry.get(h.commands.availableAtom(source)), + ); + HandoffAction.threadHandoffRequest.setState({ request: null }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + HandoffAction.threadHandoffRequest.setState({ request: null }); + HandoffAction.configureThreadHandoffActions(() => false); + }), + ); + HandoffAction.updateThreadHandoffSelection("/remote/thread"); + const oldMenu = HandoffAction.threadHandoffAction(ref); + oldMenu.run(); + const oldRequest = HandoffAction.threadHandoffRequest.getState().request!; + const stale = h.commands.start.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + environmentId: handoffTarget.environmentId, + isCurrent: () => HandoffAction.isCurrentThreadHandoffRequest(oldRequest), + }, + }); + const gate = yield* Queue.take(h.gates); + HandoffAction.updateThreadHandoffSelection("/remote/draft"); + HandoffAction.updateThreadHandoffSelection("/remote/thread"); + yield* Deferred.succeed(gate, undefined); + expect((yield* Effect.promise(() => stale))._tag).toBe("Failure"); + expect(h.sent).toEqual([]); + HandoffAction.threadHandoffRequest.setState({ request: null }); + oldMenu.run(); + expect(HandoffAction.threadHandoffRequest.getState().request).toBeNull(); + + HandoffAction.threadHandoffAction(ref).run(); + const fresh = HandoffAction.threadHandoffRequest.getState().request!; + expect(fresh.ref).toEqual(ref); + const accepted = h.commands.start.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + environmentId: handoffTarget.environmentId, + isCurrent: () => HandoffAction.isCurrentThreadHandoffRequest(fresh), + }, + }); + yield* Deferred.succeed(yield* Queue.take(h.gates), undefined); + expect((yield* Effect.promise(() => accepted))._tag).toBe("Success"); + expect(h.sent).toEqual([ + { + method: WS_METHODS.threadHandoffStart, + input: { threadId: THREAD_ID, environmentId: handoffTarget.environmentId }, + }, + ]); + }), + ); + + it.effect("honors permission revocation while an authorized start awaits dispatch", () => + Effect.gen(function* () { + const h = yield* makeHandoffHarness(); + const result = h.commands.start.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, environmentId: handoffTarget.environmentId }, + }); + const gate = yield* Queue.take(h.gates); + h.registry.set(h.session, AsyncResult.success({ ...h.grant, scopes: [], permissions: [] })); + expect(h.registry.get(h.commands.start.permissionAtom(ENVIRONMENT_ID))).toBe(false); + yield* Deferred.succeed(gate, undefined); + const denied = yield* Effect.promise(() => result); + expect(h.sent).toEqual([]); + expect(denied._tag).toBe("Failure"); + if (denied._tag === "Failure") + expect(Cause.squash(denied.cause)).toMatchObject({ _tag: "EnvironmentAuthorizationError" }); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c0b996004679a..fdc661f295e75 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -5,8 +5,10 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { WS_METHODS, + linkOriginIsFenced, type EnvironmentId, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; @@ -93,6 +95,7 @@ import { } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; export type LoadEarlierThreadHistoryInput = { @@ -139,6 +142,36 @@ export type { WatchThreadPullRequestInput, } from "../operations/commands.ts"; +export function threadHandoffIsReadOnly( + thread: { readonly handoff?: OrchestrationV2ThreadShell["handoff"] } | null | undefined, +): boolean { + return thread?.handoff?.state === "departing" || thread?.handoff?.state === "departed"; +} + +export function canStartThreadHandoff(input: { + readonly thread: OrchestrationV2ThreadShell | null; + readonly connected: boolean; + readonly supported: boolean; + readonly hasLocalWork: boolean; +}): boolean { + const { thread } = input; + return ( + input.connected && + input.supported && + !input.hasLocalWork && + thread !== null && + thread.archivedAt === null && + thread.deletedAt === null && + thread.lineage.relationshipToParent !== "subagent" && + !(thread.linkOrigin !== undefined && linkOriginIsFenced(thread.linkOrigin)) && + thread.activeRunId === null && + thread.pendingRuntimeRequest === null && + !["preparing", "queued", "starting", "running", "waiting"].includes(thread.status) && + (thread.handoff == null || thread.handoff.state === "failed") && + (thread.pendingBackgroundTasks?.length ?? 0) === 0 + ); +} + export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, @@ -278,7 +311,22 @@ export function createThreadEnvironmentAtoms( }), startTurn: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:start-turn", - execute: (input: StartThreadTurnInput) => startThreadTurn(input), + execute: (input: StartThreadTurnInput, registry, environmentId) => { + const validate = Effect.suspend(() => { + const thread = registry + .get(snapshotAtom(environmentId)) + ?.threads.find((thread) => thread.id === input.threadId); + return threadHandoffIsReadOnly(thread) + ? Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId, + message: "This thread is continuing on another environment.", + }), + ) + : Effect.void; + }); + return validate.pipe(Effect.andThen(startThreadTurn(input, validate))); + }, scheduler, concurrency, }), diff --git a/packages/client-runtime/src/state/threadHandoff.ts b/packages/client-runtime/src/state/threadHandoff.ts new file mode 100644 index 0000000000000..8450771353553 --- /dev/null +++ b/packages/client-runtime/src/state/threadHandoff.ts @@ -0,0 +1,175 @@ +import { + WS_METHODS, + type EnvironmentId, + type ScopedThreadRef, + type OrchestrationV2ThreadHandoff, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/reactivity"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + EnvironmentRpcUnavailableError, + requestGuarded, + type EnvironmentRpcInput, +} from "../rpc/client.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { canStartThreadHandoff } from "./threadCommands.ts"; + +export function createThreadHandoffEnvironmentAtoms( + runtime: Atom.AtomRuntime, + source: { + readonly threadAtom: (ref: ScopedThreadRef) => Atom.Atom; + readonly connectedAtom: (id: EnvironmentId) => Atom.Atom; + readonly supportedAtom: (id: EnvironmentId) => Atom.Atom; + readonly hasLocalWorkAtom: (ref: ScopedThreadRef) => Atom.Atom; + }, +) { + const options = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "thread-handoff:options", + tag: WS_METHODS.threadHandoffOptions, + staleTimeMs: 0, + }); + const start = createEnvironmentRpcCommand< + R, + E, + typeof WS_METHODS.threadHandoffStart, + EnvironmentRpcInput & { + readonly isCurrent?: () => boolean; + } + >(runtime, { + label: "thread-handoff:start", + tag: WS_METHODS.threadHandoffStart, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), + }, + execute: ( + input: EnvironmentRpcInput & { + readonly isCurrent?: () => boolean; + }, + registry, + environmentId, + ) => { + const { isCurrent, ...payload } = input; + const ref = { environmentId, threadId: input.threadId }; + const validate = Effect.suspend(() => { + const thread = registry.get(source.threadAtom(ref)); + const available = + registry.get(source.connectedAtom(environmentId)) && + registry.get(source.supportedAtom(environmentId)); + const handoff = thread?.handoff; + const retry = + input.handoffId !== undefined && + handoff != null && + handoff.handoffId === input.handoffId && + (handoff.state === "departing" || handoff.state === "departed") && + handoff.environmentId === input.environmentId && + (input.projectId === undefined || input.projectId === handoff.projectId); + return isCurrent?.() !== false && + available && + (input.handoffId !== undefined + ? retry + : canStartThreadHandoff({ + thread, + connected: true, + supported: true, + hasLocalWork: registry.get(source.hasLocalWorkAtom(ref)), + })) + ? Effect.void + : Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId, + message: "This thread can no longer move. Check its current state and try again.", + }), + ); + }); + return validate.pipe( + Effect.andThen(requestGuarded(WS_METHODS.threadHandoffStart, payload, validate)), + ); + }, + }); + const cancel = createEnvironmentRpcCommand< + R, + E, + typeof WS_METHODS.threadHandoffCancel, + EnvironmentRpcInput & { + readonly isCurrent?: () => boolean; + } + >(runtime, { + label: "thread-handoff:cancel", + tag: WS_METHODS.threadHandoffCancel, + execute: ( + input: EnvironmentRpcInput & { + readonly isCurrent?: () => boolean; + }, + registry, + environmentId, + ) => { + const { isCurrent, ...payload } = input; + const validate = Effect.suspend(() => { + const handoff = registry.get( + source.threadAtom({ environmentId, threadId: input.threadId }), + )?.handoff; + return isCurrent?.() !== false && + registry.get(source.connectedAtom(environmentId)) && + (handoff?.state === "pending" || + (handoff?.state === "departing" && input.abandon === true)) && + handoff.handoffId === input.handoffId + ? Effect.void + : Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId, + message: "That pending move is no longer current.", + }), + ); + }); + return validate.pipe( + Effect.andThen(requestGuarded(WS_METHODS.threadHandoffCancel, payload, validate)), + ); + }, + }); + const availability = Atom.family((key: string) => { + const ref = JSON.parse(key) as ScopedThreadRef; + return Atom.make( + (get) => + get(start.permissionAtom(ref.environmentId)) && + canStartThreadHandoff({ + thread: get(source.threadAtom(ref)), + connected: get(source.connectedAtom(ref.environmentId)), + supported: get(source.supportedAtom(ref.environmentId)), + hasLocalWork: get(source.hasLocalWorkAtom(ref)), + }), + ); + }); + return { + options, + start, + cancel, + availableAtom: (ref: ScopedThreadRef) => availability(JSON.stringify(ref)), + }; +} + +export function threadHandoffNotice(handoff: OrchestrationV2ThreadHandoff | null | undefined) { + if (!handoff) return null; + switch (handoff.state) { + case "pending": + return { + title: `Continuing on ${handoff.label} after this turn`, + detail: "Send new instructions or cancel to keep working here.", + }; + case "departing": + return { + title: `Moving to ${handoff.label}`, + detail: + "This thread is read-only while the move finishes. Retry if the connection was interrupted.", + }; + case "departed": + return { + title: `This thread continues on ${handoff.label}`, + detail: "Continue working in the destination thread.", + }; + case "failed": + return { title: `Could not continue on ${handoff.label}`, detail: handoff.lastError }; + } +} diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 413a5c2b3f575..8cd5c95434804 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,10 +1,12 @@ import { + CommandId, EnvironmentId, ProjectId, RunId, RuntimeRequestId, ThreadId, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadHandoff, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -53,6 +55,47 @@ function makeHarness(environmentIds: ReadonlyArray = [environment } describe("v2 thread shell lists", () => { + it("projects move updates and cancellation only onto the source environment's thread", () => { + const h = makeHarness([environmentId, remoteEnvironmentId]); + const source = h.threads.threadShellAtom({ environmentId, threadId: v2ThreadShell.id }); + const other = h.threads.threadShellAtom({ + environmentId: remoteEnvironmentId, + threadId: v2ThreadShell.id, + }); + const base = { + handoffId: "move-one", + environmentId: remoteEnvironmentId, + projectId: otherProjectId, + label: "Other machine", + }; + const updates: ReadonlyArray = [ + { ...base, state: "pending", requestCommandId: CommandId.make("move-request") }, + null, + { ...base, handoffId: "move-two", state: "departing" }, + { ...base, handoffId: "move-two", state: "failed", lastError: "Push the branch first." }, + { ...base, handoffId: "move-three", state: "departed", threadId: ThreadId.make("target") }, + ]; + h.registry.mount(source); + h.registry.mount(other); + const unchanged = h.registry.get(other); + try { + let snapshot = v2ShellSnapshot; + for (const handoff of updates) { + snapshot = applyShellStreamEvent(snapshot, { + kind: "thread.updated", + location: "active", + sequence: snapshot.snapshotSequence + 1, + thread: { ...v2ThreadShell, handoff }, + }); + h.registry.set(h.snapshotAtom(environmentId), snapshot); + expect(h.registry.get(source)).toMatchObject({ handoff }); + expect(h.registry.get(other)).toBe(unchanged); + } + } finally { + h.registry.dispose(); + } + }); + it("preserves ordered reference arrays when a middle thread changes", () => { const { registry, threads, snapshotAtom } = makeHarness(); const snapshot = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fc8b8e5008133..c838f3e8021c2 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -944,3 +944,5 @@ export * from "./threadFeedback.ts"; export * from "./threadDetail.ts"; export * from "./threadShell.ts"; export * from "./threadState.ts"; + +export * from "./threadHandoff.ts"; From dd373a8c30ac41c12faab80af2e04f75fa237ef4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 02:25:25 +0000 Subject: [PATCH 2/2] fix(client): keep handoff actions compatible with React Compiler --- apps/mobile/src/features/threads/ThreadHandoffCard.tsx | 7 ++++--- apps/mobile/src/state/thread-task-permissions.test.ts | 1 + .../src/components/CommandPalette.focus.browser.test.tsx | 4 +++- apps/web/src/components/ThreadHandoffBanner.tsx | 7 ++++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadHandoffCard.tsx b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx index 751edd0bf4823..8a0f71ea6429c 100644 --- a/apps/mobile/src/features/threads/ThreadHandoffCard.tsx +++ b/apps/mobile/src/features/threads/ThreadHandoffCard.tsx @@ -59,7 +59,7 @@ function Notice({ thread }: { thread: EnvironmentThreadShell }) { working.current = true; setBusy(true); setError(null); - try { + const run = async () => { const result = action === "retry" ? await threadHandoffEnvironment.start.run(appAtomRegistry, { @@ -87,12 +87,13 @@ function Notice({ thread }: { thread: EnvironmentThreadShell }) { failure instanceof Error ? failure.message : "Could not update the move. Try again.", ); } - } finally { + }; + await run().finally(() => { if (alive.current) { working.current = false; setBusy(false); } - } + }); }; const abandon = () => Dialog.confirm({ diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 7461660cbaa2c..131b734d9e97d 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -86,6 +86,7 @@ vi.mock("./use-atom-command", () => ({ useAtomCommand:
(command: A) => comman vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); return { + environmentThreadShells: { threadShellsAtom: Atom.make([state.thread]) }, environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) }, threadEnvironment: { respondToApproval: state.approve, diff --git a/apps/web/src/components/CommandPalette.focus.browser.test.tsx b/apps/web/src/components/CommandPalette.focus.browser.test.tsx index 66d8795741a1e..3ab753a0443db 100644 --- a/apps/web/src/components/CommandPalette.focus.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.focus.browser.test.tsx @@ -33,7 +33,9 @@ vi.mock("../hooks/useScratchProject", () => ({ useScratchProject: () => ({ scratchEnvironmentId: () => null }), })); vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => vi.fn() })); -vi.mock("../hooks/useHandleNewThread", () => ({ useHandleNewThread: () => ({}) })); +vi.mock("../hooks/useHandleNewThread", () => ({ + useHandleNewThread: () => ({ activeThread: null }), +})); vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); diff --git a/apps/web/src/components/ThreadHandoffBanner.tsx b/apps/web/src/components/ThreadHandoffBanner.tsx index c3b1142f1aa73..18625a639bbc0 100644 --- a/apps/web/src/components/ThreadHandoffBanner.tsx +++ b/apps/web/src/components/ThreadHandoffBanner.tsx @@ -54,7 +54,7 @@ function Notice({ thread }: { thread: EnvironmentThreadShell }) { working.current = true; setBusy(true); setError(null); - try { + const run = async () => { if (action === "abandon") { const confirmed = await readLocalApi()?.dialogs.confirm( "The move outcome is unknown. The destination may already have a copy or finish accepting it later. Continue working here anyway?", @@ -92,12 +92,13 @@ function Notice({ thread }: { thread: EnvironmentThreadShell }) { failure instanceof Error ? failure.message : "Could not update the move. Try again.", ); } - } finally { + }; + await run().finally(() => { if (alive.current) { working.current = false; setBusy(false); } - } + }); }; return (