| author | |
| committer | |
| log | c616e16c8fd1da1953fd5d0039fffa9e9b146560 |
| tree | 697ae70a800fd50c0e31cabb21f778b6d00e147b |
| parent | 380bba368a9071828616b1b03bde1876cc0af4c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 6 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | 6 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| 7 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 7 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 9 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | ||
| 9 | import type { | 10 | import 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 |
| 3 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; | 3 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; |
| 4 | import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; | 4 | import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; |
| 5 | import { ThreadContextDivider } from "./thread-context-divider"; | 5 | import { ThreadContextDivider } from "./thread-context-divider"; |
| 6 | import { ThreadHandoffRow } from "./thread-handoff-row"; | 6 | import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; |
| 7 | import { SecretRequestCard } from "./SecretRequestCard"; | 7 | import { SecretRequestCard } from "./SecretRequestCard"; |
| 8 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; | 8 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 9 | import { | 9 | import { |
| ... | @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics"; | ... | @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics"; |
| 15 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 15 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 16 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | 16 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 17 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 17 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 18 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | ||
| 18 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; | 19 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 19 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 20 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 20 | import { | 21 | import { |
| ... | @@ -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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff"; | 3 | import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff"; |
| 4 | import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; | 4 | import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 5 | import type { | 5 | import 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 | <ThreadContextDivider | 51 | <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 | |||
| 61 | export 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"; |
| 3 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; | 3 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; |
| 4 | import { useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 5 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; | 5 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; |
| 6 | import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; | ||
| 6 | import { | 7 | import { |
| 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"; |
| 110 | import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; | 110 | import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; |
| 111 | import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; | ||
| 112 | import { HandoffRow } from "./chat/V2LifecycleRow"; | ||
| 111 | import { | 113 | import { |
| 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"; |
| 10 | import { MiddleTruncate } from "../ui/middle-truncate"; | 10 | import { MiddleTruncate } from "../ui/middle-truncate"; |
| 11 | import * as DateTime from "effect/DateTime"; | 11 | import * as DateTime from "effect/DateTime"; |
| 12 | import { WorkLogRow } from "./WorkLog"; | 12 | import { WorkLogRow } from "./WorkLog"; |
| 13 | import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; | 13 | import { |
| 14 | resolveHandoffEndpoints, | ||
| 15 | type HandoffEndpoints, | ||
| 16 | type HandoffTimelineRun, | ||
| 17 | } from "@t3tools/client-runtime/handoff"; | ||
| 14 | import { Fragment } from "react"; | 18 | import { Fragment } from "react"; |
| 15 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; | 19 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 16 | import { | 20 | import { |
| ... | @@ -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 | <TimelineSystemDivider | 129 | <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 | } |
| 572 | 548 | ||
| 549 | export 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 | |||
| 573 | function HandoffEndpoint(props: { | 592 | function 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 @@ |
| 1 | import { ProviderInstanceId, RunId } from "@t3tools/contracts"; | 1 | import { |
| 2 | ProviderInstanceId, | ||
| 3 | RunId, | ||
| 4 | type OrchestrationV2ThreadProjection, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff.ts"; | 8 | import { |
| 9 | resolveHandoffEndpoints, | ||
| 10 | resolvePendingHandoff, | ||
| 11 | type HandoffTimelineRun, | ||
| 12 | } from "./handoff.ts"; | ||
| 4 | 13 | ||
| 5 | const from = ProviderInstanceId.make("codex_personal"); | 14 | const from = ProviderInstanceId.make("codex_personal"); |
| 6 | const to = ProviderInstanceId.make("claudeAgent"); | 15 | const 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 | |||
| 76 | const projection = { | ||
| 77 | runs: [{ ...run("source", 1, from, "source"), startedAt: "2026-10-11T00:00:00Z" }], | ||
| 78 | } as unknown as OrchestrationV2ThreadProjection; | ||
| 79 | |||
| 80 | describe("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 @@ |
| 1 | import type { | 1 | import 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"; |
| 6 | 9 | ||
| 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 | >; |
| 16 | 19 | ||
| 20 | export type HandoffEndpoints = ReturnType<typeof resolveHandoffEndpoints>; | ||
| 21 | |||
| 22 | /** Preview the next send's handoff without adding anything to durable history. */ | ||
| 23 | export 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 | |||
| 17 | export function resolveHandoffEndpoints( | 44 | export function resolveHandoffEndpoints( |
| 18 | item: Pick< | 45 | item: Pick< |
| 19 | Extract<OrchestrationV2TurnItem, { type: "handoff" }>, | 46 | Extract<OrchestrationV2TurnItem, { type: "handoff" }>, |