authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-13 19:57:32+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 19:57:32+00:00
logc616e16c8fd1da1953fd5d0039fffa9e9b146560
tree697ae70a800fd50c0e31cabb21f778b6d00e147b
parent380bba368a9071828616b1b03bde1876cc0af4c8
signature Signed by PGP key B5690EEEBB952194

feat(chat): show pending context handoff when selecting a model (#18369)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

9 files changed, 217 insertions(+), 43 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3
...@@ -6,6 +6,7 @@ import type { WorktreeSetupCardProps } from "./worktree-setup-card";...@@ -6,6 +6,7 @@ import type { WorktreeSetupCardProps } from "./worktree-setup-card";
6import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";6import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
7import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";7import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
8import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";8import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
9import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
9import type {10import type {
10 CodexFeedbackSubmission,11 CodexFeedbackSubmission,
11 EnvironmentThreadStatus,12 EnvironmentThreadStatus,
...@@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps {...@@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps {
162 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;163 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
163 readonly onDismissFeedback: (id: MessageId) => void;164 readonly onDismissFeedback: (id: MessageId) => void;
164 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;165 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
166 readonly pendingHandoff?: HandoffEndpoints | null;
165 readonly activityRun: ThreadFeedLatestRun | null;167 readonly activityRun: ThreadFeedLatestRun | null;
166 readonly activeWorkStartedAt: string | null;168 readonly activeWorkStartedAt: string | null;
167 /** The live work is a provider-native subagent's runless root turn. */169 /** The live work is a provider-native subagent's runless root turn. */
...@@ -1159,6 +1161,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1159,6 +1161,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1159 threadId={props.selectedThread.id}1161 threadId={props.selectedThread.id}
1160 workspaceRoot={props.threadCwd}1162 workspaceRoot={props.threadCwd}
1161 feed={props.selectedThreadFeed}1163 feed={props.selectedThreadFeed}
1164 pendingHandoff={props.pendingHandoff}
1162 worktreeSetup={props.worktreeSetup}1165 worktreeSetup={props.worktreeSetup}
1163 setupWorkingStartedAt={props.setupWorkingStartedAt}1166 setupWorkingStartedAt={props.setupWorkingStartedAt}
1164 queuedMessages={props.queuedMessages}1167 queuedMessages={props.queuedMessages}
apps/mobile/src/features/threads/ThreadFeed.tsx+13-1
...@@ -3,7 +3,7 @@ import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdown...@@ -3,7 +3,7 @@ import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdown
3import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";3import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
4import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation";4import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation";
5import { ThreadContextDivider } from "./thread-context-divider";5import { ThreadContextDivider } from "./thread-context-divider";
6import { ThreadHandoffRow } from "./thread-handoff-row";6import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row";
7import { SecretRequestCard } from "./SecretRequestCard";7import { SecretRequestCard } from "./SecretRequestCard";
8import { AppActivityIndicator } from "../../components/AppActivityIndicator";8import { AppActivityIndicator } from "../../components/AppActivityIndicator";
9import {9import {
...@@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics";...@@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics";
15import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";15import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
16import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";16import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
17import { scopeThreadRef } from "@t3tools/client-runtime/environment";17import { scopeThreadRef } from "@t3tools/client-runtime/environment";
18import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
18import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";19import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
19import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";20import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
20import {21import {
...@@ -292,6 +293,7 @@ export interface ThreadFeedProps {...@@ -292,6 +293,7 @@ export interface ThreadFeedProps {
292 readonly threadTitle: string;293 readonly threadTitle: string;
293 readonly workspaceRoot?: string | null;294 readonly workspaceRoot?: string | null;
294 readonly feed: ReadonlyArray<ThreadFeedEntry>;295 readonly feed: ReadonlyArray<ThreadFeedEntry>;
296 readonly pendingHandoff?: HandoffEndpoints | null;
295 readonly contentPresentation: ThreadContentPresentation;297 readonly contentPresentation: ThreadContentPresentation;
296 readonly agentLabel: string;298 readonly agentLabel: string;
297 readonly latestRun: ThreadFeedLatestRun | null;299 readonly latestRun: ThreadFeedLatestRun | null;
...@@ -2756,6 +2758,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2756,6 +2758,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2756 ) : null}2758 ) : null}
2757 </>2759 </>
2758 }2760 }
2761 ListFooterComponent={
2762 props.pendingHandoff ? (
2763 <ThreadHandoffDivider
2764 environmentId={props.environmentId}
2765 endpoints={props.pendingHandoff}
2766 iconColor={iconSubtleColor}
2767 label="Context handoff pending"
2768 />
2769 ) : null
2770 }
2759 contentContainerStyle={{2771 contentContainerStyle={{
2760 paddingTop: 12,2772 paddingTop: 12,
2761 paddingLeft: contentHorizontalPadding + contentLeftInset,2773 paddingLeft: contentHorizontalPadding + contentLeftInset,
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
...@@ -995,6 +995,7 @@ function ThreadRouteContent(...@@ -995,6 +995,7 @@ function ThreadRouteContent(
995 feedbackSubmissions={composer.feedbackSubmissions}995 feedbackSubmissions={composer.feedbackSubmissions}
996 onDismissFeedback={composer.dismissFeedback}996 onDismissFeedback={composer.dismissFeedback}
997 selectedThreadFeed={composer.selectedThreadFeed}997 selectedThreadFeed={composer.selectedThreadFeed}
998 pendingHandoff={composer.pendingHandoff}
998 activityRun={composer.selectedThreadActivityRun}999 activityRun={composer.selectedThreadActivityRun}
999 activeWorkStartedAt={1000 activeWorkStartedAt={
1000 creationState?.kind === "preparing" ||1001 creationState?.kind === "preparing" ||
apps/mobile/src/features/threads/thread-handoff-row.tsx+25-6
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff";3import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff";
4import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";4import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
5import type {5import type {
6 EnvironmentId,6 EnvironmentId,
...@@ -45,15 +45,34 @@ export function ThreadHandoffRow(props: {...@@ -45,15 +45,34 @@ export function ThreadHandoffRow(props: {
45 [props.environmentId, props.projectedItem],45 [props.environmentId, props.projectedItem],
46 );46 );
47 const endpoints = useAtomValue(endpointsAtom);47 const endpoints = useAtomValue(endpointsAtom);
48 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
49 if (item.type !== "handoff" || endpoints === null) return null;48 if (item.type !== "handoff" || endpoints === null) return null;
50 const color = item.status === "failed" ? "#e11d48" : props.iconColor;49 const color = item.status === "failed" ? "#e11d48" : props.iconColor;
51 return (50 return (
52 <ThreadContextDivider51 <ThreadHandoffDivider
53 label="Context handoff"52 environmentId={props.environmentId}
54 icon="arrow.left.arrow.right"53 endpoints={endpoints}
55 iconColor={color}54 iconColor={color}
55 label="Context handoff"
56 failed={item.status === "failed"}56 failed={item.status === "failed"}
57 />
58 );
59}
60
61export function ThreadHandoffDivider(props: {
62 environmentId: EnvironmentId;
63 endpoints: HandoffEndpoints;
64 iconColor: ColorValue;
65 label: string;
66 failed?: boolean;
67}) {
68 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
69 const { endpoints } = props;
70 return (
71 <ThreadContextDivider
72 label={props.label}
73 icon="arrow.left.arrow.right"
74 iconColor={props.iconColor}
75 failed={props.failed}
57 >76 >
58 <View className="flex-row flex-wrap items-center justify-center gap-1.5">77 <View className="flex-row flex-wrap items-center justify-center gap-1.5">
59 {endpoints.from.map((endpoint, index) => (78 {endpoints.from.map((endpoint, index) => (
...@@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: {...@@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: {
67 </Fragment>86 </Fragment>
68 ))}87 ))}
69 {endpoints.from.length > 0 ? (88 {endpoints.from.length > 0 ? (
70 <SymbolView name="arrow.right" size={12} tintColor={color} />89 <SymbolView name="arrow.right" size={12} tintColor={props.iconColor} />
71 ) : null}90 ) : null}
72 <HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />91 <HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />
73 </View>92 </View>
apps/mobile/src/state/use-thread-composer-state.ts+11
...@@ -3,6 +3,7 @@ import { readEnvironmentScope } from "./session";...@@ -3,6 +3,7 @@ import { readEnvironmentScope } from "./session";
3import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";3import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
4import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
5import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";5import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
6import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
6import {7import {
7 deriveProviderSubagentStatus,8 deriveProviderSubagentStatus,
8 deriveRunlessWorkStartedAt,9 deriveRunlessWorkStartedAt,
...@@ -334,6 +335,15 @@ export function useThreadComposerState() {...@@ -334,6 +335,15 @@ export function useThreadComposerState() {
334 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;335 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
335 const selectedThread = selectedThreadShell;336 const selectedThread = selectedThreadShell;
336 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;337 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;
338 const pendingHandoff = useMemo(
339 () =>
340 resolvePendingHandoff(
341 selectedThreadProjection?.projection,
342 modelSelection,
343 selectedEnvironmentRuntime?.serverConfig?.providers ?? [],
344 ),
345 [modelSelection, selectedThreadProjection, selectedEnvironmentRuntime?.serverConfig?.providers],
346 );
337 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;347 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;
338 const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find(348 const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
339 (provider) => provider.instanceId === modelSelection?.instanceId,349 (provider) => provider.instanceId === modelSelection?.instanceId,
...@@ -1091,6 +1101,7 @@ export function useThreadComposerState() {...@@ -1091,6 +1101,7 @@ export function useThreadComposerState() {
1091 cancelQueuedRunEdit,1101 cancelQueuedRunEdit,
1092 onRemoveQueuedEditAttachment,1102 onRemoveQueuedEditAttachment,
1093 modelSelection,1103 modelSelection,
1104 pendingHandoff,
1094 canSwitchThreadProvider,1105 canSwitchThreadProvider,
1095 runtimeMode,1106 runtimeMode,
1096 interactionMode,1107 interactionMode,
apps/web/src/components/ChatView.tsx+38-1
...@@ -108,6 +108,8 @@ import {...@@ -108,6 +108,8 @@ import {
108 presentProviderGoal,108 presentProviderGoal,
109} from "@t3tools/client-runtime/state/thread-execution";109} from "@t3tools/client-runtime/state/thread-execution";
110import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";110import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
111import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
112import { HandoffRow } from "./chat/V2LifecycleRow";
111import {113import {
112 codexFeedbackMessage,114 codexFeedbackMessage,
113 parseCodexFeedbackCommand,115 parseCodexFeedbackCommand,
...@@ -1802,6 +1804,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1802,6 +1804,12 @@ export default function ChatView(props: ChatViewProps) {
1802 const composerActiveProvider = useComposerDraftStore(1804 const composerActiveProvider = useComposerDraftStore(
1803 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,1805 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
1804 );1806 );
1807 const composerSelectedModelSelection = useComposerDraftStore((store) => {
1808 const draft = store.getComposerDraft(composerDraftTarget);
1809 return draft?.activeProvider
1810 ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null)
1811 : null;
1812 });
1805 const composerHasAttachments = useComposerDraftStore((store) => {1813 const composerHasAttachments = useComposerDraftStore((store) => {
1806 const draft = store.getComposerDraft(composerDraftTarget);1814 const draft = store.getComposerDraft(composerDraftTarget);
1807 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;1815 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
...@@ -7733,6 +7741,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -7733,6 +7741,20 @@ export default function ChatView(props: ChatViewProps) {
7733 item.type === "user_message" &&7741 item.type === "user_message" &&
7734 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),7742 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
7735 );7743 );
7744 const pendingHandoff = useMemo(
7745 () =>
7746 resolvePendingHandoff(
7747 serverProjection,
7748 composerSelectedModelSelection ?? activeThread?.modelSelection,
7749 providerStatuses,
7750 ),
7751 [
7752 serverProjection,
7753 composerSelectedModelSelection,
7754 activeThread?.modelSelection,
7755 providerStatuses,
7756 ],
7757 );
7736 const compactThreadUnavailable =7758 const compactThreadUnavailable =
7737 !canOperateThread ||7759 !canOperateThread ||
7738 !activeThread ||7760 !activeThread ||
...@@ -11563,7 +11585,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -11563,7 +11585,22 @@ export default function ChatView(props: ChatViewProps) {
11563 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}11585 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
11564 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}11586 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
11565 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}11587 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
11566 footer={paintOnlyDisplayedTimeline ? null : threadStatusLine}11588 footer={
11589 paintOnlyDisplayedTimeline ? null : pendingHandoff === null ? (
11590 threadStatusLine
11591 ) : (
11592 <>
11593 <div className="chat-content-lane">
11594 <HandoffRow
11595 endpoints={pendingHandoff}
11596 providers={providerStatuses}
11597 pending
11598 />
11599 </div>
11600 {threadStatusLine}
11601 </>
11602 )
11603 }
11567 listRef={legendListRef}11604 listRef={legendListRef}
11568 timelineEntries={displayedTimeline.entries}11605 timelineEntries={displayedTimeline.entries}
11569 providerStatuses={11606 providerStatuses={
apps/web/src/components/chat/V2LifecycleRow.tsx+52-33
...@@ -10,7 +10,11 @@ import { environmentThreadDetails } from "../../state/threads";...@@ -10,7 +10,11 @@ import { environmentThreadDetails } from "../../state/threads";
10import { MiddleTruncate } from "../ui/middle-truncate";10import { MiddleTruncate } from "../ui/middle-truncate";
11import * as DateTime from "effect/DateTime";11import * as DateTime from "effect/DateTime";
12import { WorkLogRow } from "./WorkLog";12import { WorkLogRow } from "./WorkLog";
13import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff";13import {
14 resolveHandoffEndpoints,
15 type HandoffEndpoints,
16 type HandoffTimelineRun,
17} from "@t3tools/client-runtime/handoff";
14import { Fragment } from "react";18import { Fragment } from "react";
15import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";19import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
16import {20import {
...@@ -121,39 +125,11 @@ export function V2LifecycleRow(props: {...@@ -121,39 +125,11 @@ export function V2LifecycleRow(props: {
121 );125 );
122 }126 }
123 if (item.type === "handoff") {127 if (item.type === "handoff") {
124 const { from: fromEndpoints, to } = resolveHandoffEndpoints(item, props.runs);
125 return (128 return (
126 <TimelineSystemDivider129 <HandoffRow
127 label="Context handoff"130 endpoints={resolveHandoffEndpoints(item, props.runs)}
128 icon={ArrowRightLeftIcon}131 providers={props.providerStatuses}
129 showDetailSeparator={false}132 failed={item.status === "failed"}
130 tone={item.status === "failed" ? "danger" : "neutral"}
131 detail={
132 <span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
133 {fromEndpoints.map((endpoint, index) => (
134 <Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}>
135 {index > 0 ? (
136 <span aria-hidden="true" className="-ml-1">
137 ,
138 </span>
139 ) : null}
140 <HandoffEndpoint
141 providers={props.providerStatuses}
142 instanceId={endpoint.instanceId}
143 model={endpoint.model}
144 />
145 </Fragment>
146 ))}
147 {fromEndpoints.length > 0 ? (
148 <ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
149 ) : null}
150 <HandoffEndpoint
151 providers={props.providerStatuses}
152 instanceId={item.toProviderInstanceId}
153 model={to.model}
154 />
155 </span>
156 }
157 />133 />
158 );134 );
159 }135 }
...@@ -570,6 +546,49 @@ function SubagentTimelineTooltip(...@@ -570,6 +546,49 @@ function SubagentTimelineTooltip(
570 );546 );
571}547}
572548
549export function HandoffRow(props: {
550 readonly endpoints: HandoffEndpoints;
551 readonly providers: ReadonlyArray<ServerProvider>;
552 readonly pending?: boolean;
553 readonly failed?: boolean;
554}) {
555 const { from: fromEndpoints, to } = props.endpoints;
556 return (
557 <TimelineSystemDivider
558 label={props.pending ? "Context handoff pending" : "Context handoff"}
559 icon={ArrowRightLeftIcon}
560 showDetailSeparator={false}
561 tone={props.failed ? "danger" : "neutral"}
562 detail={
563 <span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
564 {fromEndpoints.map((endpoint, index) => (
565 <Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}>
566 {index > 0 ? (
567 <span aria-hidden="true" className="-ml-1">
568 ,
569 </span>
570 ) : null}
571 <HandoffEndpoint
572 providers={props.providers}
573 instanceId={endpoint.instanceId}
574 model={endpoint.model}
575 />
576 </Fragment>
577 ))}
578 {fromEndpoints.length > 0 ? (
579 <ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
580 ) : null}
581 <HandoffEndpoint
582 providers={props.providers}
583 instanceId={to.instanceId}
584 model={to.model}
585 />
586 </span>
587 }
588 />
589 );
590}
591
573function HandoffEndpoint(props: {592function HandoffEndpoint(props: {
574 readonly providers: ReadonlyArray<ServerProvider>;593 readonly providers: ReadonlyArray<ServerProvider>;
575 readonly instanceId: ProviderInstanceId;594 readonly instanceId: ProviderInstanceId;
packages/client-runtime/src/handoff.test.ts+47-2
...@@ -1,6 +1,15 @@...@@ -1,6 +1,15 @@
1import { ProviderInstanceId, RunId } from "@t3tools/contracts";1import {
2 ProviderInstanceId,
3 RunId,
4 type OrchestrationV2ThreadProjection,
5 type ServerProvider,
6} from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
3import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff.ts";8import {
9 resolveHandoffEndpoints,
10 resolvePendingHandoff,
11 type HandoffTimelineRun,
12} from "./handoff.ts";
413
5const from = ProviderInstanceId.make("codex_personal");14const from = ProviderInstanceId.make("codex_personal");
6const to = ProviderInstanceId.make("claudeAgent");15const to = ProviderInstanceId.make("claudeAgent");
...@@ -63,3 +72,39 @@ describe("handoff endpoints shared by web and mobile", () => {...@@ -63,3 +72,39 @@ describe("handoff endpoints shared by web and mobile", () => {
63 ).toBeUndefined();72 ).toBeUndefined();
64 });73 });
65});74});
75
76const projection = {
77 runs: [{ ...run("source", 1, from, "source"), startedAt: "2026-10-11T00:00:00Z" }],
78} as unknown as OrchestrationV2ThreadProjection;
79
80describe("pending context handoff", () => {
81 it("appears on selection, updates the destination, and disappears on returning", () => {
82 expect(resolvePendingHandoff(projection, { instanceId: to, model: "first" }, [])).toEqual({
83 from: [{ instanceId: from, model: "source" }],
84 to: { instanceId: to, model: "first" },
85 });
86 expect(
87 resolvePendingHandoff(projection, { instanceId: to, model: "second" }, [])?.to.model,
88 ).toBe("second");
89 expect(resolvePendingHandoff(projection, { instanceId: from, model: "source" }, [])).toBeNull();
90 expect(resolvePendingHandoff(projection, { instanceId: from, model: "other" }, [])).toBeNull();
91 });
92 it("only previews a same-provider model change when it needs a fresh native thread", () => {
93 const providers = [
94 { instanceId: from, requiresNewThreadForModelChange: true },
95 ] as unknown as ServerProvider[];
96 expect(
97 resolvePendingHandoff(projection, { instanceId: from, model: "other" }, providers)?.to.model,
98 ).toBe("other");
99 });
100 it("resumes compatible account overlays without showing a handoff", () => {
101 const providers = [from, to].map((instanceId) => ({
102 instanceId,
103 driver: "codex",
104 continuation: { groupKey: "shared" },
105 })) as unknown as ServerProvider[];
106 expect(
107 resolvePendingHandoff(projection, { instanceId: to, model: "target" }, providers),
108 ).toBeNull();
109 });
110});
packages/client-runtime/src/handoff.ts+27
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import type {1import type {
2 ModelSelection,
2 OrchestrationV2Run,3 OrchestrationV2Run,
4 OrchestrationV2ThreadProjection,
3 OrchestrationV2TurnItem,5 OrchestrationV2TurnItem,
4 ProviderInstanceId,6 ProviderInstanceId,
7 ServerProvider,
5} from "@t3tools/contracts";8} from "@t3tools/contracts";
69
7/**10/**
...@@ -14,6 +17,30 @@ export type HandoffTimelineRun = Pick<...@@ -14,6 +17,30 @@ export type HandoffTimelineRun = Pick<
14 "id" | "ordinal" | "providerInstanceId" | "modelSelection"17 "id" | "ordinal" | "providerInstanceId" | "modelSelection"
15>;18>;
1619
20export type HandoffEndpoints = ReturnType<typeof resolveHandoffEndpoints>;
21
22/** Preview the next send's handoff without adding anything to durable history. */
23export function resolvePendingHandoff(
24 projection: OrchestrationV2ThreadProjection | null | undefined,
25 selection: ModelSelection | null | undefined,
26 providers: ReadonlyArray<ServerProvider>,
27): HandoffEndpoints | null {
28 const from = projection?.runs.findLast(
29 (run) => run.startedAt !== null && run.status !== "rolled_back",
30 )?.modelSelection;
31 if (from === undefined || selection == null) return null;
32 const source = providers.find((provider) => provider.instanceId === from.instanceId);
33 const target = providers.find((provider) => provider.instanceId === selection.instanceId);
34 const needsHandoff =
35 from.instanceId === selection.instanceId
36 ? from.model !== selection.model && target?.requiresNewThreadForModelChange === true
37 : // Accounts sharing a continuation group resume the same native thread.
38 source?.continuation?.groupKey === undefined ||
39 source.driver !== target?.driver ||
40 source.continuation.groupKey !== target.continuation?.groupKey;
41 return needsHandoff ? { from: [from], to: selection } : null;
42}
43
17export function resolveHandoffEndpoints(44export function resolveHandoffEndpoints(
18 item: Pick<45 item: Pick<
19 Extract<OrchestrationV2TurnItem, { type: "handoff" }>,46 Extract<OrchestrationV2TurnItem, { type: "handoff" }>,