Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,6 +163,7 @@ export interface ThreadDetailScreenProps {
readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
readonly onDismissFeedback: (id: MessageId) => void;
readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
readonly pendingHandoff?: HandoffEndpoints | null;
readonly activityRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
/** The live work is a provider-native subagent's runless root turn. */
Expand Down Expand Up @@ -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}
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
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 {
Expand All @@ -12,6 +12,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 {
Expand Down Expand Up @@ -288,6 +289,7 @@ export interface ThreadFeedProps {
readonly threadTitle: string;
readonly workspaceRoot?: string | null;
readonly feed: ReadonlyArray<ThreadFeedEntry>;
readonly pendingHandoff?: HandoffEndpoints | null;
readonly contentPresentation: ThreadContentPresentation;
readonly agentLabel: string;
readonly latestRun: ThreadFeedLatestRun | null;
Expand Down Expand Up @@ -3288,6 +3290,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
) : null}
</>
}
ListFooterComponent={
props.pendingHandoff ? (
<ThreadHandoffDivider
environmentId={props.environmentId}
endpoints={props.pendingHandoff}
iconColor={iconSubtleColor}
label="Context handoff pending"
/>
) : null
}
contentContainerStyle={{
paddingTop: 12,
paddingLeft: contentHorizontalPadding + contentLeftInset,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,7 @@ function ThreadRouteContent(
feedbackSubmissions={composer.feedbackSubmissions}
onDismissFeedback={composer.dismissFeedback}
selectedThreadFeed={composer.selectedThreadFeed}
pendingHandoff={composer.pendingHandoff}
activityRun={composer.selectedThreadActivityRun}
activeWorkStartedAt={
creationState?.kind === "preparing" ||
Expand Down
31 changes: 25 additions & 6 deletions apps/mobile/src/features/threads/thread-handoff-row.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<ThreadContextDivider
label="Context handoff"
icon="arrow.left.arrow.right"
<ThreadHandoffDivider
environmentId={props.environmentId}
endpoints={endpoints}
iconColor={color}
label="Context handoff"
failed={item.status === "failed"}
/>
);
}

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 (
<ThreadContextDivider
label={props.label}
icon="arrow.left.arrow.right"
iconColor={props.iconColor}
failed={props.failed}
>
<View className="flex-row flex-wrap items-center justify-center gap-1.5">
{endpoints.from.map((endpoint, index) => (
Expand All @@ -67,7 +86,7 @@ export function ThreadHandoffRow(props: {
</Fragment>
))}
{endpoints.from.length > 0 ? (
<SymbolView name="arrow.right" size={12} tintColor={color} />
<SymbolView name="arrow.right" size={12} tintColor={props.iconColor} />
) : null}
<HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />
</View>
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1091,6 +1101,7 @@ export function useThreadComposerState() {
cancelQueuedRunEdit,
onRemoveQueuedEditAttachment,
modelSelection,
pendingHandoff,
canSwitchThreadProvider,
runtimeMode,
interactionMode,
Expand Down
39 changes: 38 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1801,6 +1803,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;
Expand Down Expand Up @@ -7720,6 +7728,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 ||
Expand Down Expand Up @@ -11481,7 +11503,22 @@ export default function ChatView(props: ChatViewProps) {
{...(draftId ? { onWorktreeSetupWorkLocally } : {})}
{...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
footer={paintOnlyDisplayedTimeline ? null : threadStatusLine}
footer={
paintOnlyDisplayedTimeline ? null : pendingHandoff === null ? (
threadStatusLine
) : (
<>
<div className="chat-content-lane">
<HandoffRow
endpoints={pendingHandoff}
providers={providerStatuses}
pending
/>
</div>
{threadStatusLine}
</>
)
}
listRef={legendListRef}
timelineEntries={displayedTimeline.entries}
providerStatuses={
Expand Down
85 changes: 52 additions & 33 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,39 +125,11 @@ export function V2LifecycleRow(props: {
);
}
if (item.type === "handoff") {
const { from: fromEndpoints, to } = resolveHandoffEndpoints(item, props.runs);
return (
<TimelineSystemDivider
label="Context handoff"
icon={ArrowRightLeftIcon}
showDetailSeparator={false}
tone={item.status === "failed" ? "danger" : "neutral"}
detail={
<span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
{fromEndpoints.map((endpoint, index) => (
<Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}>
{index > 0 ? (
<span aria-hidden="true" className="-ml-1">
,
</span>
) : null}
<HandoffEndpoint
providers={props.providerStatuses}
instanceId={endpoint.instanceId}
model={endpoint.model}
/>
</Fragment>
))}
{fromEndpoints.length > 0 ? (
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
) : null}
<HandoffEndpoint
providers={props.providerStatuses}
instanceId={item.toProviderInstanceId}
model={to.model}
/>
</span>
}
<HandoffRow
endpoints={resolveHandoffEndpoints(item, props.runs)}
providers={props.providerStatuses}
failed={item.status === "failed"}
/>
);
}
Expand Down Expand Up @@ -570,6 +546,49 @@ function SubagentTimelineTooltip(
);
}

export function HandoffRow(props: {
readonly endpoints: HandoffEndpoints;
readonly providers: ReadonlyArray<ServerProvider>;
readonly pending?: boolean;
readonly failed?: boolean;
}) {
const { from: fromEndpoints, to } = props.endpoints;
return (
<TimelineSystemDivider
label={props.pending ? "Context handoff pending" : "Context handoff"}
icon={ArrowRightLeftIcon}
showDetailSeparator={false}
tone={props.failed ? "danger" : "neutral"}
detail={
<span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
{fromEndpoints.map((endpoint, index) => (
<Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}>
{index > 0 ? (
<span aria-hidden="true" className="-ml-1">
,
</span>
) : null}
<HandoffEndpoint
providers={props.providers}
instanceId={endpoint.instanceId}
model={endpoint.model}
/>
</Fragment>
))}
{fromEndpoints.length > 0 ? (
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
) : null}
<HandoffEndpoint
providers={props.providers}
instanceId={to.instanceId}
model={to.model}
/>
</span>
}
/>
);
}

function HandoffEndpoint(props: {
readonly providers: ReadonlyArray<ServerProvider>;
readonly instanceId: ProviderInstanceId;
Expand Down
49 changes: 47 additions & 2 deletions packages/client-runtime/src/handoff.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { ProviderInstanceId, RunId } from "@t3tools/contracts";
import {
ProviderInstanceId,
RunId,
type OrchestrationV2ThreadProjection,
type ServerProvider,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff.ts";
import {
resolveHandoffEndpoints,
resolvePendingHandoff,
type HandoffTimelineRun,
} from "./handoff.ts";

const from = ProviderInstanceId.make("codex_personal");
const to = ProviderInstanceId.make("claudeAgent");
Expand Down Expand Up @@ -63,3 +72,39 @@ describe("handoff endpoints shared by web and mobile", () => {
).toBeUndefined();
});
});

const projection = {
runs: [{ ...run("source", 1, from, "source"), startedAt: "2026-10-11T00:00:00Z" }],
} as unknown as OrchestrationV2ThreadProjection;

describe("pending context handoff", () => {
it("appears on selection, updates the destination, and disappears on returning", () => {
expect(resolvePendingHandoff(projection, { instanceId: to, model: "first" }, [])).toEqual({
from: [{ instanceId: from, model: "source" }],
to: { instanceId: to, model: "first" },
});
expect(
resolvePendingHandoff(projection, { instanceId: to, model: "second" }, [])?.to.model,
).toBe("second");
expect(resolvePendingHandoff(projection, { instanceId: from, model: "source" }, [])).toBeNull();
expect(resolvePendingHandoff(projection, { instanceId: from, model: "other" }, [])).toBeNull();
});
it("only previews a same-provider model change when it needs a fresh native thread", () => {
const providers = [
{ instanceId: from, requiresNewThreadForModelChange: true },
] as unknown as ServerProvider[];
expect(
resolvePendingHandoff(projection, { instanceId: from, model: "other" }, providers)?.to.model,
).toBe("other");
});
it("resumes compatible account overlays without showing a handoff", () => {
const providers = [from, to].map((instanceId) => ({
instanceId,
driver: "codex",
continuation: { groupKey: "shared" },
})) as unknown as ServerProvider[];
expect(
resolvePendingHandoff(projection, { instanceId: to, model: "target" }, providers),
).toBeNull();
});
});
Loading
Loading