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";
66import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
77import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
88import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
9import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
910import type {
1011 CodexFeedbackSubmission,
1112 EnvironmentThreadStatus,
......@@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps {
162163 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
163164 readonly onDismissFeedback: (id: MessageId) => void;
164165 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
166 readonly pendingHandoff?: HandoffEndpoints | null;
165167 readonly activityRun: ThreadFeedLatestRun | null;
166168 readonly activeWorkStartedAt: string | null;
167169 /** The live work is a provider-native subagent's runless root turn. */
......@@ -1159,6 +1161,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11591161 threadId={props.selectedThread.id}
11601162 workspaceRoot={props.threadCwd}
11611163 feed={props.selectedThreadFeed}
1164 pendingHandoff={props.pendingHandoff}
11621165 worktreeSetup={props.worktreeSetup}
11631166 setupWorkingStartedAt={props.setupWorkingStartedAt}
11641167 queuedMessages={props.queuedMessages}
apps/mobile/src/features/threads/ThreadFeed.tsx+13-1
......@@ -3,7 +3,7 @@ import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdown
33import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
44import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation";
55import { ThreadContextDivider } from "./thread-context-divider";
6import { ThreadHandoffRow } from "./thread-handoff-row";
6import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row";
77import { SecretRequestCard } from "./SecretRequestCard";
88import { AppActivityIndicator } from "../../components/AppActivityIndicator";
99import {
......@@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics";
1515import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
1616import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
1717import { scopeThreadRef } from "@t3tools/client-runtime/environment";
18import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
1819import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
1920import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
2021import {
......@@ -292,6 +293,7 @@ export interface ThreadFeedProps {
292293 readonly threadTitle: string;
293294 readonly workspaceRoot?: string | null;
294295 readonly feed: ReadonlyArray<ThreadFeedEntry>;
296 readonly pendingHandoff?: HandoffEndpoints | null;
295297 readonly contentPresentation: ThreadContentPresentation;
296298 readonly agentLabel: string;
297299 readonly latestRun: ThreadFeedLatestRun | null;
......@@ -2756,6 +2758,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27562758 ) : null}
27572759 </>
27582760 }
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 }
27592771 contentContainerStyle={{
27602772 paddingTop: 12,
27612773 paddingLeft: contentHorizontalPadding + contentLeftInset,
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
......@@ -995,6 +995,7 @@ function ThreadRouteContent(
995995 feedbackSubmissions={composer.feedbackSubmissions}
996996 onDismissFeedback={composer.dismissFeedback}
997997 selectedThreadFeed={composer.selectedThreadFeed}
998 pendingHandoff={composer.pendingHandoff}
998999 activityRun={composer.selectedThreadActivityRun}
9991000 activeWorkStartedAt={
10001001 creationState?.kind === "preparing" ||
apps/mobile/src/features/threads/thread-handoff-row.tsx+25-6
......@@ -1,6 +1,6 @@
11import { useAtomValue } from "@effect/atom-react";
22import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff";
3import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff";
44import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
55import type {
66 EnvironmentId,
......@@ -45,15 +45,34 @@ export function ThreadHandoffRow(props: {
4545 [props.environmentId, props.projectedItem],
4646 );
4747 const endpoints = useAtomValue(endpointsAtom);
48 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
4948 if (item.type !== "handoff" || endpoints === null) return null;
5049 const color = item.status === "failed" ? "#e11d48" : props.iconColor;
5150 return (
52 <ThreadContextDivider
53 label="Context handoff"
54 icon="arrow.left.arrow.right"
51 <ThreadHandoffDivider
52 environmentId={props.environmentId}
53 endpoints={endpoints}
5554 iconColor={color}
55 label="Context handoff"
5656 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}
5776 >
5877 <View className="flex-row flex-wrap items-center justify-center gap-1.5">
5978 {endpoints.from.map((endpoint, index) => (
......@@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: {
6786 </Fragment>
6887 ))}
6988 {endpoints.from.length > 0 ? (
70 <SymbolView name="arrow.right" size={12} tintColor={color} />
89 <SymbolView name="arrow.right" size={12} tintColor={props.iconColor} />
7190 ) : null}
7291 <HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />
7392 </View>
apps/mobile/src/state/use-thread-composer-state.ts+11
......@@ -3,6 +3,7 @@ import { readEnvironmentScope } from "./session";
33import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
44import { useAtomValue } from "@effect/atom-react";
55import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
6import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
67import {
78 deriveProviderSubagentStatus,
89 deriveRunlessWorkStartedAt,
......@@ -334,6 +335,15 @@ export function useThreadComposerState() {
334335 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
335336 const selectedThread = selectedThreadShell;
336337 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 );
337347 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;
338348 const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
339349 (provider) => provider.instanceId === modelSelection?.instanceId,
......@@ -1091,6 +1101,7 @@ export function useThreadComposerState() {
10911101 cancelQueuedRunEdit,
10921102 onRemoveQueuedEditAttachment,
10931103 modelSelection,
1104 pendingHandoff,
10941105 canSwitchThreadProvider,
10951106 runtimeMode,
10961107 interactionMode,
apps/web/src/components/ChatView.tsx+38-1
......@@ -108,6 +108,8 @@ import {
108108 presentProviderGoal,
109109} from "@t3tools/client-runtime/state/thread-execution";
110110import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
111import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
112import { HandoffRow } from "./chat/V2LifecycleRow";
111113import {
112114 codexFeedbackMessage,
113115 parseCodexFeedbackCommand,
......@@ -1802,6 +1804,12 @@ export default function ChatView(props: ChatViewProps) {
18021804 const composerActiveProvider = useComposerDraftStore(
18031805 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
18041806 );
1807 const composerSelectedModelSelection = useComposerDraftStore((store) => {
1808 const draft = store.getComposerDraft(composerDraftTarget);
1809 return draft?.activeProvider
1810 ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null)
1811 : null;
1812 });
18051813 const composerHasAttachments = useComposerDraftStore((store) => {
18061814 const draft = store.getComposerDraft(composerDraftTarget);
18071815 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
......@@ -7733,6 +7741,20 @@ export default function ChatView(props: ChatViewProps) {
77337741 item.type === "user_message" &&
77347742 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
77357743 );
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 );
77367758 const compactThreadUnavailable =
77377759 !canOperateThread ||
77387760 !activeThread ||
......@@ -11563,7 +11585,22 @@ export default function ChatView(props: ChatViewProps) {
1156311585 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
1156411586 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
1156511587 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 }
1156711604 listRef={legendListRef}
1156811605 timelineEntries={displayedTimeline.entries}
1156911606 providerStatuses={
apps/web/src/components/chat/V2LifecycleRow.tsx+52-33
......@@ -10,7 +10,11 @@ import { environmentThreadDetails } from "../../state/threads";
1010import { MiddleTruncate } from "../ui/middle-truncate";
1111import * as DateTime from "effect/DateTime";
1212import { 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";
1418import { Fragment } from "react";
1519import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
1620import {
......@@ -121,39 +125,11 @@ export function V2LifecycleRow(props: {
121125 );
122126 }
123127 if (item.type === "handoff") {
124 const { from: fromEndpoints, to } = resolveHandoffEndpoints(item, props.runs);
125128 return (
126 <TimelineSystemDivider
127 label="Context handoff"
128 icon={ArrowRightLeftIcon}
129 showDetailSeparator={false}
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 }
129 <HandoffRow
130 endpoints={resolveHandoffEndpoints(item, props.runs)}
131 providers={props.providerStatuses}
132 failed={item.status === "failed"}
157133 />
158134 );
159135 }
......@@ -570,6 +546,49 @@ function SubagentTimelineTooltip(
570546 );
571547}
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
573592function HandoffEndpoint(props: {
574593 readonly providers: ReadonlyArray<ServerProvider>;
575594 readonly instanceId: ProviderInstanceId;
packages/client-runtime/src/handoff.test.ts+47-2
......@@ -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";
27import { 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
514const from = ProviderInstanceId.make("codex_personal");
615const to = ProviderInstanceId.make("claudeAgent");
......@@ -63,3 +72,39 @@ describe("handoff endpoints shared by web and mobile", () => {
6372 ).toBeUndefined();
6473 });
6574});
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 @@
11import type {
2 ModelSelection,
23 OrchestrationV2Run,
4 OrchestrationV2ThreadProjection,
35 OrchestrationV2TurnItem,
46 ProviderInstanceId,
7 ServerProvider,
58} from "@t3tools/contracts";
69
710/**
......@@ -14,6 +17,30 @@ export type HandoffTimelineRun = Pick<
1417 "id" | "ordinal" | "providerInstanceId" | "modelSelection"
1518>;
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
1744export function resolveHandoffEndpoints(
1845 item: Pick<
1946 Extract<OrchestrationV2TurnItem, { type: "handoff" }>,