diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e4f489115f3ad53eba6af473eb9ef732244315fc..4b7919ef6a985974425ef11d52e5b9f94173efe2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -6,6 +6,7 @@ import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import type { CodexFeedbackSubmission, EnvironmentThreadStatus, @@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps { readonly feedbackSubmissions: ReadonlyArray; readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray; + readonly pendingHandoff?: HandoffEndpoints | null; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; /** The live work is a provider-native subagent's runless root turn. */ @@ -1159,6 +1161,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId={props.selectedThread.id} workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} + pendingHandoff={props.pendingHandoff} worktreeSetup={props.worktreeSetup} setupWorkingStartedAt={props.setupWorkingStartedAt} queuedMessages={props.queuedMessages} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 61d6da783bf0a724711a09cd3e1d2566f2c5619a..4a1e9d1a1f5d635496a7563f7510536d436cd788 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3,7 +3,7 @@ import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdown import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; -import { ThreadHandoffRow } from "./thread-handoff-row"; +import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row"; import { SecretRequestCard } from "./SecretRequestCard"; import { AppActivityIndicator } from "../../components/AppActivityIndicator"; import { @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { @@ -292,6 +293,7 @@ export interface ThreadFeedProps { readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; + readonly pendingHandoff?: HandoffEndpoints | null; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | null; @@ -2756,6 +2758,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} } + ListFooterComponent={ + props.pendingHandoff ? ( + + ) : null + } contentContainerStyle={{ paddingTop: 12, paddingLeft: contentHorizontalPadding + contentLeftInset, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 5f9480bb2e9d48eef58433af4fd6485e7990ef09..348f7377724b410f2173e72cb057327a09f65ba4 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -995,6 +995,7 @@ function ThreadRouteContent( feedbackSubmissions={composer.feedbackSubmissions} onDismissFeedback={composer.dismissFeedback} selectedThreadFeed={composer.selectedThreadFeed} + pendingHandoff={composer.pendingHandoff} activityRun={composer.selectedThreadActivityRun} activeWorkStartedAt={ creationState?.kind === "preparing" || diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx index 3b1b2894a8fce293bd0a7785bbd1b927718a45da..4b05662595f8f0fccc917781de352660f72aced0 100644 --- a/apps/mobile/src/features/threads/thread-handoff-row.tsx +++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff"; +import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; import type { EnvironmentId, @@ -45,15 +45,34 @@ export function ThreadHandoffRow(props: { [props.environmentId, props.projectedItem], ); const endpoints = useAtomValue(endpointsAtom); - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); if (item.type !== "handoff" || endpoints === null) return null; const color = item.status === "failed" ? "#e11d48" : props.iconColor; return ( - + ); +} + +export function ThreadHandoffDivider(props: { + environmentId: EnvironmentId; + endpoints: HandoffEndpoints; + iconColor: ColorValue; + label: string; + failed?: boolean; +}) { + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const { endpoints } = props; + return ( + {endpoints.from.map((endpoint, index) => ( @@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: { ))} {endpoints.from.length > 0 ? ( - + ) : null} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 591dbefb20c9f442b851ee12f8eea5d8e25a0cca..623eff1b0a5e6bd96544c8ee015575fc1969ff4c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,6 +3,7 @@ import { readEnvironmentScope } from "./session"; import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { deriveProviderSubagentStatus, deriveRunlessWorkStartedAt, @@ -334,6 +335,15 @@ export function useThreadComposerState() { const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const pendingHandoff = useMemo( + () => + resolvePendingHandoff( + selectedThreadProjection?.projection, + modelSelection, + selectedEnvironmentRuntime?.serverConfig?.providers ?? [], + ), + [modelSelection, selectedThreadProjection, selectedEnvironmentRuntime?.serverConfig?.providers], + ); const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( (provider) => provider.instanceId === modelSelection?.instanceId, @@ -1091,6 +1101,7 @@ export function useThreadComposerState() { cancelQueuedRunEdit, onRemoveQueuedEditAttachment, modelSelection, + pendingHandoff, canSwitchThreadProvider, runtimeMode, interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 249ce9286e5e57b698857c65a8f1faf6b4463797..dd66b021d1e44d36de859c5015da7ddcb310ed85 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -108,6 +108,8 @@ import { presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; +import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; +import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -1802,6 +1804,12 @@ export default function ChatView(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerSelectedModelSelection = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return draft?.activeProvider + ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) + : null; + }); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -7733,6 +7741,20 @@ export default function ChatView(props: ChatViewProps) { item.type === "user_message" && (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), ); + const pendingHandoff = useMemo( + () => + resolvePendingHandoff( + serverProjection, + composerSelectedModelSelection ?? activeThread?.modelSelection, + providerStatuses, + ), + [ + serverProjection, + composerSelectedModelSelection, + activeThread?.modelSelection, + providerStatuses, + ], + ); const compactThreadUnavailable = !canOperateThread || !activeThread || @@ -11563,7 +11585,22 @@ export default function ChatView(props: ChatViewProps) { {...(draftId ? { onWorktreeSetupWorkLocally } : {})} {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} - footer={paintOnlyDisplayedTimeline ? null : threadStatusLine} + footer={ + paintOnlyDisplayedTimeline ? null : pendingHandoff === null ? ( + threadStatusLine + ) : ( + <> +
+ +
+ {threadStatusLine} + + ) + } listRef={legendListRef} timelineEntries={displayedTimeline.entries} providerStatuses={ diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 4d476e0db6dd2493e289273a0da085bf6c269131..40421a34e3b77fcbf40c0fafe821e4f032502e72 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -10,7 +10,11 @@ import { environmentThreadDetails } from "../../state/threads"; import { MiddleTruncate } from "../ui/middle-truncate"; import * as DateTime from "effect/DateTime"; import { WorkLogRow } from "./WorkLog"; -import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; +import { + resolveHandoffEndpoints, + type HandoffEndpoints, + type HandoffTimelineRun, +} from "@t3tools/client-runtime/handoff"; import { Fragment } from "react"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { @@ -121,39 +125,11 @@ export function V2LifecycleRow(props: { ); } if (item.type === "handoff") { - const { from: fromEndpoints, to } = resolveHandoffEndpoints(item, props.runs); return ( - - {fromEndpoints.map((endpoint, index) => ( - - {index > 0 ? ( - - ) : null} - - - ))} - {fromEndpoints.length > 0 ? ( -